Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Yandex: add id system #11196

Merged
merged 5 commits into from
Jun 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
});