From 1e9eff802d6f91beebfacb8537d7d28ba266210f Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Wed, 19 Jul 2023 15:00:54 -0700 Subject: [PATCH 1/6] add option to use callbacks in lieu of return values when talking to CMPs --- libraries/cmp/cmpClient.js | 13 +++++++++---- test/spec/libraries/cmp/cmpClient_spec.js | 21 +++++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/libraries/cmp/cmpClient.js b/libraries/cmp/cmpClient.js index 0e2336cae7a..cc5a06db534 100644 --- a/libraries/cmp/cmpClient.js +++ b/libraries/cmp/cmpClient.js @@ -6,17 +6,21 @@ import {GreedyPromise} from '../../src/utils/promise.js'; * @param {{}} params CMP parameters. Currently this is a subset of {command, callback, parameter, version}. * @returns {Promise<*>} a promise that: * - if a `callback` param was provided, resolves (with no result) just before the first time it's run; - * - if `callback` was *not* provided, resolves to the return value of the CMP command + * - if `callback` was *not* provided, resolves to the return value of the CMP command* * @property {boolean} isDirect true if the CMP is directly accessible (no postMessage required) */ /** - * Returns a function that can interface with a CMP regardless of where it's located. + * Returns a client function that can interface with a CMP regardless of where it's located. * * @param apiName name of the CMP api, e.g. "__gpp" * @param apiVersion? CMP API version * @param apiArgs? names of the arguments taken by the api function, in order. * @param callbackArgs? names of the cross-frame response payload properties that should be passed as callback arguments, in order + * @param cbReturns? if true, disregard return values from the underlying API function; instead, pass it a callback, + * and expect it to be invoked only once with what is essentially the API function's return value + * (and will likewise be used to fulfill promises returned by the client). + * This has no effect when the client is passed a "true" callback, nor when the API lives on a different frame. * @param win * @returns {CMPClient} CMP invocation function (or null if no CMP was found). */ @@ -26,6 +30,7 @@ export function cmpClient( apiVersion, apiArgs = ['command', 'callback', 'parameter', 'version'], callbackArgs = ['returnValue', 'success'], + cbReturns = false }, win = window ) { @@ -108,9 +113,9 @@ export function cmpClient( return new GreedyPromise((resolve, reject) => { const ret = cmpFrame[apiName](...resolveParams({ ...params, - callback: params.callback && wrapCallback(params.callback, resolve, reject) + callback: (params.callback || cbReturns) && wrapCallback(params.callback, resolve, reject) }).map(([_, val]) => val)); - if (params.callback == null) { + if (params.callback == null && !cbReturns) { resolve(ret); } }); diff --git a/test/spec/libraries/cmp/cmpClient_spec.js b/test/spec/libraries/cmp/cmpClient_spec.js index 56dd8e12605..bf6d7dd12be 100644 --- a/test/spec/libraries/cmp/cmpClient_spec.js +++ b/test/spec/libraries/cmp/cmpClient_spec.js @@ -64,8 +64,9 @@ describe('cmpClient', () => { }) Object.entries({ callback: [sinon.stub(), 'undefined', undefined], - 'no callback': [undefined, 'api return value', 'val'] - }).forEach(([t, [callback, tResult, expectedResult]]) => { + 'no callback': [undefined, 'api return value', 'val'], + 'no callback, but cbReturns = true': [undefined, 'callback arg', 'cbVal', true] + }).forEach(([t, [callback, tResult, expectedResult, cbReturns]]) => { describe(`when ${t} is provided`, () => { Object.entries({ 'no success flag': undefined, @@ -73,7 +74,7 @@ describe('cmpClient', () => { }).forEach(([t, success]) => { it(`resolves to ${tResult} (${t})`, (done) => { cbResult = ['cbVal', success]; - mkClient()({callback}).then((val) => { + mkClient({cbReturns})({callback}).then((val) => { expect(val).to.equal(expectedResult); done(); }) @@ -82,14 +83,22 @@ describe('cmpClient', () => { }) }); - it('rejects to undefined when callback is provided and success = false', () => { + it('rejects to undefined when callback is provided and success = false', (done) => { cbResult = ['cbVal', false]; mkClient()({callback: sinon.stub()}).catch(val => { - expect(val).to.equal('cbVal'); + expect(val).to.not.exist; done(); }) }); + it('rejects to callback arg when callback is NOT provided, success = false, cbReturns = true', (done) => { + cbResult = ['cbVal', false]; + mkClient({cbReturns: true})().catch(val => { + expect(val).to.eql('cbVal'); + done(); + }) + }) + it('rejects when CMP api throws', (done) => { mockApiFn.reset(); const e = new Error(); @@ -98,7 +107,7 @@ describe('cmpClient', () => { expect(val).to.equal(e); done(); }); - }) + }); }) it('should use apiArgs to choose and order the arguments to pass to the API fn', () => { From e843003958e6d8ad9c4f3361b369ae85059a4bbf Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Mon, 24 Jul 2023 11:05:47 -0700 Subject: [PATCH 2/6] Add `once` option to cmpClient --- libraries/cmp/cmpClient.js | 5 ++- test/spec/libraries/cmp/cmpClient_spec.js | 46 +++++++++++++++++------ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/libraries/cmp/cmpClient.js b/libraries/cmp/cmpClient.js index cc5a06db534..482f03abfe2 100644 --- a/libraries/cmp/cmpClient.js +++ b/libraries/cmp/cmpClient.js @@ -4,6 +4,7 @@ import {GreedyPromise} from '../../src/utils/promise.js'; * @typedef {function} CMPClient * * @param {{}} params CMP parameters. Currently this is a subset of {command, callback, parameter, version}. + * @param {bool} once if true, discard cross-frame event listeners once a reply message is received. * @returns {Promise<*>} a promise that: * - if a `callback` param was provided, resolves (with no result) just before the first time it's run; * - if `callback` was *not* provided, resolves to the return value of the CMP command* @@ -123,7 +124,7 @@ export function cmpClient( } else { win.addEventListener('message', handleMessage, false); - client = function invokeCMPFrame(params) { + client = function invokeCMPFrame(params, once = false) { return new GreedyPromise((resolve, reject) => { // call CMP via postMessage const callId = Math.random().toString(); @@ -134,7 +135,7 @@ export function cmpClient( } }; - cmpCallbacks[callId] = wrapCallback(params?.callback, resolve, reject, params?.callback == null && (() => { delete cmpCallbacks[callId] })); + cmpCallbacks[callId] = wrapCallback(params?.callback, resolve, reject, (once || params?.callback == null) && (() => { delete cmpCallbacks[callId] })); cmpFrame.postMessage(msg, '*'); }); }; diff --git a/test/spec/libraries/cmp/cmpClient_spec.js b/test/spec/libraries/cmp/cmpClient_spec.js index bf6d7dd12be..c30295eec81 100644 --- a/test/spec/libraries/cmp/cmpClient_spec.js +++ b/test/spec/libraries/cmp/cmpClient_spec.js @@ -224,18 +224,40 @@ describe('cmpClient', () => { }); }); - it('should re-use callback for messages with same callId', () => { - messenger.reset(); - let callId; - messenger.callsFake((msg) => { if (msg.mockApiCall) callId = msg.mockApiCall.callId }); - const callback = sinon.stub(); - mkClient()({callback}); - expect(callId).to.exist; - win.postMessage({mockApiReturn: {callId, returnValue: 'a'}}); - win.postMessage({mockApiReturn: {callId, returnValue: 'b'}}); - sinon.assert.calledWith(callback, 'a'); - sinon.assert.calledWith(callback, 'b'); - }) + describe('messages with same callID', () => { + let callback, callId; + + function runCallback(returnValue) { + win.postMessage({mockApiReturn: {callId, returnValue}}); + } + + beforeEach(() => { + callId = null; + messenger.reset(); + messenger.callsFake((msg) => { + if (msg.mockApiCall) callId = msg.mockApiCall.callId; + }); + callback = sinon.stub(); + }); + + it('should re-use callback for messages with same callId', () => { + mkClient()({callback}); + expect(callId).to.exist; + runCallback('a'); + runCallback('b'); + sinon.assert.calledWith(callback, 'a'); + sinon.assert.calledWith(callback, 'b'); + }); + + it('should NOT re-use callback if once = true', () => { + mkClient()({callback}, true); + expect(callId).to.exist; + runCallback('a'); + runCallback('b'); + sinon.assert.calledWith(callback, 'a'); + sinon.assert.calledOnce(callback); + }); + }); }); }); }); From 5420aa92d2cdab5ee45535c2bdcadfa69d8a1253 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Mon, 24 Jul 2023 15:07:08 -0700 Subject: [PATCH 3/6] introduce MODE_RETURN; add .close() method to CMP clients --- libraries/cmp/cmpClient.js | 60 +++++++++++++++------- modules/consentManagementGpp.js | 16 ++++++ test/spec/libraries/cmp/cmpClient_spec.js | 61 +++++++++++++++++------ 3 files changed, 105 insertions(+), 32 deletions(-) diff --git a/libraries/cmp/cmpClient.js b/libraries/cmp/cmpClient.js index 482f03abfe2..03a50c37bb3 100644 --- a/libraries/cmp/cmpClient.js +++ b/libraries/cmp/cmpClient.js @@ -5,10 +5,9 @@ import {GreedyPromise} from '../../src/utils/promise.js'; * * @param {{}} params CMP parameters. Currently this is a subset of {command, callback, parameter, version}. * @param {bool} once if true, discard cross-frame event listeners once a reply message is received. - * @returns {Promise<*>} a promise that: - * - if a `callback` param was provided, resolves (with no result) just before the first time it's run; - * - if `callback` was *not* provided, resolves to the return value of the CMP command* + * @returns {Promise<*>} a promise to the API's "result" - see the `mode` argument to `cmpClient` on how that's determined. * @property {boolean} isDirect true if the CMP is directly accessible (no postMessage required) + * @property {() => void} close close the client; currently, this just stops listening for cross-frame messages. */ /** @@ -18,20 +17,40 @@ import {GreedyPromise} from '../../src/utils/promise.js'; * @param apiVersion? CMP API version * @param apiArgs? names of the arguments taken by the api function, in order. * @param callbackArgs? names of the cross-frame response payload properties that should be passed as callback arguments, in order - * @param cbReturns? if true, disregard return values from the underlying API function; instead, pass it a callback, - * and expect it to be invoked only once with what is essentially the API function's return value - * (and will likewise be used to fulfill promises returned by the client). - * This has no effect when the client is passed a "true" callback, nor when the API lives on a different frame. + * @param mode? controls the callbacks passed to the underlying API, and how the promises returned by the client are resolved. + * + * The client behaves differently when it's provided a `callback` argument vs when it's not - for short, let's name these + * cases "subscriptions" and "one-shot calls" respectively: + * + * With `mode: MODE_MIXED` (the default), promises returned on subscriptions are resolved to undefined when the callback + * is first run (that is, the promise resolves when the CMP replies, but what it replies with is discarded and + * left for the callback to deal with). For one-shot calls, the returned promise is resolved to the API's + * return value when it's directly accessible, or with the result from the first (and, presumably, the only) + * cross-frame reply when it's not; + * + * With `mode: MODE_RETURN`, the returned promise always resolves to the API's return value - which is taken to be undefined + * when cross-frame; + * + * With `mode: MODE_CALLBACK`, the underlying API is expected to never directly return anything significant; instead, + * it should always accept a callback and - for one-shot calls - invoke it only once with the result. The client will + * automatically generate an appropriate callback for one-shot calls and use the result it's given to resolve + * the returned promise. Subscriptions are treated in the same way as MODE_MIXED. + * * @param win * @returns {CMPClient} CMP invocation function (or null if no CMP was found). */ + +export const MODE_MIXED = 0; +export const MODE_RETURN = 1; +export const MODE_CALLBACK = 2; + export function cmpClient( { apiName, apiVersion, apiArgs = ['command', 'callback', 'parameter', 'version'], callbackArgs = ['returnValue', 'success'], - cbReturns = false + mode = MODE_MIXED, }, win = window ) { @@ -95,15 +114,15 @@ export function cmpClient( } function wrapCallback(callback, resolve, reject, preamble) { + const haveCb = typeof callback === 'function'; + return function (result, success) { preamble && preamble(); - const resolver = success == null || success ? resolve : reject; - if (typeof callback === 'function') { - resolver(); - return callback.apply(this, arguments); - } else { - resolver(result); + if (mode !== MODE_RETURN) { + const resolver = success == null || success ? resolve : reject; + resolver(haveCb ? undefined : result); } + haveCb && callback.apply(this, arguments); } } @@ -114,9 +133,9 @@ export function cmpClient( return new GreedyPromise((resolve, reject) => { const ret = cmpFrame[apiName](...resolveParams({ ...params, - callback: (params.callback || cbReturns) && wrapCallback(params.callback, resolve, reject) + callback: (params.callback || mode === MODE_CALLBACK) ? wrapCallback(params.callback, resolve, reject) : undefined, }).map(([_, val]) => val)); - if (params.callback == null && !cbReturns) { + if (mode === MODE_RETURN || (params.callback == null && mode === MODE_MIXED)) { resolve(ret); } }); @@ -137,9 +156,14 @@ export function cmpClient( cmpCallbacks[callId] = wrapCallback(params?.callback, resolve, reject, (once || params?.callback == null) && (() => { delete cmpCallbacks[callId] })); cmpFrame.postMessage(msg, '*'); + if (mode === MODE_RETURN) resolve(); }); }; } - client.isDirect = isDirect; - return client; + return Object.assign(client, { + isDirect, + close() { + !isDirect && win.removeEventListener('message', handleMessage); + } + }) } diff --git a/modules/consentManagementGpp.js b/modules/consentManagementGpp.js index 393b7f8fe4e..9af76c6e3ed 100644 --- a/modules/consentManagementGpp.js +++ b/modules/consentManagementGpp.js @@ -51,6 +51,22 @@ function lookupStaticConsentData({onSuccess, onError}) { processCmpData(staticConsentData, {onSuccess, onError}); } +/** + * Ping the CMP to determine its version. + * @param mkClient + * @returns {Promise<[CMPClient, {}]>} a promise to two objects: + * - a CMP client function (with settings appropriate to the CMP version); and + * - the result from the ping command. + */ +export function pingCMP(mkClient = cmpClient) { + const clientOptions = { + apiName: '__gpp', + apiArgs: ['command', 'callback', 'parameter'], // do not pass version - not clear what it's for (or what we should use) + cbReturns: true // in 1.1, all commands use callbacks instead of return values + } + let cmp = mkClient(clientOptions); +} + /** * This function handles interacting with an IAB compliant CMP to obtain the consent information of the user. * Given the async nature of the CMP's API, we pass in acting success/error callback functions to exit this function diff --git a/test/spec/libraries/cmp/cmpClient_spec.js b/test/spec/libraries/cmp/cmpClient_spec.js index c30295eec81..ac84389766d 100644 --- a/test/spec/libraries/cmp/cmpClient_spec.js +++ b/test/spec/libraries/cmp/cmpClient_spec.js @@ -1,12 +1,16 @@ -import {cmpClient} from '../../../../libraries/cmp/cmpClient.js'; +import {cmpClient, MODE_CALLBACK, MODE_RETURN} from '../../../../libraries/cmp/cmpClient.js'; describe('cmpClient', () => { + function mockWindow(props = {}) { let listeners = []; const win = { addEventListener: sinon.stub().callsFake((evt, listener) => { evt === 'message' && listeners.push(listener) }), + removeEventListener: sinon.stub().callsFake((evt, listener) => { + evt === 'message' && (listeners = listeners.filter((l) => l !== listener)); + }), postMessage: sinon.stub().callsFake((msg) => { listeners.forEach(ln => ln({data: msg})) }), @@ -62,11 +66,15 @@ describe('cmpClient', () => { return 'val' }) }) + Object.entries({ callback: [sinon.stub(), 'undefined', undefined], + 'callback, mode = MODE_CALLBACK': [sinon.stub(), 'undefined', undefined, MODE_CALLBACK], + 'callback, mode = MODE_RETURN': [sinon.stub(), 'api return value', 'val', MODE_RETURN], 'no callback': [undefined, 'api return value', 'val'], - 'no callback, but cbReturns = true': [undefined, 'callback arg', 'cbVal', true] - }).forEach(([t, [callback, tResult, expectedResult, cbReturns]]) => { + 'no callback, mode = MODE_CALLBACK': [undefined, 'callback arg', 'cbVal', MODE_CALLBACK], + 'no callback, mode = MODE_RETURN': [undefined, 'api return value', 'val', MODE_RETURN], + }).forEach(([t, [callback, tResult, expectedResult, mode]]) => { describe(`when ${t} is provided`, () => { Object.entries({ 'no success flag': undefined, @@ -74,10 +82,15 @@ describe('cmpClient', () => { }).forEach(([t, success]) => { it(`resolves to ${tResult} (${t})`, (done) => { cbResult = ['cbVal', success]; - mkClient({cbReturns})({callback}).then((val) => { + mkClient({mode})({callback}).then((val) => { expect(val).to.equal(expectedResult); done(); }) + }); + + it('should pass either a function or undefined as callback', () => { + mkClient({mode})({callback}); + sinon.assert.calledWith(mockApiFn, sinon.match.any, sinon.match(arg => typeof arg === 'undefined' || typeof arg === 'function')) }) }); }) @@ -91,9 +104,9 @@ describe('cmpClient', () => { }) }); - it('rejects to callback arg when callback is NOT provided, success = false, cbReturns = true', (done) => { + it('rejects to callback arg when callback is NOT provided, success = false, mode = MODE_CALLBACK', (done) => { cbResult = ['cbVal', false]; - mkClient({cbReturns: true})().catch(val => { + mkClient({mode: MODE_CALLBACK})().catch(val => { expect(val).to.eql('cbVal'); done(); }) @@ -118,6 +131,10 @@ describe('cmpClient', () => { }); sinon.assert.calledWith(mockApiFn, 'mockParam', 'mockCmd'); }); + + it('should not choke on .close()', () => { + mkClient({}).close(); + }) }) }) }) @@ -198,8 +215,12 @@ describe('cmpClient', () => { }) Object.entries({ 'callback': [sinon.stub(), 'undefined', undefined], + 'callback, mode = MODE_RETURN': [sinon.stub(), 'undefined', undefined, MODE_RETURN], + 'callback, mode = MODE_CALLBACK': [sinon.stub(), 'undefined', undefined, MODE_CALLBACK], 'no callback': [undefined, 'response returnValue', 'val'], - }).forEach(([t, [callback, tResult, expectedResult]]) => { + 'no callback, mode = MODE_RETURN': [undefined, 'undefined', undefined, MODE_RETURN], + 'no callback, mode = MODE_CALLBACK': [undefined, 'response returnValue', 'val', MODE_CALLBACK], + }).forEach(([t, [callback, tResult, expectedResult, mode]]) => { describe(`when ${t} is provided`, () => { Object.entries({ 'no success flag': {}, @@ -207,19 +228,21 @@ describe('cmpClient', () => { }).forEach(([t, resp]) => { it(`resolves to ${tResult} (${t})`, () => { Object.assign(response, resp); - mkClient()({callback}).then((val) => { + mkClient({mode})({callback}).then((val) => { expect(val).to.equal(expectedResult); }) }) }); - it(`rejects to ${tResult} when success = false`, (done) => { - response.success = false; - mkClient()({callback}).catch((err) => { - expect(err).to.equal(expectedResult); - done(); + if (mode !== MODE_RETURN) { // in return mode, the promise never rejects + it(`rejects to ${tResult} when success = false`, (done) => { + response.success = false; + mkClient()({mode, callback}).catch((err) => { + expect(err).to.equal(expectedResult); + done(); + }); }); - }); + } }) }); }); @@ -257,6 +280,16 @@ describe('cmpClient', () => { sinon.assert.calledWith(callback, 'a'); sinon.assert.calledOnce(callback); }); + + it('should NOT fire again after .close()', () => { + const client = mkClient(); + client({callback}); + runCallback('a'); + client.close(); + runCallback('b'); + sinon.assert.calledWith(callback, 'a'); + sinon.assert.calledOnce(callback); + }) }); }); }); From 376d14223f6e002277cf083f91726e9a4a3304ab Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Tue, 25 Jul 2023 13:04:20 -0700 Subject: [PATCH 4/6] GPP 1.0/1.1 clients --- modules/consentManagementGpp.js | 413 ++++++-- .../spec/modules/consentManagementGpp_spec.js | 992 +++++++++++------- 2 files changed, 909 insertions(+), 496 deletions(-) diff --git a/modules/consentManagementGpp.js b/modules/consentManagementGpp.js index 9af76c6e3ed..db7c390db02 100644 --- a/modules/consentManagementGpp.js +++ b/modules/consentManagementGpp.js @@ -4,19 +4,18 @@ * and make it available for any GPP supported adapters to read/pass this information to * their system and for various other features/modules in Prebid.js. */ -import {deepSetValue, isNumber, isPlainObject, isStr, logError, logInfo, logWarn} from '../src/utils.js'; +import {deepSetValue, isEmpty, isNumber, isPlainObject, isStr, logError, logInfo, logWarn} from '../src/utils.js'; import {config} from '../src/config.js'; import {gppDataHandler} from '../src/adapterManager.js'; import {timedAuctionHook} from '../src/utils/perfMetrics.js'; -import { enrichFPD } from '../src/fpd/enrichment.js'; +import {enrichFPD} from '../src/fpd/enrichment.js'; import {getGlobal} from '../src/prebidGlobal.js'; -import {cmpClient} from '../libraries/cmp/cmpClient.js'; +import {cmpClient, MODE_CALLBACK, MODE_MIXED, MODE_RETURN} from '../libraries/cmp/cmpClient.js'; import {GreedyPromise} from '../src/utils/promise.js'; import {buildActivityParams} from '../src/activities/params.js'; const DEFAULT_CMP = 'iab'; const DEFAULT_CONSENT_TIMEOUT = 10000; -const CMP_VERSION = 1; export let userCMP; export let consentTimeout; @@ -25,46 +24,275 @@ let staticConsentData; let consentData; let addedConsentHook = false; -// add new CMPs here, with their dedicated lookup function -const cmpCallMap = { - 'iab': lookupIabConsent, - 'static': lookupStaticConsentData -}; +function pipeCallbacks(fn, {onSuccess, onError}) { + GreedyPromise.resolve(fn()).then(onSuccess, (err) => { + if (err instanceof GPPError) { + onError(err.message, ...err.args); + } else { + onError(`GPP error:`, err); + } + }); +} -/** - * This function checks the state of the IAB gppData's applicableSections field (to ensure it's populated and has a valid value). - * section === 0 represents a CMP's default value when CMP is loading, it shoud not be used a real user's section. - * @param gppData represents the IAB gppData object - * @returns {Array} - */ -function applicableSections(gppData) { - return gppData && Array.isArray(gppData.applicableSections) && gppData.applicableSections.length > 0 && gppData.applicableSections[0] !== 0 - ? gppData.applicableSections - : []; +function lookupStaticConsentData(callbacks) { + return pipeCallbacks(() => processCmpData(staticConsentData), callbacks); } -/** - * This function reads the consent string from the config to obtain the consent information of the user. - * @param {function({})} onSuccess acts as a success callback when the value is read from config; pass along consentObject from CMP - */ -function lookupStaticConsentData({onSuccess, onError}) { - processCmpData(staticConsentData, {onSuccess, onError}); +const GPP_10 = '1.0'; +const GPP_11 = '1.1'; + +class GPPError { + constructor(message, arg) { + this.message = message; + this.args = arg == null ? [] : [arg]; + } } -/** - * Ping the CMP to determine its version. - * @param mkClient - * @returns {Promise<[CMPClient, {}]>} a promise to two objects: - * - a CMP client function (with settings appropriate to the CMP version); and - * - the result from the ping command. - */ -export function pingCMP(mkClient = cmpClient) { - const clientOptions = { - apiName: '__gpp', - apiArgs: ['command', 'callback', 'parameter'], // do not pass version - not clear what it's for (or what we should use) - cbReturns: true // in 1.1, all commands use callbacks instead of return values +export class GPPClient { + static CLIENTS = {}; + + static register(apiVersion, defaultVersion = false) { + this.apiVersion = apiVersion; + this.CLIENTS[apiVersion] = this; + if (defaultVersion) { + this.CLIENTS.default = this; + } + } + + static INST; + + /** + * Ping the CMP to set up an appropriate client for it, and initialize it. + * + * @param mkCmp + * @returns {Promise<[GPPClient,Promise<{}>]>} a promise to two objects: + * - a GPPClient that talks the best GPP dialect we know for the CMP's version; + * - a promise to GPP data. + */ + static init(mkCmp = cmpClient) { + if (this.INST == null) { + this.INST = this.ping(mkCmp).catch(e => { + this.INST = null; + throw e; + }); + } + return this.INST.then(([client, pingData]) => [ + client, + client.initialized ? client.refresh() : client.init(pingData) + ]); + } + + /** + * Ping the CMP to determine its version and set up a client appropriate for it. + * + * @param mkCmp + * @returns {Promise<[GPPClient, {}]>} a promise to two objects: + * - a GPPClient that talks the best GPP dialect we know for the CMP's version; + * - the result from pinging the CMP. + */ + static ping(mkCmp = cmpClient) { + const cmpOptions = { + apiName: '__gpp', + apiArgs: ['command', 'callback', 'parameter'], // do not pass version - not clear what it's for (or what we should use) + }; + + // in 1.0, 'ping' should return pingData but ignore callback; + // in 1.1 it should not return anything but run the callback + // the following looks for either - but once the version is known, produce a client that knows whether the + // rest of the interactions should pick return values or pass callbacks + + const probe = mkCmp({...cmpOptions, mode: MODE_RETURN}); + return new GreedyPromise((resolve, reject) => { + if (probe == null) { + reject(new GPPError('GPP CMP not found')); + return; + } + let done = false; // some CMPs do both return value and callbacks - avoid repeating log messages + const pong = (result, success) => { + if (done) return; + if (success != null && !success) { + reject(result); + return; + } + if (result == null) return; + done = true; + const cmpVersion = result?.gppVersion; + const Client = this.getClient(cmpVersion); + if (cmpVersion !== Client.apiVersion) { + logWarn(`Unrecognized GPP CMP version: ${cmpVersion}. Continuing using GPP API version ${Client}...`); + } else { + logInfo(`Using GPP version ${cmpVersion}`); + } + const mode = Client.apiVersion === GPP_10 ? MODE_MIXED : MODE_CALLBACK; + const client = new Client( + cmpVersion, + mkCmp({...cmpOptions, mode}) + ); + resolve([client, result]); + }; + + probe({ + command: 'ping', + callback: pong + }).then((res) => pong(res, true), reject); + }).finally(() => { + probe && probe.close(); + }); + } + + static getClient(cmpVersion) { + return this.CLIENTS.hasOwnProperty(cmpVersion) ? this.CLIENTS[cmpVersion] : this.CLIENTS.default; + } + + #resolve; + #reject; + #pending = []; + + initialized = false; + + constructor(cmpVersion, cmp) { + this.apiVersion = this.constructor.apiVersion; + this.cmpVersion = cmp; + this.cmp = cmp; + [this.#resolve, this.#reject] = [0, 1].map(slot => (result) => { + while (this.#pending.length) { + this.#pending.pop()[slot](result); + } + }); + } + + /** + * initialize this client - update consent data if already available, + * and set up event listeners to also update on CMP changes + * + * @param pingData + * @returns {Promise<{}>} a promise to GPP consent data + */ + init(pingData) { + const ready = this.updateWhenReady(pingData); + if (!this.initialized) { + this.initialized = true; + this.cmp({ + command: 'addEventListener', + callback: (event, success) => { + if (success != null && !success) { + this.#reject(new GPPError('Received error response from CMP', event)); + } else if (event?.pingData?.cmpStatus === 'error') { + this.#reject(new GPPError('CMP status is "error"; please check CMP setup', event)); + } else if (this.isCMPReady(event?.pingData || {}) && this.events.includes(event?.eventName)) { + this.#resolve(this.updateConsent(event.pingData)); + } + } + }); + } + return ready; + } + + refresh() { + return this.cmp({command: 'ping'}).then(this.updateWhenReady.bind(this)); + } + + /** + * Retrieve and store GPP consent data. + * + * @param pingData + * @returns {Promise<{}>} a promise to GPP consent data + */ + updateConsent(pingData) { + return this.getGPPData(pingData).then((data) => { + if (data == null || isEmpty(data)) { + throw new GPPError('Received empty response from CMP', data); + } + return processCmpData(data); + }).then((data) => { + logInfo('Retrieved GPP consent from CMP:', data); + return data; + }); + } + + /** + * Return a promise to GPP consent data, to be retrieved the next time the CMP signals it's ready. + * + * @returns {Promise<{}>} + */ + nextUpdate() { + return new GreedyPromise((resolve, reject) => { + this.#pending.push([resolve, reject]); + }); + } + + /** + * Return a promise to GPP consent data, to be retrieved immediately if the CMP is ready according to `pingData`, + * or as soon as it signals that it's ready otherwise. + * + * @param pingData + * @returns {Promise<{}>} + */ + updateWhenReady(pingData) { + return this.isCMPReady(pingData) ? this.updateConsent(pingData) : this.nextUpdate(); + } +} + +// eslint-disable-next-line no-unused-vars +class GPP10Client extends GPPClient { + static { + super.register(GPP_10); + } + + events = ['sectionChange', 'cmpStatus']; + + isCMPReady(pingData) { + return pingData.cmpStatus === 'loaded'; + } + + getGPPData(pingData) { + const parsedSections = GreedyPromise.all( + pingData.supportedAPIs.map((api) => this.cmp({ + command: 'getSection', + parameter: api + }).catch(err => { + logWarn(`Could not retrieve GPP section '${api}'`, err); + }).then((section) => [api, section])) + ).then(sections => { + // parse single section object into [core, gpc] to uniformize with 1.1 parsedSections + return Object.fromEntries( + sections.filter(([_, val]) => val != null) + .map(([api, section]) => { + const subsections = [ + Object.fromEntries(Object.entries(section).filter(([k]) => k !== 'Gpc')) + ]; + if (section.Gpc != null) { + subsections.push({ + SubsectionType: 1, + Gpc: section.Gpc + }); + } + return [api, subsections]; + }) + ); + }); + return GreedyPromise.all([ + this.cmp({command: 'getGPPData'}), + parsedSections + ]).then(([gppData, parsedSections]) => Object.assign({}, gppData, {parsedSections})); + } +} + +// eslint-disable-next-line no-unused-vars +class GPP11Client extends GPPClient { + static { + super.register(GPP_11, true); + } + + events = ['sectionChange', 'signalStatus']; + + isCMPReady(pingData) { + return pingData.signalStatus === 'ready'; + } + + getGPPData(pingData) { + return GreedyPromise.resolve(pingData); } - let cmp = mkClient(clientOptions); } /** @@ -74,54 +302,17 @@ export function pingCMP(mkClient = cmpClient) { * @param {function({})} onSuccess acts as a success callback when CMP returns a value; pass along consentObjectfrom CMP * @param {function(string, ...{}?)} cmpError acts as an error callback while interacting with CMP; pass along an error message (string) and any extra error arguments (purely for logging) */ -export function lookupIabConsent({onSuccess, onError}, mkClient = cmpClient) { - const cmp = mkClient({ - apiName: '__gpp', - apiVersion: CMP_VERSION, - }); - if (!cmp) { - return onError('GPP CMP not found.'); - } - - const startupMsg = (cmp.isDirect) ? 'Detected GPP CMP API is directly accessible, calling it now...' - : 'Detected GPP CMP is outside the current iframe where Prebid.js is located, calling it now...'; - logInfo(startupMsg); - - let versionMismatch = false; - - cmp({ - command: 'addEventListener', - callback: function (evt) { - if (evt && !versionMismatch) { - logInfo(`Received a ${(cmp.isDirect ? 'direct' : 'postmsg')} response from GPP CMP for event`, evt); - const cmpVer = evt?.pingData?.gppVersion; - if (cmpVer != null && cmpVer !== '1.0') { - logWarn(`Unsupported GPP CMP version: ${cmpVer}. Continuing auction without consent`); - versionMismatch = true; - onSuccess(storeConsentData()); - return; - } - if (evt.eventName === 'sectionChange' || evt.pingData.cmpStatus === 'loaded') { - cmp({command: 'getGPPData'}).then((gppData) => { - logInfo(`Received a ${cmp.isDirect ? 'direct' : 'postmsg'} response from GPP CMP for getGPPData`, gppData); - return GreedyPromise.all( - (gppData?.pingData?.supportedAPIs || []) - .map((name) => cmp({command: 'getSection', parameter: name}) - .catch(() => { logError(`Could not retrieve section data for GPP section '${name}'`) }) - .then((res) => [name, res])) - ).then((sections) => { - const sectionData = Object.fromEntries(sections.filter(([_, val]) => val != null)); - processCmpData({gppData, sectionData}, {onSuccess, onError}); - }) - }); - } else if (evt.pingData.cmpStatus === 'error') { - onError('CMP returned with a cmpStatus:error response. Please check CMP setup.'); - } - } - } - }); +export function lookupIabConsent({onSuccess, onError}, mkCmp = cmpClient) { + pipeCallbacks(() => GPPClient.init(mkCmp).then(([client, gppDataPm]) => gppDataPm), {onSuccess, onError}); } +// add new CMPs here, with their dedicated lookup function +const cmpCallMap = { + 'iab': lookupIabConsent, + 'static': lookupStaticConsentData +}; + + /** * Look up consent data and store it in the `consentData` global as well as `adapterManager.js`' gdprDataHandler. * @@ -153,19 +344,19 @@ function loadConsentData(cb) { onError: function (msg, ...extraArgs) { done(null, true, msg, ...extraArgs); } - } + }; cmpCallMap[userCMP](callbacks); if (!isDone) { const onTimeout = () => { const continueToAuction = (data) => { done(data, false, 'GPP CMP did not load, continuing auction...'); - } - processCmpData(consentData, { + }; + pipeCallbacks(() => processCmpData(consentData), { onSuccess: continueToAuction, onError: () => continueToAuction(storeConsentData()) - }) - } + }); + }; if (consentTimeout === 0) { onTimeout(); } else { @@ -220,27 +411,15 @@ export const requestBidsHook = timedAuctionHook('gpp', function requestBidsHook( }); }); -/** - * This function checks the consent data provided by CMP to ensure it's in an expected state. - * If it's bad, we call `onError` - * If it's good, then we store the value and call `onSuccess` - */ -function processCmpData(consentData, {onSuccess, onError}) { - function checkData() { - const gppString = consentData?.gppData?.gppString; - const gppSection = consentData?.gppData?.applicableSections; - - return !!( - (!Array.isArray(gppSection)) || - (Array.isArray(gppSection) && (!gppString || !isStr(gppString))) - ); - } - - if (checkData()) { - onError(`CMP returned unexpected value during lookup process.`, consentData); - } else { - onSuccess(storeConsentData(consentData)); +function processCmpData(consentData) { + if ( + (consentData?.applicableSections != null && !Array.isArray(consentData.applicableSections)) || + (consentData?.gppString != null && !isStr(consentData.gppString)) || + (consentData?.parsedSections != null && !isPlainObject(consentData.parsedSections)) + ) { + throw new GPPError('CMP returned unexpected value during lookup process.', consentData); } + return storeConsentData(consentData); } /** @@ -248,14 +427,14 @@ function processCmpData(consentData, {onSuccess, onError}) { * @param {{}} gppData the result of calling a CMP's `getGPPData` (or equivalent) * @param {{}} sectionData map from GPP section name to the result of calling a CMP's `getSection` (or equivalent) */ -export function storeConsentData({gppData, sectionData} = {}) { +export function storeConsentData(gppData = {}) { consentData = { - gppString: (gppData) ? gppData.gppString : undefined, - gppData: (gppData) || undefined, + gppString: gppData?.gppString, + applicableSections: gppData?.applicableSections || [], + parsedSections: gppData?.parsedSections || {}, + gppData: gppData }; - consentData.applicableSections = applicableSections(gppData); - consentData.apiVersion = CMP_VERSION; - consentData.sectionData = sectionData; + gppDataHandler.setConsentData(gppData); return consentData; } @@ -267,6 +446,7 @@ export function resetConsentData() { userCMP = undefined; consentTimeout = undefined; gppDataHandler.reset(); + GPPClient.INST = null; } /** @@ -296,7 +476,7 @@ export function setConsentConfig(config) { if (userCMP === 'static') { if (isPlainObject(config.consentData)) { - staticConsentData = {gppData: config.consentData, sectionData: config.sectionData}; + staticConsentData = config.consentData; consentTimeout = 0; } else { logError(`consentManagement.gpp config with cmpApi: 'static' did not specify consentData. No consents will be available to adapters.`); @@ -315,6 +495,7 @@ export function setConsentConfig(config) { gppDataHandler.enable(); loadConsentData(); // immediately look up consent data to make it available without requiring an auction } + config.getConfig('consentManagement', config => setConsentConfig(config.consentManagement)); export function enrichFPDHook(next, fpd) { diff --git a/test/spec/modules/consentManagementGpp_spec.js b/test/spec/modules/consentManagementGpp_spec.js index 37776c15cea..e15ce30940c 100644 --- a/test/spec/modules/consentManagementGpp_spec.js +++ b/test/spec/modules/consentManagementGpp_spec.js @@ -1,19 +1,23 @@ import { - setConsentConfig, + consentTimeout, + GPPClient, requestBidsHook, resetConsentData, - userCMP, - consentTimeout, - storeConsentData, lookupIabConsent + setConsentConfig, + userCMP } from 'modules/consentManagementGpp.js'; -import { gppDataHandler } from 'src/adapterManager.js'; +import {gppDataHandler} from 'src/adapterManager.js'; import * as utils from 'src/utils.js'; -import { config } from 'src/config.js'; +import {config} from 'src/config.js'; import 'src/prebid.js'; +import {MODE_CALLBACK, MODE_MIXED} from '../../../libraries/cmp/cmpClient.js'; +import {GreedyPromise} from '../../../src/utils/promise.js'; let expect = require('chai').expect; describe('consentManagementGpp', function () { + beforeEach(resetConsentData); + describe('setConsentConfig tests:', function () { describe('empty setConsentConfig value', function () { beforeEach(function () { @@ -101,80 +105,6 @@ describe('consentManagementGpp', function () { }); }); - describe('lookupIABConsent', () => { - let mockCmp, mockCmpEvent, gppData, sectionData - beforeEach(() => { - gppData = { - gppString: 'mockString', - applicableSections: [], - pingData: {} - }; - sectionData = {}; - mockCmp = sinon.stub().callsFake(({command, callback, parameter}) => { - let res; - switch (command) { - case 'addEventListener': - mockCmpEvent = callback; - break; - case 'getGPPData': - res = gppData; - break; - case 'getSection': - res = sectionData[parameter]; - break; - } - return Promise.resolve(res); - }); - }) - - function runLookup() { - return new Promise((resolve, reject) => lookupIabConsent({onSuccess: resolve, onError: reject}, () => mockCmp)); - } - - function oneShotLookup() { - const pm = runLookup(); - mockCmpEvent({eventName: 'sectionChange'}); - return pm; - } - - it('fetches all sections', () => { - gppData.pingData.supportedAPIs = ['usnat', 'usca'] - sectionData = { - usnat: {mock: 'usnat'}, - usca: {mock: 'usca'} - }; - return oneShotLookup().then((res) => { - expect(res.sectionData).to.eql(sectionData); - }); - }); - - it('does not choke if some section data is not available', () => { - gppData.pingData.supportedAPIs = ['usnat', 'usca'] - sectionData = { - usca: {mock: 'data'} - }; - return oneShotLookup().then((res) => { - expect(res.sectionData).to.eql(sectionData); - }) - }); - - it('continues with no consent when CMP version is not 1.0', () => { - const pm = runLookup(); - mockCmpEvent({ - eventName: 'listenerRegistered', - pingData: { - gppVersion: '1.1' - } - }); - return pm.then((res) => { - sinon.assert.match(res, { - gppString: undefined, - applicableSections: [] - }) - }) - }) - }) - describe('static consent string setConsentConfig value', () => { afterEach(() => { config.resetConfig(); @@ -185,17 +115,19 @@ describe('consentManagementGpp', function () { gpp: { cmpApi: 'static', timeout: 7500, - sectionData: { - usnat: { - MockUsnatParsedFlag: true - } - }, consentData: { applicableSections: [7], gppString: 'ABCDEFG1234', gppVersion: 1, sectionId: 3, - sectionList: [] + sectionList: [], + parsedSections: { + usnat: [ + { + MockUsnatParsedFlag: true + }, + ] + }, } } }; @@ -209,6 +141,588 @@ describe('consentManagementGpp', function () { }); }); }); + describe('GPPClient.ping', () => { + function mkPingData(gppVersion) { + return { + gppVersion + } + } + Object.entries({ + 'unknown': { + expectedMode: MODE_CALLBACK, + pingData: mkPingData(), + apiVersion: '1.1', + client({callback}) { + callback(this.pingData); + } + }, + '1.0': { + expectedMode: MODE_MIXED, + pingData: mkPingData('1.0'), + apiVersion: '1.0', + client() { + return this.pingData; + } + }, + '1.1 that runs callback immediately': { + expectedMode: MODE_CALLBACK, + pingData: mkPingData('1.1'), + apiVersion: '1.1', + client({callback}) { + callback(this.pingData); + } + }, + '1.1 that defers callback': { + expectedMode: MODE_CALLBACK, + pingData: mkPingData('1.1'), + apiVersion: '1.1', + client({callback}) { + setTimeout(() => callback(this.pingData), 10); + } + }, + '> 1.1': { + expectedMode: MODE_CALLBACK, + pingData: mkPingData('1.2'), + apiVersion: '1.1', + client({callback}) { + setTimeout(() => callback(this.pingData), 10); + } + } + }).forEach(([t, scenario]) => { + describe(`using CMP version ${t}`, () => { + let clients, mkClient; + beforeEach(() => { + clients = []; + mkClient = ({mode}) => { + const mockClient = function (args) { + if (args.command === 'ping') { + return Promise.resolve(scenario.client(args)); + } + } + mockClient.mode = mode; + mockClient.close = sinon.stub(); + clients.push(mockClient); + return mockClient; + } + }); + + it('should resolve to client with the correct mode', () => { + return GPPClient.ping(mkClient).then(([client]) => { + expect(client.cmp.mode).to.eql(scenario.expectedMode); + }); + }); + + it('should resolve to pingData', () => { + return GPPClient.ping(mkClient).then(([_, pingData]) => { + expect(pingData).to.eql(scenario.pingData); + }); + }); + + it('should .close the probing client', () => { + return GPPClient.ping(mkClient).then(([client]) => { + sinon.assert.called(clients[0].close); + sinon.assert.notCalled(client.cmp.close); + }) + }); + + it('should .tag the client with version', () => { + return GPPClient.ping(mkClient).then(([client]) => { + expect(client.apiVersion).to.eql(scenario.apiVersion); + }) + }) + }) + }); + + it('should reject when mkClient returns null (CMP not found)', () => { + return GPPClient.ping(() => null).catch((err) => { + expect(err.message).to.match(/not found/); + }); + }); + + it('should reject when client rejects', () => { + const err = {some: 'prop'}; + const mockClient = () => Promise.reject(err); + mockClient.close = sinon.stub(); + return GPPClient.ping(() => mockClient).catch((result) => { + expect(result).to.eql(err); + sinon.assert.called(mockClient.close); + }); + }); + + it('should reject when callback is invoked with success = false', () => { + const err = 'error'; + const mockClient = ({callback}) => callback(err, false); + mockClient.close = sinon.stub(); + return GPPClient.ping(() => mockClient).catch((result) => { + expect(result).to.eql(err); + sinon.assert.called(mockClient.close); + }) + }) + }); + + describe('GPPClient.init', () => { + let makeCmp, cmpCalls, cmpResult; + + beforeEach(() => { + cmpResult = {signalStatus: 'ready', gppString: 'mock-str'}; + cmpCalls = []; + makeCmp = sinon.stub().callsFake(() => { + function mockCmp(args) { + cmpCalls.push(args); + return GreedyPromise.resolve(cmpResult); + } + mockCmp.close = sinon.stub(); + return mockCmp; + }); + }); + + it('should re-use same client', (done) => { + GPPClient.init(makeCmp).then(([client]) => { + GPPClient.init(makeCmp).then(([client2, consentPm]) => { + expect(client2).to.equal(client); + expect(cmpCalls.filter((el) => el.command === 'ping').length).to.equal(2) // recycled client should be refreshed + consentPm.then((consent) => { + expect(consent.gppString).to.eql('mock-str'); + done() + }) + }); + }); + }); + + it('should not re-use errors', (done) => { + cmpResult = Promise.reject(new Error()); + GPPClient.init(makeCmp).catch(() => { + cmpResult = {signalStatus: 'ready'}; + return GPPClient.init(makeCmp).then(([client]) => { + expect(client).to.exist; + done() + }) + }) + }) + }) + + describe('GPP client', () => { + const CHANGE_EVENTS = ['sectionChange', 'signalStatus']; + + let gppClient, gppData, cmpReady, eventListener; + + function mockClient(apiVersion = '1.1', cmpVersion = '1.1') { + const mockCmp = sinon.stub().callsFake(function ({command, callback}) { + if (command === 'addEventListener') { + eventListener = callback; + } else { + throw new Error('unexpected command: ' + command); + } + }) + const client = new GPPClient(cmpVersion, mockCmp); + client.apiVersion = apiVersion; + client.getGPPData = sinon.stub().callsFake(() => Promise.resolve(gppData)); + client.isCMPReady = sinon.stub().callsFake(() => cmpReady); + client.events = CHANGE_EVENTS; + return client; + } + + beforeEach(() => { + gppDataHandler.reset(); + eventListener = null; + cmpReady = true; + gppData = { + applicableSections: [7], + gppString: 'mock-string', + parsedSections: { + usnat: [ + { + Field: 'val' + }, + { + SubsectionType: 1, + Gpc: false + } + ] + } + }; + gppClient = mockClient(); + }); + + describe('updateConsent', () => { + it('should update data handler with consent data', () => { + return gppClient.updateConsent().then(data => { + sinon.assert.match(data, gppData); + sinon.assert.match(gppDataHandler.getConsentData(), gppData); + expect(gppDataHandler.ready).to.be.true; + }); + }); + + Object.entries({ + 'emtpy': {}, + 'missing': null + }).forEach(([t, data]) => { + it(`should not update, and reject promise, when gpp data is ${t}`, (done) => { + gppData = data; + gppClient.updateConsent().catch(err => { + expect(err.message).to.match(/empty/); + expect(err.args).to.eql(data == null ? [] : [data]); + expect(gppDataHandler.ready).to.be.false; + done() + }) + }); + }) + + it('should not update when gpp data rejects', (done) => { + gppData = Promise.reject(new Error('err')); + gppClient.updateConsent().catch(err => { + expect(gppDataHandler.ready).to.be.false; + expect(err.message).to.eql('err'); + done(); + }) + }); + + describe('consent data validation', () => { + Object.entries({ + applicableSections: { + 'not an array': 'not-an-array', + }, + gppString: { + 'not a string': 234 + }, + parsedSections: { + 'not an object': 'not-an-object' + } + }).forEach(([prop, tests]) => { + describe(`validation: when ${prop} is`, () => { + Object.entries(tests).forEach(([t, value]) => { + describe(t, () => { + it('should not update', (done) => { + Object.assign(gppData, {[prop]: value}); + gppClient.updateConsent().catch(err => { + expect(err.message).to.match(/unexpected/); + expect(err.args).to.eql([gppData]); + expect(gppDataHandler.ready).to.be.false; + done(); + }); + }); + }) + }); + }); + }); + }); + }); + + describe('init', () => { + beforeEach(() => { + gppClient.isCMPReady = function (pingData) { + return pingData.ready; + } + gppClient.getGPPData = function (pingData) { + return Promise.resolve(pingData); + } + }) + + it('does not use initial pingData if CMP is not ready', () => { + gppClient.init({...gppData, ready: false}); + expect(eventListener).to.exist; + expect(gppDataHandler.ready).to.be.false; + }); + + it('uses initial pingData (and resolves promise) if CMP is ready', () => { + return gppClient.init({...gppData, ready: true}).then(data => { + expect(eventListener).to.exist; + sinon.assert.match(data, gppData); + sinon.assert.match(gppDataHandler.getConsentData(), gppData); + }) + }); + + it('rejects promise when CMP errors out', (done) => { + gppClient.init({ready: false}).catch((err) => { + expect(err.message).to.match(/error/); + expect(err.args).to.eql(['error']) + done(); + }); + eventListener('error', false); + }); + + Object.entries({ + 'empty': {}, + 'null': null, + 'irrelevant': {eventName: 'irrelevant'} + }).forEach(([t, evt]) => { + it(`ignores ${t} events`, () => { + let pm = gppClient.init({ready: false}).catch((err) => err.args[0] !== 'done' && Promise.reject(err)); + eventListener(evt); + eventListener('done', false); + return pm; + }) + }); + + it('rejects the promise when cmpStatus is "error"', (done) => { + const evt = {eventName: 'other', pingData: {cmpStatus: 'error'}}; + gppClient.init({ready: false}).catch(err => { + expect(err.message).to.match(/error/); + expect(err.args).to.eql([evt]); + done(); + }); + eventListener(evt); + }) + + CHANGE_EVENTS.forEach(evt => { + describe(`event: ${evt}`, () => { + function makeEvent(pingData) { + return { + eventName: evt, + pingData + } + } + + let gppData2 + beforeEach(() => { + gppData2 = Object.assign(gppData, {gppString: '2nd'}); + }); + + it('does not fire consent data updates if the CMP is not ready', (done) => { + gppClient.init({ready: false}).catch(() => { + expect(gppDataHandler.ready).to.be.false; + done(); + }); + eventListener({...gppData2, ready: false}); + eventListener('done', false); + }) + + it('fires consent data updates (and resolves promise) if CMP is ready', (done) => { + gppClient.init({ready: false}).then(data => { + sinon.assert.match(data, gppData2); + done() + }); + cmpReady = true; + eventListener(makeEvent({...gppData2, ready: true})); + }); + + it('keeps updating consent data on new events', () => { + let pm = gppClient.init({ready: false}).then(data => { + sinon.assert.match(data, gppData); + sinon.assert.match(gppDataHandler.getConsentData(), gppData); + }); + eventListener(makeEvent({...gppData, ready: true})); + return pm.then(() => { + eventListener(makeEvent({...gppData2, ready: true})) + }).then(() => { + sinon.assert.match(gppDataHandler.getConsentData(), gppData2); + }); + }); + }) + }) + }); + }); + + describe('GPP 1.0 protocol', () => { + let mockCmp, gppClient; + beforeEach(() => { + mockCmp = sinon.stub(); + gppClient = new (GPPClient.getClient('1.0'))('1.0', mockCmp); + }); + + describe('isCMPReady', () => { + Object.entries({ + 'loaded': [true, 'loaded'], + 'other': [false, 'other'], + 'undefined': [false, undefined] + }).forEach(([t, [expected, cmpStatus]]) => { + it(`should be ${expected} when cmpStatus is ${t}`, () => { + expect(gppClient.isCMPReady(Object.assign({}, {cmpStatus}))).to.equal(expected); + }); + }); + }); + + describe('getGPPData', () => { + let gppData, pingData; + beforeEach(() => { + gppData = { + gppString: 'mock-string', + supportedAPIs: ['usnat'], + applicableSections: [7, 8] + } + pingData = { + supportedAPIs: gppData.supportedAPIs + }; + }); + + function mockCmpCommands(commands) { + mockCmp.callsFake(({command, parameter}) => { + if (commands.hasOwnProperty((command))) { + return Promise.resolve(commands[command](parameter)); + } else { + return Promise.reject(new Error(`unrecognized command ${command}`)) + } + }) + } + + it('should retrieve consent string and applicableSections', () => { + mockCmpCommands({ + getGPPData: () => gppData + }) + return gppClient.getGPPData(pingData).then(data => { + sinon.assert.match(data, gppData); + }) + }); + + it('should reject when getGPPData rejects', (done) => { + mockCmpCommands({ + getGPPData: () => Promise.reject(new Error('err')) + }); + gppClient.getGPPData(pingData).catch(err => { + expect(err.message).to.eql('err'); + done(); + }); + }) + + describe('section data', () => { + let usnat, parsedUsnat; + + function mockSections(sections) { + mockCmpCommands({ + getGPPData: () => gppData, + getSection: (api) => (sections[api]) + }); + }; + + beforeEach(() => { + usnat = { + MockField: 'val', + OtherField: 'o', + Gpc: true + }; + parsedUsnat = [ + { + MockField: 'val', + OtherField: 'o' + }, + { + SubsectionType: 1, + Gpc: true + } + ] + }); + + it('retrieves section data', () => { + mockSections({usnat}); + return gppClient.getGPPData(pingData).then(data => { + expect(data.parsedSections).to.eql({usnat: parsedUsnat}) + }); + }); + + it('does not choke if a section is missing', () => { + mockSections({usnat}); + gppData.supportedAPIs = ['usnat', 'missing']; + return gppClient.getGPPData(pingData).then(data => { + expect(data.parsedSections).to.eql({usnat: parsedUsnat}); + }) + }); + + it('does not choke if a section fails', () => { + mockSections({usnat, err: Promise.reject(new Error('err'))}); + gppData.supportedAPIs = ['usnat', 'err']; + return gppClient.getGPPData(pingData).then(data => { + expect(data.parsedSections).to.eql({usnat: parsedUsnat}); + }) + }); + }) + }); + }); + + describe('GPP 1.1 protocol', () => { + let mockCmp, gppClient; + beforeEach(() => { + mockCmp = sinon.stub(); + gppClient = new (GPPClient.getClient('1.1'))('1.1', mockCmp); + }); + + describe('isCMPReady', () => { + Object.entries({ + 'ready': [true, 'ready'], + 'not ready': [false, 'not ready'], + 'undefined': [false, undefined] + }).forEach(([t, [expected, signalStatus]]) => { + it(`should be ${expected} when signalStatus is ${t}`, () => { + expect(gppClient.isCMPReady(Object.assign({}, {signalStatus}))).to.equal(expected); + }); + }); + }); + + it('gets GPPData from pingData', () => { + mockCmp.throws(new Error()); + const pingData = { + 'gppVersion': '1.1', + 'cmpStatus': 'loaded', + 'cmpDisplayStatus': 'disabled', + 'supportedAPIs': [ + '5:tcfcav1', + '7:usnat', + '8:usca', + '9:usva', + '10:usco', + '11:usut', + '12:usct' + ], + 'signalStatus': 'ready', + 'cmpId': 31, + 'sectionList': [ + 7 + ], + 'applicableSections': [ + 7 + ], + 'gppString': 'DBABL~BAAAAAAAAgA.QA', + 'parsedSections': { + 'usnat': [ + { + 'Version': 1, + 'SharingNotice': 0, + 'SaleOptOutNotice': 0, + 'SharingOptOutNotice': 0, + 'TargetedAdvertisingOptOutNotice': 0, + 'SensitiveDataProcessingOptOutNotice': 0, + 'SensitiveDataLimitUseNotice': 0, + 'SaleOptOut': 0, + 'SharingOptOut': 0, + 'TargetedAdvertisingOptOut': 0, + 'SensitiveDataProcessing': [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + 'KnownChildSensitiveDataConsents': [ + 0, + 0 + ], + 'PersonalDataConsents': 0, + 'MspaCoveredTransaction': 2, + 'MspaOptOutOptionMode': 0, + 'MspaServiceProviderMode': 0 + }, + { + 'SubsectionType': 1, + 'Gpc': false + } + ] + } + }; + return gppClient.getGPPData(pingData).then((gppData) => { + sinon.assert.match(gppData, { + gppString: pingData.gppString, + applicableSections: pingData.applicableSections, + parsedSections: pingData.parsedSections + }) + }) + }) + }) describe('requestBidsHook tests:', function () { let goodConfig = { @@ -313,13 +827,13 @@ describe('consentManagementGpp', function () { }); it('should continue the auction immediately, without consent data, if timeout is 0', (done) => { + window.__gpp = function () {}; setConsentConfig({ gpp: { cmpApi: 'iab', timeout: 0 } }); - window.__gpp = function () {}; try { requestBidsHook(() => { const consent = gppDataHandler.getConsentData(); @@ -336,14 +850,16 @@ describe('consentManagementGpp', function () { describe('already known consentData:', function () { let cmpStub = sinon.stub(); - function mockCMP(cmpResponse) { - return function (...args) { - if (args[0] === 'addEventListener') { - args[1](({ - eventName: 'sectionChange' - })); - } else if (args[0] === 'getGPPData') { - return cmpResponse; + function mockCMP(pingData) { + return function (command, callback) { + switch (command) { + case 'addEventListener': + // eslint-disable-next-line standard/no-callback-literal + callback({eventName: 'sectionChange', pingData}) + break; + case 'ping': + callback(pingData) + break; } } } @@ -366,7 +882,7 @@ describe('consentManagementGpp', function () { gppString: 'xyz', }; - cmpStub = sinon.stub(window, '__gpp').callsFake(mockCMP(testConsentData)); + cmpStub = sinon.stub(window, '__gpp').callsFake(mockCMP({...testConsentData, signalStatus: 'ready'})); setConsentConfig(goodConfig); requestBidsHook(() => {}, {}); cmpStub.reset(); @@ -382,289 +898,5 @@ describe('consentManagementGpp', function () { sinon.assert.notCalled(cmpStub); }); }); - - describe('iframe tests', function () { - let cmpPostMessageCb = () => {}; - let stringifyResponse; - - function createIFrameMarker(frameName) { - let ifr = document.createElement('iframe'); - ifr.width = 0; - ifr.height = 0; - ifr.name = frameName; - document.body.appendChild(ifr); - return ifr; - } - - function creatCmpMessageHandler(prefix, returnEvtValue, returnGPPValue) { - return function (event) { - if (event && event.data) { - let data = event.data; - if (data[`${prefix}Call`]) { - let callId = data[`${prefix}Call`].callId; - let response; - if (data[`${prefix}Call`].command === 'addEventListener') { - response = { - [`${prefix}Return`]: { - callId, - returnValue: returnEvtValue, - success: true - } - } - } else if (data[`${prefix}Call`].command === 'getGPPData') { - response = { - [`${prefix}Return`]: { - callId, - returnValue: returnGPPValue, - success: true - } - } - } else if (data[`${prefix}Call`].command === 'getSection') { - response = { - [`${prefix}Return`]: { - callId, - returnValue: {}, - success: true - } - } - } - event.source.postMessage(stringifyResponse ? JSON.stringify(response) : response, '*'); - } - } - } - } - - function testIFramedPage(testName, messageFormatString, tarConsentString, tarSections) { - it(`should return the consent string from a postmessage + addEventListener response - ${testName}`, (done) => { - stringifyResponse = messageFormatString; - setConsentConfig(goodConfig); - requestBidsHook(() => { - let consent = gppDataHandler.getConsentData(); - sinon.assert.notCalled(utils.logError); - expect(consent.gppString).to.equal(tarConsentString); - expect(consent.applicableSections).to.deep.equal(tarSections); - done(); - }, {}); - }); - } - - beforeEach(function () { - sinon.stub(utils, 'logError'); - sinon.stub(utils, 'logWarn'); - }); - - afterEach(function () { - utils.logError.restore(); - utils.logWarn.restore(); - config.resetConfig(); - resetConsentData(); - }); - - describe('workflow for iframe pages:', function () { - stringifyResponse = false; - let ifr2 = null; - - beforeEach(function () { - ifr2 = createIFrameMarker('__gppLocator'); - cmpPostMessageCb = creatCmpMessageHandler('__gpp', { - eventName: 'sectionChange' - }, { - gppString: 'abc12345234', - applicableSections: [7] - }); - window.addEventListener('message', cmpPostMessageCb, false); - }); - - afterEach(function () { - delete window.__gpp; // deletes the local copy made by the postMessage CMP call function - document.body.removeChild(ifr2); - window.removeEventListener('message', cmpPostMessageCb); - }); - - testIFramedPage('with/JSON response', false, 'abc12345234', [7]); - testIFramedPage('with/String response', true, 'abc12345234', [7]); - }); - }); - - describe('direct calls to CMP API tests', function () { - let cmpStub = sinon.stub(); - - beforeEach(function () { - didHookReturn = false; - sinon.stub(utils, 'logError'); - sinon.stub(utils, 'logWarn'); - }); - - afterEach(function () { - config.resetConfig(); - cmpStub.restore(); - utils.logError.restore(); - utils.logWarn.restore(); - resetConsentData(); - }); - - describe('CMP workflow for normal pages:', function () { - beforeEach(function () { - window.__gpp = function () {}; - }); - - afterEach(function () { - delete window.__gpp; - }); - - it('performs lookup check and stores consentData for a valid existing user', function () { - let testConsentData = { - gppString: 'abc12345234', - applicableSections: [7] - }; - cmpStub = sinon.stub(window, '__gpp').callsFake((...args) => { - if (args[0] === 'addEventListener') { - args[1]({ - eventName: 'sectionChange' - }); - } else if (args[0] === 'getGPPData') { - return testConsentData; - } - }); - - setConsentConfig(goodConfig); - - requestBidsHook(() => { - didHookReturn = true; - }, {}); - let consent = gppDataHandler.getConsentData(); - sinon.assert.notCalled(utils.logError); - expect(didHookReturn).to.be.true; - expect(consent.gppString).to.equal(testConsentData.gppString); - expect(consent.applicableSections).to.deep.equal(testConsentData.applicableSections); - }); - - it('produces gdpr metadata', function () { - let testConsentData = { - gppString: 'abc12345234', - applicableSections: [7] - }; - cmpStub = sinon.stub(window, '__gpp').callsFake((...args) => { - if (args[0] === 'addEventListener') { - args[1]({ - eventName: 'sectionChange' - }); - } else if (args[0] === 'getGPPData') { - return testConsentData; - } - }); - - setConsentConfig(goodConfig); - - requestBidsHook(() => { - didHookReturn = true; - }, {}); - let consentMeta = gppDataHandler.getConsentMeta(); - sinon.assert.notCalled(utils.logError); - expect(consentMeta.generatedAt).to.be.above(1644367751709); - }); - - it('throws an error when processCmpData check fails + does not call requestBids callback', function () { - let testConsentData = {}; - let bidsBackHandlerReturn = false; - - cmpStub = sinon.stub(window, '__gpp').callsFake((...args) => { - if (args[0] === 'addEventListener') { - args[1]({ - eventName: 'sectionChange' - }); - } else if (args[0] === 'getGPPData') { - return testConsentData; - } - }); - - setConsentConfig(goodConfig); - - sinon.assert.notCalled(utils.logWarn); - sinon.assert.notCalled(utils.logError); - - [utils.logWarn, utils.logError].forEach((stub) => stub.reset()); - - requestBidsHook(() => { - didHookReturn = true; - }, { - bidsBackHandler: () => bidsBackHandlerReturn = true - }); - let consent = gppDataHandler.getConsentData(); - - sinon.assert.calledOnce(utils.logError); - sinon.assert.notCalled(utils.logWarn); - expect(didHookReturn).to.be.false; - expect(bidsBackHandlerReturn).to.be.true; - expect(consent).to.be.null; - expect(gppDataHandler.ready).to.be.true; - }); - - describe('when proper consent is not available', () => { - let gppStub; - - function runAuction() { - setConsentConfig({ - gpp: { - cmpApi: 'iab', - timeout: 10, - } - }); - return new Promise((resolve, reject) => { - requestBidsHook(() => { - didHookReturn = true; - }, {}); - setTimeout(() => didHookReturn ? resolve() : reject(new Error('Auction did not run')), 20); - }) - } - - function mockGppCmp(gppdata) { - gppStub.callsFake((api, cb) => { - if (api === 'addEventListener') { - // eslint-disable-next-line standard/no-callback-literal - cb({ - pingData: { - cmpStatus: 'loaded' - } - }, true); - } - if (api === 'getGPPData') { - return gppdata; - } - }); - } - - beforeEach(() => { - gppStub = sinon.stub(window, '__gpp'); - }); - - afterEach(() => { - gppStub.restore(); - }) - - it('should continue auction with null consent when CMP is unresponsive', () => { - return runAuction().then(() => { - const consent = gppDataHandler.getConsentData(); - expect(consent.applicableSections).to.deep.equal([]); - expect(consent.gppString).to.be.undefined; - expect(gppDataHandler.ready).to.be.true; - }); - }); - - it('should use consent provided by events other than sectionChange', () => { - mockGppCmp({ - gppString: 'mock-consent-string', - applicableSections: [7] - }); - return runAuction().then(() => { - const consent = gppDataHandler.getConsentData(); - expect(consent.applicableSections).to.deep.equal([7]); - expect(consent.gppString).to.equal('mock-consent-string'); - expect(gppDataHandler.ready).to.be.true; - }); - }); - }); - }); - }); }); }); From 6bec19e8a211940cf17db233916c426b75e4b2fd Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Wed, 26 Jul 2023 08:13:21 -0700 Subject: [PATCH 5/6] update gppControl for 1.1 --- libraries/mspa/activityControls.js | 30 +++++++++++++------ modules/consentManagementGpp.js | 1 - .../libraries/mspa/activityControls_spec.js | 14 +++++---- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/libraries/mspa/activityControls.js b/libraries/mspa/activityControls.js index 3359862a5b3..03250dd3201 100644 --- a/libraries/mspa/activityControls.js +++ b/libraries/mspa/activityControls.js @@ -24,7 +24,7 @@ export function isBasicConsentDenied(cd) { } export function isSensitiveNoticeMissing(cd) { - return ['SensitiveDataProcessingOptOutNotice', 'SensitiveDataLimitUseNotice'].some(prop => cd[prop] === 2) + return ['SensitiveDataProcessingOptOutNotice', 'SensitiveDataLimitUseNotice'].some(prop => cd[prop] === 2); } export function isConsentDenied(cd) { @@ -58,7 +58,7 @@ export function isTransmitUfpdConsentDenied(cd) { export function isTransmitGeoConsentDenied(cd) { return isBasicConsentDenied(cd) || isSensitiveNoticeMissing(cd) || - cd.SensitiveDataProcessing[7] === 1 + cd.SensitiveDataProcessing[7] === 1; } const CONSENT_RULES = { @@ -66,26 +66,38 @@ const CONSENT_RULES = { [ACTIVITY_ENRICH_EIDS]: isConsentDenied, [ACTIVITY_ENRICH_UFPD]: isTransmitUfpdConsentDenied, [ACTIVITY_TRANSMIT_PRECISE_GEO]: isTransmitGeoConsentDenied -} +}; export function mspaRule(sids, getConsent, denies, applicableSids = () => gppDataHandler.getConsentData()?.applicableSections) { - return function() { + return function () { if (applicableSids().some(sid => sids.includes(sid))) { const consent = getConsent(); if (consent == null) { return {allow: false, reason: 'consent data not available'}; } if (denies(consent)) { - return {allow: false} + return {allow: false}; } } - } + }; +} + +function flatSection(subsections) { + if (subsections == null) return subsections; + return subsections.reduceRight((subsection, consent) => { + return Object.assign(consent, subsection); + }, {}); } export function setupRules(api, sids, normalizeConsent = (c) => c, rules = CONSENT_RULES, registerRule = registerActivityControl, getConsentData = () => gppDataHandler.getConsentData()) { const unreg = []; Object.entries(rules).forEach(([activity, denies]) => { - unreg.push(registerRule(activity, `MSPA (${api})`, mspaRule(sids, () => normalizeConsent(getConsentData()?.sectionData?.[api]), denies, () => getConsentData()?.applicableSections || []))) - }) - return () => unreg.forEach(ur => ur()) + unreg.push(registerRule(activity, `MSPA (${api})`, mspaRule( + sids, + () => normalizeConsent(flatSection(getConsentData()?.parsedSections?.[api])), + denies, + () => getConsentData()?.applicableSections || [] + ))); + }); + return () => unreg.forEach(ur => ur()); } diff --git a/modules/consentManagementGpp.js b/modules/consentManagementGpp.js index db7c390db02..a75807e31df 100644 --- a/modules/consentManagementGpp.js +++ b/modules/consentManagementGpp.js @@ -312,7 +312,6 @@ const cmpCallMap = { 'static': lookupStaticConsentData }; - /** * Look up consent data and store it in the `consentData` global as well as `adapterManager.js`' gdprDataHandler. * diff --git a/test/spec/libraries/mspa/activityControls_spec.js b/test/spec/libraries/mspa/activityControls_spec.js index 5286f1d47f0..3513b08d523 100644 --- a/test/spec/libraries/mspa/activityControls_spec.js +++ b/test/spec/libraries/mspa/activityControls_spec.js @@ -270,10 +270,12 @@ describe('setupRules', () => { ([registerRule, isAllowed] = ruleRegistry()); consent = { applicableSections: [1], - sectionData: { - mockApi: { - mock: 'consent' - } + parsedSections: { + mockApi: [ + { + mock: 'consent' + } + ] } }; }); @@ -282,7 +284,7 @@ describe('setupRules', () => { return setupRules(api, sids, normalize, rules, registerRule, () => consent) } - it('should use section data for the given api', () => { + it('should use flatten section data for the given api', () => { runSetup('mockApi', [1]); expect(isAllowed('mockActivity', {})).to.equal(false); sinon.assert.calledWith(rules.mockActivity, {mock: 'consent'}) @@ -299,7 +301,7 @@ describe('setupRules', () => { expect(isAllowed('mockActivity', {})).to.equal(true); }); - it('should pass consent through normalizeConsent', () => { + it('should pass flattened consent through normalizeConsent', () => { const normalize = sinon.stub().returns({normalized: 'consent'}) runSetup('mockApi', [1], normalize); expect(isAllowed('mockActivity', {})).to.equal(false); From c7378de5236d8e78da016a6082278d76e59cda5f Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Wed, 26 Jul 2023 08:30:31 -0700 Subject: [PATCH 6/6] linting --- modules/consentManagementGpp.js | 2 +- test/spec/libraries/cmp/cmpClient_spec.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/consentManagementGpp.js b/modules/consentManagementGpp.js index a75807e31df..69fc5789953 100644 --- a/modules/consentManagementGpp.js +++ b/modules/consentManagementGpp.js @@ -25,7 +25,7 @@ let consentData; let addedConsentHook = false; function pipeCallbacks(fn, {onSuccess, onError}) { - GreedyPromise.resolve(fn()).then(onSuccess, (err) => { + new GreedyPromise((resolve) => resolve(fn())).then(onSuccess, (err) => { if (err instanceof GPPError) { onError(err.message, ...err.args); } else { diff --git a/test/spec/libraries/cmp/cmpClient_spec.js b/test/spec/libraries/cmp/cmpClient_spec.js index ac84389766d..adbbbf5cb1d 100644 --- a/test/spec/libraries/cmp/cmpClient_spec.js +++ b/test/spec/libraries/cmp/cmpClient_spec.js @@ -1,7 +1,6 @@ import {cmpClient, MODE_CALLBACK, MODE_RETURN} from '../../../../libraries/cmp/cmpClient.js'; describe('cmpClient', () => { - function mockWindow(props = {}) { let listeners = []; const win = {