Skip to content

Commit

Permalink
Core & userId module: reload userIDs when GDPR consent changes (prebi…
Browse files Browse the repository at this point in the history
  • Loading branch information
dgirardi authored Nov 8, 2022
1 parent 8e023b1 commit 7046fc2
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 20 deletions.
36 changes: 22 additions & 14 deletions modules/userId/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion src/consentHandler.js
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -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);
}
})
}
}

/**
Expand All @@ -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);
}

/**
Expand Down
11 changes: 8 additions & 3 deletions test/helpers/consentData.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
33 changes: 31 additions & 2 deletions test/spec/modules/userId_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
});

Expand Down Expand Up @@ -985,6 +989,7 @@ describe('User ID', function () {
let adUnits;
let mockIdCallback;
let auctionSpy;
let mockIdSystem;

beforeEach(function () {
sandbox = sinon.createSandbox();
Expand All @@ -998,7 +1003,7 @@ describe('User ID', function () {

auctionSpy = sandbox.spy();
mockIdCallback = sandbox.stub();
const mockIdSystem = {
mockIdSystem = {
name: 'mockId',
decode: function (value) {
return {
Expand All @@ -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: {
Expand Down
34 changes: 34 additions & 0 deletions test/spec/unit/core/consentHandler_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})
})
})

0 comments on commit 7046fc2

Please sign in to comment.