Skip to content

Commit

Permalink
Yandex: add id system (prebid#11196)
Browse files Browse the repository at this point in the history
* feat: add yandex id system

* refactor: improve yandex user id adapter codestyle

* tests: add unit tests for yandex user id module

* fix: adjust eid key

* refactor: remove explicit calls to cookie storage
  • Loading branch information
chernodub authored Jun 13, 2024
1 parent 7ded2b7 commit fd40156
Show file tree
Hide file tree
Showing 3 changed files with 284 additions and 1 deletion.
3 changes: 2 additions & 1 deletion modules/.submodules.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
"euidIdSystem",
"unifiedIdSystem",
"verizonMediaIdSystem",
"zeotapIdPlusIdSystem"
"zeotapIdPlusIdSystem",
"yandexIdSystem"
],
"adpod": [
"freeWheelAdserverVideo",
Expand Down
145 changes: 145 additions & 0 deletions modules/yandexIdSystem.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* The {@link module:modules/userId} module is required
* @module modules/yandexIdSystem
* @requires module:modules/userId
*/

// @ts-check

import { MODULE_TYPE_UID } from '../src/activities/modules.js';
import { submodule } from '../src/hook.js';
import { getStorageManager } from '../src/storageManager.js';
import { logError, logInfo } from '../src/utils.js';

// .com suffix is just a convention for naming the bidder eids
// See https://github.com/prebid/Prebid.js/pull/11196#discussion_r1591165139
const BIDDER_EID_KEY = 'yandex.com';
const YANDEX_ID_KEY = 'yandexId';
export const BIDDER_CODE = 'yandex';
export const YANDEX_USER_ID_KEY = '_ym_uid';
export const YANDEX_COOKIE_STORAGE_TYPE = 'cookie';
export const YANDEX_MIN_EXPIRE_DAYS = 30;

export const PREBID_STORAGE = getStorageManager({
moduleType: MODULE_TYPE_UID,
moduleName: BIDDER_CODE,
bidderCode: undefined
});

export const yandexIdSubmodule = {
/**
* Used to link submodule with config.
* @type {string}
*/
name: BIDDER_CODE,
/**
* Decodes the stored id value for passing to bid requests.
* @param {string} value
*/
decode(value) {
logInfo('decoded value yandexId', value);

return { [YANDEX_ID_KEY]: value };
},
/**
* @param {import('./userId/index.js').SubmoduleConfig} submoduleConfig
* @param {unknown} [_consentData]
* @param {string} [storedId] Id that was saved by the core previously.
*/
getId(submoduleConfig, _consentData, storedId) {
if (checkConfigHasErrorsAndReport(submoduleConfig)) {
return;
}

if (storedId) {
return {
id: storedId
};
}

return {
id: new YandexUidGenerator().generateUid(),
};
},
eids: {
[YANDEX_ID_KEY]: {
source: BIDDER_EID_KEY,
atype: 1,
},
},
};

/**
* @param {import('./userId/index.js').SubmoduleConfig} submoduleConfig
* @returns {boolean} `true` - when there are errors, `false` - otherwise.
*/
function checkConfigHasErrorsAndReport(submoduleConfig) {
let error = false;

const READABLE_MODULE_NAME = 'Yandex ID module';

if (submoduleConfig.storage == null) {
logError(`Misconfigured ${READABLE_MODULE_NAME}. "storage" is required.`)
return true;
}

if (submoduleConfig.storage?.name !== YANDEX_USER_ID_KEY) {
logError(`Misconfigured ${READABLE_MODULE_NAME}, "storage.name" is required to be "${YANDEX_USER_ID_KEY}"`);
error = true;
}

if (submoduleConfig.storage?.type !== YANDEX_COOKIE_STORAGE_TYPE) {
logError(`Misconfigured ${READABLE_MODULE_NAME}, "storage.type" is required to be "${YANDEX_COOKIE_STORAGE_TYPE}"`);
error = true;
}

if ((submoduleConfig.storage?.expires ?? 0) < YANDEX_MIN_EXPIRE_DAYS) {
logError(`Misconfigured ${READABLE_MODULE_NAME}, "storage.expires" is required to be not less than "${YANDEX_MIN_EXPIRE_DAYS}"`);
error = true;
}

return error;
}

/**
* Yandex-specific generator for uid. Needs to be compatible with Yandex Metrica tag.
* @see https://github.com/yandex/metrica-tag/blob/main/src/utils/uid/uid.ts#L51
*/
class YandexUidGenerator {
/**
* @param {number} min
* @param {number} max
*/
_getRandomInteger(min, max) {
const generateRandom = this._getRandomGenerator();

return Math.floor(generateRandom() * (max - min)) + min;
}

_getCurrentSecTimestamp() {
return Math.round(Date.now() / 1000);
}

generateUid() {
return [
this._getCurrentSecTimestamp(),
this._getRandomInteger(1000000, 999999999),
].join('');
}

_getRandomGenerator() {
if (crypto) {
return () => {
const buffer = new Uint32Array(1);
crypto.getRandomValues(buffer);

return buffer[0] / 0xffffffff;
};
}

// Polyfill for environments that don't support Crypto API
return () => Math.random();
}
}

submodule('userId', yandexIdSubmodule);
137 changes: 137 additions & 0 deletions test/spec/modules/yandexIdSystem_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// @ts-check

import { yandexIdSubmodule, PREBID_STORAGE, BIDDER_CODE, YANDEX_USER_ID_KEY, YANDEX_COOKIE_STORAGE_TYPE, YANDEX_MIN_EXPIRE_DAYS } from '../../../modules/yandexIdSystem.js';
import {createSandbox} from 'sinon'
import * as utils from '../../../src/utils.js';

/**
* @typedef {import('sinon').SinonStub} SinonStub
* @typedef {import('sinon').SinonSpy} SinonSpy
* @typedef {import('sinon').SinonSandbox} SinonSandbox
*/

const MIN_METRICA_ID_LEN = 17;

/** @satisfies {import('../../../modules/userId/index.js').SubmoduleConfig} */
const CORRECT_SUBMODULE_CONFIG = {
name: BIDDER_CODE,
storage: {
expires: YANDEX_MIN_EXPIRE_DAYS,
name: YANDEX_USER_ID_KEY,
type: YANDEX_COOKIE_STORAGE_TYPE,
refreshInSeconds: undefined,
},
params: undefined,
value: undefined,
};

/** @type {import('../../../modules/userId/index.js').SubmoduleConfig[]} */
const INCORRECT_SUBMODULE_CONFIGS = [
{
...CORRECT_SUBMODULE_CONFIG,
storage: {
...CORRECT_SUBMODULE_CONFIG.storage,
expires: 0,
}
},
{
...CORRECT_SUBMODULE_CONFIG,
storage: {
...CORRECT_SUBMODULE_CONFIG.storage,
type: 'html5'
}
},
{
...CORRECT_SUBMODULE_CONFIG,
storage: {
...CORRECT_SUBMODULE_CONFIG.storage,
name: 'custom_key'
}
},
];

describe('YandexId module', () => {
/** @type {SinonSandbox} */
let sandbox;
/** @type {SinonStub} */
let getCryptoRandomValuesStub;
/** @type {SinonStub} */
let randomStub;
/** @type {SinonSpy} */
let logErrorSpy;

beforeEach(() => {
sandbox = createSandbox();
logErrorSpy = sandbox.spy(utils, 'logError');

getCryptoRandomValuesStub = sandbox
.stub(window.crypto, 'getRandomValues')
.callsFake((bufferView) => {
if (bufferView != null) {
bufferView[0] = 10000;
}

return null;
});
randomStub = sandbox.stub(window.Math, 'random').returns(0.555);
});

afterEach(() => {
sandbox.restore();
});

describe('getId()', () => {
it('user id matches Yandex Metrica format', () => {
const generatedId = yandexIdSubmodule.getId(CORRECT_SUBMODULE_CONFIG)?.id;

expect(isNaN(Number(generatedId))).to.be.false;
expect(generatedId).to.have.length.greaterThanOrEqual(
MIN_METRICA_ID_LEN
);
});

it('uses stored id', () => {
const storedId = '11111111111111111';
const generatedId = yandexIdSubmodule.getId(CORRECT_SUBMODULE_CONFIG, undefined, storedId)?.id;

expect(generatedId).to.be.equal(storedId);
})

describe('config validation', () => {
INCORRECT_SUBMODULE_CONFIGS.forEach((config, i) => {
it(`invalid config #${i} fails`, () => {
const generatedId = yandexIdSubmodule.getId(config)?.id;

expect(generatedId).to.be.undefined;
expect(logErrorSpy.called).to.be.true;
})
})
})

describe('crypto', () => {
it('uses Math.random when crypto is not available', () => {
sandbox.stub(window, 'crypto').value(undefined);

yandexIdSubmodule.getId(CORRECT_SUBMODULE_CONFIG);

expect(randomStub.calledOnce).to.be.true;
expect(getCryptoRandomValuesStub.called).to.be.false;
});

it('uses crypto when it is available', () => {
yandexIdSubmodule.getId(CORRECT_SUBMODULE_CONFIG);

expect(randomStub.called).to.be.false;
expect(getCryptoRandomValuesStub.calledOnce).to.be.true;
});
});
});

describe('decode()', () => {
it('should not transform value', () => {
const value = 'test value';

expect(yandexIdSubmodule.decode(value).yandexId).to.equal(value);
});
});
});

0 comments on commit fd40156

Please sign in to comment.