diff --git a/modules/33acrossIdSystem.js b/modules/33acrossIdSystem.js index 0f370237e21..d9816254d75 100644 --- a/modules/33acrossIdSystem.js +++ b/modules/33acrossIdSystem.js @@ -5,44 +5,53 @@ * @requires module:modules/userId */ -import { logMessage, logError } from '../src/utils.js'; +import { logMessage, logError, logWarn } from '../src/utils.js'; import { ajaxBuilder } from '../src/ajax.js'; import { submodule } from '../src/hook.js'; import { uspDataHandler, coppaDataHandler, gppDataHandler } from '../src/adapterManager.js'; +import { getStorageManager, STORAGE_TYPE_COOKIES, STORAGE_TYPE_LOCALSTORAGE } from '../src/storageManager.js'; +import { MODULE_TYPE_UID } from '../src/activities/modules.js'; const MODULE_NAME = '33acrossId'; const API_URL = 'https://lexicon.33across.com/v1/envelope'; const AJAX_TIMEOUT = 10000; const CALLER_NAME = 'pbjs'; +const GVLID = 58; -function getEnvelope(response) { +const STORAGE_FPID_KEY = '33acrossIdFp'; + +export const storage = getStorageManager({ moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME }); + +function calculateResponseObj(response) { if (!response.succeeded) { if (response.error == 'Cookied User') { logMessage(`${MODULE_NAME}: Unsuccessful response`.concat(' ', response.error)); } else { logError(`${MODULE_NAME}: Unsuccessful response`.concat(' ', response.error)); } - return; + return {}; } if (!response.data.envelope) { logMessage(`${MODULE_NAME}: No envelope was received`); - return; + return {}; } - return response.data.envelope; + return { + envelope: response.data.envelope, + fp: response.data.fp + }; } -function calculateQueryStringParams(pid, gdprConsentData) { +function calculateQueryStringParams(pid, gdprConsentData, storageConfig) { const uspString = uspDataHandler.getConsentData(); - const gdprApplies = Boolean(gdprConsentData?.gdprApplies); const coppaValue = coppaDataHandler.getCoppa(); const gppConsent = gppDataHandler.getConsentData(); const params = { pid, - gdpr: Number(gdprApplies), + gdpr: 0, src: CALLER_NAME, ver: '$prebid.version$', coppa: Number(coppaValue) @@ -63,9 +72,49 @@ function calculateQueryStringParams(pid, gdprConsentData) { params.gdpr_consent = gdprConsentData.consentString; } + const fp = getStoredValue(STORAGE_FPID_KEY, storageConfig); + if (fp) { + params.fp = fp; + } + return params; } +function deleteFromStorage(key) { + if (storage.cookiesAreEnabled()) { + const expiredDate = new Date(0).toUTCString(); + + storage.setCookie(key, '', expiredDate, 'Lax'); + } + + storage.removeDataFromLocalStorage(key); +} + +function storeValue(key, value, storageConfig = {}) { + if (storageConfig.type === STORAGE_TYPE_COOKIES && storage.cookiesAreEnabled()) { + const expirationInMs = 60 * 60 * 24 * 1000 * storageConfig.expires; + const expirationTime = new Date(Date.now() + expirationInMs); + + storage.setCookie(key, value, expirationTime.toUTCString(), 'Lax'); + } else if (storageConfig.type === STORAGE_TYPE_LOCALSTORAGE) { + storage.setDataInLocalStorage(key, value); + } +} + +function getStoredValue(key, storageConfig = {}) { + if (storageConfig.type === STORAGE_TYPE_COOKIES && storage.cookiesAreEnabled()) { + return storage.getCookie(key); + } else if (storageConfig.type === STORAGE_TYPE_LOCALSTORAGE) { + return storage.getDataFromLocalStorage(key); + } +} + +function handleFpId(fpId, storageConfig = {}) { + fpId + ? storeValue(STORAGE_FPID_KEY, fpId, storageConfig) + : deleteFromStorage(STORAGE_FPID_KEY); +} + /** @type {Submodule} */ export const thirthyThreeAcrossIdSubmodule = { /** @@ -74,7 +123,7 @@ export const thirthyThreeAcrossIdSubmodule = { */ name: MODULE_NAME, - gvlid: 58, + gvlid: GVLID, /** * decode the stored id value for passing to bid requests @@ -96,34 +145,49 @@ export const thirthyThreeAcrossIdSubmodule = { * @param {SubmoduleConfig} [config] * @returns {IdResponse|undefined} */ - getId({ params = { } }, gdprConsentData) { + getId({ params = { }, storage: storageConfig }, gdprConsentData) { if (typeof params.pid !== 'string') { logError(`${MODULE_NAME}: Submodule requires a partner ID to be defined`); return; } - const { pid, apiUrl = API_URL } = params; + if (gdprConsentData?.gdprApplies === true) { + logWarn(`${MODULE_NAME}: Submodule cannot be used where GDPR applies`); + + return; + } + + const { pid, storeFpid, apiUrl = API_URL } = params; return { callback(cb) { ajaxBuilder(AJAX_TIMEOUT)(apiUrl, { success(response) { - let envelope; + let responseObj = { }; try { - envelope = getEnvelope(JSON.parse(response)) + responseObj = calculateResponseObj(JSON.parse(response)); } catch (err) { logError(`${MODULE_NAME}: ID reading error:`, err); } - cb(envelope); + + if (!responseObj.envelope) { + deleteFromStorage(MODULE_NAME); + } + + if (storeFpid) { + handleFpId(responseObj.fp, storageConfig); + } + + cb(responseObj.envelope); }, error(err) { logError(`${MODULE_NAME}: ID error response`, err); cb(); } - }, calculateQueryStringParams(pid, gdprConsentData), { method: 'GET', withCredentials: true }); + }, calculateQueryStringParams(pid, gdprConsentData, storageConfig), { method: 'GET', withCredentials: true }); } }; }, diff --git a/modules/33acrossIdSystem.md b/modules/33acrossIdSystem.md index 1e4af89344f..930d0c8c824 100644 --- a/modules/33acrossIdSystem.md +++ b/modules/33acrossIdSystem.md @@ -51,3 +51,4 @@ The following settings are available in the `params` property in `userSync.userI | Param name | Scope | Type | Description | Example | | --- | --- | --- | --- | --- | | pid | Required | String | Partner ID provided by 33Across | `"0010b00002GYU4eBAH"` | +| storeFpid | Optional | Boolean | Indicates whether a supplemental first-party ID may be stored to improve addressability | `false` (default) or `true` | diff --git a/test/spec/modules/33acrossIdSystem_spec.js b/test/spec/modules/33acrossIdSystem_spec.js index 4f6d7c4a6c5..a54c4590f3f 100644 --- a/test/spec/modules/33acrossIdSystem_spec.js +++ b/test/spec/modules/33acrossIdSystem_spec.js @@ -1,4 +1,4 @@ -import { thirthyThreeAcrossIdSubmodule } from 'modules/33acrossIdSystem.js'; +import { thirthyThreeAcrossIdSubmodule, storage } from 'modules/33acrossIdSystem.js'; import * as utils from 'src/utils.js'; import { server } from 'test/mocks/xhr.js'; @@ -50,60 +50,300 @@ describe('33acrossIdSystem', () => { expect(completeCallback.calledOnceWithExactly('foo')).to.be.true; }); - context('when GDPR applies', () => { - it('should call endpoint with \'gdpr=1\'', () => { + context('if the use of a first-party ID has been enabled', () => { + context('and the response includes a first-party ID', () => { + context('and the storage type is "cookie"', () => { + it('should store the provided first-party ID in a cookie', () => { + const completeCallback = () => {}; + + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + params: { + pid: '12345', + storeFpid: true + }, + storage: { + type: 'cookie', + expires: 90 + } + }); + + callback(completeCallback); + + const [request] = server.requests; + + const setCookie = sinon.stub(storage, 'setCookie'); + const cookiesAreEnabled = sinon.stub(storage, 'cookiesAreEnabled').returns(true); + + request.respond(200, { + 'Content-Type': 'application/json' + }, JSON.stringify({ + succeeded: true, + data: { + envelope: 'foo', + fp: 'bar' + }, + expires: 1645667805067 + })); + + expect(setCookie.calledOnceWithExactly('33acrossIdFp', 'bar', sinon.match.string, 'Lax')).to.be.true; + + setCookie.restore(); + cookiesAreEnabled.restore(); + }); + }); + + context('and the storage type is "html5"', () => { + it('should store the provided first-party ID in local storage', () => { + const completeCallback = () => {}; + + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + params: { + pid: '12345', + storeFpid: true + }, + storage: { + type: 'html5' + } + }); + + callback(completeCallback); + + const [request] = server.requests; + + const setDataInLocalStorage = sinon.stub(storage, 'setDataInLocalStorage'); + + request.respond(200, { + 'Content-Type': 'application/json' + }, JSON.stringify({ + succeeded: true, + data: { + envelope: 'foo', + fp: 'bar' + }, + expires: 1645667805067 + })); + + expect(setDataInLocalStorage.calledOnceWithExactly('33acrossIdFp', 'bar')).to.be.true; + + setDataInLocalStorage.restore(); + }); + }); + }); + + context('and the response lacks a first-party ID', () => { + it('should wipe any existing first-party ID from storage', () => { + const completeCallback = () => {}; + + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + params: { + pid: '12345', + storeFpid: true + }, + storage: { + type: 'html5' + } + }); + + callback(completeCallback); + + const [request] = server.requests; + + const removeDataFromLocalStorage = sinon.stub(storage, 'removeDataFromLocalStorage'); + const setCookie = sinon.stub(storage, 'setCookie'); + const cookiesAreEnabled = sinon.stub(storage, 'cookiesAreEnabled').returns(true); + + request.respond(200, { + 'Content-Type': 'application/json' + }, JSON.stringify({ + succeeded: true, + data: { + envelope: 'foo' // no 'fp' field + }, + expires: 1645667805067 + })); + + expect(removeDataFromLocalStorage.calledOnceWithExactly('33acrossIdFp')).to.be.true; + expect(setCookie.calledOnceWithExactly('33acrossIdFp', '', sinon.match.string, 'Lax')).to.be.true; + + removeDataFromLocalStorage.restore(); + setCookie.restore(); + cookiesAreEnabled.restore(); + }); + }); + }); + + context('if the use of a first-party ID has been disabled (default value)', () => { + context('and the response includes a first-party ID', () => { + it('should not store the provided first-party ID in a cookie', () => { + const completeCallback = () => {}; + + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + params: { + pid: '12345' + // no storeFpid param + }, + storage: { + type: 'cookie', + expires: 90 + } + }); + + callback(completeCallback); + + const [request] = server.requests; + + const setCookie = sinon.stub(storage, 'setCookie'); + const cookiesAreEnabled = sinon.stub(storage, 'cookiesAreEnabled').returns(true); + + request.respond(200, { + 'Content-Type': 'application/json' + }, JSON.stringify({ + succeeded: true, + data: { + envelope: 'foo', + fp: 'bar' + }, + expires: 1645667805067 + })); + + expect(setCookie.calledOnceWithExactly('33acrossIdFp', 'bar', sinon.match.string, 'Lax')).to.be.false; + + setCookie.restore(); + cookiesAreEnabled.restore(); + }); + + it('should not store the provided first-party ID in local storage', () => { + const completeCallback = () => {}; + + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + params: { + pid: '12345' + // no storeFpid param + }, + storage: { + type: 'html5' + } + }); + + callback(completeCallback); + + const [request] = server.requests; + + const setDataInLocalStorage = sinon.stub(storage, 'setDataInLocalStorage'); + + request.respond(200, { + 'Content-Type': 'application/json' + }, JSON.stringify({ + succeeded: true, + data: { + envelope: 'foo', + fp: 'bar' + }, + expires: 1645667805067 + })); + + expect(setDataInLocalStorage.calledOnceWithExactly('33acrossIdFp', 'bar')).to.be.false; + + setDataInLocalStorage.restore(); + }); + }); + }); + + context('if the response lacks the 33across "envelope" ID', () => { + it('should wipe any existing "envelope" ID from storage', () => { const completeCallback = () => {}; + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ params: { pid: '12345' + }, + storage: { + type: 'html5' } - }, { - gdprApplies: true }); callback(completeCallback); const [request] = server.requests; - expect(request.url).to.contain('gdpr=1'); + const removeDataFromLocalStorage = sinon.stub(storage, 'removeDataFromLocalStorage'); + const setCookie = sinon.stub(storage, 'setCookie'); + const cookiesAreEnabled = sinon.stub(storage, 'cookiesAreEnabled').returns(true); + + request.respond(200, { + 'Content-Type': 'application/json' + }, JSON.stringify({ + succeeded: true, + data: { + envelope: '' // no 'envelope' field + }, + expires: 1645667805067 + })); + + expect(removeDataFromLocalStorage.calledWith('33acrossId')).to.be.true; + expect(setCookie.calledWith('33acrossId', '', sinon.match.string, 'Lax')).to.be.true; + + removeDataFromLocalStorage.restore(); + setCookie.restore(); + cookiesAreEnabled.restore(); }); }); - context('when GDPR doesn\'t apply', () => { - it('should call endpoint with \'gdpr=0\'', () => { - const completeCallback = () => {}; - const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + context('when GDPR applies', () => { + it('should log a warning and don\'t expect a call to the endpoint', () => { + const logWarnSpy = sinon.spy(utils, 'logWarn'); + + const result = thirthyThreeAcrossIdSubmodule.getId({ params: { pid: '12345' } }, { - gdprApplies: false + gdprApplies: true }); - callback(completeCallback); - - const [request] = server.requests; + expect(logWarnSpy.calledOnceWithExactly('33acrossId: Submodule cannot be used where GDPR applies')).to.be.true; + expect(result).to.be.undefined; - expect(request.url).to.contain('gdpr=0'); + logWarnSpy.restore(); }); }); - context('when the GDPR consent string is given', () => { - it('should call endpoint with the GDPR consent string', () => { + context('when GDPR doesn\'t apply', () => { + it('should call endpoint with \'gdpr=0\'', () => { const completeCallback = () => {}; const { callback } = thirthyThreeAcrossIdSubmodule.getId({ params: { pid: '12345' } }, { - consentString: 'foo' + gdprApplies: false }); callback(completeCallback); const [request] = server.requests; - expect(request.url).to.contain('gdpr_consent=foo'); + expect(request.url).to.contain('gdpr=0'); + }); + + context('but the GDPR consent string is given', () => { + it('should call endpoint with the GDPR consent string', () => { + const completeCallback = () => {}; + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + params: { + pid: '12345' + } + }, { + gdprApplies: false, + consentString: 'foo' + }); + + callback(completeCallback); + + const [request] = server.requests; + + expect(request.url).to.contain('gdpr_consent=foo'); + }); }); }); @@ -252,6 +492,75 @@ describe('33acrossIdSystem', () => { }); }); + context('when a first-party ID is present in local storage', () => { + it('should call endpoint with the first-party ID included', () => { + const completeCallback = () => {}; + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + params: { + pid: '12345' + }, + storage: { + type: 'html5' + } + }); + + sinon.stub(storage, 'getDataFromLocalStorage') + .withArgs('33acrossIdFp') + .returns('33acrossIdFpValue'); + + callback(completeCallback); + + const [request] = server.requests; + + expect(request.url).to.contain('fp=33acrossIdFpValue'); + + storage.getDataFromLocalStorage.restore(); + }); + }); + + context('when a first-party ID is present in cookie storage', () => { + it('should call endpoint with the first-party ID included', () => { + const completeCallback = () => {}; + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + params: { + pid: '12345' + }, + storage: { + type: 'cookie' + } + }); + + sinon.stub(storage, 'getCookie') + .withArgs('33acrossIdFp') + .returns('33acrossIdFpValue'); + + callback(completeCallback); + + const [request] = server.requests; + + expect(request.url).to.contain('fp=33acrossIdFpValue'); + + storage.getCookie.restore(); + }); + }); + + context('when a first-party ID is not present in storage', () => { + it('should not call endpoint with the first-party ID included', () => { + const completeCallback = () => {}; + const { callback } = thirthyThreeAcrossIdSubmodule.getId({ + params: { + pid: '12345' + } + }); + + callback(completeCallback); + + const [request] = server.requests; + + expect(request.url).not.to.contain('fp='); + }); + }); + context('when the partner ID is not given', () => { it('should log an error', () => { const logErrorSpy = sinon.spy(utils, 'logError');