diff --git a/modules/openPairIdSystem.js b/modules/openPairIdSystem.js new file mode 100644 index 000000000000..8d104625ca99 --- /dev/null +++ b/modules/openPairIdSystem.js @@ -0,0 +1,123 @@ +/** + * This module adds PAIR Id to the User ID module + * The {@link module:modules/userId} module is required + * @module modules/pairIdSystem + * @requires module:modules/userId + */ + +import { submodule } from '../src/hook.js'; +import {getStorageManager} from '../src/storageManager.js' +import { logInfo } from '../src/utils.js'; +import {MODULE_TYPE_UID} from '../src/activities/modules.js'; +import {VENDORLESS_GVLID} from '../src/consentHandler.js'; + +/** + * @typedef {import('../modules/userId/index.js').Submodule} Submodule + */ + +const MODULE_NAME = 'openPairId'; +const PAIR_ID_KEY = 'pairId'; + +const DEFAULT_STORAGE_PAID_ID_KEYS = { + liveramp: '_lr_pairId' +}; + +export const storage = getStorageManager({moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME}); + +function pairIdFromLocalStorage(key) { + return storage.localStorageIsEnabled() ? storage.getDataFromLocalStorage(key) : null; +} + +function pairIdFromCookie(key) { + return storage.cookiesAreEnabled() ? storage.getCookie(key) : null; +} + +/** @type {Submodule} */ +export const pairIdSubmodule = { + /** + * used to link submodule with config + * @type {string} + */ + name: MODULE_NAME, + /** + * used to specify vendor id + * @type {number} + */ + gvlid: VENDORLESS_GVLID, + /** + * decode the stored id value for passing to bid requests + * @function + * @param { string | undefined } value + * @returns {{pairId:string} | undefined } + */ + decode(value) { + return value && Array.isArray(value) ? {'pairId': value} : undefined + }, + /** + * Performs action to obtain ID and return a value in the callback's response argument. + * @function getId + * @param {Object} config - The configuration object. + * @param {Object} config.params - The parameters from the configuration. + * @returns {{id: string[] | undefined}} The obtained IDs or undefined if no IDs are found. + */ + getId(config) { + const pairIdsString = pairIdFromLocalStorage(PAIR_ID_KEY) || pairIdFromCookie(PAIR_ID_KEY) + let ids = [] + + if (pairIdsString && typeof pairIdsString == 'string') { + try { + ids = ids.concat(JSON.parse(atob(pairIdsString))) + } catch (error) { + logInfo(error) + } + } + + const configParams = (config && config.params) ? config.params : {}; + const cleanRooms = Object.keys(configParams); + + for (let i = 0; i < cleanRooms.length; i++) { + const cleanRoom = cleanRooms[i]; + const cleanRoomParams = configParams[cleanRoom]; + + const cleanRoomStorageLocation = cleanRoomParams.storageKey || DEFAULT_STORAGE_PAID_ID_KEYS[cleanRoom]; + const cleanRoomValue = pairIdFromLocalStorage(cleanRoomStorageLocation) || pairIdFromCookie(cleanRoomStorageLocation); + + if (cleanRoomValue) { + try { + const parsedValue = atob(cleanRoomValue); + + if (parsedValue) { + const obj = JSON.parse(parsedValue); + + if (obj && typeof obj === 'object' && obj.envelope) { + ids = ids.concat(obj.envelope); + } else { + logInfo('Pairid: Parsed object is not valid or does not contain envelope'); + } + } else { + logInfo('Pairid: Decoded value is empty'); + } + } catch (error) { + logInfo('Pairid: Error parsing JSON: ', error); + } + } else { + logInfo('Pairid: data clean room value for pairId from storage is empty or null'); + } + } + + if (ids.length == 0) { + logInfo('PairId not found.') + return undefined; + } + + return {'id': ids}; + }, + eids: { + 'openPairId': { + source: 'pair-protocol.com', + atype: 3 + }, + } +}; + +submodule('userId', pairIdSubmodule); diff --git a/test/spec/modules/openPairIdSystem_spec.js b/test/spec/modules/openPairIdSystem_spec.js new file mode 100644 index 000000000000..0f5e9b3c5dc5 --- /dev/null +++ b/test/spec/modules/openPairIdSystem_spec.js @@ -0,0 +1,114 @@ +import { storage, pairIdSubmodule } from 'modules/openPairIdSystem.js'; +import * as utils from 'src/utils.js'; + +describe('openPairId', function () { + let sandbox; + let logInfoStub; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + logInfoStub = sandbox.stub(utils, 'logInfo'); + }); + afterEach(() => { + sandbox.restore(); + }); + + it('should read pairId from specified clean room if configured with storageKey', function() { + let pairIds = ['test-pair-id1', 'test-pair-id2', 'test-pair-id3']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('habu_pairId_custom').returns(btoa(JSON.stringify({'envelope': pairIds}))); + + let id = pairIdSubmodule.getId({ + params: { + habu: { + storageKey: 'habu_pairId_custom' + } + }}) + + expect(id).to.be.deep.equal({id: pairIds}); + }) + + it('should read pairID from liveramp with default storageKey and additional clean room with configured storageKey', function() { + let getDataStub = sandbox.stub(storage, 'getDataFromLocalStorage'); + let liveRampPairIds = ['lr-test-pair-id1', 'lr-test-pair-id2', 'lr-test-pair-id3']; + getDataStub.withArgs('_lr_pairId').returns(btoa(JSON.stringify({'envelope': liveRampPairIds}))); + + let habuPairIds = ['habu-test-pair-id1', 'habu-test-pair-id2', 'habu-test-pair-id3']; + getDataStub.withArgs('habu_pairId_custom').returns(btoa(JSON.stringify({'envelope': habuPairIds}))); + + let id = pairIdSubmodule.getId({ + params: { + habu: { + storageKey: 'habu_pairId_custom' + }, + liveramp: {} + }}) + + expect(id).to.be.deep.equal({id: habuPairIds.concat(liveRampPairIds)}); + }); + + it('should log an error if no ID is found when getId', function() { + pairIdSubmodule.getId({ params: {} }); + expect(logInfoStub.calledOnce).to.be.true; + }); + + it('should read pairId from local storage if exists', function() { + let pairIds = ['test-pair-id1', 'test-pair-id2', 'test-pair-id3']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('pairId').returns(btoa(JSON.stringify(pairIds))); + + let id = pairIdSubmodule.getId({ params: {} }); + expect(id).to.be.deep.equal({id: pairIds}); + }); + + it('should read pairId from cookie if exists', function() { + let pairIds = ['test-pair-id4', 'test-pair-id5', 'test-pair-id6']; + sandbox.stub(storage, 'getCookie').withArgs('pairId').returns(btoa(JSON.stringify(pairIds))); + + let id = pairIdSubmodule.getId({ params: {} }); + expect(id).to.be.deep.equal({id: pairIds}); + }); + + it('should read pairId from default liveramp envelope local storage key if configured', function() { + let pairIds = ['test-pair-id1', 'test-pair-id2', 'test-pair-id3']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('_lr_pairId').returns(btoa(JSON.stringify({'envelope': pairIds}))); + let id = pairIdSubmodule.getId({ + params: { + liveramp: {} + }}) + expect(id).to.be.deep.equal({id: pairIds}) + }) + + it('should read pairId from default liveramp envelope cookie entry if configured', function() { + let pairIds = ['test-pair-id4', 'test-pair-id5', 'test-pair-id6']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('_lr_pairId').returns(btoa(JSON.stringify({'envelope': pairIds}))); + let id = pairIdSubmodule.getId({ + params: { + liveramp: {} + }}) + expect(id).to.be.deep.equal({id: pairIds}) + }) + + it('should read pairId from specified liveramp envelope cookie entry if configured with storageKey', function() { + let pairIds = ['test-pair-id7', 'test-pair-id8', 'test-pair-id9']; + sandbox.stub(storage, 'getDataFromLocalStorage').withArgs('lr_pairId_custom').returns(btoa(JSON.stringify({'envelope': pairIds}))); + let id = pairIdSubmodule.getId({ + params: { + liveramp: { + storageKey: 'lr_pairId_custom' + } + }}) + expect(id).to.be.deep.equal({id: pairIds}) + }) + + it('should not get data from storage if local storage and cookies are disabled', function () { + sandbox.stub(storage, 'localStorageIsEnabled').returns(false); + sandbox.stub(storage, 'cookiesAreEnabled').returns(false); + let id = pairIdSubmodule.getId({ + params: { + liveramp: { + storageKey: 'lr_pairId_custom' + } + } + }) + expect(id).to.equal(undefined) + }) +});