From 0a7ca9f6782263020f590a15daf003c49e47df89 Mon Sep 17 00:00:00 2001 From: Demetrio Girardi Date: Thu, 1 Jun 2023 11:03:27 -0700 Subject: [PATCH] Core & Multiple modules: activity controls (#9802) * Core: allow restriction of cookies / localStorage through `bidderSettings.*.storageAllowed` * Add test cases * Remove gvlid param from storage manager logic * Refactor every invocation of `getStorageManager` * GVL ID registry * Refactor gdprEnforcement gvlid lookup * fix lint * Remove empty file * Undo https://github.com/prebid/Prebid.js/pull/9728 for realVu * Fix typo * Activity control rules * Rule un-registration * fetchBids enforcement * fetchBids rule for gdpr * enableAnalytics check * reportAnalytics TCF2 rule * Update logging condition for multiple GVL IDs * Change core to prebid * Refactor userID to use non-core storage manager when storing for submodules * enrichEids check * gdpr enforcement for enrichEids * syncUser activity check * gdpr enforcement for syncUser * refactor gdprEnforcement * storageManager activity checks * gdpr enforcement for accessDevice * move alias resolution logic to adapterManager * Refactor file structure to get around circular deps * transmit(Eids/Ufpd/PreciseGeo) enforcement for bid adapters * Object transformers and guards * transmit* and enrich* enforcement for RTD modules * allowActivities configuration * improve comments * do not pass private activity params to pub-defined rules * fix objectGuard edge case: null values * move config logic into a module * dedupe log messages --- libraries/objectGuard/objectGuard.js | 108 ++++ libraries/objectGuard/ortbGuard.js | 88 +++ modules/allowActivities.js | 74 +++ modules/gdprEnforcement.js | 267 ++++----- modules/rtdModule/index.js | 13 +- modules/userId/index.js | 110 ++-- src/activities/activities.js | 47 ++ src/activities/activityParams.js | 8 + src/activities/modules.js | 2 +- src/activities/params.js | 59 ++ src/activities/redactor.js | 157 ++++++ src/activities/rules.js | 95 ++++ src/adapterManager.js | 53 +- src/fpd/rootDomain.js | 2 +- src/storageManager.js | 101 ++-- src/userSync.js | 53 +- test/spec/activities/allowActivites_spec.js | 138 +++++ test/spec/activities/objectGuard_spec.js | 144 +++++ test/spec/activities/ortbGuard_spec.js | 140 +++++ test/spec/activities/params_spec.js | 25 + test/spec/activities/redactor_spec.js | 296 ++++++++++ test/spec/activities/rules_spec.js | 135 +++++ test/spec/modules/gdprEnforcement_spec.js | 541 +++++-------------- test/spec/modules/realTimeDataModule_spec.js | 2 +- test/spec/modules/userId_spec.js | 101 +++- test/spec/unit/core/adapterManager_spec.js | 182 ++++++- test/spec/unit/core/storageManager_spec.js | 144 ++--- test/spec/unit/pbjs_api_spec.js | 1 + test/spec/userSync_spec.js | 61 ++- 29 files changed, 2343 insertions(+), 804 deletions(-) create mode 100644 libraries/objectGuard/objectGuard.js create mode 100644 libraries/objectGuard/ortbGuard.js create mode 100644 modules/allowActivities.js create mode 100644 src/activities/activities.js create mode 100644 src/activities/activityParams.js create mode 100644 src/activities/params.js create mode 100644 src/activities/redactor.js create mode 100644 src/activities/rules.js create mode 100644 test/spec/activities/allowActivites_spec.js create mode 100644 test/spec/activities/objectGuard_spec.js create mode 100644 test/spec/activities/ortbGuard_spec.js create mode 100644 test/spec/activities/params_spec.js create mode 100644 test/spec/activities/redactor_spec.js create mode 100644 test/spec/activities/rules_spec.js diff --git a/libraries/objectGuard/objectGuard.js b/libraries/objectGuard/objectGuard.js new file mode 100644 index 00000000000..a404f8653f8 --- /dev/null +++ b/libraries/objectGuard/objectGuard.js @@ -0,0 +1,108 @@ +import {isData, objectTransformer} from '../../src/activities/redactor.js'; +import {deepAccess, deepClone, deepEqual, deepSetValue} from '../../src/utils.js'; + +/** + * @typedef {Object} ObjectGuard + * @property {*} obj a view on the guarded object + * @property {function(): void} verify a function that checks for and rolls back disallowed changes to the guarded object + */ + +/** + * Create a factory function for object guards using the given rules. + * + * An object guard is a pair {obj, verify} where: + * - `obj` is a view on the guarded object that applies "redact" rules (the same rules used in activites/redactor.js) + * - `verify` is a function that, when called, will check that the guarded object was not modified + * in a way that violates any "write protect" rules, and rolls back any offending changes. + * + * This is meant to provide sandboxed version of a privacy-sensitive object, where reads + * are filtered through redaction rules and writes are checked against write protect rules. + * + * @param {Array[TransformationRule]} rules + * @return {function(*, ...[*]): ObjectGuard} + */ +export function objectGuard(rules) { + const root = {}; + const writeRules = []; + + rules.forEach(rule => { + if (rule.wp) writeRules.push(rule); + if (!rule.get) return; + rule.paths.forEach(path => { + let node = root; + path.split('.').forEach(el => { + node.children = node.children || {}; + node.children[el] = node.children[el] || {}; + node = node.children[el]; + }) + node.rule = rule; + }); + }); + + const wpTransformer = objectTransformer(writeRules); + + function mkApplies(session, args) { + return function applies(rule) { + if (!session.hasOwnProperty(rule.name)) { + session[rule.name] = rule.applies(...args); + } + return session[rule.name]; + } + } + + function mkGuard(obj, tree, applies) { + return new Proxy(obj, { + get(target, prop, receiver) { + const val = Reflect.get(target, prop, receiver); + if (tree.hasOwnProperty(prop)) { + const {children, rule} = tree[prop]; + if (children && val != null && typeof val === 'object') { + return mkGuard(val, children, applies); + } else if (rule && isData(val) && applies(rule)) { + return rule.get(val); + } + } + return val; + }, + }); + } + + function mkVerify(transformResult) { + return function () { + transformResult.forEach(fn => fn()); + } + } + + return function guard(obj, ...args) { + const session = {}; + return { + obj: mkGuard(obj, root.children || {}, mkApplies(session, args)), + verify: mkVerify(wpTransformer(session, obj, ...args)) + } + }; +} + +/** + * @param {TransformationRuleDef} ruleDef + * @return {TransformationRule} + */ +export function writeProtectRule(ruleDef) { + return Object.assign({ + wp: true, + run(root, path, object, property, applies) { + const origHasProp = object && object.hasOwnProperty(property); + const original = origHasProp ? object[property] : undefined; + const origCopy = origHasProp && original != null && typeof original === 'object' ? deepClone(original) : original; + return function () { + const object = path == null ? root : deepAccess(root, path); + const finalHasProp = object && isData(object[property]); + const finalValue = finalHasProp ? object[property] : undefined; + if (!origHasProp && finalHasProp && applies()) { + delete object[property]; + } else if ((origHasProp !== finalHasProp || finalValue !== original || !deepEqual(finalValue, origCopy)) && applies()) { + deepSetValue(root, (path == null ? [] : [path]).concat(property).join('.'), origCopy); + } + } + } + }, ruleDef) +} diff --git a/libraries/objectGuard/ortbGuard.js b/libraries/objectGuard/ortbGuard.js new file mode 100644 index 00000000000..7911b378c3d --- /dev/null +++ b/libraries/objectGuard/ortbGuard.js @@ -0,0 +1,88 @@ +import {isActivityAllowed} from '../../src/activities/rules.js'; +import {ACTIVITY_ENRICH_EIDS, ACTIVITY_ENRICH_UFPD} from '../../src/activities/activities.js'; +import { + appliesWhenActivityDenied, + ortb2TransmitRules, + ORTB_EIDS_PATHS, + ORTB_UFPD_PATHS +} from '../../src/activities/redactor.js'; +import {objectGuard, writeProtectRule} from './objectGuard.js'; +import {mergeDeep} from '../../src/utils.js'; + +function ortb2EnrichRules(isAllowed = isActivityAllowed) { + return [ + { + name: ACTIVITY_ENRICH_EIDS, + paths: ORTB_EIDS_PATHS, + applies: appliesWhenActivityDenied(ACTIVITY_ENRICH_EIDS, isAllowed) + }, + { + name: ACTIVITY_ENRICH_UFPD, + paths: ORTB_UFPD_PATHS, + applies: appliesWhenActivityDenied(ACTIVITY_ENRICH_UFPD, isAllowed) + } + ].map(writeProtectRule) +} + +export function ortb2GuardFactory(isAllowed = isActivityAllowed) { + return objectGuard(ortb2TransmitRules(isAllowed).concat(ortb2EnrichRules(isAllowed))); +} + +/** + * + * + * @typedef {Function} ortb2Guard + * @param {{}} ortb2 ORTB object to guard + * @param {{}} params activity params to use for activity checks + * @returns {ObjectGuard} + */ + +/* + * Get a guard for an ORTB object. Read access is restricted in the same way it'd be redacted (see activites/redactor.js); + * and writes are checked against the enrich* activites. + * + * @type ortb2Guard + */ +export const ortb2Guard = ortb2GuardFactory(); + +export function ortb2FragmentsGuardFactory(guardOrtb2 = ortb2Guard) { + return function guardOrtb2Fragments(fragments, params) { + fragments.global = fragments.global || {}; + fragments.bidder = fragments.bidder || {}; + const bidders = new Set(Object.keys(fragments.bidder)); + const verifiers = []; + + function makeGuard(ortb2) { + const guard = guardOrtb2(ortb2, params); + verifiers.push(guard.verify); + return guard.obj; + } + + const obj = { + global: makeGuard(fragments.global), + bidder: Object.fromEntries(Object.entries(fragments.bidder).map(([bidder, ortb2]) => [bidder, makeGuard(ortb2)])) + }; + + return { + obj, + verify() { + Object.entries(obj.bidder) + .filter(([bidder]) => !bidders.has(bidder)) + .forEach(([bidder, ortb2]) => { + const repl = {}; + const guard = guardOrtb2(repl, params); + mergeDeep(guard.obj, ortb2); + guard.verify(); + fragments.bidder[bidder] = repl; + }) + verifiers.forEach(fn => fn()); + } + } + } +} + +/** + * Get a guard for an ortb2Fragments object. + * @type {function(*, *): ObjectGuard} + */ +export const guardOrtb2Fragments = ortb2FragmentsGuardFactory(); diff --git a/modules/allowActivities.js b/modules/allowActivities.js new file mode 100644 index 00000000000..6af7eb36a62 --- /dev/null +++ b/modules/allowActivities.js @@ -0,0 +1,74 @@ +import {config} from '../src/config.js'; +import {registerActivityControl} from '../src/activities/rules.js'; + +const CFG_NAME = 'allowActivities'; +const RULE_NAME = `${CFG_NAME} config`; +const DEFAULT_PRIORITY = 1; + +export function updateRulesFromConfig(registerRule) { + const activeRuleHandles = new Map(); + const defaultRuleHandles = new Map(); + const rulesByActivity = new Map(); + + function clearAllRules() { + rulesByActivity.clear(); + Array.from(activeRuleHandles.values()) + .flatMap(ruleset => Array.from(ruleset.values())) + .forEach(fn => fn()); + activeRuleHandles.clear(); + Array.from(defaultRuleHandles.values()).forEach(fn => fn()); + defaultRuleHandles.clear(); + } + + function cleanParams(params) { + // remove private parameters for publisher condition checks + return Object.fromEntries(Object.entries(params).filter(([k]) => !k.startsWith('_'))) + } + + function setupRule(activity, priority) { + if (!activeRuleHandles.has(activity)) { + activeRuleHandles.set(activity, new Map()) + } + const handles = activeRuleHandles.get(activity); + if (!handles.has(priority)) { + handles.set(priority, registerRule(activity, RULE_NAME, function (params) { + for (const rule of rulesByActivity.get(activity).get(priority)) { + if (!rule.condition || rule.condition(cleanParams(params))) { + return {allow: rule.allow, reason: rule} + } + } + }, priority)); + } + } + + function setupDefaultRule(activity) { + if (!defaultRuleHandles.has(activity)) { + defaultRuleHandles.set(activity, registerRule(activity, RULE_NAME, function () { + return {allow: false, reason: 'activity denied by default'} + }, Number.POSITIVE_INFINITY)) + } + } + + config.getConfig(CFG_NAME, (cfg) => { + clearAllRules(); + Object.entries(cfg[CFG_NAME]).forEach(([activity, activityCfg]) => { + if (activityCfg.default === false) { + setupDefaultRule(activity); + } + const rules = new Map(); + rulesByActivity.set(activity, rules); + + (activityCfg.rules || []).forEach(rule => { + const priority = rule.priority == null ? DEFAULT_PRIORITY : rule.priority; + if (!rules.has(priority)) { + rules.set(priority, []) + } + rules.get(priority).push(rule); + }); + + Array.from(rules.keys()).forEach(priority => setupRule(activity, priority)); + }); + }) +} + +updateRulesFromConfig(registerActivityControl); diff --git a/modules/gdprEnforcement.js b/modules/gdprEnforcement.js index 798dfc848da..a834c0da2d5 100644 --- a/modules/gdprEnforcement.js +++ b/modules/gdprEnforcement.js @@ -2,30 +2,42 @@ * This module gives publishers extra set of features to enforce individual purposes of TCF v2 */ -import {deepAccess, hasDeviceAccess, isArray, logError, logWarn} from '../src/utils.js'; +import {deepAccess, logError, logWarn} from '../src/utils.js'; import {config} from '../src/config.js'; import adapterManager, {gdprDataHandler} from '../src/adapterManager.js'; -import {find, includes} from '../src/polyfill.js'; -import {registerSyncInner} from '../src/adapters/bidderFactory.js'; +import {find} from '../src/polyfill.js'; import {getHook} from '../src/hook.js'; -import {validateStorageEnforcement} from '../src/storageManager.js'; import * as events from '../src/events.js'; import CONSTANTS from '../src/constants.json'; import {GDPR_GVLIDS, VENDORLESS_GVLID} from '../src/consentHandler.js'; import { MODULE_TYPE_ANALYTICS, MODULE_TYPE_BIDDER, - MODULE_TYPE_CORE, MODULE_TYPE_RTD, + MODULE_TYPE_PREBID, + MODULE_TYPE_RTD, MODULE_TYPE_UID } from '../src/activities/modules.js'; +import { + ACTIVITY_PARAM_ANL_CONFIG, + ACTIVITY_PARAM_COMPONENT_NAME, + ACTIVITY_PARAM_COMPONENT_TYPE +} from '../src/activities/params.js'; +import {registerActivityControl} from '../src/activities/rules.js'; +import { + ACTIVITY_ACCESS_DEVICE, + ACTIVITY_ENRICH_EIDS, + ACTIVITY_FETCH_BIDS, + ACTIVITY_REPORT_ANALYTICS, + ACTIVITY_SYNC_USER +} from '../src/activities/activities.js'; export const STRICT_STORAGE_ENFORCEMENT = 'strictStorageEnforcement'; const TCF2 = { - 'purpose1': { id: 1, name: 'storage' }, - 'purpose2': { id: 2, name: 'basicAds' }, - 'purpose7': { id: 7, name: 'measurement' } -} + 'purpose1': {id: 1, name: 'storage'}, + 'purpose2': {id: 2, name: 'basicAds'}, + 'purpose7': {id: 7, name: 'measurement'} +}; /* These rules would be used if `consentManagement.gdpr.rules` is undefined by the publisher. @@ -48,9 +60,9 @@ export let purpose7Rule; export let enforcementRules; -const storageBlocked = []; -const biddersBlocked = []; -const analyticsBlocked = []; +const storageBlocked = new Set(); +const biddersBlocked = new Set(); +const analyticsBlocked = new Set(); let hooksAdded = false; let strictStorageEnforcement = false; @@ -62,6 +74,9 @@ const GVLID_LOOKUP_PRIORITY = [ MODULE_TYPE_RTD ]; +const RULE_NAME = 'TCF2'; +const RULE_HANDLES = []; + /** * Retrieve a module's GVL ID. */ @@ -73,7 +88,7 @@ export function getGvlid(moduleType, moduleName, fallbackFn) { // Return GVL ID from user defined gvlMapping if (gvlMapping && gvlMapping[moduleName]) { return gvlMapping[moduleName]; - } else if (moduleType === MODULE_TYPE_CORE) { + } else if (moduleType === MODULE_TYPE_PREBID) { return VENDORLESS_GVLID; } else { let {gvlid, modules} = GDPR_GVLIDS.get(moduleName); @@ -83,8 +98,8 @@ export function getGvlid(moduleType, moduleName, fallbackFn) { for (const type of GVLID_LOOKUP_PRIORITY) { if (modules.hasOwnProperty(type)) { gvlid = modules[type]; - if (type !== moduleType && !fallbackFn) { - logWarn(`Multiple GVL IDs found for module '${moduleName}'; using the ${type} module's ID (${gvlid}) instead of the ${moduleType}'s ID (${modules[moduleType]})`) + if (type !== moduleType) { + logWarn(`Multiple GVL IDs found for module '${moduleName}'; using the ${type} module's ID (${gvlid}) instead of the ${moduleType}'s ID (${modules[moduleType]})`); } break; } @@ -109,9 +124,9 @@ export function getGvlidFromAnalyticsAdapter(code, config) { try { return gvlid.call(adapter.adapter, config); } catch (e) { - logError(`Error invoking ${code} adapter.gvlid()`, e) + logError(`Error invoking ${code} adapter.gvlid()`, e); } - })(adapter?.adapter?.gvlid) + })(adapter?.adapter?.gvlid); } export function shouldEnforce(consentData, purpose, name) { @@ -120,7 +135,7 @@ export function shouldEnforce(consentData, purpose, name) { // NOTE: this check is not foolproof, as when Prebid first loads, enforcement hooks have not been attached yet // This piece of code would not run at all, and `gdprDataHandler.enabled` would be false, until the first // `setConfig({consentManagement})` - logWarn(`Attempting operation that requires purpose ${purpose} consent while consent data is not available${name ? ` (module: ${name})` : ''}. Assuming no consent was given.`) + logWarn(`Attempting operation that requires purpose ${purpose} consent while consent data is not available${name ? ` (module: ${name})` : ''}. Assuming no consent was given.`); return true; } return consentData && consentData.gdprApplies; @@ -142,7 +157,7 @@ export function validateRules(rule, consentData, currentModule, gvlId) { if ((rule.vendorExceptions || []).includes(currentModule)) { return true; } - const vendorConsentRequred = !((gvlId === VENDORLESS_GVLID || (rule.softVendorExceptions || []).includes(currentModule))) + const vendorConsentRequred = !((gvlId === VENDORLESS_GVLID || (rule.softVendorExceptions || []).includes(currentModule))); // get data from the consent string const purposeConsent = deepAccess(consentData, `vendorData.purpose.consents.${purposeId}`); @@ -169,170 +184,80 @@ export function validateRules(rule, consentData, currentModule, gvlId) { } /** - * This hook checks whether module has permission to access device or not. Device access include cookie and local storage + * all activity rules follow the same structure: + * if GDPR is in scope, check configuration for a particular purpose, and if that enables enforcement, + * check against consent data for that purpose and vendor * - * @param {Function} fn reference to original function (used by hook logic) - * @param {string} moduleType type of the module - * @param {string=} moduleName name of the module - * @param result - * @param validate + * @param purposeNo TCF purpose number to check for this activity + * @param getEnforcementRule getter for gdprEnforcement rule definition to use + * @param blocked optional set to use for collecting denied vendors + * @param gvlidFallback optional factory function for a gvlid falllback function */ -export function deviceAccessHook(fn, moduleType, moduleName, result, {validate = validateRules} = {}) { - result = Object.assign({}, { - hasEnforcementHook: true - }); - if (!hasDeviceAccess()) { - logWarn('Device access is disabled by Publisher'); - result.valid = false; - } else if (moduleType === MODULE_TYPE_CORE && !strictStorageEnforcement) { - // for vendorless (core) storage, do not enforce rules unless strictStorageEnforcement is set - result.valid = true; - } else { +function gdprRule(purposeNo, getEnforcementRule, blocked = null, gvlidFallback = () => null) { + return function (params) { const consentData = gdprDataHandler.getConsentData(); - let gvlid; - if (shouldEnforce(consentData, 1, moduleName)) { - const curBidder = config.getCurrentBidder(); - // Bidders have a copy of storage object with bidder code binded. Aliases will also pass the same bidder code when invoking storage functions and hence if alias tries to access device we will try to grab the gvl id for alias instead of original bidder - if (curBidder && (curBidder !== moduleName) && adapterManager.aliasRegistry[curBidder] === moduleName) { - gvlid = getGvlid(moduleType, curBidder); - } else { - gvlid = getGvlid(moduleType, moduleName) - } - const curModule = moduleName || curBidder; - let isAllowed = validate(purpose1Rule, consentData, curModule, gvlid,); - if (isAllowed) { - result.valid = true; - } else { - curModule && logWarn(`TCF2 denied device access for ${curModule}`); - result.valid = false; - storageBlocked.push(curModule); + const modName = params[ACTIVITY_PARAM_COMPONENT_NAME]; + if (shouldEnforce(consentData, purposeNo, modName)) { + const gvlid = getGvlid(params[ACTIVITY_PARAM_COMPONENT_TYPE], modName, gvlidFallback(params)); + let allow = !!validateRules(getEnforcementRule(), consentData, modName, gvlid); + if (!allow) { + blocked && blocked.add(modName); + return {allow}; } - } else { - result.valid = true; } - } - fn.call(this, moduleType, moduleName, result); + }; } -/** - * This hook checks if a bidder has consent for user sync or not - * @param {Function} fn reference to original function (used by hook logic) - * @param {...any} args args - */ -export function userSyncHook(fn, ...args) { - const consentData = gdprDataHandler.getConsentData(); - const curBidder = config.getCurrentBidder(); - if (shouldEnforce(consentData, 1, curBidder)) { - const gvlid = getGvlid(MODULE_TYPE_BIDDER, curBidder); - let isAllowed = validateRules(purpose1Rule, consentData, curBidder, gvlid); - if (isAllowed) { - fn.call(this, ...args); - } else { - logWarn(`User sync not allowed for ${curBidder}`); - storageBlocked.push(curBidder); - } - } else { - fn.call(this, ...args); - } -} +export const accessDeviceRule = ((rule) => { + return function (params) { + // for vendorless (core) storage, do not enforce rules unless strictStorageEnforcement is set + if (params[ACTIVITY_PARAM_COMPONENT_TYPE] === MODULE_TYPE_PREBID && !strictStorageEnforcement) return; + return rule(params); + }; +})(gdprRule(1, () => purpose1Rule, storageBlocked)); + +export const syncUserRule = gdprRule(1, () => purpose1Rule, storageBlocked); +export const enrichEidsRule = gdprRule(1, () => purpose1Rule, storageBlocked); -/** - * This hook checks if user id module is given consent or not - * @param {Function} fn reference to original function (used by hook logic) - * @param {Submodule[]} submodules Array of user id submodules - * @param {Object} consentData GDPR consent data - */ export function userIdHook(fn, submodules, consentData) { + // TODO: remove this in v8 (https://github.com/prebid/Prebid.js/issues/9766) if (shouldEnforce(consentData, 1, 'User ID')) { - let userIdModules = submodules.map((submodule) => { - const moduleName = submodule.submodule.name; - const gvlid = getGvlid(MODULE_TYPE_UID, moduleName); - let isAllowed = validateRules(purpose1Rule, consentData, moduleName, gvlid); - if (isAllowed) { - return submodule; - } else { - logWarn(`User denied permission to fetch user id for ${moduleName} User id module`); - storageBlocked.push(moduleName); - } - return undefined; - }).filter(module => module) - fn.call(this, userIdModules, { ...consentData, hasValidated: true }); + fn.call(this, submodules, {...consentData, hasValidated: true}); } else { fn.call(this, submodules, consentData); } } -/** - * Checks if bidders are allowed in the auction. - * Enforces "purpose 2 (Basic Ads)" of TCF v2.0 spec - * @param {Function} fn - Function reference to the original function. - * @param {Array} adUnits - */ -export function makeBidRequestsHook(fn, adUnits, ...args) { - const consentData = gdprDataHandler.getConsentData(); - if (shouldEnforce(consentData, 2)) { - adUnits.forEach(adUnit => { - adUnit.bids = adUnit.bids.filter(bid => { - const currBidder = bid.bidder; - const gvlId = getGvlid(MODULE_TYPE_BIDDER, currBidder); - if (includes(biddersBlocked, currBidder)) return false; - const isAllowed = !!validateRules(purpose2Rule, consentData, currBidder, gvlId); - if (!isAllowed) { - logWarn(`TCF2 blocked auction for ${currBidder}`); - biddersBlocked.push(currBidder); - } - return isAllowed; - }); - }); - fn.call(this, adUnits, ...args); - } else { - fn.call(this, adUnits, ...args); - } -} - -/** - * Checks if Analytics adapters are allowed to send data to their servers for furhter processing. - * Enforces "purpose 7 (Measurement)" of TCF v2.0 spec - * @param {Function} fn - Function reference to the original function. - * @param {Array} config - Configuration object passed to pbjs.enableAnalytics() - */ -export function enableAnalyticsHook(fn, config) { - const consentData = gdprDataHandler.getConsentData(); - if (shouldEnforce(consentData, 7, 'Analytics')) { - if (!isArray(config)) { - config = [config] +export const fetchBidsRule = ((rule) => { + return function (params) { + if (params[ACTIVITY_PARAM_COMPONENT_TYPE] !== MODULE_TYPE_BIDDER) { + // TODO: this special case is for the PBS adapter (componentType is 'prebid') + // we should check for generic purpose 2 consent & vendor consent based on the PBS vendor's GVL ID; + // that is, however, a breaking change and skipped for now + return; } - config = config.filter(conf => { - const analyticsAdapterCode = conf.provider; - const gvlid = getGvlid(MODULE_TYPE_ANALYTICS, analyticsAdapterCode, () => getGvlidFromAnalyticsAdapter(analyticsAdapterCode, conf)); - const isAllowed = !!validateRules(purpose7Rule, consentData, analyticsAdapterCode, gvlid); - if (!isAllowed) { - analyticsBlocked.push(analyticsAdapterCode); - logWarn(`TCF2 blocked analytics adapter ${conf.provider}`); - } - return isAllowed; - }); - fn.call(this, config); - } else { - fn.call(this, config); - } -} + return rule(params); + }; +})(gdprRule(2, () => purpose2Rule, biddersBlocked)); + +export const reportAnalyticsRule = gdprRule(7, () => purpose7Rule, analyticsBlocked, (params) => getGvlidFromAnalyticsAdapter(params[ACTIVITY_PARAM_COMPONENT_NAME], params[ACTIVITY_PARAM_ANL_CONFIG])); /** * Compiles the TCF2.0 enforcement results into an object, which is emitted as an event payload to "tcf2Enforcement" event. */ function emitTCF2FinalResults() { // remove null and duplicate values - const formatArray = function (arr) { - return arr.filter((i, k) => i !== null && arr.indexOf(i) === k); - } + const formatSet = function (st) { + return Array.from(st.keys()).filter(el => el != null); + }; const tcf2FinalResults = { - storageBlocked: formatArray(storageBlocked), - biddersBlocked: formatArray(biddersBlocked), - analyticsBlocked: formatArray(analyticsBlocked) + storageBlocked: formatSet(storageBlocked), + biddersBlocked: formatSet(biddersBlocked), + analyticsBlocked: formatSet(analyticsBlocked) }; events.emit(CONSTANTS.EVENTS.TCF2_ENFORCEMENT, tcf2FinalResults); + [storageBlocked, biddersBlocked, analyticsBlocked].forEach(el => el.clear()); } events.on(CONSTANTS.EVENTS.AUCTION_END, emitTCF2FinalResults); @@ -340,9 +265,15 @@ events.on(CONSTANTS.EVENTS.AUCTION_END, emitTCF2FinalResults); /* Set of callback functions used to detect presence of a TCF rule, passed as the second argument to find(). */ -const hasPurpose1 = (rule) => { return rule.purpose === TCF2.purpose1.name } -const hasPurpose2 = (rule) => { return rule.purpose === TCF2.purpose2.name } -const hasPurpose7 = (rule) => { return rule.purpose === TCF2.purpose7.name } +const hasPurpose1 = (rule) => { + return rule.purpose === TCF2.purpose1.name; +}; +const hasPurpose2 = (rule) => { + return rule.purpose === TCF2.purpose2.name; +}; +const hasPurpose7 = (rule) => { + return rule.purpose === TCF2.purpose7.name; +}; /** * A configuration function that initializes some module variables, as well as adds hooks @@ -373,27 +304,25 @@ export function setEnforcementConfig(config) { if (!hooksAdded) { if (purpose1Rule) { hooksAdded = true; - validateStorageEnforcement.before(deviceAccessHook, 49); - registerSyncInner.before(userSyncHook, 48); - // Using getHook as user id and gdprEnforcement are both optional modules. Using import will auto include the file in build + RULE_HANDLES.push(registerActivityControl(ACTIVITY_ACCESS_DEVICE, RULE_NAME, accessDeviceRule)); + RULE_HANDLES.push(registerActivityControl(ACTIVITY_SYNC_USER, RULE_NAME, syncUserRule)); + RULE_HANDLES.push(registerActivityControl(ACTIVITY_ENRICH_EIDS, RULE_NAME, enrichEidsRule)); + // TODO: remove this hook in v8 (https://github.com/prebid/Prebid.js/issues/9766) getHook('validateGdprEnforcement').before(userIdHook, 47); } if (purpose2Rule) { - getHook('makeBidRequests').before(makeBidRequestsHook); + RULE_HANDLES.push(registerActivityControl(ACTIVITY_FETCH_BIDS, RULE_NAME, fetchBidsRule)); } if (purpose7Rule) { - getHook('enableAnalyticsCb').before(enableAnalyticsHook); + RULE_HANDLES.push(registerActivityControl(ACTIVITY_REPORT_ANALYTICS, RULE_NAME, reportAnalyticsRule)); } } } export function uninstall() { + while (RULE_HANDLES.length) RULE_HANDLES.pop()(); [ - validateStorageEnforcement.getHooks({hook: deviceAccessHook}), - registerSyncInner.getHooks({hook: userSyncHook}), getHook('validateGdprEnforcement').getHooks({hook: userIdHook}), - getHook('makeBidRequests').getHooks({hook: makeBidRequestsHook}), - getHook('enableAnalyticsCb').getHooks({hook: enableAnalyticsHook}), ].forEach(hook => hook.remove()); hooksAdded = false; } diff --git a/modules/rtdModule/index.js b/modules/rtdModule/index.js index 29e2ce3de43..633c4f4cdc1 100644 --- a/modules/rtdModule/index.js +++ b/modules/rtdModule/index.js @@ -168,6 +168,10 @@ import {find} from '../../src/polyfill.js'; import {timedAuctionHook} from '../../src/utils/perfMetrics.js'; import {GDPR_GVLIDS} from '../../src/consentHandler.js'; import {MODULE_TYPE_RTD} from '../../src/activities/modules.js'; +import {guardOrtb2Fragments} from '../../libraries/objectGuard/ortbGuard.js'; +import {activityParamsBuilder} from '../../src/activities/params.js'; + +const activityParams = activityParamsBuilder((al) => adapterManager.resolveAlias(al)); /** @type {string} */ const MODULE_NAME = 'realTimeData'; @@ -299,6 +303,7 @@ export const setBidRequestsData = timedAuctionHook('rtd', function setBidRequest let callbacksExpected = prioritySubModules.length; let isDone = false; let waitTimeout; + const verifiers = []; if (!relevantSubModules.length) { return exitHook(); @@ -307,7 +312,12 @@ export const setBidRequestsData = timedAuctionHook('rtd', function setBidRequest waitTimeout = setTimeout(exitHook, shouldDelayAuction ? _moduleConfig.auctionDelay : 0); relevantSubModules.forEach(sm => { - sm.getBidRequestData(reqBidsConfigObj, onGetBidRequestDataCallback.bind(sm), sm.config, _userConsent) + const fpdGuard = guardOrtb2Fragments(reqBidsConfigObj.ortb2Fragments || {}, activityParams(MODULE_TYPE_RTD, sm.name)); + verifiers.push(fpdGuard.verify); + sm.getBidRequestData({ + ...reqBidsConfigObj, + ortb2Fragments: fpdGuard.obj + }, onGetBidRequestDataCallback.bind(sm), sm.config, _userConsent) }); function onGetBidRequestDataCallback() { @@ -328,6 +338,7 @@ export const setBidRequestsData = timedAuctionHook('rtd', function setBidRequest } isDone = true; clearTimeout(waitTimeout); + verifiers.forEach(fn => fn()); fn.call(this, reqBidsConfigObj); } }); diff --git a/modules/userId/index.js b/modules/userId/index.js index a6b7bb25db0..f906458a3f5 100644 --- a/modules/userId/index.js +++ b/modules/userId/index.js @@ -110,6 +110,7 @@ * @property {SubmoduleConfig} config * @property {(Object|undefined)} idObj - cache decoded id value (this is copied to every adUnit bid) * @property {(function|undefined)} callback - holds reference to submodule.getId() result if it returned a function. Will be set to undefined after callback executes + * @property {StorageManager} storageMgr */ /** @@ -133,7 +134,12 @@ import adapterManager, {gdprDataHandler} from '../../src/adapterManager.js'; import CONSTANTS from '../../src/constants.json'; import {hook, module, ready as hooksReady} from '../../src/hook.js'; import {buildEidPermissions, createEidsArray, USER_IDS_CONFIG} from './eids.js'; -import {getCoreStorageManager, STORAGE_TYPE_COOKIES, STORAGE_TYPE_LOCALSTORAGE} from '../../src/storageManager.js'; +import { + getCoreStorageManager, + getStorageManager, + STORAGE_TYPE_COOKIES, + STORAGE_TYPE_LOCALSTORAGE +} from '../../src/storageManager.js'; import { cyrb53Hash, deepAccess, @@ -159,6 +165,9 @@ import {newMetrics, timedAuctionHook, useMetrics} from '../../src/utils/perfMetr import {findRootDomain} from '../../src/fpd/rootDomain.js'; import {GDPR_GVLIDS} from '../../src/consentHandler.js'; import {MODULE_TYPE_UID} from '../../src/activities/modules.js'; +import {isActivityAllowed} from '../../src/activities/rules.js'; +import {ACTIVITY_ENRICH_EIDS} from '../../src/activities/activities.js'; +import {activityParams} from '../../src/activities/activityParams.js'; const MODULE_NAME = 'User ID'; const COOKIE = STORAGE_TYPE_COOKIES; @@ -170,7 +179,10 @@ const CONSENT_DATA_COOKIE_STORAGE_CONFIG = { expires: 30 // 30 days expiration, which should match how often consent is refreshed by CMPs }; export const PBJS_USER_ID_OPTOUT_NAME = '_pbjs_id_optout'; -export const coreStorage = getCoreStorageManager('userid'); +export const coreStorage = getCoreStorageManager('userId'); +export const dep = { + isAllowed: isActivityAllowed +} /** @type {boolean} */ let addedUserIdHook = false; @@ -220,11 +232,12 @@ export function setSubmoduleRegistry(submodules) { submoduleRegistry = submodules; } -function cookieSetter(submodule) { +function cookieSetter(submodule, storageMgr) { + storageMgr = storageMgr || submodule.storageMgr; const domainOverride = (typeof submodule.submodule.domainOverride === 'function') ? submodule.submodule.domainOverride() : null; const name = submodule.config.storage.name; return function setCookie(suffix, value, expiration) { - coreStorage.setCookie(name + (suffix || ''), value, expiration, 'Lax', domainOverride); + storageMgr.setCookie(name + (suffix || ''), value, expiration, 'Lax', domainOverride); } } @@ -237,6 +250,7 @@ export function setStoredValue(submodule, value) { * @type {SubmoduleStorage} */ const storage = submodule.config.storage; + const mgr = submodule.storageMgr; try { const expiresStr = (new Date(Date.now() + (storage.expires * (60 * 60 * 24 * 1000)))).toUTCString(); @@ -248,10 +262,10 @@ export function setStoredValue(submodule, value) { setCookie('_last', new Date().toUTCString(), expiresStr); } } else if (storage.type === LOCAL_STORAGE) { - coreStorage.setDataInLocalStorage(`${storage.name}_exp`, expiresStr); - coreStorage.setDataInLocalStorage(storage.name, encodeURIComponent(valueStr)); + mgr.setDataInLocalStorage(`${storage.name}_exp`, expiresStr); + mgr.setDataInLocalStorage(storage.name, encodeURIComponent(valueStr)); if (typeof storage.refreshInSeconds === 'number') { - coreStorage.setDataInLocalStorage(`${storage.name}_last`, new Date().toUTCString()); + mgr.setDataInLocalStorage(`${storage.name}_last`, new Date().toUTCString()); } } } catch (error) { @@ -263,7 +277,7 @@ export function deleteStoredValue(submodule) { let deleter, suffixes; switch (submodule.config?.storage?.type) { case COOKIE: - const setCookie = cookieSetter(submodule); + const setCookie = cookieSetter(submodule, coreStorage); const expiry = (new Date(Date.now() - 1000 * 60 * 60 * 24)).toUTCString(); deleter = (suffix) => setCookie(suffix, '', expiry) suffixes = ['', '_last']; @@ -292,25 +306,26 @@ function setPrebidServerEidPermissions(initializedSubmodules) { } /** -/** - * @param {SubmoduleStorage} storage + * @param {SubmoduleContainer} submodule * @param {String|undefined} key optional key of the value * @returns {string} */ -function getStoredValue(storage, key = undefined) { +function getStoredValue(submodule, key = undefined) { + const mgr = submodule.storageMgr; + const storage = submodule.config.storage; const storedKey = key ? `${storage.name}_${key}` : storage.name; let storedValue; try { if (storage.type === COOKIE) { - storedValue = coreStorage.getCookie(storedKey); + storedValue = mgr.getCookie(storedKey); } else if (storage.type === LOCAL_STORAGE) { - const storedValueExp = coreStorage.getDataFromLocalStorage(`${storage.name}_exp`); + const storedValueExp = mgr.getDataFromLocalStorage(`${storage.name}_exp`); // empty string means no expiration set if (storedValueExp === '') { - storedValue = coreStorage.getDataFromLocalStorage(storedKey); + storedValue = mgr.getDataFromLocalStorage(storedKey); } else if (storedValueExp) { if ((new Date(storedValueExp)).getTime() - Date.now() > 0) { - storedValue = decodeURIComponent(coreStorage.getDataFromLocalStorage(storedKey)); + storedValue = decodeURIComponent(mgr.getDataFromLocalStorage(storedKey)); } } } @@ -415,7 +430,7 @@ function processSubmoduleCallbacks(submodules, cb) { moduleDone(); } try { - submodule.callback(callbackCompleted, getStoredValue.bind(null, submodule.config?.storage)); + submodule.callback(callbackCompleted, getStoredValue.bind(null, submodule)); } catch (e) { logError(`Error in userID module '${submodule.submodule.name}':`, e); moduleDone(); @@ -773,6 +788,8 @@ function getUserIdsAsync() { * This hook returns updated list of submodules which are allowed to do get user id based on TCF 2 enforcement rules configured */ export const validateGdprEnforcement = hook('sync', function (submodules, consentData) { + // TODO: remove the `hasValidated` check in v8. Enforcement should be OFF by default. + // https://github.com/prebid/Prebid.js/issues/9766 return { userIdModules: submodules, hasValidated: consentData && consentData.hasValidated }; }, 'validateGdprEnforcement'); @@ -781,12 +798,12 @@ function populateSubmoduleId(submodule, consentData, storedConsentData, forceRef // 1. storage: retrieve user id data from cookie/html storage or with the submodule's getId method // 2. value: pass directly to bids if (submodule.config.storage) { - let storedId = getStoredValue(submodule.config.storage); + let storedId = getStoredValue(submodule); let response; let refreshNeeded = false; if (typeof submodule.config.storage.refreshInSeconds === 'number') { - const storedDate = new Date(getStoredValue(submodule.config.storage, 'last')); + const storedDate = new Date(getStoredValue(submodule, 'last')); refreshNeeded = storedDate && (Date.now() - storedDate.getTime() > submodule.config.storage.refreshInSeconds * 1000); } @@ -849,17 +866,23 @@ function initSubmodules(dest, submodules, consentData, forceRefresh = false) { return uidMetrics().fork().measureTime('userId.init.modules', function () { if (!submodules.length) return []; // to simplify log messages from here on - // filter out submodules whose storage type is not enabled - // this needs to be done here (after consent data has loaded) so that enforcement may disable storage globally - const storageTypes = getActiveStorageTypes(); - submodules = submodules.filter((submod) => !submod.config.storage || storageTypes.has(submod.config.storage.type)); + /** + * filter out submodules that: + * + * - cannot use the storage they've been set up with (storage not available / not allowed / disabled) + * - are not allowed to perform the `enrichEids` activity + */ + submodules = submodules.filter((submod) => { + return (!submod.config.storage || canUseStorage(submod)) && + dep.isAllowed(ACTIVITY_ENRICH_EIDS, activityParams(MODULE_TYPE_UID, submod.config.name)); + }); if (!submodules.length) { - logWarn(`${MODULE_NAME} - no ID module is configured for one of the available storage types:`, Array.from(storageTypes)); + logWarn(`${MODULE_NAME} - no ID module configured`); return []; } - // another consent check, this time each module is checked for consent with its own gvlid + // TODO: remove this check in v8 (https://github.com/prebid/Prebid.js/issues/9766) let { userIdModules, hasValidated } = validateGdprEnforcement(submodules, consentData); if (!hasValidated && !hasPurpose1Consent(consentData)) { logWarn(`${MODULE_NAME} - gdpr permission not valid for local storage or cookies, exit module`); @@ -940,24 +963,28 @@ function getValidSubmoduleConfigs(configRegistry, submoduleRegistry) { const ALL_STORAGE_TYPES = new Set([LOCAL_STORAGE, COOKIE]); -function getActiveStorageTypes() { - const storageTypes = []; - let disabled = false; - if (coreStorage.localStorageIsEnabled()) { - storageTypes.push(LOCAL_STORAGE); - if (coreStorage.getDataFromLocalStorage(PBJS_USER_ID_OPTOUT_NAME)) { - logInfo(`${MODULE_NAME} - opt-out localStorage found, storage disabled`); - disabled = true; - } - } - if (coreStorage.cookiesAreEnabled()) { - storageTypes.push(COOKIE); - if (coreStorage.getCookie(PBJS_USER_ID_OPTOUT_NAME)) { - logInfo(`${MODULE_NAME} - opt-out cookie found, storage disabled`); - disabled = true; - } +function canUseStorage(submodule) { + switch (submodule.config?.storage?.type) { + case LOCAL_STORAGE: + if (submodule.storageMgr.localStorageIsEnabled()) { + if (coreStorage.getDataFromLocalStorage(PBJS_USER_ID_OPTOUT_NAME)) { + logInfo(`${MODULE_NAME} - opt-out localStorage found, storage disabled`); + return false + } + return true; + } + break; + case COOKIE: + if (submodule.storageMgr.cookiesAreEnabled()) { + if (coreStorage.getCookie(PBJS_USER_ID_OPTOUT_NAME)) { + logInfo(`${MODULE_NAME} - opt-out cookie found, storage disabled`); + return false; + } + return true + } + break; } - return new Set(disabled ? [] : storageTypes) + return false; } /** @@ -985,6 +1012,7 @@ function updateSubmodules() { config: submoduleConfig, callback: undefined, idObj: undefined, + storageMgr: getStorageManager({moduleType: MODULE_TYPE_UID, moduleName: submoduleConfig.name}), } : null; }).filter(submodule => submodule !== null) .forEach((sm) => submodules.push(sm)); diff --git a/src/activities/activities.js b/src/activities/activities.js new file mode 100644 index 00000000000..fdb64587bfa --- /dev/null +++ b/src/activities/activities.js @@ -0,0 +1,47 @@ +/** + * Activity (that are relevant for privacy) definitions + * + * ref. https://docs.google.com/document/d/1dRxFUFmhh2jGanzGZvfkK_6jtHPpHXWD7Qsi6KEugeE + * & https://github.com/prebid/Prebid.js/issues/9546 + */ + +/** + * accessDevice: some component wants to read or write to localStorage or cookies. + */ +export const ACTIVITY_ACCESS_DEVICE = 'accessDevice'; +/** + * syncUser: A bid adapter wants to run a user sync. + */ +export const ACTIVITY_SYNC_USER = 'syncUser'; +/** + * enrichUfpd: some component wants to add user first-party data to bid requests. + */ +export const ACTIVITY_ENRICH_UFPD = 'enrichUfpd'; +/** + * enrichEids: some component wants to add user IDs to bid requests. + */ +export const ACTIVITY_ENRICH_EIDS = 'enrichEids'; +/** + * fetchBid: a bidder wants to bid. + */ +export const ACTIVITY_FETCH_BIDS = 'fetchBids'; + +/** + * reportAnalytics: some component wants to phone home with analytics data. + */ +export const ACTIVITY_REPORT_ANALYTICS = 'reportAnalytics'; + +/** + * some component wants access to (and send along) user IDs + */ +export const ACTIVITY_TRANSMIT_EIDS = 'transmitEids' + +/** + * transmitUfpd: some component wants access to (and send along) user FPD + */ +export const ACTIVITY_TRANSMIT_UFPD = 'transmitUfpd'; + +/** + * transmitPreciseGeo: some component wants access to (and send along) geolocation info + */ +export const ACTIVITY_TRANSMIT_PRECISE_GEO = 'transmitPreciseGeo'; diff --git a/src/activities/activityParams.js b/src/activities/activityParams.js new file mode 100644 index 00000000000..f33ceb2a9a4 --- /dev/null +++ b/src/activities/activityParams.js @@ -0,0 +1,8 @@ +import adapterManager from '../adapterManager.js'; +import {activityParamsBuilder} from './params.js'; + +/** + * Utility function for building common activity parameters - broken out to its own + * file to avoid circular imports. + */ +export const activityParams = activityParamsBuilder((alias) => adapterManager.resolveAlias(alias)); diff --git a/src/activities/modules.js b/src/activities/modules.js index d140b10387f..474c546c07b 100644 --- a/src/activities/modules.js +++ b/src/activities/modules.js @@ -1,4 +1,4 @@ -export const MODULE_TYPE_CORE = 'core'; +export const MODULE_TYPE_PREBID = 'prebid'; export const MODULE_TYPE_BIDDER = 'bidder'; export const MODULE_TYPE_UID = 'userId'; export const MODULE_TYPE_RTD = 'rtd'; diff --git a/src/activities/params.js b/src/activities/params.js new file mode 100644 index 00000000000..ff181bb55a4 --- /dev/null +++ b/src/activities/params.js @@ -0,0 +1,59 @@ +import {MODULE_TYPE_BIDDER} from './modules.js'; + +/** + * Component ID - who is trying to perform the activity? + * Relevant for all activities. + */ +export const ACTIVITY_PARAM_COMPONENT = 'component'; +export const ACTIVITY_PARAM_COMPONENT_TYPE = ACTIVITY_PARAM_COMPONENT + 'Type'; +export const ACTIVITY_PARAM_COMPONENT_NAME = ACTIVITY_PARAM_COMPONENT + 'Name'; + +/** + * Code of the bid adapter that `componentName` is an alias of. + * May be the same as the component name. + * + * relevant for all activities, but only when componentType is 'bidder'. + */ +export const ACTIVITY_PARAM_ADAPTER_CODE = 'adapterCode'; + +/** + * Storage type - either 'html5' or 'cookie'. + * Relevant for: accessDevice + */ +export const ACTIVITY_PARAM_STORAGE_TYPE = 'storageType'; + +/** + * s2sConfig[].configName, used to identify a particular s2s instance + * relevant for: fetchBids, but only when component is 'prebid.pbsBidAdapter' + */ +export const ACTIVITY_PARAM_S2S_NAME = 'configName'; +/** + * user sync type - 'iframe' or 'pixel' + * relevant for: syncUser + */ +export const ACTIVITY_PARAM_SYNC_TYPE = 'syncType' +/** + * user sync URL + * relevant for: syncUser + */ +export const ACTIVITY_PARAM_SYNC_URL = 'syncUrl'; +/** + * @private + * configuration options for analytics adapter - the argument passed to `enableAnalytics`. + * relevant for: reportAnalytics + */ +export const ACTIVITY_PARAM_ANL_CONFIG = '_config'; + +export function activityParamsBuilder(resolveAlias) { + return function activityParams(moduleType, moduleName, params) { + const defaults = { + [ACTIVITY_PARAM_COMPONENT_TYPE]: moduleType, + [ACTIVITY_PARAM_COMPONENT_NAME]: moduleName, + [ACTIVITY_PARAM_COMPONENT]: `${moduleType}.${moduleName}` + }; + if (moduleType === MODULE_TYPE_BIDDER) { + defaults[ACTIVITY_PARAM_ADAPTER_CODE] = resolveAlias(moduleName); + } + return Object.assign(defaults, params); + } +} diff --git a/src/activities/redactor.js b/src/activities/redactor.js new file mode 100644 index 00000000000..3c80019c750 --- /dev/null +++ b/src/activities/redactor.js @@ -0,0 +1,157 @@ +import {deepAccess} from '../utils.js'; +import {isActivityAllowed} from './rules.js'; +import {ACTIVITY_TRANSMIT_EIDS, ACTIVITY_TRANSMIT_PRECISE_GEO, ACTIVITY_TRANSMIT_UFPD} from './activities.js'; + +export const ORTB_UFPD_PATHS = ['user.data', 'user.ext.data']; +export const ORTB_EIDS_PATHS = ['user.eids', 'user.ext.eids']; +export const ORTB_GEO_PATHS = ['user.geo.lat', 'user.geo.lon', 'device.geo.lat', 'device.geo.lon']; + +/** + * @typedef TransformationRuleDef + * @property {name} + * @property {Array[string]} paths dot-separated list of paths that this rule applies to. + * @property {function(*): boolean} applies a predicate that should return true if this rule applies + * (and the transformation defined herein should be applied). The arguments are those passed to the transformation function. + * @property {name} a name for the rule; used to debounce calls to `applies` (and avoid excessive logging): + * if a rule with the same name was already found to apply (or not), this one will (or won't) as well. + */ + +/** + * @typedef RedactRuleDef A rule that removes, or replaces, values from an object (modifications are done in-place). + * @augments TransformationRuleDef + * @property {function(*): *} get? substitution functions for values that should be redacted; + * takes in the original (unredacted) value as an input, and returns a substitute to use in the redacted + * version. If it returns undefined, or this option is omitted, protected paths will be removed + * from the redacted object. + */ + +/** + * @param {RedactRuleDef} ruleDef + * @return {TransformationRule} + */ +export function redactRule(ruleDef) { + return Object.assign({ + get() {}, + run(root, path, object, property, applies) { + const val = object && object[property]; + if (isData(val) && applies()) { + const repl = this.get(val); + if (repl === undefined) { + delete object[property]; + } else { + object[property] = repl; + } + } + } + }, ruleDef) +} + +/** + * @typedef TransformationRule + * @augments TransformationRuleDef + * @property {function} run rule logic - see `redactRule` for an example. + */ + +/** + * @typedef {Function} TransformationFunction + * @param object object to transform + * @param ...args arguments to pass down to rule's `apply` methods. + */ + +/** + * Return a transformation function that will apply the given rules to an object. + * + * @param {Array[TransformationRule]} rules + * @return {TransformationFunction} + */ +export function objectTransformer(rules) { + rules.forEach(rule => { + rule.paths = rule.paths.map((path) => { + const parts = path.split('.'); + const tail = parts.pop(); + return [parts.length > 0 ? parts.join('.') : null, tail] + }) + }) + return function applyTransform(session, obj, ...args) { + const result = []; + rules.forEach(rule => { + if (session[rule.name] === false) return; + for (const [head, tail] of rule.paths) { + const parent = head == null ? obj : deepAccess(obj, head); + result.push(rule.run(obj, head, parent, tail, () => { + if (!session.hasOwnProperty(rule.name)) { + session[rule.name] = !!rule.applies(...args); + } + return session[rule.name] + })) + if (session[rule.name] === false) return; + } + }) + return result.filter(el => el != null); + } +} + +export function isData(val) { + return val != null && (typeof val !== 'object' || Object.keys(val).length > 0) +} + +export function appliesWhenActivityDenied(activity, isAllowed = isActivityAllowed) { + return function applies(params) { + return !isAllowed(activity, params); + }; +} + +function bidRequestTransmitRules(isAllowed = isActivityAllowed) { + return [ + { + name: ACTIVITY_TRANSMIT_EIDS, + paths: ['userId', 'userIdAsEids'], + applies: appliesWhenActivityDenied(ACTIVITY_TRANSMIT_EIDS, isAllowed), + } + ].map(redactRule) +} + +export function ortb2TransmitRules(isAllowed = isActivityAllowed) { + return [ + { + name: ACTIVITY_TRANSMIT_UFPD, + paths: ORTB_UFPD_PATHS, + applies: appliesWhenActivityDenied(ACTIVITY_TRANSMIT_UFPD, isAllowed), + }, + { + name: ACTIVITY_TRANSMIT_EIDS, + paths: ORTB_EIDS_PATHS, + applies: appliesWhenActivityDenied(ACTIVITY_TRANSMIT_EIDS, isAllowed), + }, + { + name: ACTIVITY_TRANSMIT_PRECISE_GEO, + paths: ORTB_GEO_PATHS, + applies: appliesWhenActivityDenied(ACTIVITY_TRANSMIT_PRECISE_GEO, isAllowed), + get(val) { + return Math.round((val + Number.EPSILON) * 100) / 100; + } + } + ].map(redactRule); +} + +export function redactorFactory(isAllowed = isActivityAllowed) { + const redactOrtb2 = objectTransformer(ortb2TransmitRules(isAllowed)); + const redactBidRequest = objectTransformer(bidRequestTransmitRules(isAllowed)); + return function redactor(params) { + const session = {}; + return { + ortb2(obj) { redactOrtb2(session, obj, params); return obj }, + bidRequest(obj) { redactBidRequest(session, obj, params); return obj } + } + } +} + +/** + * Returns an object that can redact other privacy-sensitive objects according + * to activity rules. + * + * @param {{}} params activity parameters to use for activity checks + * @return {{ortb2: function({}): {}, bidRequest: function({}): {}}} methods + * that can redact disallowed data from ORTB2 and/or bid request objects. + */ +export const redactor = redactorFactory(); diff --git a/src/activities/rules.js b/src/activities/rules.js new file mode 100644 index 00000000000..f84f1080843 --- /dev/null +++ b/src/activities/rules.js @@ -0,0 +1,95 @@ +import {prefixLog} from '../utils.js'; +import {ACTIVITY_PARAM_COMPONENT} from './params.js'; + +export function ruleRegistry(logger = prefixLog('Activity control:')) { + const registry = {}; + + function getRules(activity) { + return registry[activity] = registry[activity] || []; + } + + function runRule(activity, name, rule, params) { + let res; + try { + res = rule(params); + } catch (e) { + logger.logError(`Exception in rule ${name} for '${activity}'`, e); + res = {allow: false, reason: e}; + } + return res && Object.assign({activity, name, component: params[ACTIVITY_PARAM_COMPONENT]}, res); + } + + const dupes = {}; + const DEDUPE_INTERVAL = 1000; + + function logResult({activity, name, allow, reason, component}) { + const msg = `${name} ${allow ? 'allowed' : 'denied'} '${activity}' for '${component}'${reason ? ':' : ''}`; + const deduping = dupes.hasOwnProperty(msg); + if (deduping) { + clearTimeout(dupes[msg]); + } + dupes[msg] = setTimeout(() => delete dupes[msg], DEDUPE_INTERVAL); + if (!deduping) { + const parts = [msg]; + reason && parts.push(reason); + (allow ? logger.logInfo : logger.logWarn).apply(logger, parts); + } + } + + return [ + /** + * Register an activity control rule. + * + * @param {string} activity activity name - set is defined in `activities.js` + * @param {string} ruleName a name for this rule; used for logging. + * @param {function({}): {allow: boolean, reason?: string}} rule definition function. Takes in activity + * parameters as a single map; MAY return an object {allow, reason}, where allow is true/false, + * and reason is an optional message used for logging. + * + * {allow: true} will allow this activity AS LONG AS no other rules with same or higher priority return {allow: false}; + * {allow: false} will deny this activity AS LONG AS no other rules with higher priority return {allow: true}; + * returning null/undefined has no effect - the decision is left to other rules. + * If no rule returns an allow value, the default is to allow the activity. + * + * @param {number} priority rule priority; lower number means higher priority + * @returns {function(void): void} a function that unregisters the rule when called. + */ + function registerActivityControl(activity, ruleName, rule, priority = 10) { + const rules = getRules(activity); + const pos = rules.findIndex(([itemPriority]) => priority < itemPriority); + const entry = [priority, ruleName, rule]; + rules.splice(pos < 0 ? rules.length : pos, 0, entry); + return function () { + const idx = rules.indexOf(entry); + if (idx >= 0) rules.splice(idx, 1); + } + }, + /** + * Test whether an activity is allowed. + * + * @param {string} activity activity name + * @param {{}} params activity parameters; should be generated through the `activityParams` utility. + * @return {boolean} true for allow, false for deny. + */ + function isActivityAllowed(activity, params) { + let lastPriority, foundAllow; + for (const [priority, name, rule] of getRules(activity)) { + if (lastPriority !== priority && foundAllow) break; + lastPriority = priority; + const ruleResult = runRule(activity, name, rule, params); + if (ruleResult) { + if (!ruleResult.allow) { + logResult(ruleResult); + return false; + } else { + foundAllow = ruleResult; + } + } + } + foundAllow && logResult(foundAllow); + return true; + } + ]; +} + +export const [registerActivityControl, isActivityAllowed] = ruleRegistry(); diff --git a/src/adapterManager.js b/src/adapterManager.js index 8fcf04c7b41..750d2e93603 100644 --- a/src/adapterManager.js +++ b/src/adapterManager.js @@ -31,18 +31,28 @@ import {hook} from './hook.js'; import {find, includes} from './polyfill.js'; import {adunitCounter} from './adUnits.js'; import {getRefererInfo} from './refererDetection.js'; -import {GdprConsentHandler, UspConsentHandler, GppConsentHandler, GDPR_GVLIDS} from './consentHandler.js'; +import {GDPR_GVLIDS, GdprConsentHandler, GppConsentHandler, UspConsentHandler} from './consentHandler.js'; import * as events from './events.js'; import CONSTANTS from './constants.json'; import {useMetrics} from './utils/perfMetrics.js'; import {auctionManager} from './auctionManager.js'; -import {MODULE_TYPE_ANALYTICS, MODULE_TYPE_BIDDER} from './activities/modules.js'; +import {MODULE_TYPE_ANALYTICS, MODULE_TYPE_BIDDER, MODULE_TYPE_PREBID} from './activities/modules.js'; +import {isActivityAllowed} from './activities/rules.js'; +import {ACTIVITY_FETCH_BIDS, ACTIVITY_REPORT_ANALYTICS} from './activities/activities.js'; +import {ACTIVITY_PARAM_ANL_CONFIG, ACTIVITY_PARAM_S2S_NAME, activityParamsBuilder} from './activities/params.js'; +import {redactor} from './activities/redactor.js'; +const PBS_ADAPTER_NAME = 'pbsBidAdapter'; export const PARTITIONS = { CLIENT: 'client', SERVER: 'server' } +export const dep = { + isAllowed: isActivityAllowed, + redact: redactor +} + let adapterManager = {}; let _bidderRegistry = adapterManager.bidderRegistry = {}; @@ -57,6 +67,8 @@ config.getConfig('s2sConfig', config => { var _analyticsRegistry = {}; +const activityParams = activityParamsBuilder((alias) => adapterManager.resolveAlias(alias)); + /** * @typedef {object} LabelDescriptor * @property {boolean} labelAll describes whether or not this object expects all labels to match, or any label to match @@ -139,7 +151,7 @@ function getAdUnitCopyForPrebidServer(adUnits, s2sConfig) { adUnitsCopy.forEach((adUnit) => { // filter out client side bids - const s2sBids = adUnit.bids.filter((b) => b.module === 'pbsBidAdapter' && b.params?.configName === s2sConfig.configName); + const s2sBids = adUnit.bids.filter((b) => b.module === PBS_ADAPTER_NAME && b.params?.configName === s2sConfig.configName); if (s2sBids.length === 1) { adUnit.s2sBid = s2sBids[0]; hasModuleBids = true; @@ -237,6 +249,10 @@ adapterManager.makeBidRequests = hook('sync', function (adUnits, auctionStart, a if (FEATURES.NATIVE) { decorateAdUnitsWithNativeParams(adUnits); } + + // filter out bidders that cannot participate in the auction + adUnits.forEach(au => au.bids = au.bids.filter((bid) => !bid.bidder || dep.isAllowed(ACTIVITY_FETCH_BIDS, activityParams(MODULE_TYPE_BIDDER, bid.bidder)))) + adUnits = setupAdUnitMediaTypes(adUnits, labels); let {[PARTITIONS.CLIENT]: clientBidders, [PARTITIONS.SERVER]: serverBidders} = partitionBidders(adUnits, _s2sConfigs); @@ -252,14 +268,24 @@ adapterManager.makeBidRequests = hook('sync', function (adUnits, auctionStart, a const bidderOrtb2 = ortb2Fragments.bidder || {}; function addOrtb2(bidderRequest) { - const fpd = Object.freeze(mergeDeep({}, ortb2, bidderOrtb2[bidderRequest.bidderCode])); + const redact = dep.redact(activityParams(MODULE_TYPE_BIDDER, bidderRequest.bidderCode)); + const fpd = Object.freeze(redact.ortb2(mergeDeep({}, ortb2, bidderOrtb2[bidderRequest.bidderCode]))); bidderRequest.ortb2 = fpd; - bidderRequest.bids.forEach((bid) => bid.ortb2 = fpd); + bidderRequest.bids = bidderRequest.bids.map((bid) => { + bid.ortb2 = fpd; + return redact.bidRequest(bid); + }) return bidderRequest; } + function isS2SAllowed(s2sConfig) { + return dep.isAllowed(ACTIVITY_FETCH_BIDS, activityParams(MODULE_TYPE_PREBID, PBS_ADAPTER_NAME, { + [ACTIVITY_PARAM_S2S_NAME]: s2sConfig.configName + })); + } + _s2sConfigs.forEach(s2sConfig => { - if (s2sConfig && s2sConfig.enabled) { + if (s2sConfig && s2sConfig.enabled && isS2SAllowed(s2sConfig)) { let {adUnits: adUnitsS2SCopy, hasModuleBids} = getAdUnitCopyForPrebidServer(adUnits, s2sConfig); // uniquePbsTid is so we know which server to send which bids to during the callBids function @@ -338,7 +364,6 @@ adapterManager.makeBidRequests = hook('sync', function (adUnits, auctionStart, a bidRequest['gppConsent'] = gppDataHandler.getConsentData(); } }); - return bidRequests; }, 'makeBidRequests'); @@ -529,6 +554,16 @@ adapterManager.aliasBidAdapter = function (bidderCode, alias, options) { } }; +adapterManager.resolveAlias = function (alias) { + let code = alias; + let visited; + while (_aliasRegistry[code] && (!visited || !visited.has(code))) { + code = _aliasRegistry[code]; + (visited = visited || new Set()).add(code); + } + return code; +} + adapterManager.registerAnalyticsAdapter = function ({adapter, code, gvlid}) { if (adapter && code) { if (typeof adapter.enableAnalytics === 'function') { @@ -552,7 +587,9 @@ adapterManager.enableAnalytics = function (config) { _each(config, adapterConfig => { const entry = _analyticsRegistry[adapterConfig.provider]; if (entry && entry.adapter) { - entry.adapter.enableAnalytics(adapterConfig); + if (dep.isAllowed(ACTIVITY_REPORT_ANALYTICS, activityParams(MODULE_TYPE_ANALYTICS, adapterConfig.provider, {[ACTIVITY_PARAM_ANL_CONFIG]: adapterConfig}))) { + entry.adapter.enableAnalytics(adapterConfig); + } } else { logError(`Prebid Error: no analytics adapter found in registry for '${adapterConfig.provider}'.`); } diff --git a/src/fpd/rootDomain.js b/src/fpd/rootDomain.js index 4095613672f..21547de8e2e 100644 --- a/src/fpd/rootDomain.js +++ b/src/fpd/rootDomain.js @@ -1,7 +1,7 @@ import {memoize, timestamp} from '../utils.js'; import {getCoreStorageManager} from '../storageManager.js'; -export const coreStorage = getCoreStorageManager(); +export const coreStorage = getCoreStorageManager('fpdEnrichment'); /** * Find the root domain by testing for the topmost domain that will allow setting cookies. diff --git a/src/storageManager.js b/src/storageManager.js index 0248237fbc4..87d714f77b8 100644 --- a/src/storageManager.js +++ b/src/storageManager.js @@ -1,7 +1,17 @@ -import {hook} from './hook.js'; -import {checkCookieSupport, hasDeviceAccess, logError, logInfo} from './utils.js'; -import {bidderSettings as defaultBidderSettings} from './bidderSettings.js'; -import {MODULE_TYPE_BIDDER, MODULE_TYPE_CORE} from './activities/modules.js'; +import {checkCookieSupport, hasDeviceAccess, logError} from './utils.js'; +import {bidderSettings} from './bidderSettings.js'; +import {MODULE_TYPE_BIDDER, MODULE_TYPE_PREBID} from './activities/modules.js'; +import {isActivityAllowed, registerActivityControl} from './activities/rules.js'; +import { + ACTIVITY_PARAM_ADAPTER_CODE, + ACTIVITY_PARAM_COMPONENT_TYPE, + ACTIVITY_PARAM_STORAGE_TYPE +} from './activities/params.js'; + +import {ACTIVITY_ACCESS_DEVICE} from './activities/activities.js'; +import {config} from './config.js'; +import adapterManager from './adapterManager.js'; +import {activityParams} from './activities/activityParams.js'; export const STORAGE_TYPE_LOCALSTORAGE = 'html5'; export const STORAGE_TYPE_COOKIES = 'cookie'; @@ -11,40 +21,19 @@ export let storageCallbacks = []; /* * Storage manager constructor. Consumers should prefer one of `getStorageManager` or `getCoreStorageManager`. */ -export function newStorageManager({moduleName, moduleType} = {}, {bidderSettings = defaultBidderSettings} = {}) { - function isBidderAllowed(storageType) { - if (moduleType !== MODULE_TYPE_BIDDER) { - return true; - } - const storageAllowed = bidderSettings.get(moduleName, 'storageAllowed'); - if (!storageAllowed || storageAllowed === true) return !!storageAllowed; - if (Array.isArray(storageAllowed)) return storageAllowed.some((e) => e === storageType); - return storageAllowed === storageType; - } - +export function newStorageManager({moduleName, moduleType} = {}, {isAllowed = isActivityAllowed} = {}) { function isValid(cb, storageType) { - if (!isBidderAllowed(storageType)) { - logInfo(`bidderSettings denied access to device storage for bidder '${moduleName}'`); - const result = {valid: false}; - return cb(result); - } else { - let value; - let hookDetails = { - hasEnforcementHook: false - } - validateStorageEnforcement(moduleType, moduleName, hookDetails, function(result) { - if (result && result.hasEnforcementHook) { - value = cb(result); - } else { - let result = { - hasEnforcementHook: false, - valid: hasDeviceAccess() - } - value = cb(result); - } - }); - return value; + let mod = moduleName; + const curBidder = config.getCurrentBidder(); + if (curBidder && moduleType === MODULE_TYPE_BIDDER && adapterManager.aliasRegistry[curBidder] === moduleName) { + mod = curBidder; } + const result = { + valid: isAllowed(ACTIVITY_ACCESS_DEVICE, activityParams(moduleType, mod, { + [ACTIVITY_PARAM_STORAGE_TYPE]: storageType + })) + }; + return cb(result); } function schedule(operation, storageType, done) { @@ -228,13 +217,6 @@ export function newStorageManager({moduleName, moduleType} = {}, {bidderSettings } } -/** - * This hook validates the storage enforcement if gdprEnforcement module is included - */ -export const validateStorageEnforcement = hook('async', function(moduleType, moduleName, hookDetails, callback) { - callback(hookDetails); -}, 'validateStorageEnforcement'); - /** * Get a storage manager for a particular module. * @@ -262,9 +244,40 @@ export function getStorageManager({moduleType, moduleName, bidderCode} = {}) { * @param {string} moduleName Module name */ export function getCoreStorageManager(moduleName) { - return newStorageManager({moduleName: moduleName, moduleType: MODULE_TYPE_CORE}); + return newStorageManager({moduleName: moduleName, moduleType: MODULE_TYPE_PREBID}); } +/** + * Block all access to storage when deviceAccess = false + */ +export function deviceAccessRule() { + if (!hasDeviceAccess()) { + return {allow: false} + } +} +registerActivityControl(ACTIVITY_ACCESS_DEVICE, 'deviceAccess config', deviceAccessRule); + +/** + * By default, deny bidders accessDevice unless they enable it through bidderSettings + * + * // TODO: for backwards compat, the check is done on the adapter - rather than bidder's code. + */ +export function storageAllowedRule(params, bs = bidderSettings) { + if (params[ACTIVITY_PARAM_COMPONENT_TYPE] !== MODULE_TYPE_BIDDER) return; + let allow = bs.get(params[ACTIVITY_PARAM_ADAPTER_CODE], 'storageAllowed'); + if (!allow || allow === true) { + allow = !!allow + } else { + const storageType = params[ACTIVITY_PARAM_STORAGE_TYPE]; + allow = Array.isArray(allow) ? allow.some((e) => e === storageType) : allow === storageType; + } + if (!allow) { + return {allow}; + } +} + +registerActivityControl(ACTIVITY_ACCESS_DEVICE, 'bidderSettings.*.storageAllowed', storageAllowedRule); + export function resetData() { storageCallbacks = []; } diff --git a/src/userSync.js b/src/userSync.js index ed3cbb5d5f6..936836eb12e 100644 --- a/src/userSync.js +++ b/src/userSync.js @@ -5,6 +5,15 @@ import { import { config } from './config.js'; import {includes} from './polyfill.js'; import { getCoreStorageManager } from './storageManager.js'; +import {isActivityAllowed, registerActivityControl} from './activities/rules.js'; +import {ACTIVITY_SYNC_USER} from './activities/activities.js'; +import { + ACTIVITY_PARAM_COMPONENT_NAME, + ACTIVITY_PARAM_COMPONENT_TYPE, + ACTIVITY_PARAM_SYNC_TYPE, ACTIVITY_PARAM_SYNC_URL +} from './activities/params.js'; +import {MODULE_TYPE_BIDDER} from './activities/modules.js'; +import {activityParams} from './activities/activityParams.js'; export const USERSYNC_DEFAULT_CONFIG = { syncEnabled: true, @@ -29,10 +38,10 @@ const storage = getCoreStorageManager('usersync'); /** * Factory function which creates a new UserSyncPool. * - * @param {UserSyncDependencies} userSyncDependencies Configuration options and dependencies which the + * @param {} deps Configuration options and dependencies which the * UserSync object needs in order to behave properly. */ -export function newUserSync(userSyncDependencies) { +export function newUserSync(deps) { let publicApi = {}; // A queue of user syncs for each adapter // Let getDefaultQueue() set the defaults @@ -50,7 +59,7 @@ export function newUserSync(userSyncDependencies) { }; // Use what is in config by default - let usConfig = userSyncDependencies.config; + let usConfig = deps.config; // Update if it's (re)set config.getConfig('userSync', (conf) => { // Added this logic for https://github.com/prebid/Prebid.js/issues/4864 @@ -70,6 +79,19 @@ export function newUserSync(userSyncDependencies) { usConfig = Object.assign(usConfig, conf.userSync); }); + deps.regRule(ACTIVITY_SYNC_USER, 'userSync config', (params) => { + if (!usConfig.syncEnabled) { + return {allow: false, reason: 'syncs are disabled'} + } + if (params[ACTIVITY_PARAM_COMPONENT_TYPE] === MODULE_TYPE_BIDDER) { + const syncType = params[ACTIVITY_PARAM_SYNC_TYPE]; + const bidder = params[ACTIVITY_PARAM_COMPONENT_NAME]; + if (!publicApi.canBidderRegisterSync(syncType, bidder)) { + return {allow: false, reason: `${syncType} syncs are not enabled for ${bidder}`} + } + } + }); + /** * @function getDefaultQueue * @summary Returns the default empty queue @@ -89,7 +111,7 @@ export function newUserSync(userSyncDependencies) { * @private */ function fireSyncs() { - if (!usConfig.syncEnabled || !userSyncDependencies.browserSupportsCookies) { + if (!usConfig.syncEnabled || !deps.browserSupportsCookies) { return; } @@ -199,14 +221,14 @@ export function newUserSync(userSyncDependencies) { return logWarn(`Number of user syncs exceeded for "${bidder}"`); } - const canBidderRegisterSync = publicApi.canBidderRegisterSync(type, bidder); - if (!canBidderRegisterSync) { - return logWarn(`Bidder "${bidder}" not permitted to register their "${type}" userSync pixels.`); + if (deps.isAllowed(ACTIVITY_SYNC_USER, activityParams(MODULE_TYPE_BIDDER, bidder, { + [ACTIVITY_PARAM_SYNC_TYPE]: type, + [ACTIVITY_PARAM_SYNC_URL]: url + }))) { + // the bidder's pixel has passed all checks and is allowed to register + queue[type].push([bidder, url]); + numAdapterBids = incrementAdapterBids(numAdapterBids, bidder); } - - // the bidder's pixel has passed all checks and is allowed to register - queue[type].push([bidder, url]); - numAdapterBids = incrementAdapterBids(numAdapterBids, bidder); }; /** @@ -320,6 +342,8 @@ export function newUserSync(userSyncDependencies) { export const userSync = newUserSync(Object.defineProperties({ config: config.getConfig('userSync'), + isAllowed: isActivityAllowed, + regRule: registerActivityControl, }, { browserSupportsCookies: { get: function() { @@ -329,13 +353,6 @@ export const userSync = newUserSync(Object.defineProperties({ } })); -/** - * @typedef {Object} UserSyncDependencies - * - * @property {UserSyncConfig} config - * @property {boolean} browserSupportsCookies True if the current browser supports cookies, and false otherwise. - */ - /** * @typedef {Object} UserSyncConfig * diff --git a/test/spec/activities/allowActivites_spec.js b/test/spec/activities/allowActivites_spec.js new file mode 100644 index 00000000000..cc1c83ec4c9 --- /dev/null +++ b/test/spec/activities/allowActivites_spec.js @@ -0,0 +1,138 @@ +import {config} from 'src/config.js'; +import {ruleRegistry} from '../../../src/activities/rules.js'; +import {updateRulesFromConfig} from '../../../modules/allowActivities.js'; +import {activityParams} from '../../../src/activities/activityParams.js'; + +describe('allowActivities config', () => { + const MODULE_TYPE = 'test' + const MODULE_NAME = 'testMod'; + const ACTIVITY = 'testActivity'; + + let isAllowed, params; + + beforeEach(() => { + let registerRule; + [registerRule, isAllowed] = ruleRegistry(); + updateRulesFromConfig(registerRule); + params = activityParams(MODULE_TYPE, MODULE_NAME) + }); + + afterEach(() => { + config.resetConfig(); + }); + + function setupActivityConfig(cfg) { + config.setConfig({ + allowActivities: { + [ACTIVITY]: cfg + } + }) + } + + describe('default = false', () => { + it('should deny activites with no other rules', () => { + setupActivityConfig({ + default: false + }) + expect(isAllowed(ACTIVITY, {})).to.be.false; + }); + it('should not deny activities that are explicitly allowed', () => { + setupActivityConfig({ + default: false, + rules: [ + { + condition({componentName}) { + return componentName === MODULE_NAME + }, + allow: true + } + ] + }) + expect(isAllowed(ACTIVITY, params)).to.be.true; + }); + it('should be removable by a config update', () => { + setupActivityConfig({ + default: false + }); + setupActivityConfig({}); + expect(isAllowed(ACTIVITY, params)).to.be.true; + }) + }); + + describe('rules', () => { + it('are tested for their condition', () => { + setupActivityConfig({ + rules: [{ + condition({flag}) { return flag }, + allow: false + }] + }); + expect(isAllowed(ACTIVITY, params)).to.be.true; + params.flag = true; + expect(isAllowed(ACTIVITY, params)).to.be.false; + }); + + it('always apply if they have no condition', () => { + setupActivityConfig({ + rules: [{allow: false}] + }); + expect(isAllowed(ACTIVITY, params)).to.be.false; + }); + + it('do not choke when the condition throws', () => { + setupActivityConfig({ + rules: [{ + condition() { + throw new Error() + }, + allow: true + }] + }); + expect(isAllowed(ACTIVITY, params)).to.be.false; + }); + + it('does not pass private (underscored) parameters to condition', () => { + setupActivityConfig({ + rules: [{ + condition({_priv}) { return _priv }, + allow: false + }] + }); + params._priv = true; + expect(isAllowed(ACTIVITY, params)).to.be.true; + }) + + it('are evaluated in order of priority', () => { + setupActivityConfig({ + rules: [{ + priority: 1000, + allow: false + }, { + priority: 100, + allow: true + }] + }); + expect(isAllowed(ACTIVITY, params)).to.be.true; + }); + + it('can be set with priority 0', () => { + setupActivityConfig({ + rules: [{ + allow: false + }, { + priority: 0, + allow: true + }] + }); + expect(isAllowed(ACTIVITY, params)).to.be.true; + }) + + it('can be reset with a config update', () => { + setupActivityConfig({ + allow: false + }); + config.setConfig({allowActivities: {}}); + expect(isAllowed(ACTIVITY, params)).to.be.true; + }); + }); +}); diff --git a/test/spec/activities/objectGuard_spec.js b/test/spec/activities/objectGuard_spec.js new file mode 100644 index 00000000000..c88442e9111 --- /dev/null +++ b/test/spec/activities/objectGuard_spec.js @@ -0,0 +1,144 @@ +import {objectGuard, writeProtectRule} from '../../../libraries/objectGuard/objectGuard.js'; + +describe('objectGuard', () => { + describe('read rule', () => { + let rule, applies; + beforeEach(() => { + applies = true; + rule = { + paths: ['foo', 'outer.inner.foo'], + name: 'testRule', + applies: sinon.stub().callsFake(() => applies), + get(val) { return `repl${val}` }, + } + }) + it('can prevent top level read access', () => { + const {obj} = objectGuard([rule])({'foo': 1, 'other': 2}); + expect(obj).to.eql({ + foo: 'repl1', + other: 2 + }); + }); + + it('does not choke if a guarded property is missing', () => { + const {obj} = objectGuard([rule])({}); + expect(obj.foo).to.not.exist; + }); + + it('does not prevent access if applies returns false', () => { + applies = false; + const {obj} = objectGuard([rule])({foo: 1}); + expect(obj).to.eql({ + foo: 1 + }); + }) + + it('can prevent nested property access', () => { + const {obj} = objectGuard([rule])({ + other: 0, + outer: { + foo: 1, + inner: { + foo: 2 + }, + bar: { + foo: 3 + } + } + }); + expect(obj).to.eql({ + other: 0, + outer: { + foo: 1, + inner: { + foo: 'repl2', + }, + bar: { + foo: 3 + } + } + }) + }); + + it('does not call applies more than once', () => { + JSON.stringify(objectGuard([rule])({ + foo: 0, + outer: { + inner: { + foo: 1 + } + } + }).obj); + expect(rule.applies.callCount).to.equal(1); + }) + }); + + describe('write protection', () => { + let applies, rule; + + beforeEach(() => { + applies = true; + rule = writeProtectRule({ + paths: ['foo', 'bar', 'outer.inner.foo', 'outer.inner.bar'], + applies: sinon.stub().callsFake(() => applies) + }); + }); + + it('should undo top-level writes', () => { + const obj = {bar: {nested: 'val'}, other: 'val'}; + const guard = objectGuard([rule])(obj); + guard.obj.foo = 'denied'; + guard.obj.bar.nested = 'denied'; + guard.obj.bar.other = 'denied'; + guard.obj.other = 'allowed'; + guard.verify(); + expect(obj).to.eql({bar: {nested: 'val'}, other: 'allowed'}); + }); + + it('should undo top-level deletes', () => { + const obj = {foo: {nested: 'val'}, bar: 'val'}; + const guard = objectGuard([rule])(obj); + delete guard.obj.foo.nested; + delete guard.obj.bar; + guard.verify(); + expect(obj).to.eql({foo: {nested: 'val'}, bar: 'val'}); + }) + + it('should undo nested writes', () => { + const obj = {outer: {inner: {bar: {nested: 'val'}, other: 'val'}}}; + const guard = objectGuard([rule])(obj); + guard.obj.outer.inner.bar.other = 'denied'; + guard.obj.outer.inner.bar.nested = 'denied'; + guard.obj.outer.inner.foo = 'denied'; + guard.obj.outer.inner.other = 'allowed'; + guard.verify(); + expect(obj).to.eql({ + outer: { + inner: { + bar: { + nested: 'val' + }, + other: 'allowed' + } + } + }) + }); + + it('should undo nested deletes', () => { + const obj = {outer: {inner: {foo: {nested: 'val'}, bar: 'val'}}}; + const guard = objectGuard([rule])(obj); + delete guard.obj.outer.inner.foo.nested; + delete guard.obj.outer.inner.bar; + guard.verify(); + expect(obj).to.eql({outer: {inner: {foo: {nested: 'val'}, bar: 'val'}}}) + }); + + it('should work on null properties', () => { + const obj = {foo: null}; + const guard = objectGuard([rule])(obj); + guard.obj.foo = 'denied'; + guard.verify(); + expect(obj).to.eql({foo: null}); + }); + }); +}); diff --git a/test/spec/activities/ortbGuard_spec.js b/test/spec/activities/ortbGuard_spec.js new file mode 100644 index 00000000000..828cbe4e328 --- /dev/null +++ b/test/spec/activities/ortbGuard_spec.js @@ -0,0 +1,140 @@ +import {ortb2FragmentsGuardFactory, ortb2GuardFactory} from '../../../libraries/objectGuard/ortbGuard.js'; +import {ACTIVITY_PARAM_COMPONENT_NAME, ACTIVITY_PARAM_COMPONENT_TYPE} from '../../../src/activities/params.js'; +import { + ACTIVITY_ENRICH_EIDS, ACTIVITY_ENRICH_UFPD, + ACTIVITY_TRANSMIT_EIDS, + ACTIVITY_TRANSMIT_UFPD +} from '../../../src/activities/activities.js'; +import {activityParams} from '../../../src/activities/activityParams.js'; +import {deepAccess, deepClone, deepSetValue, mergeDeep} from '../../../src/utils.js'; +import {ORTB_EIDS_PATHS, ORTB_UFPD_PATHS} from '../../../src/activities/redactor.js'; +import {objectGuard, writeProtectRule} from '../../../libraries/objectGuard/objectGuard.js'; + +describe('ortb2Guard', () => { + const MOD_TYPE = 'test'; + const MOD_NAME = 'mock'; + let isAllowed, ortb2Guard; + beforeEach(() => { + isAllowed = sinon.stub(); + ortb2Guard = ortb2GuardFactory(function (activity, params) { + if (params[ACTIVITY_PARAM_COMPONENT_TYPE] === MOD_TYPE && params[ACTIVITY_PARAM_COMPONENT_NAME] === MOD_NAME) { + return isAllowed(activity) + } else { + throw new Error('wrong component') + } + }) + }); + + function testAllowDeny(transmitActivity, enrichActivity, fn) { + Object.entries({ + allowed: true, + denied: false + }).forEach(([t, allowed]) => { + describe(`when '${enrichActivity}' is ${t}`, () => { + beforeEach(() => { + isAllowed.callsFake((activity) => { + if (activity === transmitActivity) return true; + if (activity === enrichActivity) return allowed; + throw new Error('wrong activity'); + }) + }); + fn(allowed); + }) + }) + } + + function testPropertiesAreProtected(properties, allowed) { + properties.forEach(prop => { + it(`should ${allowed ? 'keep' : 'undo'} additions to ${prop}`, () => { + const orig = [{n: 'orig'}]; + const ortb2 = {}; + deepSetValue(ortb2, prop, deepClone(orig)); + const guard = ortb2Guard(ortb2, activityParams(MOD_TYPE, MOD_NAME)); + const mod = {}; + const insert = [{n: 'new'}]; + deepSetValue(mod, prop, insert); + mergeDeep(guard.obj, mod); + guard.verify(); + const actual = deepAccess(ortb2, prop); + if (allowed) { + expect(actual).to.eql(orig.concat(insert)) + } else { + expect(actual).to.eql(orig); + } + }); + + it(`should ${allowed ? 'keep' : 'undo'} modifications to ${prop}`, () => { + const orig = [{n: 'orig'}]; + const ortb2 = {}; + deepSetValue(ortb2, prop, orig); + const guard = ortb2Guard(ortb2, activityParams(MOD_TYPE, MOD_NAME)); + deepSetValue(guard.obj, `${prop}.0.n`, 'new'); + guard.verify(); + const actual = deepAccess(ortb2, prop); + if (allowed) { + expect(actual).to.eql([{n: 'new'}]); + } else { + expect(actual).to.eql([{n: 'orig'}]); + } + }); + }) + } + + testAllowDeny(ACTIVITY_TRANSMIT_EIDS, ACTIVITY_ENRICH_EIDS, (allowed) => { + testPropertiesAreProtected(ORTB_EIDS_PATHS, allowed); + }); + + testAllowDeny(ACTIVITY_TRANSMIT_UFPD, ACTIVITY_ENRICH_UFPD, (allowed) => { + testPropertiesAreProtected(ORTB_UFPD_PATHS, allowed); + }); +}); + +describe('ortb2FragmentsGuard', () => { + let guardFragments + beforeEach(() => { + const testGuard = objectGuard([ + writeProtectRule({ + paths: ['foo'], + applies: () => true, + name: 'testRule' + }) + ]) + guardFragments = ortb2FragmentsGuardFactory(testGuard); + }); + + it('should undo changes to global FPD', () => { + const fragments = { + global: { + foo: {inner: 'val'} + } + } + const guard = guardFragments(fragments); + guard.obj.global.foo = 'other'; + guard.verify(); + expect(fragments.global.foo).to.eql({inner: 'val'}); + }); + + it('should undo changes to bidder FPD', () => { + const fragments = { + bidder: { + A: { + foo: 'val' + } + } + }; + const guard = guardFragments(fragments); + guard.obj.bidder.A.foo = 'denied'; + guard.verify(); + expect(fragments.bidder.A).to.eql({foo: 'val'}); + }); + + it('should undo changes to bidder FPD that was not initially there', () => { + const fragments = { + bidder: {} + }; + const guard = guardFragments(fragments); + guard.obj.bidder.A = {foo: 'denied', other: 'allowed'}; + guard.verify(); + expect(fragments.bidder.A).to.eql({other: 'allowed'}); + }); +}) diff --git a/test/spec/activities/params_spec.js b/test/spec/activities/params_spec.js new file mode 100644 index 00000000000..d949cd41cb4 --- /dev/null +++ b/test/spec/activities/params_spec.js @@ -0,0 +1,25 @@ +import { + ACTIVITY_PARAM_ADAPTER_CODE, + ACTIVITY_PARAM_COMPONENT, ACTIVITY_PARAM_COMPONENT_NAME, + ACTIVITY_PARAM_COMPONENT_TYPE +} from '../../../src/activities/params.js'; +import adapterManager from '../../../src/adapterManager.js'; +import {MODULE_TYPE_BIDDER} from '../../../src/activities/modules.js'; +import {activityParams} from '../../../src/activities/activityParams.js'; + +describe('activityParams', () => { + it('fills out component params', () => { + sinon.assert.match(activityParams('bidder', 'mockBidder', {foo: 'bar'}), { + [ACTIVITY_PARAM_COMPONENT]: 'bidder.mockBidder', + [ACTIVITY_PARAM_COMPONENT_TYPE]: 'bidder', + [ACTIVITY_PARAM_COMPONENT_NAME]: 'mockBidder', + foo: 'bar' + }) + }); + + it('fills out adapterCode', () => { + adapterManager.registerBidAdapter({callBids: sinon.stub(), getSpec: sinon.stub().returns({})}, 'mockBidder') + adapterManager.aliasBidAdapter('mockBidder', 'mockAlias'); + expect(activityParams(MODULE_TYPE_BIDDER, 'mockAlias')[ACTIVITY_PARAM_ADAPTER_CODE]).to.equal('mockBidder'); + }); +}); diff --git a/test/spec/activities/redactor_spec.js b/test/spec/activities/redactor_spec.js new file mode 100644 index 00000000000..5cfa1cfc643 --- /dev/null +++ b/test/spec/activities/redactor_spec.js @@ -0,0 +1,296 @@ +import { + objectTransformer, + ORTB_EIDS_PATHS, ORTB_GEO_PATHS, + ORTB_UFPD_PATHS, + redactorFactory, redactRule +} from '../../../src/activities/redactor.js'; +import {ACTIVITY_PARAM_COMPONENT_NAME, ACTIVITY_PARAM_COMPONENT_TYPE} from '../../../src/activities/params.js'; +import { + ACTIVITY_TRANSMIT_EIDS, + ACTIVITY_TRANSMIT_PRECISE_GEO, + ACTIVITY_TRANSMIT_UFPD +} from '../../../src/activities/activities.js'; +import {deepAccess, deepSetValue} from '../../../src/utils.js'; +import {activityParams} from '../../../src/activities/activityParams.js'; + +describe('objectTransformer', () => { + describe('using dummy rules', () => { + let rule, applies, run; + beforeEach(() => { + run = sinon.stub(); + applies = sinon.stub().callsFake(() => true) + rule = { + name: 'mockRule', + paths: ['foo', 'bar.baz'], + applies, + run, + } + }); + + it('runs rule for each path', () => { + const obj = {foo: 'val'}; + objectTransformer([rule])({}, obj); + sinon.assert.calledWith(run, obj, null, obj, 'foo'); + sinon.assert.calledWith(run, obj, 'bar', undefined, 'baz'); + }); + + it('does not run rule once it is known that it does not apply', () => { + applies.reset(); + applies.callsFake(() => false); + run.callsFake((_1, _2, _3, _4, applies) => applies()); + objectTransformer([rule])({}, {}); + expect(applies.callCount).to.equal(1); + expect(run.callCount).to.equal(1); + }); + + it('does not call apply more than once', () => { + run.callsFake((_1, _2, _3, _4, applies) => { + applies(); + applies(); + }); + objectTransformer([rule])({}, {}); + expect(applies.callCount).to.equal(1); + }); + + it('does not call apply if session already contains a result for the rule', () => { + objectTransformer([rule])({[rule.name]: false}, {}); + expect(applies.callCount).to.equal(0); + expect(run.callCount).to.equal(0); + }) + + it('passes arguments to applies', () => { + run.callsFake((_1, _2, _3, _4, applies) => applies()); + const arg1 = {n: 0}; + const arg2 = {n: 1}; + objectTransformer([rule])({}, {}, arg1, arg2); + sinon.assert.calledWith(applies, arg1, arg2); + }); + + it('collects rule results', () => { + let i = 0; + run.callsFake(() => i++); + const result = objectTransformer([rule])({}, {}); + expect(result).to.eql([0, 1]); + }) + }); + + describe('using redact rules', () => { + Object.entries({ + replacement: { + get(path, val) { + return `repl${val}` + }, + expectation(parent, prop, val) { + sinon.assert.match(parent, { + [prop]: val + }) + } + }, + removal: { + get(path, val) {}, + expectation(parent, prop, val) { + expect(Object.keys(parent)).to.not.include.members([prop]); + } + } + }).forEach(([t, {get, expectation}]) => { + describe(`property ${t}`, () => { + it('should work on top level properties', () => { + const obj = {foo: 1, bar: 2}; + objectTransformer([ + redactRule({ + name: 'test', + get, + paths: ['foo'], + applies() { return true } + }) + ])({}, obj); + sinon.assert.match(obj, { + bar: 2 + }); + expectation(obj, 'foo', get(1)); + }); + it('should work on nested properties', () => { + const obj = {outer: {inner: {foo: 'bar'}, baz: 0}}; + objectTransformer([ + redactRule({ + name: 'test', + get, + paths: ['outer.inner.foo'], + applies() { return true; } + }) + ])({}, obj); + sinon.assert.match(obj, { + outer: { + baz: 0 + } + }); + expectation(obj.outer.inner, 'foo', get('bar')) + }) + }); + }); + describe('should not run rule if property is', () => { + Object.entries({ + 'missing': {}, + 'empty array': {foo: []}, + 'empty object': {foo: {}}, + 'null': {foo: null}, + 'undefined': {foo: undefined} + }).forEach(([t, obj]) => { + it(t, () => { + const get = sinon.stub(); + const applies = sinon.stub() + objectTransformer([redactRule({ + name: 'test', + paths: ['foo'], + applies, + get, + })])({}, obj); + expect(get.called).to.be.false; + expect(applies.called).to.be.false; + }) + }) + }); + + describe('should run rule on falsy, but non-empty, value', () => { + Object.entries({ + zero: 0, + false: false + }).forEach(([t, val]) => { + it(t, () => { + const obj = {foo: val}; + objectTransformer([redactRule({ + name: 'test', + paths: ['foo'], + applies() { return true }, + get(val) { return 'repl' }, + })])({}, obj); + expect(obj).to.eql({foo: 'repl'}); + }) + }) + }); + + it('should not run applies twice for the same name/session combination', () => { + const applies = sinon.stub().callsFake(() => true); + const notApplies = sinon.stub().callsFake(() => false); + const t1 = objectTransformer([ + { + name: 'applies', + paths: ['foo'], + applies, + get(val) { return `repl_r1_${val}`; }, + }, + { + name: 'notApplies', + paths: ['notFoo'], + applies: notApplies, + } + ].map(redactRule)); + const t2 = objectTransformer([ + { + name: 'applies', + paths: ['bar'], + applies, + get(val) { return `repl_r2_${val}` } + }, + { + name: 'notApplies', + paths: ['notBar'], + applies: notApplies, + } + ].map(redactRule)); + const obj = { + foo: '1', + notFoo: '2', + bar: '3', + notBar: '4' + } + const session = {}; + t1(session, obj); + t2(session, obj); + expect(obj).to.eql({ + foo: 'repl_r1_1', + notFoo: '2', + bar: 'repl_r2_3', + notBar: '4' + }); + expect(applies.callCount).to.equal(1); + expect(notApplies.callCount).to.equal(1); + }) + }); +}); + +describe('redactor', () => { + const MODULE_TYPE = 'mockType'; + const MODULE_NAME = 'mockModule'; + + let isAllowed, redactor; + + beforeEach(() => { + isAllowed = sinon.stub(); + redactor = redactorFactory((activity, params) => { + if (params[ACTIVITY_PARAM_COMPONENT_TYPE] === MODULE_TYPE && params[ACTIVITY_PARAM_COMPONENT_NAME] === MODULE_NAME) { + return isAllowed(activity) + } else { + throw new Error('wrong component') + } + })(activityParams(MODULE_TYPE, MODULE_NAME)); + }); + + function testAllowDeny(activity, fn) { + Object.entries({ + allowed: true, + denied: false + }).forEach(([t, allowed]) => { + describe(`when '${activity}' is ${t}`, () => { + beforeEach(() => { + isAllowed.callsFake((act) => { + if (act === activity) { + return allowed; + } else { + throw new Error('wrong activity'); + } + }); + }); + fn(allowed); + }); + }); + } + + function testPropertiesAreRemoved(method, properties, allowed) { + properties.forEach(prop => { + it(`should ${allowed ? 'NOT ' : ''}remove ${prop}`, () => { + const obj = {}; + deepSetValue(obj, prop, 'mockVal'); + method()(obj); + expect(deepAccess(obj, prop)).to.eql(allowed ? 'mockVal' : undefined); + }) + }) + } + + describe('.bidRequest', () => { + testAllowDeny(ACTIVITY_TRANSMIT_EIDS, (allowed) => { + testPropertiesAreRemoved(() => redactor.bidRequest, ['userId', 'userIdAsEids'], allowed); + }); + }); + + describe('.ortb2', () => { + testAllowDeny(ACTIVITY_TRANSMIT_EIDS, (allowed) => { + testPropertiesAreRemoved(() => redactor.ortb2, ORTB_EIDS_PATHS, allowed) + }); + + testAllowDeny(ACTIVITY_TRANSMIT_UFPD, (allowed) => { + testPropertiesAreRemoved(() => redactor.ortb2, ORTB_UFPD_PATHS, allowed) + }); + + testAllowDeny(ACTIVITY_TRANSMIT_PRECISE_GEO, (allowed) => { + ORTB_GEO_PATHS.forEach(path => { + it(`should ${allowed ? 'NOT ' : ''} round down ${path}`, () => { + const ortb2 = {}; + deepSetValue(ortb2, path, 1.2345); + redactor.ortb2(ortb2); + expect(deepAccess(ortb2, path)).to.eql(allowed ? 1.2345 : 1.23); + }) + }) + }) + }); +}) diff --git a/test/spec/activities/rules_spec.js b/test/spec/activities/rules_spec.js new file mode 100644 index 00000000000..2acfae57980 --- /dev/null +++ b/test/spec/activities/rules_spec.js @@ -0,0 +1,135 @@ +import {ruleRegistry} from '../../../src/activities/rules.js'; + +describe('Activity control rules', () => { + const MOCK_ACTIVITY = 'mockActivity'; + const MOCK_RULE = 'mockRule'; + + let registerRule, isAllowed, logger; + + beforeEach(() => { + logger = { + logInfo: sinon.stub(), + logWarn: sinon.stub(), + logError: sinon.stub(), + }; + [registerRule, isAllowed] = ruleRegistry(logger); + }); + + it('allows by default', () => { + expect(isAllowed(MOCK_ACTIVITY, {})).to.be.true; + }); + + it('denies if a rule throws', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => { + throw new Error('argh'); + }); + expect(isAllowed(MOCK_ACTIVITY, {})).to.be.false; + }); + + it('denies if a rule denies', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: false})); + expect(isAllowed(MOCK_ACTIVITY, {})).to.be.false; + }); + + it('partitions rules by activity', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: false})); + expect(isAllowed('other', {})).to.be.true; + }); + + it('passes params to rules', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, (params) => ({allow: params.foo !== 'bar'})); + expect(isAllowed(MOCK_ACTIVITY, {foo: 'notbar'})).to.be.true; + expect(isAllowed(MOCK_ACTIVITY, {foo: 'bar'})).to.be.false; + }); + + it('allows if rules do not opine', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => null); + expect(isAllowed(MOCK_ACTIVITY, {foo: 'bar'})).to.be.true; + }); + + it('denies if any rule denies', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => null); + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: true})); + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: false})); + expect(isAllowed(MOCK_ACTIVITY, {foo: 'bar'})).to.be.false; + }); + + it('allows if higher priority allow rule trumps a lower priority deny rule', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: false})); + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: true}), 0); + expect(isAllowed(MOCK_ACTIVITY, {})).to.be.true; + }); + + it('denies if a higher priority deny rule trumps a lower priority allow rule', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: true})); + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: false}), 0); + expect(isAllowed(MOCK_ACTIVITY, {})).to.be.false; + }); + + it('can unregister rules', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: true})); + const r = registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: false}), 0); + expect(isAllowed(MOCK_ACTIVITY, {})).to.be.false; + r(); + expect(isAllowed(MOCK_ACTIVITY, {})).to.be.true; + }) + + it('logs INFO when explicit allow is found', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: true})); + isAllowed(MOCK_ACTIVITY, {}); + sinon.assert.calledWithMatch(logger.logInfo, new RegExp(MOCK_RULE)); + }); + + it('logs INFO with reason if the rule provides one', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: true, reason: 'because'})); + isAllowed(MOCK_ACTIVITY, {}); + sinon.assert.calledWithMatch(logger.logInfo, new RegExp(MOCK_RULE), /because/); + }); + + it('logs WARN when a deny is found', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: false})); + isAllowed(MOCK_ACTIVITY, {}); + sinon.assert.calledWithMatch(logger.logWarn, new RegExp(MOCK_RULE)); + }); + + it('logs WARN with reason if the rule provides one', () => { + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({allow: false, reason: 'fail'})); + isAllowed(MOCK_ACTIVITY, {}); + sinon.assert.calledWithMatch(logger.logWarn, new RegExp(MOCK_RULE), /fail/); + }); + + describe('log message deduping', () => { + let clock, allow; + beforeEach(() => { + allow = false; + registerRule(MOCK_ACTIVITY, MOCK_RULE, () => ({ allow })); + clock = sinon.useFakeTimers(); + }); + afterEach(() => { + clock.restore(); + }); + + it('is applied to identical messages that are close in time', () => { + isAllowed(MOCK_ACTIVITY, {}); + clock.tick(100); + isAllowed(MOCK_ACTIVITY, {}); + expect(logger.logWarn.callCount).to.equal(1); + }); + + it('not to messages that show different results', () => { + isAllowed(MOCK_ACTIVITY, {}); + allow = true; + clock.tick(100); + isAllowed(MOCK_ACTIVITY, {}); + expect(logger.logWarn.callCount).to.equal(1); + expect(logger.logInfo.callCount).to.equal(1); + }); + + it('not to messages that are further apart in time', () => { + isAllowed(MOCK_ACTIVITY, {}); + clock.tick(2000); + isAllowed(MOCK_ACTIVITY, {}); + expect(logger.logWarn.callCount).to.equal(2); + }) + }) +}); diff --git a/test/spec/modules/gdprEnforcement_spec.js b/test/spec/modules/gdprEnforcement_spec.js index 941f2b3c8df..1585b8346ba 100644 --- a/test/spec/modules/gdprEnforcement_spec.js +++ b/test/spec/modules/gdprEnforcement_spec.js @@ -1,16 +1,17 @@ import { + accessDeviceRule, deviceAccessHook, - enableAnalyticsHook, enforcementRules, + enrichEidsRule, + fetchBidsRule, getGvlid, getGvlidFromAnalyticsAdapter, - makeBidRequestsHook, purpose1Rule, purpose2Rule, + reportAnalyticsRule, setEnforcementConfig, STRICT_STORAGE_ENFORCEMENT, - userIdHook, - userSyncHook, + syncUserRule, validateRules } from 'modules/gdprEnforcement.js'; import {config} from 'src/config.js'; @@ -19,7 +20,7 @@ import * as utils from 'src/utils.js'; import { MODULE_TYPE_ANALYTICS, MODULE_TYPE_BIDDER, - MODULE_TYPE_CORE, + MODULE_TYPE_PREBID, MODULE_TYPE_UID } from '../../../src/activities/modules.js'; import * as events from 'src/events.js'; @@ -28,6 +29,7 @@ import 'src/prebid.js'; import {hook} from '../../../src/hook.js'; import {GDPR_GVLIDS, VENDORLESS_GVLID} from '../../../src/consentHandler.js'; import {validateStorageEnforcement} from '../../../src/storageManager.js'; +import {activityParams} from '../../../src/activities/activityParams.js'; describe('gdpr enforcement', function () { let nextFnSpy; @@ -107,65 +109,47 @@ describe('gdpr enforcement', function () { } } }; - let gvlids; + let gvlids, sandbox; + + function setupConsentData({gdprApplies = true, apiVersion = 2} = {}) { + const cd = utils.deepClone(staticConfig); + const consent = { + vendorData: cd.consentData.getTCData, + gdprApplies, + apiVersion + }; + sandbox.stub(gdprDataHandler, 'getConsentData').callsFake(() => consent) + return consent; + } before(() => { hook.ready(); }); after(function () { - validateStorageEnforcement.getHooks({ hook: deviceAccessHook }).remove(); $$PREBID_GLOBAL$$.requestBids.getHooks().remove(); - adapterManager.makeBidRequests.getHooks({ hook: makeBidRequestsHook }).remove(); }) + function expectAllow(allow, ruleResult) { + allow ? expect(ruleResult).to.not.exist : sinon.assert.match(ruleResult, {allow: false}); + } + beforeEach(() => { + sandbox = sinon.sandbox.create(); gvlids = {}; - sinon.stub(GDPR_GVLIDS, 'get').callsFake((name) => ({gvlid: gvlids[name], modules: {}})); + sandbox.stub(GDPR_GVLIDS, 'get').callsFake((name) => ({gvlid: gvlids[name], modules: {}})); }); afterEach(() => { - GDPR_GVLIDS.get.restore(); - }); - - describe('deviceAccessHook', function () { - beforeEach(function () { - nextFnSpy = sinon.spy(); - gdprDataHandlerStub = sinon.stub(gdprDataHandler, 'getConsentData'); - logWarnSpy = sinon.spy(utils, 'logWarn'); - }); + sandbox.restore(); + }) - afterEach(function () { + describe('deviceAccessRule', () => { + afterEach(() => { config.resetConfig(); - gdprDataHandler.getConsentData.restore(); - logWarnSpy.restore(); - }); - - it('should not allow device access when device access flag is set to false', function () { - config.setConfig({ - deviceAccess: false, - consentManagement: { - gdpr: { - rules: [{ - purpose: 'storage', - enforcePurpose: false, - enforceVendor: false, - vendorExceptions: ['appnexus', 'rubicon'] - }] - } - } - }); - - deviceAccessHook(nextFnSpy); - expect(nextFnSpy.calledOnce).to.equal(true); - let result = { - hasEnforcementHook: true, - valid: false - } - sinon.assert.calledWith(nextFnSpy, undefined, undefined, result); }); - it('should only check for consent for vendor exceptions when enforcePurpose and enforceVendor are false', function () { + it('should not check for consent when enforcePurpose and enforceVendor are false', function () { Object.assign(gvlids, { appnexus: 1, rubicon: 5 @@ -180,15 +164,8 @@ describe('gdpr enforcement', function () { }] } }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.gdprApplies = true; - consentData.apiVersion = 2; - gdprDataHandlerStub.returns(consentData); - - deviceAccessHook(nextFnSpy, MODULE_TYPE_BIDDER, 'appnexus'); - deviceAccessHook(nextFnSpy, MODULE_TYPE_BIDDER, 'rubicon'); - expect(logWarnSpy.callCount).to.equal(0); + setupConsentData(); + ['appnexus', 'rubicon'].forEach(bidder => expectAllow(true, accessDeviceRule(activityParams(MODULE_TYPE_BIDDER, bidder)))); }); it('should check consent for all vendors when enforcePurpose and enforceVendor are true', function () { @@ -205,15 +182,13 @@ describe('gdpr enforcement', function () { }] } }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.gdprApplies = true; - consentData.apiVersion = 2; - gdprDataHandlerStub.returns(consentData); - - deviceAccessHook(nextFnSpy, MODULE_TYPE_BIDDER, 'appnexus'); - deviceAccessHook(nextFnSpy, MODULE_TYPE_BIDDER, 'rubicon'); - expect(logWarnSpy.callCount).to.equal(1); + setupConsentData(); + Object.entries({ + appnexus: true, + rubicon: false + }).forEach(([bidder, isAllowed]) => { + expectAllow(isAllowed, accessDeviceRule(activityParams(MODULE_TYPE_BIDDER, bidder))); + }) }); it('should allow device access when gdprApplies is false and hasDeviceAccess flag is true', function () { @@ -228,19 +203,8 @@ describe('gdpr enforcement', function () { }] } }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.gdprApplies = false; - consentData.apiVersion = 2; - gdprDataHandlerStub.returns(consentData); - - deviceAccessHook(nextFnSpy, MODULE_TYPE_BIDDER, 'appnexus'); - expect(nextFnSpy.calledOnce).to.equal(true); - let result = { - hasEnforcementHook: true, - valid: true - } - sinon.assert.calledWith(nextFnSpy, MODULE_TYPE_BIDDER, 'appnexus', result); + setupConsentData(); + expectAllow(true, accessDeviceRule(activityParams(MODULE_TYPE_BIDDER, 'appnexus'))); }); it('should use gvlMapping set by publisher', function() { @@ -259,92 +223,18 @@ describe('gdpr enforcement', function () { }] } }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.gdprApplies = true; - consentData.apiVersion = 2; - gdprDataHandlerStub.returns(consentData); - - deviceAccessHook(nextFnSpy, MODULE_TYPE_BIDDER, 'appnexus'); - expect(nextFnSpy.calledOnce).to.equal(true); - let result = { - hasEnforcementHook: true, - valid: true - } - sinon.assert.calledWith(nextFnSpy, MODULE_TYPE_BIDDER, 'appnexus', result); - config.resetConfig(); - }); - - it('should use gvl id of alias and not of parent', function() { - let curBidderStub = sinon.stub(config, 'getCurrentBidder'); - curBidderStub.returns('appnexus-alias'); - adapterManager.aliasBidAdapter('appnexus', 'appnexus-alias'); - config.setConfig({ - 'gvlMapping': { - 'appnexus-alias': 4 - } - }); - setEnforcementConfig({ - gdpr: { - rules: [{ - purpose: 'storage', - enforcePurpose: true, - enforceVendor: true, - vendorExceptions: [] - }] - } - }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.gdprApplies = true; - consentData.apiVersion = 2; - gdprDataHandlerStub.returns(consentData); - - deviceAccessHook(nextFnSpy, MODULE_TYPE_BIDDER, 'appnexus'); - expect(nextFnSpy.calledOnce).to.equal(true); - let result = { - hasEnforcementHook: true, - valid: true - } - sinon.assert.calledWith(nextFnSpy, MODULE_TYPE_BIDDER, 'appnexus', result); - config.resetConfig(); - curBidderStub.restore(); + setupConsentData(); + expectAllow(true, accessDeviceRule(activityParams(MODULE_TYPE_BIDDER, 'appnexus'))); }); it(`should not enforce consent for vendorless modules if ${STRICT_STORAGE_ENFORCEMENT} is not set`, () => { setEnforcementConfig({}); - let consentData = { - vendorData: staticConfig.consentData.getTCData, - gdprApplies: true - } - gdprDataHandlerStub.returns(consentData); - const validate = sinon.stub().callsFake(() => false); - deviceAccessHook(nextFnSpy, MODULE_TYPE_CORE, 'mockModule', undefined, {validate}); - sinon.assert.callCount(validate, 0); - sinon.assert.calledWith(nextFnSpy, MODULE_TYPE_CORE, 'mockModule', {hasEnforcementHook: true, valid: true}); + setupConsentData(); + expectAllow(true, accessDeviceRule(activityParams(MODULE_TYPE_PREBID, 'mockCoreModule'))); }) }); - describe('userSyncHook', function () { - let curBidderStub; - let adapterManagerStub; - - beforeEach(function () { - gdprDataHandlerStub = sinon.stub(gdprDataHandler, 'getConsentData'); - logWarnSpy = sinon.spy(utils, 'logWarn'); - curBidderStub = sinon.stub(config, 'getCurrentBidder'); - adapterManagerStub = sinon.stub(adapterManager, 'getBidAdapter'); - nextFnSpy = sinon.spy(); - }); - - afterEach(function () { - config.getCurrentBidder.restore(); - config.resetConfig(); - gdprDataHandler.getConsentData.restore(); - adapterManager.getBidAdapter.restore(); - logWarnSpy.restore(); - }); - + describe('syncUserRule', () => { it('should allow bidder to do user sync if consent is true', function () { setEnforcementConfig({ gdpr: { @@ -356,20 +246,12 @@ describe('gdpr enforcement', function () { }] } }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.gdprApplies = true; - consentData.apiVersion = 2; - gdprDataHandlerStub.returns(consentData); - - curBidderStub.returns('sampleBidder1'); - gvlids.sampleBidder1 = 1; - userSyncHook(nextFnSpy); - - curBidderStub.returns('sampleBidder2'); - gvlids.sampleBidder2 = 3; - userSyncHook(nextFnSpy); - expect(nextFnSpy.calledTwice).to.equal(true); + setupConsentData(); + Object.assign(gvlids, { + sampleBidder1: 1, + sampleBidder2: 2 + }) + Object.keys(gvlids).forEach(bidder => expect(syncUserRule(activityParams(MODULE_TYPE_BIDDER, bidder))).to.not.exist); }); it('should not allow bidder to do user sync if user has denied consent', function () { @@ -383,21 +265,18 @@ describe('gdpr enforcement', function () { }] } }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.apiVersion = 2; - consentData.gdprApplies = true; - gdprDataHandlerStub.returns(consentData); - - curBidderStub.returns('sampleBidder1'); - gvlids.sampleBidder1 = 1; - userSyncHook(nextFnSpy); - - curBidderStub.returns('sampleBidder2'); - gvlids.sampleBidder2 = 3; - userSyncHook(nextFnSpy); - expect(nextFnSpy.calledOnce).to.equal(true); - expect(logWarnSpy.callCount).to.equal(1); + setupConsentData(); + Object.assign(gvlids, { + sampleBidder1: 1, + sampleBidder2: 3 + }) + + Object.entries({ + sampleBidder1: true, + sampleBidder2: false + }).forEach(([bidder, isAllowed]) => { + expectAllow(isAllowed, syncUserRule(activityParams(MODULE_TYPE_BIDDER, bidder))); + }) }); it('should not check vendor consent when enforceVendor is false', function () { @@ -411,33 +290,15 @@ describe('gdpr enforcement', function () { }] } }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.apiVersion = 2; - consentData.gdprApplies = true; - gdprDataHandlerStub.returns(consentData); - - curBidderStub.returns('sampleBidder1'); - gvlids.sampleBidder1 = 1; - userSyncHook(nextFnSpy); - - curBidderStub.returns('sampleBidder2'); - gvlids.sampleBidder2 = 3; - userSyncHook(nextFnSpy); - expect(nextFnSpy.calledTwice).to.equal(true); - expect(logWarnSpy.callCount).to.equal(0); + setupConsentData(); + Object.assign(gvlids, { + sampleBidder1: 1, + sampleBidder2: 3 + }) + Object.keys(gvlids).forEach(bidder => expect(syncUserRule(activityParams(MODULE_TYPE_BIDDER, bidder))).to.not.exist); }); }); - - describe('userIdHook', function () { - beforeEach(function () { - logWarnSpy = sinon.spy(utils, 'logWarn'); - nextFnSpy = sinon.spy(); - }); - afterEach(function () { - config.resetConfig(); - logWarnSpy.restore(); - }); + describe('enrichEidsRule', () => { it('should allow user id module if consent is given', function () { setEnforcementConfig({ gdpr: { @@ -449,40 +310,16 @@ describe('gdpr enforcement', function () { }] } }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.apiVersion = 2; - consentData.gdprApplies = true; - let submodules = [{ - submodule: { - gvlid: 1, - name: 'sampleUserId' - } - }] + setupConsentData(); gvlids.sampleUserId = 1; - userIdHook(nextFnSpy, submodules, consentData); - // Should pass back hasValidated flag since version 2 - const args = nextFnSpy.getCalls()[0].args; - expect(args[1].hasValidated).to.be.true; - expect(nextFnSpy.calledOnce).to.equal(true); - sinon.assert.calledWith(nextFnSpy, submodules, { ...consentData, hasValidated: true }); + expect(enrichEidsRule(activityParams(MODULE_TYPE_UID, 'sampleUserId'))).to.not.exist; }); it('should allow userId module if gdpr not in scope', function () { - let submodules = [{ - submodule: { - gvlid: 1, - name: 'sampleUserId' - } - }]; gvlids.sampleUserId = 1; - let consentData = null; - userIdHook(nextFnSpy, submodules, consentData); - // Should not pass back hasValidated flag since version 2 - const args = nextFnSpy.getCalls()[0].args; - expect(args[1]).to.be.null; - expect(nextFnSpy.calledOnce).to.equal(true); - sinon.assert.calledWith(nextFnSpy, submodules, consentData); + const consent = setupConsentData({gdprApplies: false}); + consent.vendorData.purpose.consents['1'] = false; + expect(enrichEidsRule(activityParams(MODULE_TYPE_UID, 'sampleUserId'))).to.not.exist; }); it('should not allow user id module if user denied consent', function () { @@ -496,72 +333,23 @@ describe('gdpr enforcement', function () { }] } }); - let consentData = {} - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.apiVersion = 2; - consentData.gdprApplies = true; - - let submodules = [{ - submodule: { - gvlid: 1, - name: 'sampleUserId' - } - }, { - submodule: { - gvlid: 3, - name: 'sampleUserId1' - } - }] + setupConsentData(); Object.assign(gvlids, { sampleUserId: 1, sampleUserId1: 3 }); - userIdHook(nextFnSpy, submodules, consentData); - expect(logWarnSpy.callCount).to.equal(1); - let expectedSubmodules = [{ - submodule: { - gvlid: 1, - name: 'sampleUserId' - } - }] - sinon.assert.calledWith(nextFnSpy, expectedSubmodules, { ...consentData, hasValidated: true }); + Object.entries({ + sampleUserId: true, + sampleUserId1: false + }).forEach(([name, allow]) => { + expectAllow(allow, enrichEidsRule(activityParams(MODULE_TYPE_UID, name))) + }); }); }); - describe('makeBidRequestsHook', function () { - let sandbox; - let adapterManagerStub; - let emitEventSpy; - - const MOCK_AD_UNITS = [{ - code: 'ad-unit-1', - mediaTypes: {}, - bids: [{ - bidder: 'bidder_1' // has consent - }, { - bidder: 'bidder_2' // doesn't have consent, but liTransparency is true. Bidder remains active. - }] - }, { - code: 'ad-unit-2', - mediaTypes: {}, - bids: [{ - bidder: 'bidder_2' - }, { - bidder: 'bidder_3' - }] - }]; - - beforeEach(function () { - sandbox = sinon.createSandbox(); - gdprDataHandlerStub = sandbox.stub(gdprDataHandler, 'getConsentData'); - adapterManagerStub = sandbox.stub(adapterManager, 'getBidAdapter'); - logWarnSpy = sandbox.spy(utils, 'logWarn'); - nextFnSpy = sandbox.spy(); - emitEventSpy = sandbox.spy(events, 'emit'); - }); + describe('fetchBidsRule', () => { afterEach(function () { config.resetConfig(); - sandbox.restore(); }); it('should block bidder which does not have consent and allow bidder which has consent (liTransparency is established)', function () { @@ -575,35 +363,12 @@ describe('gdpr enforcement', function () { }] } }); - const consentData = {}; - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.apiVersion = 2; - consentData.gdprApplies = true; - - gdprDataHandlerStub.returns(consentData); + setupConsentData() Object.assign(gvlids, { bidder_1: 4, bidder_2: 5, }); - makeBidRequestsHook(nextFnSpy, MOCK_AD_UNITS, []); - - // Assertions - expect(nextFnSpy.calledOnce).to.equal(true); - sinon.assert.calledWith(nextFnSpy, [{ - code: 'ad-unit-1', - mediaTypes: {}, - bids: [ - sinon.match({ bidder: 'bidder_1' }), - sinon.match({ bidder: 'bidder_2' }) - ] - }, { - code: 'ad-unit-2', - mediaTypes: {}, - bids: [ - sinon.match({ bidder: 'bidder_2' }), - sinon.match({ bidder: 'bidder_3' }) // should be allowed even though it's doesn't have a gvlId because liTransparency is established. - ] - }], []); + ['bidder_1', 'bidder_2', 'bidder_3'].forEach(bidder => expect(fetchBidsRule(activityParams(MODULE_TYPE_BIDDER, bidder))).to.not.exist); }); it('should block bidder which does not have consent and allow bidder which has consent (liTransparency is NOT established)', function() { @@ -617,41 +382,19 @@ describe('gdpr enforcement', function () { }] } }); - const consentData = {}; - - // set li for purpose 2 to false - const newConsentData = utils.deepClone(staticConfig); - newConsentData.consentData.getTCData.purpose.legitimateInterests['2'] = false; - - consentData.vendorData = newConsentData.consentData.getTCData; - consentData.apiVersion = 2; - consentData.gdprApplies = true; - - gdprDataHandlerStub.returns(consentData); + const consent = setupConsentData(); + consent.vendorData.purpose.legitimateInterests['2'] = false; Object.assign(gvlids, { bidder_1: 4, bidder_2: 5, }) - - makeBidRequestsHook(nextFnSpy, MOCK_AD_UNITS, []); - - // Assertions - expect(nextFnSpy.calledOnce).to.equal(true); - sinon.assert.calledWith(nextFnSpy, [{ - code: 'ad-unit-1', - mediaTypes: {}, - bids: [ - sinon.match({ bidder: 'bidder_1' }), // 'bidder_2' is not present because it doesn't have vendorConsent - ] - }, { - code: 'ad-unit-2', - mediaTypes: {}, - bids: [ - sinon.match({ bidder: 'bidder_3' }), // 'bidder_3' is allowed despite gvlId being undefined because it's part of vendorExceptions - ] - }], []); - - expect(logWarnSpy.calledOnce).to.equal(true); + Object.entries({ + bidder_1: true, + bidder_2: false, + bidder_3: true + }).forEach(([bidder, allowed]) => { + expectAllow(allowed, fetchBidsRule(activityParams(MODULE_TYPE_BIDDER, bidder))); + }) }); it('should skip validation checks if GDPR version is not equal to "2"', function () { @@ -659,57 +402,36 @@ describe('gdpr enforcement', function () { gdpr: { rules: [{ purpose: 'storage', - enforePurpose: false, - enforceVendor: false, + enforcePurpose: true, + enforceVendor: true, vendorExceptions: [] }] } }); - - const consentData = {}; - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.apiVersion = 1; - consentData.gdprApplies = true; - gdprDataHandlerStub.returns(consentData); - - makeBidRequestsHook(nextFnSpy, MOCK_AD_UNITS, []); - - // Assertions - expect(nextFnSpy.calledOnce).to.equal(true); - sinon.assert.calledWith(nextFnSpy, sinon.match.array.deepEquals(MOCK_AD_UNITS), []); - expect(emitEventSpy.notCalled).to.equal(true); - expect(logWarnSpy.notCalled).to.equal(true); - }); - }); - - describe('enableAnalyticsHook', function () { - let sandbox; - let adapterManagerStub; - - const MOCK_ANALYTICS_ADAPTER_CONFIG = [{ - provider: 'analyticsAdapter_A', - options: {} - }, { - provider: 'analyticsAdapter_B', - options: {} - }, { - provider: 'analyticsAdapter_C', - options: {} - }]; - - beforeEach(function () { - sandbox = sinon.createSandbox(); - gdprDataHandlerStub = sandbox.stub(gdprDataHandler, 'getConsentData'); - adapterManagerStub = sandbox.stub(adapterManager, 'getAnalyticsAdapter'); - logWarnSpy = sandbox.spy(utils, 'logWarn'); - nextFnSpy = sandbox.spy(); + const consent = setupConsentData(); + consent.vendorData.purpose.consents['2'] = false; + consent.apiVersion = 1; + ['bidder_1', 'bidder_2', 'bidder_3'].forEach(bidder => expect(fetchBidsRule(activityParams(MODULE_TYPE_BIDDER, bidder))).to.not.exist); }); - afterEach(function() { - config.resetConfig(); - sandbox.restore(); - }); + it('should skip validation if enforcePurpose is false', () => { + setEnforcementConfig({ + gdpr: { + rules: [{ + purpose: 'storage', + enforcePurpose: false, + enforceVendor: true, + vendorExceptions: [] + }] + } + }); + const consent = setupConsentData(); + consent.vendorData.purpose.consents['2'] = false; + ['bidder_1', 'bidder_2', 'bidder_3'].forEach(bidder => expect(fetchBidsRule(activityParams(MODULE_TYPE_BIDDER, bidder))).to.not.exist); + }) + }); + describe('reportAnalyticsRule', () => { it('should block analytics adapter which does not have consent and allow the one(s) which have consent', function() { setEnforcementConfig({ gdpr: { @@ -722,30 +444,21 @@ describe('gdpr enforcement', function () { } }); - const consentData = {}; - consentData.vendorData = staticConfig.consentData.getTCData; - consentData.apiVersion = 2; - consentData.gdprApplies = true; - - gdprDataHandlerStub.returns(consentData); Object.assign(gvlids, { analyticsAdapter_A: 3, analyticsAdapter_B: 5, analyticsAdapter_C: 1 }); - enableAnalyticsHook(nextFnSpy, MOCK_ANALYTICS_ADAPTER_CONFIG); + setupConsentData() - // Assertions - expect(nextFnSpy.calledOnce).to.equal(true); - sinon.assert.calledWith(nextFnSpy, [{ - provider: 'analyticsAdapter_B', - options: {} - }, { - provider: 'analyticsAdapter_C', - options: {} - }]); - expect(logWarnSpy.calledOnce).to.equal(true); + Object.entries({ + analyticsAdapter_A: false, + analyticsAdapter_B: true, + analyticsAdapter_C: true + }).forEach(([adapter, allow]) => { + expectAllow(allow, reportAnalyticsRule(activityParams(MODULE_TYPE_ANALYTICS, adapter))) + }) }); }); @@ -1150,7 +863,7 @@ describe('gdpr enforcement', function () { it('should return VENDORLESS_GVLID for core modules', () => { entry = {gvlid: 123}; - expect(getGvlid(MODULE_TYPE_CORE, MOCK_MODULE, fallbackFn)).to.equal(VENDORLESS_GVLID); + expect(getGvlid(MODULE_TYPE_PREBID, MOCK_MODULE, fallbackFn)).to.equal(VENDORLESS_GVLID); }); describe('multiple GVL IDs are found', () => { diff --git a/test/spec/modules/realTimeDataModule_spec.js b/test/spec/modules/realTimeDataModule_spec.js index f9c41b2fda0..938e2e2f3c1 100644 --- a/test/spec/modules/realTimeDataModule_spec.js +++ b/test/spec/modules/realTimeDataModule_spec.js @@ -128,7 +128,7 @@ describe('Real time module', function () { it('should be able to modify bid request', function (done) { rtdModule.setBidRequestsData(() => { assert(getBidRequestDataSpy.calledTwice); - assert(getBidRequestDataSpy.calledWith({bidRequest: {}})); + assert(getBidRequestDataSpy.calledWith(sinon.match({bidRequest: {}}))); done(); }, {bidRequest: {}}) }); diff --git a/test/spec/modules/userId_spec.js b/test/spec/modules/userId_spec.js index a2f2bfd8713..33ebf557e15 100644 --- a/test/spec/modules/userId_spec.js +++ b/test/spec/modules/userId_spec.js @@ -1,25 +1,25 @@ import { attachIdSystem, auctionDelay, - coreStorage, + coreStorage, dep, + findRootDomain, init, + PBJS_USER_ID_OPTOUT_NAME, requestBidsHook, + requestDataDeletion, setStoredConsentData, setStoredValue, setSubmoduleRegistry, syncDelay, - PBJS_USER_ID_OPTOUT_NAME, - findRootDomain, requestDataDeletion, } from 'modules/userId/index.js'; import {createEidsArray} from 'modules/userId/eids.js'; import {config} from 'src/config.js'; import * as utils from 'src/utils.js'; +import {getPrebidInternal} from 'src/utils.js'; import * as events from 'src/events.js'; import CONSTANTS from 'src/constants.json'; import {getGlobal} from 'src/prebidGlobal.js'; -import { - resetConsentData, -} from 'modules/consentManagement.js'; +import {resetConsentData, } from 'modules/consentManagement.js'; import {server} from 'test/mocks/xhr.js'; import {find} from 'src/polyfill.js'; import {unifiedIdSubmodule} from 'modules/unifiedIdSystem.js'; @@ -27,7 +27,10 @@ import {britepoolIdSubmodule} from 'modules/britepoolIdSystem.js'; import {id5IdSubmodule} from 'modules/id5IdSystem.js'; import {identityLinkSubmodule} from 'modules/identityLinkIdSystem.js'; import {dmdIdSubmodule} from 'modules/dmdIdSystem.js'; -import {liveIntentIdSubmodule, setEventFiredFlag as liveIntentIdSubmoduleDoNotFireEvent} from 'modules/liveIntentIdSystem.js'; +import { + liveIntentIdSubmodule, + setEventFiredFlag as liveIntentIdSubmoduleDoNotFireEvent +} from 'modules/liveIntentIdSystem.js'; import {merkleIdSubmodule} from 'modules/merkleIdSystem.js'; import {netIdSubmodule} from 'modules/netIdSystem.js'; import {intentIqIdSubmodule} from 'modules/intentIqIdSystem.js'; @@ -39,7 +42,6 @@ import {criteoIdSubmodule} from 'modules/criteoIdSystem.js'; import {mwOpenLinkIdSubModule} from 'modules/mwOpenLinkIdSystem.js'; import {tapadIdSubmodule} from 'modules/tapadIdSystem.js'; import {tncidSubModule} from 'modules/tncIdSystem.js'; -import {getPrebidInternal} from 'src/utils.js'; import {uid2IdSubmodule} from 'modules/uid2IdSystem.js'; import {admixerIdSubmodule} from 'modules/admixerIdSystem.js'; import {deepintentDpesSubmodule} from 'modules/deepintentDpesIdSystem.js'; @@ -55,6 +57,8 @@ import {getPPID} from '../../../src/adserver.js'; import {uninstall as uninstallGdprEnforcement} from 'modules/gdprEnforcement.js'; import {GDPR_GVLIDS} from '../../../src/consentHandler.js'; import {MODULE_TYPE_UID} from '../../../src/activities/modules.js'; +import {ACTIVITY_ENRICH_EIDS} from '../../../src/activities/activities.js'; +import {ACTIVITY_PARAM_COMPONENT_NAME, ACTIVITY_PARAM_COMPONENT_TYPE} from '../../../src/activities/params.js'; let assert = require('chai').assert; let expect = require('chai').expect; @@ -188,8 +192,7 @@ describe('User ID', function () { mockGpt.disable(); mockGpt.enable(); coreStorage.setCookie('pubcid', '', EXPIRED_COOKIE_DATE); - coreStorage.setCookie('pubcid_alt', 'altpubcid200000', (new Date(Date.now() + 5000).toUTCString())); - let origSK = coreStorage.setCookie.bind(coreStorage); + coreStorage.setCookie('pubcid_alt', 'altpubcid200000', (new Date(Date.now() + 20000).toUTCString())); sinon.spy(coreStorage, 'setCookie'); sinon.stub(utils, 'logWarn'); }); @@ -320,10 +323,6 @@ describe('User ID', function () { }); }); }); - // Because the consent cookie doesn't exist yet, we'll have 2 setCookie calls: - // 1) for the consent cookie - // 2) from the getId() call that results in a new call to store the results - expect(coreStorage.setCookie.callCount).to.equal(2); }); }); @@ -350,7 +349,6 @@ describe('User ID', function () { }); }); }); - expect(coreStorage.setCookie.callCount).to.equal(2); }); }); @@ -2506,6 +2504,53 @@ describe('User ID', function () { done(); }, {adUnits}); }); + + describe('activity controls', () => { + let isAllowed; + const MOCK_IDS = ['mockId1', 'mockId2'] + beforeEach(() => { + isAllowed = sinon.stub(dep, 'isAllowed'); + init(config); + setSubmoduleRegistry([]); + const mods = MOCK_IDS.map((name) => ({ + name, + decode: function (value) { + return { + [name]: value + }; + }, + getId: function () { + return {id: `${name}Value`}; + } + })); + mods.forEach(attachIdSystem); + }); + afterEach(() => { + isAllowed.restore(); + }); + + it('should check for enrichEids activity permissions', (done) => { + isAllowed.callsFake((activity, params) => { + return !(activity === ACTIVITY_ENRICH_EIDS && + params[ACTIVITY_PARAM_COMPONENT_TYPE] === MODULE_TYPE_UID && + params[ACTIVITY_PARAM_COMPONENT_NAME] === MOCK_IDS[0]) + }) + + config.setConfig({ + userSync: { + syncDelay: 0, + userIds: MOCK_IDS.map(name => ({ + name, storage: {name, type: 'cookie'} + })) + } + }); + requestBidsHook((req) => { + const activeIds = req.adUnits.flatMap(au => au.bids).flatMap(bid => Object.keys(bid.userId)); + expect(Array.from(new Set(activeIds))).to.have.members([MOCK_IDS[1]]); + done(); + }, {adUnits}) + }); + }) }); describe('callbacks at the end of auction', function () { @@ -2591,15 +2636,21 @@ describe('User ID', function () { }); describe('Set cookie behavior', function () { - let coreStorageSpy; + let cookie, cookieStub; + beforeEach(function () { - coreStorageSpy = sinon.spy(coreStorage, 'setCookie'); setSubmoduleRegistry([sharedIdSystemSubmodule]); init(config); + cookie = document.cookie; + cookieStub = sinon.stub(document, 'cookie'); + cookieStub.get(() => cookie); + cookieStub.set((val) => cookie = val); }); + afterEach(function () { - coreStorageSpy.restore(); + cookieStub.restore(); }); + it('should allow submodules to override the domain', function () { const submodule = { submodule: { @@ -2608,26 +2659,34 @@ describe('User ID', function () { } }, config: { + name: 'mockId', storage: { type: 'cookie' } + }, + storageMgr: { + setCookie: sinon.stub() } } setStoredValue(submodule, 'bar'); - expect(coreStorage.setCookie.getCall(0).args[4]).to.equal('foo.com'); + expect(submodule.storageMgr.setCookie.getCall(0).args[4]).to.equal('foo.com'); }); - it('should pass null for domain if submodule does not override the domain', function () { + it('should pass no domain if submodule does not override the domain', function () { const submodule = { submodule: {}, config: { + name: 'mockId', storage: { type: 'cookie' } + }, + storageMgr: { + setCookie: sinon.stub() } } setStoredValue(submodule, 'bar'); - expect(coreStorage.setCookie.getCall(0).args[4]).to.equal(null); + expect(submodule.storageMgr.setCookie.getCall(0).args[4]).to.equal(null); }); }); diff --git a/test/spec/unit/core/adapterManager_spec.js b/test/spec/unit/core/adapterManager_spec.js index 8d887474180..c60293bbf3f 100644 --- a/test/spec/unit/core/adapterManager_spec.js +++ b/test/spec/unit/core/adapterManager_spec.js @@ -4,7 +4,7 @@ import adapterManager, { coppaDataHandler, _partitionBidders, PARTITIONS, - getS2SBidderSet, _filterBidsForAdUnit + getS2SBidderSet, _filterBidsForAdUnit, dep } from 'src/adapterManager.js'; import { getAdUnits, @@ -23,6 +23,7 @@ import {hook} from '../../../../src/hook.js'; import {auctionManager} from '../../../../src/auctionManager.js'; import {GDPR_GVLIDS} from '../../../../src/consentHandler.js'; import {MODULE_TYPE_ANALYTICS, MODULE_TYPE_BIDDER} from '../../../../src/activities/modules.js'; +import {ACTIVITY_FETCH_BIDS, ACTIVITY_REPORT_ANALYTICS} from '../../../../src/activities/activities.js'; var events = require('../../../../src/events'); const CONFIG = { @@ -1721,6 +1722,129 @@ describe('adapterManager tests', function () { expect(sizes1).not.to.deep.equal(sizes2); }); + describe('and activity controls', () => { + let redactOrtb2; + let redactBidRequest; + const MOCK_BIDDERS = ['1', '2', '3', '4', '5'].map((n) => `mockBidder${n}`); + + beforeEach(() => { + sinon.stub(dep, 'isAllowed'); + redactOrtb2 = sinon.stub().callsFake(ob => ob); + redactBidRequest = sinon.stub().callsFake(ob => ob); + sinon.stub(dep, 'redact').callsFake(() => ({ + ortb2: redactOrtb2, + bidRequest: redactBidRequest + })) + MOCK_BIDDERS.forEach((bidder) => adapterManager.bidderRegistry[bidder] = {}); + }); + afterEach(() => { + dep.isAllowed.restore(); + dep.redact.restore(); + MOCK_BIDDERS.forEach(bidder => { delete adapterManager.bidderRegistry[bidder] }); + config.resetConfig(); + }) + it('should not generate requests for bidders that cannot fetchBids', () => { + adUnits = [ + {code: 'one', bids: ['mockBidder1', 'mockBidder2', 'mockBidder3'].map((bidder) => ({bidder}))}, + {code: 'two', bids: ['mockBidder4', 'mockBidder5', 'mockBidder4'].map((bidder) => ({bidder}))} + ]; + const allowed = ['mockBidder2', 'mockBidder5']; + dep.isAllowed.callsFake((activity, {componentType, componentName}) => { + return activity === ACTIVITY_FETCH_BIDS && + componentType === MODULE_TYPE_BIDDER && + allowed.includes(componentName); + }); + let reqs = adapterManager.makeBidRequests( + adUnits, + Date.now(), + utils.getUniqueIdentifierStr(), + function callback() {}, + [] + ); + const bidders = Array.from(new Set(reqs.flatMap(br => br.bids).map(bid => bid.bidder)).keys()); + expect(bidders).to.have.members(allowed); + }); + + it('should redact ortb2 and bid request objects', () => { + dep.isAllowed.callsFake(() => true); + adUnits = [ + {code: 'one', bids: [{bidder: 'mockBidder1'}]} + ]; + let reqs = adapterManager.makeBidRequests( + adUnits, + Date.now(), + utils.getUniqueIdentifierStr(), + function callback() {}, + [] + ); + sinon.assert.calledWith(redactBidRequest, reqs[0].bids[0]); + sinon.assert.calledWith(redactOrtb2, reqs[0].ortb2); + }) + + describe('with multiple s2s configs', () => { + beforeEach(() => { + config.setConfig({ + s2sConfig: [ + { + enabled: true, + adapter: 'mockS2SDefault', + bidders: ['mockBidder1'] + }, + { + enabled: true, + adapter: 'mockS2S1', + configName: 'mock1', + }, + { + enabled: true, + adapter: 'mockS2S2', + configName: 'mock2', + } + ] + }); + }); + it('should keep stored impressions, even if everything else is denied', () => { + adUnits = [ + {code: 'one', bids: [{bidder: null}]}, + {code: 'two', bids: [{module: 'pbsBidAdapter', params: {configName: 'mock1'}}, {module: 'pbsBidAdapter', params: {configName: 'mock2'}}]} + ] + dep.isAllowed.callsFake(({componentType}) => componentType !== 'bidder'); + let bidRequests = adapterManager.makeBidRequests( + adUnits, + Date.now(), + utils.getUniqueIdentifierStr(), + function callback() {}, + [] + ); + expect(new Set(bidRequests.map(br => br.uniquePbsTid)).size).to.equal(3); + }); + + it('should check if the s2s adapter itself is allowed to fetch bids', () => { + adUnits = [ + { + code: 'au', + bids: [ + {bidder: null}, + {module: 'pbsBidAdapter', params: {configName: 'mock1'}}, + {module: 'pbsBidAdapter', params: {configName: 'mock2'}}, + {bidder: 'mockBidder1'} + ] + } + ]; + dep.isAllowed.callsFake((_, {configName, componentName}) => !(componentName === 'pbsBidAdapter' && configName === 'mock1')); + let bidRequests = adapterManager.makeBidRequests( + adUnits, + Date.now(), + utils.getUniqueIdentifierStr(), + function callback() { + }, + [] + ); + expect(new Set(bidRequests.map(br => br.uniquePbsTid)).size).to.eql(2) + }); + }); + }); + it('should make FPD available under `ortb2`', () => { const global = { k1: 'v1', @@ -2692,12 +2816,29 @@ describe('adapterManager tests', function () { beforeEach(() => { bidderRequests = []; + ['mockBidder', 'mockBidder1', 'mockBidder2'].forEach(bidder => { + adapterManager.registerBidAdapter({callBids: sinon.stub(), getSpec: () => ({code: bidder})}, bidder); + }) sinon.stub(auctionManager, 'getBidsRequested').callsFake(() => bidderRequests); }) afterEach(() => { auctionManager.getBidsRequested.restore(); }) + it('can resolve aliases', () => { + adapterManager.aliasBidAdapter('mockBidder', 'mockBidderAlias'); + expect(adapterManager.resolveAlias('mockBidderAlias')).to.eql('mockBidder'); + }); + it('does not stuck in alias cycles', () => { + adapterManager.aliasRegistry['alias1'] = 'alias2'; + adapterManager.aliasRegistry['alias2'] = 'alias2'; + expect(adapterManager.resolveAlias('alias2')).to.eql('alias2'); + }) + it('returns self when not an alias', () => { + delete adapterManager.aliasRegistry['missing']; + expect(adapterManager.resolveAlias('missing')).to.eql('missing'); + }) + it('does not invoke onDataDeletionRequest on aliases', () => { const del = delMethodForBidder('mockBidder'); adapterManager.aliasBidAdapter('mockBidder', 'mockBidderAlias'); @@ -2740,6 +2881,45 @@ describe('adapterManager tests', function () { }) }); + describe('reportAnalytics check', () => { + beforeEach(() => { + sinon.stub(dep, 'isAllowed'); + }); + afterEach(() => { + dep.isAllowed.restore(); + }); + + it('should check for reportAnalytics before registering analytics adapter', () => { + const enabled = {}; + ['mockAnalytics1', 'mockAnalytics2'].forEach((code) => { + adapterManager.registerAnalyticsAdapter({ + code, + adapter: { + enableAnalytics: sinon.stub().callsFake(() => { enabled[code] = true }) + } + }) + }) + + const anlCfg = [ + { + provider: 'mockAnalytics1', + random: 'values' + }, + { + provider: 'mockAnalytics2' + } + ] + dep.isAllowed.callsFake((activity, {component, _config}) => { + return activity === ACTIVITY_REPORT_ANALYTICS && + component === `${MODULE_TYPE_ANALYTICS}.${anlCfg[0].provider}` && + _config === anlCfg[0] + }) + + adapterManager.enableAnalytics(anlCfg); + expect(enabled).to.eql({mockAnalytics1: true}); + }); + }); + describe('registers GVL IDs', () => { beforeEach(() => { sinon.stub(GDPR_GVLIDS, 'register'); diff --git a/test/spec/unit/core/storageManager_spec.js b/test/spec/unit/core/storageManager_spec.js index 9e31389d96f..edead126c2c 100644 --- a/test/spec/unit/core/storageManager_spec.js +++ b/test/spec/unit/core/storageManager_spec.js @@ -1,14 +1,25 @@ import { - getCoreStorageManager, getStorageManager, + deviceAccessRule, + getCoreStorageManager, newStorageManager, resetData, + STORAGE_TYPE_COOKIES, + STORAGE_TYPE_LOCALSTORAGE, + storageAllowedRule, storageCallbacks, - validateStorageEnforcement } from 'src/storageManager.js'; +import adapterManager from 'src/adapterManager.js'; import {config} from 'src/config.js'; import * as utils from 'src/utils.js'; import {hook} from '../../../../src/hook.js'; -import {MODULE_TYPE_BIDDER} from '../../../../src/activities/modules.js'; +import {MODULE_TYPE_BIDDER, MODULE_TYPE_PREBID} from '../../../../src/activities/modules.js'; +import {ACTIVITY_ACCESS_DEVICE} from '../../../../src/activities/activities.js'; +import { + ACTIVITY_PARAM_COMPONENT_NAME, + ACTIVITY_PARAM_COMPONENT_TYPE, + ACTIVITY_PARAM_STORAGE_TYPE +} from '../../../../src/activities/params.js'; +import {activityParams} from '../../../../src/activities/activityParams.js'; describe('storage manager', function() { before(() => { @@ -56,41 +67,42 @@ describe('storage manager', function() { deviceAccessSpy.restore(); }); - describe(`enforcement`, () => { - let validateHook; + describe(`accessDevice activity check`, () => { + let isAllowed; + + function mkManager(moduleType, moduleName) { + return newStorageManager({moduleType, moduleName}, {isAllowed}); + } beforeEach(() => { - validateHook = sinon.stub().callsFake(function (next, ...args) { - next.apply(this, args); - }); - validateStorageEnforcement.before(validateHook, 99); + isAllowed = sinon.stub(); }); - afterEach(() => { - validateStorageEnforcement.getHooks({hook: validateHook}).remove(); - config.resetConfig(); - }) - - Object.entries({ - 'core': () => getCoreStorageManager('mock'), - 'other': () => getStorageManager({moduleType: 'other', moduleName: 'mock'}) - }).forEach(([moduleType, getMgr]) => { - describe(`for ${moduleType} modules`, () => { - let storage; - beforeEach(() => { - storage = getMgr(); - }); - it(`should pass '${moduleType}' module type to consent enforcement`, () => { - storage.localStorageIsEnabled(); - expect(validateHook.args[0][1]).to.equal(moduleType); - }); + it('should pass module type and name as activity params', () => { + mkManager(MODULE_TYPE_PREBID, 'mockMod').localStorageIsEnabled(); + sinon.assert.calledWith(isAllowed, ACTIVITY_ACCESS_DEVICE, sinon.match({ + [ACTIVITY_PARAM_COMPONENT_TYPE]: MODULE_TYPE_PREBID, + [ACTIVITY_PARAM_COMPONENT_NAME]: 'mockMod', + [ACTIVITY_PARAM_STORAGE_TYPE]: STORAGE_TYPE_LOCALSTORAGE + })); + }); - it('should respect the deviceAccess flag', () => { - config.setConfig({deviceAccess: false}); - expect(storage.localStorageIsEnabled()).to.be.false - }); - }); + it('should deny access if activity is denied', () => { + isAllowed.returns(false); + const mgr = mkManager(MODULE_TYPE_PREBID, 'mockMod'); + mgr.setDataInLocalStorage('testKey', 'val'); + expect(mgr.getDataFromLocalStorage('testKey')).to.not.exist; }); + + it('should use bidder aliases when possible', () => { + adapterManager.registerBidAdapter({callBids: sinon.stub(), getSpec: () => ({})}, 'mockBidder'); + adapterManager.aliasBidAdapter('mockBidder', 'mockAlias'); + const mgr = mkManager(MODULE_TYPE_BIDDER, 'mockBidder'); + config.runWithBidder('mockAlias', () => mgr.cookiesAreEnabled()); + sinon.assert.calledWith(isAllowed, ACTIVITY_ACCESS_DEVICE, sinon.match({ + [ACTIVITY_PARAM_COMPONENT_NAME]: 'mockAlias' + })) + }) }) describe('localstorage forbidden access in 3rd-party context', function() { @@ -145,13 +157,26 @@ describe('storage manager', function() { }); }); - describe('when bidderSettings.allowStorage is defined', () => { + describe('deviceAccess control', () => { + afterEach(() => { + config.resetConfig() + }); + + it('should allow by default', () => { + config.resetConfig(); + expect(deviceAccessRule()).to.not.exist; + }); + + it('should deny access when set', () => { + config.setConfig({deviceAccess: false}); + sinon.assert.match(deviceAccessRule(), {allow: false}); + }) + }); + + describe('allowStorage access control rule', () => { const ALLOWED_BIDDER = 'allowed-bidder'; const ALLOW_KEY = 'storageAllowed'; - const COOKIE = 'test-cookie'; - const LS_KEY = 'test-localstorage'; - function mockBidderSettings(val) { return { get(bidder, key) { @@ -213,39 +238,22 @@ describe('storage manager', function() { }).forEach(([t, {configValues, shouldWork: {cookie, html5}}]) => { describe(`when ${t} is allowed`, () => { configValues.forEach(configValue => describe(`storageAllowed = ${configValue}`, () => { - let mgr; - - beforeEach(() => { - mgr = newStorageManager({moduleType: MODULE_TYPE_BIDDER, moduleName: bidderCode}, {bidderSettings: mockBidderSettings(configValue)}); - }) - - afterEach(() => { - mgr.setCookie(COOKIE, 'delete', new Date().toUTCString()); - mgr.removeDataFromLocalStorage(LS_KEY); - }) - - function scenario(type, desc, fn) { + Object.entries({ + [STORAGE_TYPE_LOCALSTORAGE]: 'allow localStorage', + [STORAGE_TYPE_COOKIES]: 'allow cookies' + }).forEach(([type, desc]) => { const shouldWork = isBidderAllowed && ({html5, cookie})[type]; - it(`${shouldWork ? '' : 'NOT'} ${desc}`, () => fn(shouldWork)); - } - - scenario('cookie', 'allow cookies', (shouldWork) => { - mgr.setCookie(COOKIE, 'value'); - expect(mgr.getCookie(COOKIE)).to.equal(shouldWork ? 'value' : null); - }); - - scenario('html5', 'allow localStorage', (shouldWork) => { - mgr.setDataInLocalStorage(LS_KEY, 'value'); - expect(mgr.getDataFromLocalStorage(LS_KEY)).to.equal(shouldWork ? 'value' : null); - }); - - scenario('html5', 'report localStorage as available', (shouldWork) => { - expect(mgr.hasLocalStorage()).to.equal(shouldWork); - }); - - scenario('cookie', 'report cookies as available', (shouldWork) => { - expect(mgr.cookiesAreEnabled()).to.equal(shouldWork); - }); + it(`${shouldWork ? '' : 'NOT'} ${desc}`, () => { + const res = storageAllowedRule(activityParams(MODULE_TYPE_BIDDER, bidderCode, { + [ACTIVITY_PARAM_STORAGE_TYPE]: type + }), mockBidderSettings(configValue)); + if (shouldWork) { + expect(res).to.not.exist; + } else { + sinon.assert.match(res, {allow: false}); + } + }); + }) })); }); }); diff --git a/test/spec/unit/pbjs_api_spec.js b/test/spec/unit/pbjs_api_spec.js index a7620c160b9..5c361d186c0 100644 --- a/test/spec/unit/pbjs_api_spec.js +++ b/test/spec/unit/pbjs_api_spec.js @@ -25,6 +25,7 @@ import {stubAuctionIndex} from '../../helpers/indexStub.js'; import {createBid} from '../../../src/bidfactory.js'; import {enrichFPD} from '../../../src/fpd/enrichment.js'; import {mockFpdEnrichments} from '../../helpers/fpd.js'; + var assert = require('chai').assert; var expect = require('chai').expect; diff --git a/test/spec/userSync_spec.js b/test/spec/userSync_spec.js index 6d0953f68ac..c403014fcd6 100644 --- a/test/spec/userSync_spec.js +++ b/test/spec/userSync_spec.js @@ -1,5 +1,13 @@ import { expect } from 'chai'; import { config } from 'src/config.js'; +import {ruleRegistry} from '../../src/activities/rules.js'; +import {ACTIVITY_SYNC_USER} from '../../src/activities/activities.js'; +import { + ACTIVITY_PARAM_COMPONENT, + ACTIVITY_PARAM_SYNC_TYPE, + ACTIVITY_PARAM_SYNC_URL +} from '../../src/activities/params.js'; +import {MODULE_TYPE_BIDDER} from '../../src/activities/modules.js'; // Use require since we need to be able to write to these vars const utils = require('../../src/utils'); let { newUserSync, USERSYNC_DEFAULT_CONFIG } = require('../../src/userSync'); @@ -14,12 +22,18 @@ describe('user sync', function () { let idPrefix = 'test-generated-id-'; let lastId = 0; let defaultUserSyncConfig = config.getConfig('userSync'); - function getUserSyncConfig(userSyncConfig) { - return Object.assign({}, defaultUserSyncConfig, userSyncConfig); + let regRule, isAllowed; + + function mkUserSync(deps) { + [regRule, isAllowed] = ruleRegistry(); + return newUserSync(Object.assign({ + regRule, isAllowed + }, deps)) } + function newTestUserSync(configOverrides, disableBrowserCookies) { const thisConfig = Object.assign({}, defaultUserSyncConfig, configOverrides); - return newUserSync({ + return mkUserSync({ config: thisConfig, browserSupportsCookies: !disableBrowserCookies, }) @@ -59,6 +73,22 @@ describe('user sync', function () { expect(triggerPixelStub.getCall(0).args[0]).to.exist.and.to.equal('http://example.com'); }); + it('should NOT fire a sync if a rule blocks syncUser', () => { + const userSync = newTestUserSync() + regRule(ACTIVITY_SYNC_USER, 'testRule', (params) => { + if ( + params[ACTIVITY_PARAM_COMPONENT] === `${MODULE_TYPE_BIDDER}.testBidder` && + params[ACTIVITY_PARAM_SYNC_TYPE] === 'image' && + params[ACTIVITY_PARAM_SYNC_URL] === 'http://example.com' + ) { + return {allow: false} + } + }) + userSync.registerSync('image', 'testBidder', 'http://example.com'); + userSync.syncUsers(); + expect(triggerPixelStub.called).to.be.false; + }) + it('should clear queue after sync', function () { const userSync = newTestUserSync(); userSync.syncUsers(); @@ -371,14 +401,13 @@ describe('user sync', function () { userSync.registerSync('image', 'atestBidder', 'http://example.com/1'); userSync.registerSync('iframe', 'testBidder', 'http://example.com/iframe'); userSync.syncUsers(); - expect(logWarnStub.getCall(0).args[0]).to.exist; expect(triggerPixelStub.getCall(0)).to.not.be.null; expect(triggerPixelStub.getCall(0).args[0]).to.exist.and.to.equal('http://example.com/1'); expect(insertUserSyncIframeStub.getCall(0)).to.be.null; }); it('should still allow default image syncs if setConfig only defined iframe', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: config.getConfig('userSync'), browserSupportsCookies: true }); @@ -403,7 +432,7 @@ describe('user sync', function () { }); it('should not fire image pixel for a bidder if iframe pixel is fired for same bidder', function() { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: config.getConfig('userSync'), browserSupportsCookies: true }); @@ -430,7 +459,7 @@ describe('user sync', function () { }); it('should override default image syncs if setConfig used image filter', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: config.getConfig('userSync'), browserSupportsCookies: true }); @@ -455,7 +484,7 @@ describe('user sync', function () { }); it('should override default image syncs if setConfig used all filter', function() { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: config.getConfig('userSync'), browserSupportsCookies: true }); @@ -488,7 +517,7 @@ describe('user sync', function () { describe('canBidderRegisterSync', function () { describe('with filterSettings', function () { it('should return false if filter settings does not allow it', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: { filterSettings: { image: { @@ -505,7 +534,7 @@ describe('user sync', function () { expect(userSync.canBidderRegisterSync('iframe', 'otherTestBidder')).to.equal(false); }); it('should return false for iframe if there is no iframe filterSettings', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: { syncEnabled: true, filterSettings: { @@ -523,7 +552,7 @@ describe('user sync', function () { expect(userSync.canBidderRegisterSync('iframe', 'otherTestBidder')).to.equal(false); }); it('should return true if filter settings does allow it', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: { filterSettings: { image: { @@ -543,7 +572,7 @@ describe('user sync', function () { describe('almost deprecated - without filterSettings', function () { describe('enabledBidders contains testBidder', function () { it('should return false if type is iframe and iframeEnabled is false', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: { filterSettings: { iframe: { @@ -557,7 +586,7 @@ describe('user sync', function () { }); it('should return true if type is iframe and iframeEnabled is true', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: { pixelEnabled: true, iframeEnabled: true, @@ -568,7 +597,7 @@ describe('user sync', function () { }); it('should return false if type is image and pixelEnabled is false', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: { filterSettings: { image: { @@ -582,7 +611,7 @@ describe('user sync', function () { }); it('should return true if type is image and pixelEnabled is true', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: { pixelEnabled: true, iframeEnabled: true, @@ -595,7 +624,7 @@ describe('user sync', function () { describe('enabledBidders does not container testBidder', function () { it('should return false since testBidder is not in enabledBidders', function () { - const userSync = newUserSync({ + const userSync = mkUserSync({ config: { filterSettings: { image: {