diff --git a/modules/quantcastIdSystem.js b/modules/quantcastIdSystem.js index e86c130dc5b..9e2c53a28ff 100644 --- a/modules/quantcastIdSystem.js +++ b/modules/quantcastIdSystem.js @@ -7,11 +7,159 @@ import {submodule} from '../src/hook.js' import { getStorageManager } from '../src/storageManager.js'; +import { triggerPixel, logInfo } from '../src/utils.js'; +import { uspDataHandler, coppaDataHandler, gdprDataHandler } from '../src/adapterManager.js'; const QUANTCAST_FPA = '__qca'; +const DEFAULT_COOKIE_EXP_DAYS = 392; // (13 months - 2 days) +const DAY_MS = 86400000; +const PREBID_PCODE = 'p-KceJUEvXN48CE'; +const QSERVE_URL = 'https://pixel.quantserve.com/pixel'; +const QUANTCAST_VENDOR_ID = '11'; +const PURPOSE_DATA_COLLECT = '1'; +const PURPOSE_PRODUCT_IMPROVEMENT = '10'; +const QC_TCF_REQUIRED_PURPOSES = [PURPOSE_DATA_COLLECT, PURPOSE_PRODUCT_IMPROVEMENT]; +const QC_TCF_CONSENT_FIRST_PURPOSES = [PURPOSE_DATA_COLLECT]; +const QC_TCF_CONSENT_ONLY_PUPROSES = [PURPOSE_DATA_COLLECT]; +const GDPR_PRIVACY_STRING = gdprDataHandler.getConsentData(); +const US_PRIVACY_STRING = uspDataHandler.getConsentData(); export const storage = getStorageManager(); +export function firePixel(clientId, cookieExpDays = DEFAULT_COOKIE_EXP_DAYS) { + // check for presence of Quantcast Measure tag _qevent obj and publisher provided clientID + if (!window._qevents && clientId && clientId != '') { + var fpa = storage.getCookie(QUANTCAST_FPA); + var fpan = '0'; + var domain = quantcastIdSubmodule.findRootDomain(); + var now = new Date(); + var usPrivacyParamString = ''; + var firstPartyParamStrings; + var gdprParamStrings; + + if (!fpa) { + var et = now.getTime(); + var expires = new Date(et+ (cookieExpDays * DAY_MS)).toGMTString(); + var rand = Math.round(Math.random() * 2147483647); + fpa = `B0-${rand}-${et}`; + fpan = '1'; + storage.setCookie(QUANTCAST_FPA, fpa, expires, '/', domain, null); + } + + firstPartyParamStrings = `&fpan=${fpan}&fpa=${fpa}`; + gdprParamStrings = '&gdpr=0'; + if (GDPR_PRIVACY_STRING && typeof GDPR_PRIVACY_STRING.gdprApplies === 'boolean' && GDPR_PRIVACY_STRING.gdprApplies) { + gdprParamStrings = `gdpr=1&gdpr_consent=${GDPR_PRIVACY_STRING.consentString}`; + } + if (US_PRIVACY_STRING && typeof US_PRIVACY_STRING === 'string') { + usPrivacyParamString = `&us_privacy=${US_PRIVACY_STRING}`; + } + + let url = QSERVE_URL + + '?d=' + domain + + '&client_id=' + clientId + + '&a=' + PREBID_PCODE + + usPrivacyParamString + + gdprParamStrings + + firstPartyParamStrings; + + triggerPixel(url); + } +}; + +export function hasGDPRConsent(gdprConsent) { + // Check for GDPR consent for purpose 1 and 10, and drop request if consent has not been given + // Remaining consent checks are performed server-side. + if (gdprConsent && typeof gdprConsent.gdprApplies === 'boolean' && gdprConsent.gdprApplies) { + if (!gdprConsent.vendorData) { + return false; + } + if (gdprConsent.apiVersion === 1) { + // We are not supporting TCF v1 + return false; + } + if (gdprConsent.apiVersion === 2) { + return checkTCFv2(gdprConsent.vendorData); + } + } + return true; +} + +export function checkTCFv2(vendorData, requiredPurposes = QC_TCF_REQUIRED_PURPOSES) { + var gdprApplies = vendorData.gdprApplies; + var purposes = vendorData.purpose; + var vendors = vendorData.vendor; + var qcConsent = vendors && vendors.consents && vendors.consents[QUANTCAST_VENDOR_ID]; + var qcInterest = vendors && vendors.legitimateInterests && vendors.legitimateInterests[QUANTCAST_VENDOR_ID]; + var restrictions = vendorData.publisher ? vendorData.publisher.restrictions : {}; + + if (!gdprApplies) { + return true; + } + + return requiredPurposes.map(function(purpose) { + var purposeConsent = purposes.consents ? purposes.consents[purpose] : false; + var purposeInterest = purposes.legitimateInterests ? purposes.legitimateInterests[purpose] : false; + + var qcRestriction = restrictions && restrictions[purpose] + ? restrictions[purpose][QUANTCAST_VENDOR_ID] + : null; + + if (qcRestriction === 0) { + return false; + } + + // Seek consent or legitimate interest based on our default legal + // basis for the purpose, falling back to the other if possible. + if ( + // we have positive vendor consent + qcConsent && + // there is positive purpose consent + purposeConsent && + // publisher does not require legitimate interest + qcRestriction !== 2 && + // purpose is a consent-first purpose or publisher has explicitly restricted to consent + (QC_TCF_CONSENT_FIRST_PURPOSES.indexOf(purpose) != -1 || qcRestriction === 1) + ) { + return true; + } else if ( + // publisher does not require consent + qcRestriction !== 1 && + // we have legitimate interest for vendor + qcInterest && + // there is legitimate interest for purpose + purposeInterest && + // purpose's legal basis does not require consent + QC_TCF_CONSENT_ONLY_PUPROSES.indexOf(purpose) == -1 && + // purpose is a legitimate-interest-first purpose or publisher has explicitly restricted to legitimate interest + (QC_TCF_CONSENT_FIRST_PURPOSES.indexOf(purpose) == -1 || qcRestriction === 2) + ) { + return true; + } + + return false; + }).reduce(function(a, b) { + return a && b; + }, true); +} + +/** + * tests if us_privacy consent string is present, us_privacy applies, and notice_given / do-not-sell is set to yes + * @returns {boolean} + */ +export function hasCCPAConsent(usPrivacyConsent) { + if ( + usPrivacyConsent && + typeof usPrivacyConsent === 'string' && + usPrivacyConsent.length == 4 && + usPrivacyConsent.charAt(1) == 'Y' && + usPrivacyConsent.charAt(2) == 'Y' + ) { + return false + } + return true; +} + /** @type {Submodule} */ export const quantcastIdSubmodule = { /** @@ -20,6 +168,12 @@ export const quantcastIdSubmodule = { */ name: 'quantcastId', + /** + * Vendor id of Quantcast + * @type {Number} + */ + gvlid: QUANTCAST_VENDOR_ID, + /** * decode the stored id value for passing to bid requests * @function @@ -34,9 +188,34 @@ export const quantcastIdSubmodule = { * @function * @returns {{id: {quantcastId: string} | undefined}}} */ - getId() { + getId(config) { // Consent signals are currently checked on the server side. let fpa = storage.getCookie(QUANTCAST_FPA); + + const coppa = coppaDataHandler.getCoppa(); + + if (coppa || !hasCCPAConsent(US_PRIVACY_STRING) || !hasGDPRConsent(GDPR_PRIVACY_STRING)) { + var expired = new Date(0).toUTCString(); + var domain = quantcastIdSubmodule.findRootDomain(); + logInfo('QuantcastId: Necessary consent not present for Id, exiting QuantcastId'); + storage.setCookie(QUANTCAST_FPA, '', expired, '/', domain, null); + return undefined; + } + + const configParams = (config && config.params) || {}; + const storageParams = (config && config.storage) || {}; + + var clientId = configParams.clientId || ''; + var cookieExpDays = storageParams.expires || DEFAULT_COOKIE_EXP_DAYS; + + // Callbacks on Event Listeners won't trigger if the event is already complete so this check is required + if (document.readyState === 'complete') { + firePixel(clientId, cookieExpDays); + } + window.addEventListener('load', function () { + firePixel(clientId, cookieExpDays); + }); + return { id: fpa ? { quantcastId: fpa } : undefined } } }; diff --git a/test/spec/modules/quantcastIdSystem_spec.js b/test/spec/modules/quantcastIdSystem_spec.js index 12c8689fd3f..199f7e52be8 100644 --- a/test/spec/modules/quantcastIdSystem_spec.js +++ b/test/spec/modules/quantcastIdSystem_spec.js @@ -1,8 +1,19 @@ -import { quantcastIdSubmodule, storage } from 'modules/quantcastIdSystem.js'; +import { quantcastIdSubmodule, storage, firePixel, hasGDPRConsent, checkTCFv2 } from 'modules/quantcastIdSystem.js'; +import * as utils from 'src/utils.js'; +import {coppaDataHandler, gdprDataHandler, uspDataHandler} from 'src/adapterManager'; describe('QuantcastId module', function () { beforeEach(function() { storage.setCookie('__qca', '', 'Thu, 01 Jan 1970 00:00:00 GMT'); + sinon.stub(coppaDataHandler, 'getCoppa'); + sinon.stub(utils, 'triggerPixel'); + sinon.stub(window, 'addEventListener'); + }); + + afterEach(function () { + utils.triggerPixel.restore(); + coppaDataHandler.getCoppa.restore(); + window.addEventListener.restore(); }); it('getId() should return a quantcast id when the Quantcast first party cookie exists', function () { @@ -17,3 +28,360 @@ describe('QuantcastId module', function () { expect(id).to.be.deep.equal({id: undefined}); }); }); + +describe('QuantcastId fire pixel', function () { + beforeEach(function () { + storage.setCookie('__qca', '', 'Thu, 01 Jan 1970 00:00:00 GMT'); + sinon.stub(utils, 'triggerPixel'); + sinon.stub(uspDataHandler, 'getConsentData'); + sinon.stub(gdprDataHandler, 'getConsentData'); + }); + + afterEach(function () { + utils.triggerPixel.restore(); + uspDataHandler.getConsentData.restore(); + gdprDataHandler.getConsentData.restore(); + }); + + it('fpa should be set when not present on this call', function () { + firePixel('clientId'); + let urlString = utils.triggerPixel.getCall(0).args[0]; + let url = new URL(urlString); + let urlSearchParams = new URLSearchParams(url.search); + assert.equal(urlSearchParams.get('fpan'), '1'); + assert.notEqual(urlSearchParams.get('fpa'), null); + }); + + it('fpa should be extracted from the Quantcast first party cookie when present on this call', function () { + storage.setCookie('__qca', 'P0-TestFPA'); + firePixel('clientId'); + let urlString = utils.triggerPixel.getCall(0).args[0]; + let url = new URL(urlString); + let urlSearchParams = new URLSearchParams(url.search); + assert.equal(urlSearchParams.get('fpan'), '0'); + assert.equal(urlSearchParams.get('fpa'), 'P0-TestFPA'); + }); + + it('function to trigger pixel is called once', function () { + storage.setCookie('__qca', 'P0-TestFPA'); + firePixel('clientId'); + expect(utils.triggerPixel.calledOnce).to.equal(true); + }); + + it('function to trigger pixel is not called when client id is absent', function () { + storage.setCookie('__qca', 'P0-TestFPA'); + firePixel(); + expect(utils.triggerPixel.calledOnce).to.equal(false); + }); +}); + +describe('Quantcast CCPA consent check', function() { + it('returns true when CCPA constent string is not present', function() { + expect(hasCCPAConsent()).to.equal(true); + }); + + it("returns true when notice_given or do-not-sell in CCPA constent string is not 'Y' ", function() { + expect(hasCCPAConsent('1NNN')).to.equal(true); + expect(hasCCPAConsent('1YNN')).to.equal(true); + expect(hasCCPAConsent('1NYN')).to.equal(true); + }); + + it("returns false when CCPA consent string is present, and notice_given or do-not-sell in the string is 'Y' ", function() { + expect(hasCCPAConsent('1YYN')).to.equal(false); + }); +}); + +describe('Quantcast GDPR consent check', function() { + it("returns true when GDPR doesn't apply", function() { + expect(hasGDPRConsent({gdprApplies: false})).to.equal(true); + }); + + it('returns false if denied consent, even if special purpose 1 treatment is true in DE', function() { + expect(checkTCFv2({ + gdprApplies: true, + publisherCC: 'DE', + purposeOneTreatment: true, + vendor: { + consents: { '11': false } + }, + purpose: { + consents: { '1': false } + }, + publisher: { + restrictions: { + '1': { + '11': 0 // flatly disallow Quantcast + } + } + } + }, ['1'])).to.equal(false); + }); + + it('returns false if publisher flatly denies required purpose', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true } + }, + purpose: { + consents: { '1': true } + }, + publisher: { + restrictions: { + '1': { + '11': 0 // flatly disallow Quantcast + } + } + } + }, ['1'])).to.equal(false); + }); + + it('returns true if positive consent for required purpose', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true } + }, + purpose: { + consents: { '1': true } + } + }, ['1'])).to.equal(true); + }); + + it('returns false if positive consent but publisher requires legitimate interest for required purpose', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true } + }, + purpose: { + consents: { '1': true } + }, + publisher: { + restrictions: { + '1': { + '11': 2 // require legitimate interest for Quantcast + } + } + } + }, ['1'])).to.equal(false); + }); + + it('returns false if no vendor consent and no legitimate interest', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': false } + }, + purpose: { + consents: { '1': true } + } + }, ['1'])).to.equal(false); + }); + + it('returns false if no purpose consent and no legitimate interest', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true } + }, + purpose: { + consents: { '1': false } + } + }, ['1'])).to.equal(false); + }); + + it('returns false if no consent, but legitimate interest for consent-first purpose, and no restrictions specified', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true }, + legitimateInterests: { '11': true } + }, + purpose: { + consents: { '1': false }, + legitimateInterests: { '1': true } + } + }, ['1'])).to.equal(false); + }); + + it('returns false if consent, but no legitimate interest for legitimate-interest-first purpose, and no restrictions specified', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true }, + legitimateInterests: { '11': true } + }, + purpose: { + consents: { '10': true }, + legitimateInterests: { '10': false } + } + }, ['10'])).to.equal(false); + }); + + it('returns true if consent, but no legitimate interest for legitimate-interest-first purpose, and corresponding consent restriction specified', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true }, + legitimateInterests: { '11': true } + }, + purpose: { + consents: { '10': true }, + legitimateInterests: { '10': false } + }, + publisher: { + restrictions: { + '10': { + '11': 1 // require consent for Quantcast + } + } + } + }, ['10'])).to.equal(true); + }); + + it('returns false if no consent but legitimate interest for required purpose other than 1, but publisher requires consent', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': false }, + legitimateInterests: { '11': true } + }, + purpose: { + consents: { '10': false }, + legitimateInterests: { '10': true } + }, + publisher: { + restrictions: { + '10': { + '11': 1 // require consent for Quantcast + } + } + } + }, ['10'])).to.equal(false); + }); + + it('returns false if no consent and no legitimate interest for vendor for required purpose other than 1', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': false }, + legitimateInterests: { '11': false } + }, + purpose: { + consents: { '10': false }, + legitimateInterests: { '10': true } + } + }, ['10'])).to.equal(false); + }); + + it('returns false if no consent and no legitimate interest for required purpose other than 1', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': false }, + legitimateInterests: { '11': true } + }, + purpose: { + consents: { '10': false }, + legitimateInterests: { '10': false } + } + }, ['10'])).to.equal(false); + }); + + it('returns false if no consent but legitimate interest for required purpose, but required purpose is purpose 1', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': false }, + legitimateInterests: { '11': true } + }, + purpose: { + consents: { '1': false }, + legitimateInterests: { '1': true } + } + }, ['1'])).to.equal(false); + }); + + it('returns true if different legal bases for multiple required purposes', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true }, + legitimateInterests: { '11': true } + }, + purpose: { + consents: { + '1': true, + '10': false + }, + legitimateInterests: { + '1': false, + '10': true + } + }, + publisher: { + restrictions: { + '10': { + '11': 2 // require legitimate interest for Quantcast + } + } + } + })).to.equal(true); + }); + + it('returns true if full consent and legitimate interest for all required purposes with no restrictions specified', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true }, + legitimateInterests: { '11': true } + }, + purpose: { + consents: { + '1': true, + '3': true, + '7': true, + '8': true, + '9': true, + '10': true + }, + legitimateInterests: { + '1': true, + '3': true, + '7': true, + '8': true, + '9': true, + '10': true + } + } + })).to.equal(true); + }); + + it('returns false if one of multiple required purposes has no legal basis', function() { + expect(checkTCFv2({ + gdprApplies: true, + vendor: { + consents: { '11': true }, + legitimateInterests: { '11': true } + }, + purpose: { + consents: { + '1': true, + '10': false + }, + legitimateInterests: { + '11': false, + '10': true + } + }, + publisher: { + restrictions: { + '10': { + '11': 1 // require consent for Quantcast + } + } + } + })).to.equal(false); + }); +});