diff --git a/modules/.submodules.json b/modules/.submodules.json index 9dfeaf910f8..224fdd6ab04 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -51,7 +51,8 @@ "euidIdSystem", "unifiedIdSystem", "verizonMediaIdSystem", - "zeotapIdPlusIdSystem" + "zeotapIdPlusIdSystem", + "yandexIdSystem" ], "adpod": [ "freeWheelAdserverVideo", diff --git a/modules/yandexIdSystem.js b/modules/yandexIdSystem.js new file mode 100644 index 00000000000..f24e33a8c44 --- /dev/null +++ b/modules/yandexIdSystem.js @@ -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); diff --git a/test/spec/modules/yandexIdSystem_spec.js b/test/spec/modules/yandexIdSystem_spec.js new file mode 100644 index 00000000000..d5f614dafb9 --- /dev/null +++ b/test/spec/modules/yandexIdSystem_spec.js @@ -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); + }); + }); +});