diff --git a/modules/userId/index.js b/modules/userId/index.js index 8fdd4319dfc..9e9eb5057bc 100644 --- a/modules/userId/index.js +++ b/modules/userId/index.js @@ -588,20 +588,28 @@ function idSystemInitializer({delay = GreedyPromise.timeout} = {}) { return gdprDataHandler.promise.finally(initMetrics.startTiming('userId.init.gdpr')); } - let done = cancelAndTry( - GreedyPromise.all([hooksReady, startInit.promise]) - .then(timeGdpr) - .then(checkRefs((consentData) => { - initSubmodules(initModules, allModules, consentData); - })) - .then(() => startCallbacks.promise.finally(initMetrics.startTiming('userId.callbacks.pending'))) - .then(checkRefs(() => { - const modWithCb = initModules.filter(item => isFn(item.callback)); - if (modWithCb.length) { - return new GreedyPromise((resolve) => processSubmoduleCallbacks(modWithCb, resolve)); - } - })) - ); + let done = GreedyPromise.resolve(); + + function loadIds() { + done = cancelAndTry( + done.catch(() => null) + .then(() => GreedyPromise.all([hooksReady, startInit.promise])) + .then(timeGdpr) + .then(checkRefs((consentData) => { + initSubmodules(initModules, allModules, consentData); + })) + .then(() => startCallbacks.promise.finally(initMetrics.startTiming('userId.callbacks.pending'))) + .then(checkRefs(() => { + const modWithCb = initModules.filter(item => isFn(item.callback)); + if (modWithCb.length) { + return new GreedyPromise((resolve) => processSubmoduleCallbacks(modWithCb, resolve)); + } + })) + ); + } + + loadIds(); + gdprDataHandler.onConsentChange(loadIds); /** * with `ready` = true, starts initialization; with `refresh` = true, reinitialize submodules (optionally diff --git a/src/consentHandler.js b/src/consentHandler.js index 861a9894a2c..4a56cb4c402 100644 --- a/src/consentHandler.js +++ b/src/consentHandler.js @@ -1,11 +1,13 @@ -import {isStr, timestamp} from './utils.js'; +import {isStr, timestamp, deepEqual, logError} from './utils.js'; import {defer, GreedyPromise} from './utils/promise.js'; export class ConsentHandler { #enabled; #data; + #hasData = false; #defer; #ready; + #listeners; generatedTime; constructor() { @@ -14,8 +16,19 @@ export class ConsentHandler { #resolve(data) { this.#ready = true; + const hasChanged = !this.#hasData || !deepEqual(this.#data, data); this.#data = data; + this.#hasData = true; this.#defer.resolve(data); + if (hasChanged) { + this.#listeners.forEach(cb => { + try { + cb(data) + } catch (e) { + logError(e); + } + }) + } } /** @@ -27,6 +40,15 @@ export class ConsentHandler { this.#data = null; this.#ready = false; this.generatedTime = null; + this.#listeners = []; + } + + /** + * Register a callback to run each time consent data changes. + * @param {(consentData) => any} fn + */ + onConsentChange(fn) { + this.#listeners.push(fn); } /** diff --git a/test/helpers/consentData.js b/test/helpers/consentData.js index c708e397bd6..841e19ffe52 100644 --- a/test/helpers/consentData.js +++ b/test/helpers/consentData.js @@ -2,9 +2,14 @@ import {gdprDataHandler} from 'src/adapterManager.js'; import {GreedyPromise} from '../../src/utils/promise.js'; export function mockGdprConsent(sandbox, getConsentData = () => null) { - sandbox.stub(gdprDataHandler, 'enabled').get(() => true) - sandbox.stub(gdprDataHandler, 'promise').get(() => GreedyPromise.resolve(getConsentData())); - sandbox.stub(gdprDataHandler, 'getConsentData').callsFake(getConsentData) + const s1 = sandbox.stub(gdprDataHandler, 'enabled').get(() => true) + const s2 = sandbox.stub(gdprDataHandler, 'promise').get(() => GreedyPromise.resolve(getConsentData())); + const s3 = sandbox.stub(gdprDataHandler, 'getConsentData').callsFake(getConsentData) + return function unMock() { + s1.restore(); + s2.restore(); + s3.restore(); + } } beforeEach(() => { diff --git a/test/spec/modules/userId_spec.js b/test/spec/modules/userId_spec.js index f4abcc4b9f6..e4c6891b967 100644 --- a/test/spec/modules/userId_spec.js +++ b/test/spec/modules/userId_spec.js @@ -53,6 +53,8 @@ import {hook} from '../../../src/hook.js'; import {mockGdprConsent} from '../../helpers/consentData.js'; import {getPPID} from '../../../src/adserver.js'; import {uninstall as uninstallGdprEnforcement} from 'modules/gdprEnforcement.js'; +import {gdprDataHandler} from '../../../src/adapterManager.js'; +import {GreedyPromise} from '../../../src/utils/promise.js'; let assert = require('chai').assert; let expect = require('chai').expect; @@ -149,6 +151,8 @@ describe('User ID', function () { localStorage.removeItem(PBJS_USER_ID_OPTOUT_NAME); }); + let restoreGdprConsent; + beforeEach(function () { // TODO: this whole suite needs to be redesigned; it is passing by accident // some tests do not pass if consent data is available @@ -157,7 +161,7 @@ describe('User ID', function () { resetConsentData(); sandbox = sinon.sandbox.create(); consentData = null; - mockGdprConsent(sandbox, () => consentData); + restoreGdprConsent = mockGdprConsent(sandbox, () => consentData); coreStorage.setCookie(CONSENT_LOCAL_STORAGE_NAME, '', EXPIRED_COOKIE_DATE); }); @@ -985,6 +989,7 @@ describe('User ID', function () { let adUnits; let mockIdCallback; let auctionSpy; + let mockIdSystem; beforeEach(function () { sandbox = sinon.createSandbox(); @@ -998,7 +1003,7 @@ describe('User ID', function () { auctionSpy = sandbox.spy(); mockIdCallback = sandbox.stub(); - const mockIdSystem = { + mockIdSystem = { name: 'mockId', decode: function (value) { return { @@ -1023,6 +1028,30 @@ describe('User ID', function () { sandbox.restore(); }); + it('waits for GDPR if it was enabled after userId', () => { + restoreGdprConsent(); + mockIdSystem.getId = function (_, consent) { + if (consent?.given) { + return {id: {MOCKID: 'valid'}}; + } else { + return {id: {MOCKID: 'invalid'}}; + } + } + config.setConfig({ + userSync: { + auctionDelay: 0, + userIds: [{ + name: 'mockId', storage: {name: 'MOCKID', type: 'cookie'} + }] + } + }); + const consent = {given: true}; + gdprDataHandler.setConsentData(consent); + return expectImmediateBidHook(auctionSpy, {adUnits}).then(() => { + expect(adUnits[0].bids[0].userId.mid).to.eql('valid'); + }) + }) + it('delays auction if auctionDelay is set, timing out at auction delay', function () { config.setConfig({ userSync: { diff --git a/test/spec/unit/core/consentHandler_spec.js b/test/spec/unit/core/consentHandler_spec.js index 082ff34f90c..9d5170eb667 100644 --- a/test/spec/unit/core/consentHandler_spec.js +++ b/test/spec/unit/core/consentHandler_spec.js @@ -56,4 +56,38 @@ describe('Consent data handler', () => { }) }) }); + + it('should run onConsentChange listeners when consent data changes', () => { + handler.setConsentData({consent: 'data'}); + const listener = sinon.stub(); + handler.onConsentChange(listener); + handler.setConsentData({consent: 'data'}); + sinon.assert.notCalled(listener); + const newConsent = {other: 'data'}; + handler.setConsentData(newConsent); + sinon.assert.calledWith(listener, newConsent); + }); + + it('should not choke if listener throws', () => { + handler.onConsentChange(sinon.stub().throws(new Error())); + const listener = sinon.stub(); + handler.onConsentChange(listener); + const consent = {consent: 'data'}; + handler.setConsentData(consent); + sinon.assert.calledWith(listener, consent); + }); + + Object.entries({ + 'undefined': undefined, + 'null': null + }).forEach(([t, val]) => { + it(`should run onConsentChange when consent is first set to ${t}`, () => { + const listener = sinon.stub(); + handler.onConsentChange(listener); + handler.setConsentData(val); + handler.setConsentData(val); + sinon.assert.calledOnce(listener); + sinon.assert.calledWith(listener, val); + }) + }) })