Skip to content
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

GDPR (consentManagement): fix actionTimeout behavior #9600

Merged
merged 3 commits into from
Feb 28, 2023
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
85 changes: 38 additions & 47 deletions modules/consentManagement.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,12 @@ const CMP_VERSION = 2;

export let userCMP;
export let consentTimeout;
export let actionTimeout;
export let gdprScope;
export let staticConsentData;
let actionTimeout;

let consentData;
let addedConsentHook = false;
let provisionalConsent;
let onTimeout;
let timer = null;
let actionTimer = null;

// add new CMPs here, with their dedicated lookup function
const cmpCallMap = {
Expand All @@ -43,20 +39,14 @@ function lookupStaticConsentData({onSuccess, onError}) {
processCmpData(staticConsentData, {onSuccess, onError})
}

export function setActionTimeout(timeout = setTimeout) {
clearTimeout(timer);
timer = null;
actionTimer = timeout(onTimeout, actionTimeout);
}

/**
* 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
* based on the appropriate result.
* @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)
*/
function lookupIabConsent({onSuccess, onError}) {
function lookupIabConsent({onSuccess, onError, onEvent}) {
function findCMP() {
let f = window;
let cmpFrame;
Expand Down Expand Up @@ -90,11 +80,9 @@ function lookupIabConsent({onSuccess, onError}) {
function cmpResponseCallback(tcfData, success) {
logInfo('Received a response from CMP', tcfData);
if (success) {
onEvent(tcfData);
if (tcfData.gdprApplies === false || tcfData.eventStatus === 'tcloaded' || tcfData.eventStatus === 'useractioncomplete') {
processCmpData(tcfData, {onSuccess, onError});
} else {
provisionalConsent = tcfData;
if (!isNaN(actionTimeout) && actionTimer === null && timer != null) setActionTimeout();
}
} else {
onError('CMP unable to register callback function. Please check CMP setup.');
Expand Down Expand Up @@ -173,20 +161,27 @@ function lookupIabConsent({onSuccess, onError}) {
* @param cb A callback that takes: a boolean that is true if the auction should be canceled; an error message and extra
* error arguments that will be undefined if there's no error.
*/
export function loadConsentData(cb, callMap = cmpCallMap, timeout = setTimeout) {
function loadConsentData(cb) {
let isDone = false;
let timer = null;
let onTimeout, provisionalConsent;
let cmpLoaded = false;

function done(consentData, shouldCancelAuction, errMsg, ...extraArgs) {
function resetTimeout(timeout) {
if (timer != null) {
clearTimeout(timer);
timer = null;
}

if (actionTimer != null) {
clearTimeout(actionTimer);
actionTimer = null;
if (!isDone && timeout != null) {
if (timeout === 0) {
onTimeout()
} else {
timer = setTimeout(onTimeout, timeout);
}
}
}

function done(consentData, shouldCancelAuction, errMsg, ...extraArgs) {
resetTimeout(null);
isDone = true;
gdprDataHandler.setConsentData(consentData);
if (typeof cb === 'function') {
Expand All @@ -203,32 +198,30 @@ export function loadConsentData(cb, callMap = cmpCallMap, timeout = setTimeout)
onSuccess: (data) => done(data, false),
onError: function (msg, ...extraArgs) {
done(null, true, msg, ...extraArgs);
},
onEvent: function (consentData) {
provisionalConsent = consentData;
if (cmpLoaded) return;
cmpLoaded = true;
if (actionTimeout != null) {
resetTimeout(actionTimeout);
}
}
}

callMap[userCMP](callbacks);

if (!isDone) {
onTimeout = () => {
const continueToAuction = (data) => {
done(data, false, 'CMP did not load, continuing auction...');
}
processCmpData(provisionalConsent, {
onSuccess: continueToAuction,
onError: () => continueToAuction(storeConsentData(undefined))
})
onTimeout = () => {
const continueToAuction = (data) => {
done(data, false, `${cmpLoaded ? 'Timeout waiting for user action on CMP' : 'CMP did not load'}, continuing auction...`);
}
processCmpData(provisionalConsent, {
onSuccess: continueToAuction,
onError: () => continueToAuction(storeConsentData(undefined)),
})
}

if (consentTimeout === 0) {
onTimeout();
} else {
if (timer != null) {
clearTimeout(timer);
timer = null;
}

timer = timeout(onTimeout, consentTimeout);
}
cmpCallMap[userCMP](callbacks);
if (!(actionTimeout != null && cmpLoaded)) {
resetTimeout(consentTimeout);
}
}

Expand Down Expand Up @@ -352,17 +345,15 @@ export function setConsentConfig(config) {
logInfo(`consentManagement config did not specify cmp. Using system default setting (${DEFAULT_CMP}).`);
}

if (isNumber(config.actionTimeout)) {
actionTimeout = config.actionTimeout;
}

if (isNumber(config.timeout)) {
consentTimeout = config.timeout;
} else {
consentTimeout = DEFAULT_CONSENT_TIMEOUT;
logInfo(`consentManagement config did not specify timeout. Using system default setting (${DEFAULT_CONSENT_TIMEOUT}).`);
}

actionTimeout = isNumber(config.actionTimeout) ? config.actionTimeout : null;

// if true, then gdprApplies should be set to true
gdprScope = config.defaultGdprScope === true;

Expand Down
77 changes: 40 additions & 37 deletions test/spec/modules/consentManagement_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,46 @@ describe('consentManagement', function () {
});
});

it('should timeout after actionTimeout from the first CMP event', (done) => {
mockTcfEvent({
eventStatus: 'cmpuishown',
tcString: 'mock-consent-string',
vendorData: {}
});
setConsentConfig({
timeout: 1000,
actionTimeout: 100,
cmpApi: 'iab',
defaultGdprScope: true
});
let hookRan = false;
requestBidsHook(() => {
hookRan = true;
}, {});
setTimeout(() => {
expect(hookRan).to.be.true;
done();
}, 200)
});

it('should still pick up consent data when actionTimeout is 0', (done) => {
mockTcfEvent({
eventStatus: 'tcloaded',
tcString: 'mock-consent-string',
vendorData: {}
});
setConsentConfig({
timeout: 1000,
actionTimeout: 0,
cmpApi: 'iab',
defaultGdprScope: true
});
requestBidsHook(() => {
expect(gdprDataHandler.getConsentData().consentString).to.eql('mock-consent-string');
done();
}, {})
})

Object.entries({
'null': null,
'empty': '',
Expand Down Expand Up @@ -737,41 +777,4 @@ describe('consentManagement', function () {
});
});
});

describe('actionTimeout', function () {
afterEach(function () {
config.resetConfig();
resetConsentData();
});

it('should set actionTimeout if present', () => {
setConsentConfig({
gdpr: { timeout: 5000, actionTimeout: 5500 }
});

expect(userCMP).to.be.equal('iab');
expect(consentTimeout).to.be.equal(5000);
expect(actionTimeout).to.be.equal(5500);
});

it('should utilize actionTimeout duration on initial user visit when user action is pending', () => {
const cb = () => {};
const cmpCallMap = {
'iab': () => {},
'static': () => {}
};
const timeout = sinon.spy();

setConsentConfig({
gdpr: { timeout: 5000, actionTimeout: 5500 }
});
loadConsentData(cb, cmpCallMap, timeout);

sinon.assert.calledWith(timeout, sinon.match.any, 5000);

setActionTimeout();

timeout.lastCall.lastArg === 5500;
});
});
});