Skip to content

Prebid core: add support for asynchronous access to consent data #8071

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion modules/consentManagement.js
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ export function requestBidsHook(fn, reqBidsConfigObj) {

if (!includes(Object.keys(cmpCallMap), userCMP)) {
logWarn(`CMP framework (${userCMP}) is not a supported framework. Aborting consentManagement module and resuming auction.`);
gdprDataHandler.setConsentData(null);
return hookConfig.nextFn.apply(hookConfig.context, hookConfig.args);
}

Expand Down Expand Up @@ -452,6 +453,7 @@ function exitModule(errMsg, hookConfig, extraArgs) {
nextFn.apply(context, args);
} else {
logError(errMsg + ' Canceling auction as per consentManagement config.', extraArgs);
gdprDataHandler.setConsentData(null);
if (typeof hookConfig.bidsBackHandler === 'function') {
hookConfig.bidsBackHandler();
} else {
Expand All @@ -471,7 +473,7 @@ export function resetConsentData() {
consentData = undefined;
userCMP = undefined;
cmpVersion = 0;
gdprDataHandler.setConsentData(null);
gdprDataHandler.reset();
}

/**
Expand Down Expand Up @@ -509,6 +511,7 @@ export function setConsentConfig(config) {
gdprScope = config.defaultGdprScope === true;

logInfo('consentManagement module has been activated...');
gdprDataHandler.enable();

if (userCMP === 'static') {
if (isPlainObject(config.consentData)) {
Expand Down
5 changes: 4 additions & 1 deletion modules/consentManagementUsp.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export function requestBidsHook(fn, reqBidsConfigObj) {

if (!uspCallMap[consentAPI]) {
logWarn(`USP framework (${consentAPI}) is not a supported framework. Aborting consentManagement module and resuming auction.`);
uspDataHandler.setConsentData(null);
return hookConfig.nextFn.apply(hookConfig.context, hookConfig.args);
}

Expand Down Expand Up @@ -276,6 +277,7 @@ function exitModule(errMsg, hookConfig, extraArgs) {

if (errMsg) {
logWarn(errMsg + ' Resuming auction without consent data as per consentManagement config.', extraArgs);
uspDataHandler.setConsentData(null) // let core know that no consent data is available
}
nextFn.apply(context, args);
}
Expand All @@ -287,7 +289,7 @@ function exitModule(errMsg, hookConfig, extraArgs) {
export function resetConsentData() {
consentData = undefined;
consentAPI = undefined;
uspDataHandler.setConsentData(null);
uspDataHandler.reset();
}

/**
Expand Down Expand Up @@ -315,6 +317,7 @@ export function setConsentConfig(config) {
}

logInfo('USPAPI consentManagement module has been activated...');
uspDataHandler.enable();

if (consentAPI === 'static') {
if (isPlainObject(config.consentData) && isPlainObject(config.consentData.getUSPData)) {
Expand Down
45 changes: 3 additions & 42 deletions src/adapterManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
logWarn,
shuffle,
timestamp,
isStr
} from './utils.js';
import { getLabels, resolveStatus } from './sizeMapping.js';
import { decorateAdUnitsWithNativeParams, nativeAdapters } from './native.js';
Expand All @@ -32,6 +31,7 @@ import includes from 'core-js-pure/features/array/includes.js';
import find from 'core-js-pure/features/array/find.js';
import { adunitCounter } from './adUnits.js';
import { getRefererInfo } from './refererDetection.js';
import {GdprConsentHandler, UspConsentHandler} from './consentHandler.js';

var CONSTANTS = require('./constants.json');
var events = require('./events.js');
Expand Down Expand Up @@ -172,47 +172,8 @@ function getAdUnitCopyForClientAdapters(adUnits) {
return adUnitsClientCopy;
}

export let gdprDataHandler = {
consentData: null,
generatedTime: null,
setConsentData: function(consentInfo, time = timestamp()) {
gdprDataHandler.consentData = consentInfo;
gdprDataHandler.generatedTime = time;
},
getConsentData: function() {
return gdprDataHandler.consentData;
},
getConsentMeta: function() {
if (gdprDataHandler.consentData && gdprDataHandler.consentData.vendorData && gdprDataHandler.generatedTime) {
return {
gdprApplies: gdprDataHandler.consentData.gdprApplies,
consentStringSize: (isStr(gdprDataHandler.consentData.vendorData.tcString)) ? gdprDataHandler.consentData.vendorData.tcString.length : 0,
generatedAt: gdprDataHandler.generatedTime,
apiVersion: gdprDataHandler.consentData.apiVersion
};
}
}
};

export let uspDataHandler = {
consentData: null,
generatedTime: null,
setConsentData: function(consentInfo, time = timestamp()) {
uspDataHandler.consentData = consentInfo;
uspDataHandler.generatedTime = time;
},
getConsentData: function() {
return uspDataHandler.consentData;
},
getConsentMeta: function() {
if (uspDataHandler.consentData && uspDataHandler.generatedTime) {
return {
usp: uspDataHandler.consentData,
generatedAt: uspDataHandler.generatedTime
}
}
}
};
export let gdprDataHandler = new GdprConsentHandler();
export let uspDataHandler = new UspConsentHandler();

export let coppaDataHandler = {
getCoppa: function() {
Expand Down
98 changes: 98 additions & 0 deletions src/consentHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {isStr, timestamp} from './utils.js';

export class ConsentHandler {
#enabled;
#data;
#promise;
#resolve;
#ready;
generatedTime;

constructor() {
this.reset();
}

/**
* reset this handler (mainly for tests)
*/
reset() {
this.#promise = new Promise((resolve) => {
this.#resolve = (data) => {
this.#ready = true;
this.#data = data;
resolve(data);
};
});
this.#enabled = false;
this.#data = null;
this.#ready = false;
this.generatedTime = null;
}

/**
* Enable this consent handler. This should be called by the relevant consent management module
* on initialization.
*/
enable() {
this.#enabled = true;
}

/**
* @returns {boolean} true if the related consent management module is enabled.
*/
get enabled() {
return this.#enabled;
}

/**
* @returns {boolean} true if consent data has been resolved (it may be `null` if the resolution failed).
*/
get ready() {
return this.#ready;
}

/**
* @returns a promise than resolves to the consent data, or null if no consent data is available
*/
get promise() {
if (!this.#enabled) {
this.#resolve(null);
Copy link
Contributor

@Fawke Fawke Feb 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would rejecting the promise suit better here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so (but I'm not that sure). My reasoning is that unless you make it so that data is never null when the promise resolves, it's just asking the caller to add some complexity, because they will have to check for null and treat it as an error anyways. I am not against making every null (including, for example, USP timeouts) a rejection, but it also makes sense to me that you would want to have exactly what getConsentData returns, just asynchronously.

If someone is interested in whether the module is installed, they could look at .enabled.

}
return this.#promise;
}

setConsentData(data, time = timestamp()) {
this.generatedTime = time;
this.#resolve(data);
}

getConsentData() {
return this.#data;
}
}

export class UspConsentHandler extends ConsentHandler {
getConsentMeta() {
const consentData = this.getConsentData();
if (consentData && this.generatedTime) {
return {
usp: consentData,
generatedAt: this.generatedTime
};
}
}
}

export class GdprConsentHandler extends ConsentHandler {
getConsentMeta() {
const consentData = this.getConsentData();
if (consentData && consentData.vendorData && this.generatedTime) {
return {
gdprApplies: consentData.gdprApplies,
consentStringSize: (isStr(consentData.vendorData.tcString)) ? consentData.vendorData.tcString.length : 0,
generatedAt: this.generatedTime,
apiVersion: consentData.apiVersion
}
}
}
}
45 changes: 43 additions & 2 deletions test/spec/modules/consentManagementUsp_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +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 {gdprDataHandler, uspDataHandler} from 'src/adapterManager.js';
import 'src/prebid.js';

let assert = require('chai').assert;
let expect = require('chai').expect;

function createIFrameMarker() {
Expand Down Expand Up @@ -97,6 +97,21 @@ describe('consentManagement', function () {
expect(consentAPI).to.be.equal('daa');
expect(consentTimeout).to.be.equal(7500);
});

it('should enable uspDataHandler', () => {
setConsentConfig({usp: {cmpApi: 'daa', timeout: 7500}});
expect(uspDataHandler.enabled).to.be.true;
});

it('should call setConsentData(null) on invalid CMP api', () => {
setConsentConfig({usp: {cmpApi: 'invalid'}});
let hookRan = false;
requestBidsHook(() => {
hookRan = true;
}, {});
expect(hookRan).to.be.true;
expect(uspDataHandler.ready).to.be.true;
});
});

describe('static consent string setConsentConfig value', () => {
Expand Down Expand Up @@ -226,6 +241,32 @@ describe('consentManagement', function () {
expect(consent).to.equal(testConsentData.uspString);
sinon.assert.called(uspStub);
});

it('should call uspDataHandler.setConsentData(null) on error', () => {
let hookRan = false;
uspStub = sinon.stub(window, '__uspapi').callsFake((...args) => {
args[2](null, false);
});
requestBidsHook(() => {
hookRan = true;
}, {});
expect(hookRan).to.be.true;
expect(uspDataHandler.ready).to.be.true;
expect(uspDataHandler.getConsentData()).to.equal(null);
});

it('should call uspDataHandler.setConsentData(null) on timeout', (done) => {
setConsentConfig({usp: {timeout: 10}});
let hookRan = false;
uspStub = sinon.stub(window, '__uspapi').callsFake(() => {});
requestBidsHook(() => { hookRan = true; }, {});
setTimeout(() => {
expect(hookRan).to.be.true;
expect(uspDataHandler.ready).to.be.true;
expect(uspDataHandler.getConsentData()).to.equal(null);
done();
}, 20)
});
});

describe('USPAPI workflow for iframed page', function () {
Expand Down
17 changes: 17 additions & 0 deletions test/spec/modules/consentManagement_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { setConsentConfig, requestBidsHook, resetConsentData, userCMP, consentTi
import { gdprDataHandler } from 'src/adapterManager.js';
import * as utils from 'src/utils.js';
import { config } from 'src/config.js';
import 'src/prebid.js';

let expect = require('chai').expect;

Expand Down Expand Up @@ -131,6 +132,11 @@ describe('consentManagement', function () {
});
expect(gdprScope).to.be.equal(false);
});

it('should enable gdprDataHandler', () => {
setConsentConfig({gdpr: {}});
expect(gdprDataHandler.enabled).to.be.true;
});
});

describe('static consent string setConsentConfig value', () => {
Expand Down Expand Up @@ -325,6 +331,14 @@ describe('consentManagement', function () {
expect(consent).to.be.null;
});

it('should call gpdrDataHandler.setConsentData() when unknown CMP api is used', () => {
setConsentConfig({gdpr: {cmpApi: 'invalid'}});
let hookRan = false;
requestBidsHook(() => { hookRan = true; }, {});
expect(hookRan).to.be.true;
expect(gdprDataHandler.ready).to.be.true;
})

it('should throw proper errors when CMP is not found', function () {
setConsentConfig(goodConfigWithCancelAuction);

Expand All @@ -336,6 +350,7 @@ describe('consentManagement', function () {
sinon.assert.calledTwice(utils.logError);
expect(didHookReturn).to.be.false;
expect(consent).to.be.null;
expect(gdprDataHandler.ready).to.be.true;
});
});

Expand Down Expand Up @@ -747,6 +762,7 @@ describe('consentManagement', function () {
expect(didHookReturn).to.be.false;
expect(bidsBackHandlerReturn).to.be.true;
expect(consent).to.be.null;
expect(gdprDataHandler.ready).to.be.true;
});

it('allows the auction when CMP is unresponsive', (done) => {
Expand All @@ -765,6 +781,7 @@ describe('consentManagement', function () {
const consent = gdprDataHandler.getConsentData();
expect(consent.gdprApplies).to.be.true;
expect(consent.consentString).to.be.undefined;
expect(gdprDataHandler.ready).to.be.true;
done();
}, 20);
});
Expand Down
41 changes: 41 additions & 0 deletions test/spec/unit/core/consentHandler_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {ConsentHandler} from '../../../../src/consentHandler.js';

describe('Consent data handler', () => {
let handler;
beforeEach(() => {
handler = new ConsentHandler();
})

it('should be disabled, return null data on init', () => {
expect(handler.enabled).to.be.false;
expect(handler.getConsentData()).to.equal(null);
})

it('should resolve promise to null when disabled', () => {
return handler.promise.then((data) => {
expect(data).to.equal(null);
});
});

it('should return data after setConsentData', () => {
const data = {consent: 'string'};
handler.enable();
handler.setConsentData(data);
expect(handler.getConsentData()).to.equal(data);
});

it('should resolve .promise to data after setConsentData', (done) => {
let actual = null;
const data = {consent: 'string'};
handler.enable();
handler.promise.then((d) => actual = d);
setTimeout(() => {
expect(actual).to.equal(null);
handler.setConsentData(data);
setTimeout(() => {
expect(actual).to.equal(data);
done();
})
})
});
})