diff --git a/modules/consentManagementUsp.js b/modules/consentManagementUsp.js index 76bc7982be7..a90342cf885 100644 --- a/modules/consentManagementUsp.js +++ b/modules/consentManagementUsp.js @@ -6,7 +6,7 @@ */ import {isFn, isNumber, isPlainObject, isStr, logError, logInfo, logWarn} from '../src/utils.js'; import {config} from '../src/config.js'; -import {uspDataHandler} from '../src/adapterManager.js'; +import adapterManager, {uspDataHandler} from '../src/adapterManager.js'; import {timedAuctionHook} from '../src/utils/perfMetrics.js'; import {getHook} from '../src/hook.js'; @@ -114,6 +114,11 @@ function lookupUspConsent({onSuccess, onError}) { USPAPI_VERSION, callbackHandler.consentDataCallback ); + uspapiFunction( + 'registerDeletion', + USPAPI_VERSION, + adapterManager.callDataDeletionRequest + ) } else { logInfo( 'Detected USP CMP is outside the current iframe where Prebid.js is located, calling it now...' @@ -123,12 +128,17 @@ function lookupUspConsent({onSuccess, onError}) { uspapiFrame, callbackHandler.consentDataCallback ); + callUspApiWhileInIframe( + 'registerDeletion', + uspapiFrame, + adapterManager.callDataDeletionRequest + ); } + let listening = false; + function callUspApiWhileInIframe(commandName, uspapiFrame, moduleCallback) { - /* Setup up a __uspapi function to do the postMessage and stash the callback. - This function behaves, from the caller's perspective, identicially to the in-frame __uspapi call (although it is not synchronous) */ - window.__uspapi = function (cmd, ver, callback) { + function callUsp(cmd, ver, callback) { let callId = Math.random() + ''; let msg = { __uspapiCall: { @@ -143,10 +153,13 @@ function lookupUspConsent({onSuccess, onError}) { }; /** when we get the return message, call the stashed callback */ - window.addEventListener('message', readPostMessageResponse, false); + if (!listening) { + window.addEventListener('message', readPostMessageResponse, false); + listening = true; + } // call uspapi - window.__uspapi(commandName, USPAPI_VERSION, moduleCallback); + callUsp(commandName, USPAPI_VERSION, moduleCallback); function readPostMessageResponse(event) { const res = event && event.data && event.data.__uspapiReturn; diff --git a/modules/userId/index.js b/modules/userId/index.js index ca4a67019ec..27453c331b0 100644 --- a/modules/userId/index.js +++ b/modules/userId/index.js @@ -129,7 +129,7 @@ import {find, includes} from '../../src/polyfill.js'; import {config} from '../../src/config.js'; import * as events from '../../src/events.js'; import {getGlobal} from '../../src/prebidGlobal.js'; -import {gdprDataHandler} from '../../src/adapterManager.js'; +import adapterManager, {gdprDataHandler} from '../../src/adapterManager.js'; import CONSTANTS from '../../src/constants.json'; import {hook, module, ready as hooksReady} from '../../src/hook.js'; import {buildEidPermissions, createEidsArray, USER_IDS_CONFIG} from './eids.js'; @@ -216,6 +216,14 @@ export function setSubmoduleRegistry(submodules) { submoduleRegistry = submodules; } +function cookieSetter(submodule) { + const domainOverride = (typeof submodule.submodule.domainOverride === 'function') ? submodule.submodule.domainOverride() : null; + const name = submodule.config.storage.name; + return function setCookie(suffix, value, expiration) { + coreStorage.setCookie(name + (suffix || ''), value, expiration, 'Lax', domainOverride); + } +} + /** * @param {SubmoduleContainer} submodule * @param {(Object|string)} value @@ -225,15 +233,15 @@ export function setStoredValue(submodule, value) { * @type {SubmoduleStorage} */ const storage = submodule.config.storage; - const domainOverride = (typeof submodule.submodule.domainOverride === 'function') ? submodule.submodule.domainOverride() : null; try { - const valueStr = isPlainObject(value) ? JSON.stringify(value) : value; const expiresStr = (new Date(Date.now() + (storage.expires * (60 * 60 * 24 * 1000)))).toUTCString(); + const valueStr = isPlainObject(value) ? JSON.stringify(value) : value; if (storage.type === COOKIE) { - coreStorage.setCookie(storage.name, valueStr, expiresStr, 'Lax', domainOverride); + const setCookie = cookieSetter(submodule); + setCookie(null, value, expiresStr); if (typeof storage.refreshInSeconds === 'number') { - coreStorage.setCookie(`${storage.name}_last`, new Date().toUTCString(), expiresStr, 'Lax', domainOverride); + setCookie('_last', new Date().toUTCString(), expiresStr); } } else if (storage.type === LOCAL_STORAGE) { coreStorage.setDataInLocalStorage(`${storage.name}_exp`, expiresStr); @@ -247,6 +255,31 @@ export function setStoredValue(submodule, value) { } } +export function deleteStoredValue(submodule) { + let deleter, suffixes; + switch (submodule.config?.storage?.type) { + case COOKIE: + const setCookie = cookieSetter(submodule); + const expiry = (new Date(Date.now() - 1000 * 60 * 60 * 24)).toUTCString(); + deleter = (suffix) => setCookie(suffix, '', expiry) + suffixes = ['', '_last']; + break; + case LOCAL_STORAGE: + deleter = (suffix) => coreStorage.removeDataFromLocalStorage(submodule.config.storage.name + suffix) + suffixes = ['', '_last', '_exp']; + break; + } + if (deleter) { + suffixes.forEach(suffix => { + try { + deleter(suffix) + } catch (e) { + logError(e); + } + }); + } +} + function setPrebidServerEidPermissions(initializedSubmodules) { let setEidPermissions = getPrebidInternal().setEidPermissions; if (typeof setEidPermissions === 'function' && isArray(initializedSubmodules)) { @@ -1002,12 +1035,28 @@ function updateSubmodules() { if (!addedUserIdHook && submodules.length) { // priority value 40 will load after consentManagement with a priority of 50 getGlobal().requestBids.before(requestBidsHook, 40); + adapterManager.callDataDeletionRequest.before(requestDataDeletion); coreGetPPID.after((next) => next(getPPID())); logInfo(`${MODULE_NAME} - usersync config updated for ${submodules.length} submodules: `, submodules.map(a => a.submodule.name)); addedUserIdHook = true; } } +export function requestDataDeletion(next, ...args) { + logInfo('UserID: received data deletion request; deleting all stored IDs...') + submodules.forEach(submodule => { + if (typeof submodule.submodule.onDataDeletionRequest === 'function') { + try { + submodule.submodule.onDataDeletionRequest(submodule.config, submodule.idObj, ...args); + } catch (e) { + logError(`Error calling onDataDeletionRequest for ID submodule ${submodule.submodule.name}`, e); + } + } + deleteStoredValue(submodule); + }) + next.apply(this, args); +} + /** * enable submodule in User ID * @param {Submodule} submodule diff --git a/src/adapterManager.js b/src/adapterManager.js index 4722013d5f0..eb6a74a82a4 100644 --- a/src/adapterManager.js +++ b/src/adapterManager.js @@ -35,6 +35,7 @@ import {GdprConsentHandler, UspConsentHandler} from './consentHandler.js'; import * as events from './events.js'; import CONSTANTS from './constants.json'; import {useMetrics} from './utils/perfMetrics.js'; +import {auctionManager} from './auctionManager.js'; export const PARTITIONS = { CLIENT: 'client', @@ -553,19 +554,30 @@ adapterManager.getAnalyticsAdapter = function(code) { return _analyticsRegistry[code]; } -function tryCallBidderMethod(bidder, method, param) { +function getBidderMethod(bidder, method) { + const adapter = _bidderRegistry[bidder]; + const spec = adapter?.getSpec && adapter.getSpec(); + if (spec && spec[method] && typeof spec[method] === 'function') { + return [spec, spec[method]] + } +} + +function invokeBidderMethod(bidder, method, spec, fn, ...params) { try { - const adapter = _bidderRegistry[bidder]; - const spec = adapter.getSpec(); - if (spec && spec[method] && typeof spec[method] === 'function') { - logInfo(`Invoking ${bidder}.${method}`); - config.runWithBidder(bidder, bind.call(spec[method], spec, param)); - } + logInfo(`Invoking ${bidder}.${method}`); + config.runWithBidder(bidder, fn.bind(spec, ...params)); } catch (e) { logWarn(`Error calling ${method} of ${bidder}`); } } +function tryCallBidderMethod(bidder, method, param) { + const target = getBidderMethod(bidder, method); + if (target != null) { + invokeBidderMethod(bidder, method, ...target, param); + } +} + adapterManager.callTimedOutBidders = function(adUnits, timedOutBidders, cbTimeout) { timedOutBidders = timedOutBidders.map((timedOutBidder) => { // Adding user configured params & timeout to timeout event data @@ -600,4 +612,41 @@ adapterManager.callBidderError = function(bidder, error, bidderRequest) { tryCallBidderMethod(bidder, 'onBidderError', param); }; +function resolveAlias(alias) { + const seen = new Set(); + while (_aliasRegistry.hasOwnProperty(alias) && !seen.has(alias)) { + seen.add(alias); + alias = _aliasRegistry[alias]; + } + return alias; +} +/** + * Ask every adapter to delete PII. + * See https://github.com/prebid/Prebid.js/issues/9081 + */ +adapterManager.callDataDeletionRequest = hook('sync', function (...args) { + const method = 'onDataDeletionRequest'; + Object.keys(_bidderRegistry) + .filter((bidder) => !_aliasRegistry.hasOwnProperty(bidder)) + .forEach(bidder => { + const target = getBidderMethod(bidder, method); + if (target != null) { + const bidderRequests = auctionManager.getBidsRequested().filter((br) => + resolveAlias(br.bidderCode) === bidder + ); + invokeBidderMethod(bidder, method, ...target, bidderRequests, ...args); + } + }); + Object.entries(_analyticsRegistry).forEach(([name, entry]) => { + const fn = entry?.adapter?.[method]; + if (typeof fn === 'function') { + try { + fn.apply(entry.adapter, args); + } catch (e) { + logError(`error calling ${method} of ${name}`, e); + } + } + }); +}); + export default adapterManager; diff --git a/test/spec/modules/consentManagementUsp_spec.js b/test/spec/modules/consentManagementUsp_spec.js index 39aeee4470b..f9c3cd5890e 100644 --- a/test/spec/modules/consentManagementUsp_spec.js +++ b/test/spec/modules/consentManagementUsp_spec.js @@ -8,8 +8,9 @@ import { } from 'modules/consentManagementUsp.js'; import * as utils from 'src/utils.js'; import { config } from 'src/config.js'; -import {uspDataHandler} from 'src/adapterManager.js'; +import adapterManager, {uspDataHandler} from 'src/adapterManager.js'; import 'src/prebid.js'; +import {defer} from '../../../src/utils/promise.js'; let expect = require('chai').expect; @@ -23,6 +24,16 @@ function createIFrameMarker() { } describe('consentManagement', function () { + let sandbox; + beforeEach(() => { + sandbox = sinon.sandbox.create(); + sandbox.stub(adapterManager, 'callDataDeletionRequest'); + }); + + afterEach(() => { + sandbox.restore(); + }); + it('should enable itself on requestBids using default values', (done) => { requestBidsHook(() => { expect(uspDataHandler.enabled).to.be.true; @@ -301,8 +312,11 @@ describe('consentManagement', function () { describe('USPAPI workflow for iframed page', function () { let ifr = null; let stringifyResponse = false; + let mockApi, replySent; beforeEach(function () { + mockApi = sinon.stub(); + replySent = defer(); sinon.stub(utils, 'logError'); sinon.stub(utils, 'logWarn'); ifr = createIFrameMarker(); @@ -322,17 +336,21 @@ describe('consentManagement', function () { function uspapiMessageHandler(event) { if (event && event.data) { - var data = event.data; + const data = event.data; if (data.__uspapiCall) { - var callId = data.__uspapiCall.callId; - var response = { - __uspapiReturn: { - callId, - returnValue: { uspString: '1YY' }, - success: true + const {command, version, callId} = data.__uspapiCall; + let response = mockApi(command, version, callId); + if (response) { + response = { + __uspapiReturn: { + callId, + returnValue: response, + success: true + } } - }; - event.source.postMessage(stringifyResponse ? JSON.stringify(response) : response, '*'); + event.source.postMessage(stringifyResponse ? JSON.stringify(response) : response, '*'); + replySent.resolve(); + } } } } @@ -345,6 +363,11 @@ describe('consentManagement', function () { function testIFramedPage(testName, messageFormatString) { it(`should return the consent string from a postmessage + addEventListener response - ${testName}`, (done) => { stringifyResponse = messageFormatString; + mockApi.callsFake((cmd) => { + if (cmd === 'getUSPData') { + return { uspString: '1YY' } + } + }) setConsentConfig(goodConfig); requestBidsHook(() => { let consent = uspDataHandler.getConsentData(); @@ -355,6 +378,20 @@ describe('consentManagement', function () { }, {}); }); } + + it('fires deletion request on registerDeletion', (done) => { + mockApi.callsFake((cmd) => { + return cmd === 'registerDeletion' + }) + sinon.assert.notCalled(adapterManager.callDataDeletionRequest); + setConsentConfig(goodConfig); + replySent.promise.then(() => { + setTimeout(() => { // defer again to give time for the message to get through + sinon.assert.calledOnce(adapterManager.callDataDeletionRequest); + done() + }, 200) + }) + }); }); describe('test without iframe locater', function() { @@ -400,23 +437,19 @@ describe('consentManagement', function () { }); describe('USPAPI workflow for normal pages:', function () { - let uspapiStub = sinon.stub(); let ifr = null; beforeEach(function () { didHookReturn = false; ifr = createIFrameMarker(); - sinon.stub(utils, 'logError'); - sinon.stub(utils, 'logWarn'); + sandbox.stub(utils, 'logError'); + sandbox.stub(utils, 'logWarn'); window.__uspapi = function() {}; }); afterEach(function () { config.resetConfig(); $$PREBID_GLOBAL$$.requestBids.removeAll(); - uspapiStub.restore(); - utils.logError.restore(); - utils.logWarn.restore(); document.body.removeChild(ifr); delete window.__uspapi; resetConsentData(); @@ -427,7 +460,7 @@ describe('consentManagement', function () { uspString: '1NY' }; - uspapiStub = sinon.stub(window, '__uspapi').callsFake((...args) => { + sandbox.stub(window, '__uspapi').callsFake((...args) => { args[2](testConsentData, true); }); @@ -448,7 +481,7 @@ describe('consentManagement', function () { uspString: '1NY' }; - uspapiStub = sinon.stub(window, '__uspapi').callsFake((...args) => { + sandbox.stub(window, '__uspapi').callsFake((...args) => { args[2](testConsentData, true); }); @@ -463,6 +496,19 @@ describe('consentManagement', function () { expect(consentMeta.usp).to.equal(testConsentData.uspString); expect(consentMeta.generatedAt).to.be.above(1644367751709); }); + + it('registers deletion request event listener', () => { + let listener; + sandbox.stub(window, '__uspapi').callsFake((cmd, _, cb) => { + if (cmd === 'registerDeletion') { + listener = cb; + } + }); + setConsentConfig(goodConfig); + sinon.assert.notCalled(adapterManager.callDataDeletionRequest); + listener(); + sinon.assert.calledOnce(adapterManager.callDataDeletionRequest); + }) }); }); }); diff --git a/test/spec/modules/userId_spec.js b/test/spec/modules/userId_spec.js index e6058673d41..3c2b3e38442 100644 --- a/test/spec/modules/userId_spec.js +++ b/test/spec/modules/userId_spec.js @@ -9,7 +9,7 @@ import { setSubmoduleRegistry, syncDelay, PBJS_USER_ID_OPTOUT_NAME, - findRootDomain, + findRootDomain, requestDataDeletion, } from 'modules/userId/index.js'; import {createEidsArray} from 'modules/userId/eids.js'; import {config} from 'src/config.js'; @@ -2668,6 +2668,72 @@ describe('User ID', function () { }); }); + describe('requestDataDeletion', () => { + function idMod(name, value) { + return { + name, + getId() { + return {id: value} + }, + decode(d) { + return {[name]: d} + }, + onDataDeletionRequest: sinon.stub() + } + } + let mod1, mod2, mod3, cfg1, cfg2, cfg3; + + beforeEach(() => { + init(config); + mod1 = idMod('id1', 'val1'); + mod2 = idMod('id2', 'val2'); + mod3 = idMod('id3', 'val3'); + cfg1 = getStorageMock('id1', 'id1', 'cookie'); + cfg2 = getStorageMock('id2', 'id2', 'html5'); + cfg3 = {name: 'id3', value: {id3: 'val3'}}; + setSubmoduleRegistry([mod1, mod2, mod3]); + config.setConfig({ + auctionDelay: 1, + userSync: { + userIds: [cfg1, cfg2, cfg3] + } + }); + return getGlobal().refreshUserIds(); + }); + + it('deletes stored IDs', () => { + expect(coreStorage.getCookie('id1')).to.exist; + expect(coreStorage.getDataFromLocalStorage('id2')).to.exist; + requestDataDeletion(sinon.stub()); + expect(coreStorage.getCookie('id1')).to.not.exist; + expect(coreStorage.getDataFromLocalStorage('id2')).to.not.exist; + }); + + it('invokes onDataDeletionRequest', () => { + requestDataDeletion(sinon.stub()); + sinon.assert.calledWith(mod1.onDataDeletionRequest, cfg1, {id1: 'val1'}); + sinon.assert.calledWith(mod2.onDataDeletionRequest, cfg2, {id2: 'val2'}) + sinon.assert.calledWith(mod3.onDataDeletionRequest, cfg3, {id3: 'val3'}) + }); + + describe('does not choke when onDataDeletionRequest', () => { + Object.entries({ + 'is missing': () => { delete mod1.onDataDeletionRequest }, + 'throws': () => { mod1.onDataDeletionRequest.throws(new Error()) } + }).forEach(([t, setup]) => { + it(t, () => { + setup(); + const next = sinon.stub(); + const arg = {random: 'value'}; + requestDataDeletion(next, arg); + sinon.assert.calledOnce(mod2.onDataDeletionRequest); + sinon.assert.calledOnce(mod3.onDataDeletionRequest); + sinon.assert.calledWith(next, arg); + }) + }) + }) + }); + describe('findRootDomain', function () { let sandbox; diff --git a/test/spec/unit/core/adapterManager_spec.js b/test/spec/unit/core/adapterManager_spec.js index 6e37da4e85f..3fea8988a95 100644 --- a/test/spec/unit/core/adapterManager_spec.js +++ b/test/spec/unit/core/adapterManager_spec.js @@ -20,6 +20,7 @@ import { setSizeConfig } from 'src/sizeMapping.js'; import {find, includes} from 'src/polyfill.js'; import s2sTesting from 'modules/s2sTesting.js'; import {hook} from '../../../../src/hook.js'; +import {auctionManager} from '../../../../src/auctionManager.js'; var events = require('../../../../src/events'); const CONFIG = { @@ -2543,4 +2544,105 @@ describe('adapterManager tests', function () { }) }); }); + + describe('callDataDeletionRequest', () => { + function delMethodForBidder(bidderCode) { + const del = sinon.stub(); + adapterManager.registerBidAdapter({ + callBids: sinon.stub(), + getSpec() { + return { + onDataDeletionRequest: del + } + } + }, bidderCode); + return del; + } + + function delMethodForAnalytics(provider) { + const del = sinon.stub(); + adapterManager.registerAnalyticsAdapter({ + code: provider, + adapter: { + enableAnalytics: sinon.stub(), + onDataDeletionRequest: del, + }, + }) + return del; + } + + Object.entries({ + 'bid adapters': delMethodForBidder, + 'analytics adapters': delMethodForAnalytics + }).forEach(([t, getDelMethod]) => { + describe(t, () => { + it('invokes onDataDeletionRequest', () => { + const del = getDelMethod('mockAdapter'); + adapterManager.callDataDeletionRequest(); + sinon.assert.calledOnce(del); + }); + + it('does not choke if onDeletionRequest throws', () => { + const del1 = getDelMethod('mockAdapter1'); + const del2 = getDelMethod('mockAdapter2'); + del1.throws(new Error()); + adapterManager.callDataDeletionRequest(); + sinon.assert.calledOnce(del1); + sinon.assert.calledOnce(del2); + }); + }) + }) + + describe('for bid adapters', () => { + let bidderRequests; + + beforeEach(() => { + bidderRequests = []; + sinon.stub(auctionManager, 'getBidsRequested').callsFake(() => bidderRequests); + }) + afterEach(() => { + auctionManager.getBidsRequested.restore(); + }) + + it('does not invoke onDataDeletionRequest on aliases', () => { + const del = delMethodForBidder('mockBidder'); + adapterManager.aliasBidAdapter('mockBidder', 'mockBidderAlias'); + adapterManager.aliasBidAdapter('mockBidderAlias2', 'mockBidderAlias'); + adapterManager.callDataDeletionRequest(); + sinon.assert.calledOnce(del); + }); + + it('passes known bidder requests', () => { + const del1 = delMethodForBidder('mockBidder1'); + const del2 = delMethodForBidder('mockBidder2'); + adapterManager.aliasBidAdapter('mockBidder1', 'mockBidder1Alias'); + adapterManager.aliasBidAdapter('mockBidder1Alias', 'mockBidder1Alias2') + bidderRequests = [ + { + bidderCode: 'mockBidder1', + id: 0 + }, + { + bidderCode: 'mockBidder2', + id: 1, + }, + { + bidderCode: 'mockBidder1Alias', + id: 2, + }, + { + bidderCode: 'someOtherBidder', + id: 3 + }, + { + bidderCode: 'mockBidder1Alias2', + id: 4 + } + ]; + adapterManager.callDataDeletionRequest(); + sinon.assert.calledWith(del1, [bidderRequests[0], bidderRequests[2], bidderRequests[4]]); + sinon.assert.calledWith(del2, [bidderRequests[1]]); + }) + }) + }); });