diff --git a/integrationExamples/gpt/userId_example.html b/integrationExamples/gpt/userId_example.html index 5a6c80c4adf..4f94c73c281 100644 --- a/integrationExamples/gpt/userId_example.html +++ b/integrationExamples/gpt/userId_example.html @@ -249,7 +249,17 @@ } }, { - "name": "uid2" + "name": "uid2", + "params": { + "uid2Token": { + "advertising_token": "example token", + "refresh_token": "aslkdjaslkjdaslkhj", + "identity_expires": Date.now() + 60*1000, + "refresh_from": Date.now() - 10*1000, + "refresh_expires": Date.now() + 12*60*60*1000, + "refresh_response_key": null + } + } } , { diff --git a/karma.conf.maker.js b/karma.conf.maker.js index b20f73d74bc..692db7755e8 100644 --- a/karma.conf.maker.js +++ b/karma.conf.maker.js @@ -110,7 +110,7 @@ module.exports = function(codeCoverage, browserstack, watchMode, file, disableFe var webpackConfig = newWebpackConfig(codeCoverage, disableFeatures); var plugins = newPluginsArray(browserstack); - var files = file ? ['test/test_deps.js', file] : ['test/test_index.js']; + var files = file ? ['test/test_deps.js', file].flatMap(f => f) : ['test/test_index.js']; // This file opens the /debug.html tab automatically. // It has no real value unless you're running --watch, and intend to do some debugging in the browser. if (watchMode) { diff --git a/modules/uid2IdSystem.js b/modules/uid2IdSystem.js index 23656639532..9fd75f12591 100644 --- a/modules/uid2IdSystem.js +++ b/modules/uid2IdSystem.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ /** * This module adds uid2 ID support to the User ID module * The {@link module:modules/userId} module is required. @@ -10,48 +11,142 @@ import {submodule} from '../src/hook.js'; import { getStorageManager } from '../src/storageManager.js'; const MODULE_NAME = 'uid2'; +const MODULE_REVISION = `1.0`; +const PREBID_VERSION = '$prebid.version$'; +const UID2_CLIENT_ID = `PrebidJS-${PREBID_VERSION}-UID2Module-${MODULE_REVISION}`; const GVLID = 887; const LOG_PRE_FIX = 'UID2: '; const ADVERTISING_COOKIE = '__uid2_advertising_token'; -function readCookie() { - return storage.cookiesAreEnabled() ? storage.getCookie(ADVERTISING_COOKIE) : null; +// eslint-disable-next-line no-unused-vars +const UID2_TEST_URL = 'https://operator-integ.uidapi.com'; +const UID2_PROD_URL = 'https://prod.uidapi.com'; +const UID2_BASE_URL = UID2_PROD_URL; + +function getStorage() { + return getStorageManager({gvlid: GVLID, moduleName: MODULE_NAME}); +} + +function createLogInfo(prefix) { + return function (...strings) { + logInfo(prefix + ' ', ...strings); + } } +export const storage = getStorage(); +const _logInfo = createLogInfo(LOG_PRE_FIX); function readFromLocalStorage() { return storage.localStorageIsEnabled() ? storage.getDataFromLocalStorage(ADVERTISING_COOKIE) : null; } -function getStorage() { - return getStorageManager({gvlid: GVLID, moduleName: MODULE_NAME}); +function readModuleCookie() { + const cookie = readCookie(ADVERTISING_COOKIE); + if (cookie && cookie.includes('{')) { + return JSON.parse(cookie); + } + return cookie; } -const storage = getStorage(); +function readJsonCookie(cookieName) { + return JSON.parse(readCookie(cookieName)); +} -const _logInfo = createLogInfo(LOG_PRE_FIX); +function readCookie(cookieName) { + const cookie = storage.cookiesAreEnabled() ? storage.getCookie(cookieName) : null; + if (!cookie) { + _logInfo(`Attempted to read UID2 from cookie '${cookieName}' but it was empty`); + return null; + }; + _logInfo(`Read UID2 from cookie '${cookieName}'`); + return cookie; +} -function createLogInfo(prefix) { - return function (...strings) { - logInfo(prefix + ' ', ...strings); +function storeValue(value) { + if (storage.cookiesAreEnabled()) { + storage.setCookie(ADVERTISING_COOKIE, JSON.stringify(value), Date.now() + 60 * 60 * 24 * 1000); + } else if (storage.localStorageIsEnabled()) { + storage.setLocalStorage(ADVERTISING_COOKIE, value); } } -/** - * Encode the id - * @param value - * @returns {string|*} - */ -function encodeId(value) { - const result = {}; - if (value) { - const bidIds = { - id: value +function isValidIdentity(identity) { + return !!(typeof identity === 'object' && identity !== null && identity.advertising_token && identity.identity_expires && identity.refresh_from && identity.refresh_token && identity.refresh_expires); +} + +// This is extracted from an in-progress API client. Once it's available via NPM, this class should be replaced with the NPM package. +class Uid2ApiClient { + constructor(opts) { + this._baseUrl = opts.baseUrl ? opts.baseUrl : UID2_BASE_URL; + this._clientVersion = UID2_CLIENT_ID; + } + createArrayBuffer(text) { + const arrayBuffer = new Uint8Array(text.length); + for (let i = 0; i < text.length; i++) { + arrayBuffer[i] = text.charCodeAt(i); } - result.uid2 = bidIds; - _logInfo('Decoded value ' + JSON.stringify(result)); - return result; + return arrayBuffer; + } + hasStatusResponse(response) { + return typeof (response) === 'object' && response && response.status; + } + isValidRefreshResponse(response) { + return this.hasStatusResponse(response) && ( + response.status === 'optout' || response.status === 'expired_token' || (response.status === 'success' && response.body && isValidIdentity(response.body)) + ); + } + ResponseToRefreshResult(response) { + if (this.isValidRefreshResponse(response)) { + if (response.status === 'success') { return { status: response.status, identity: response.body }; } + return response; + } else { return "Response didn't contain a valid status"; } + } + callRefreshApi(refreshDetails) { + const url = this._baseUrl + '/v2/token/refresh'; + const req = new XMLHttpRequest(); + req.overrideMimeType('text/plain'); + req.open('POST', url, true); + req.setRequestHeader('X-UID2-Client-Version', this._clientVersion); + let resolvePromise; + let rejectPromise; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + req.onreadystatechange = () => { + if (req.readyState !== req.DONE) { return; } + try { + if (!refreshDetails.refresh_response_key || req.status !== 200) { + _logInfo('Error status OR no response decryption key available, assuming unencrypted JSON'); + const response = JSON.parse(req.responseText); + const result = this.ResponseToRefreshResult(response); + if (typeof result === 'string') { rejectPromise(result); } else { resolvePromise(result); } + } else { + _logInfo('Decrypting refresh API response'); + const encodeResp = this.createArrayBuffer(atob(req.responseText)); + window.crypto.subtle.importKey('raw', this.createArrayBuffer(atob(refreshDetails.refresh_response_key)), { name: 'AES-GCM' }, false, ['decrypt']).then((key) => { + _logInfo('Imported decryption key') + // returns the symmetric key + window.crypto.subtle.decrypt({ + name: 'AES-GCM', + iv: encodeResp.slice(0, 12), + tagLength: 128, // The tagLength you used to encrypt (if any) + }, key, encodeResp.slice(12)).then((decrypted) => { + const decryptedResponse = String.fromCharCode(...new Uint8Array(decrypted)); + _logInfo('Decrypted to:', decryptedResponse); + const response = JSON.parse(decryptedResponse); + const result = this.ResponseToRefreshResult(response); + if (typeof result === 'string') { rejectPromise(result); } else { resolvePromise(result); } + }, (reason) => console.warn(`Call to UID2 API failed`, reason)); + }, (reason) => console.warn(`Call to UID2 API failed`, reason)); + } + } catch (err) { + rejectPromise(err); + } + }; + _logInfo('Sending refresh request', req); + req.send(refreshDetails.refresh_token); + return promise; } - return undefined; } /** @type {Submodule} */ @@ -71,27 +166,118 @@ export const uid2IdSubmodule = { * decode the stored id value for passing to bid requests * @function * @param {string} value - * @returns {{uid2:{ id: string }} or undefined if value doesn't exists + * @returns {{uid2:{ id: string } }} or undefined if value doesn't exists */ decode(value) { - return (value) ? encodeId(value) : undefined; + const result = decodeImpl(value); + _logInfo('UID2 decode returned', result); + return result; }, /** * performs action to obtain id and return a value. * @function - * @param {SubmoduleConfig} [config] + * @param {SubmoduleConfig} [configparams] * @param {ConsentData|undefined} consentData * @returns {uid2Id} */ getId(config, consentData) { - _logInfo('Creating UID 2.0'); - let value = readCookie() || readFromLocalStorage(); - _logInfo('The advertising token: ' + value); - return {id: value} + const result = getIdImpl(config, consentData); + _logInfo(`UID2 getId returned`, result); + return result; }, - }; +function refreshTokenAndStore(baseUrl, token) { + _logInfo('UID2 base url provided: ', baseUrl); + const client = new Uid2ApiClient({baseUrl}); + return client.callRefreshApi(token).then((response) => { + _logInfo('Refresh endpoint responded with:', response); + const tokens = { + originalToken: token, + latestToken: response.identity, + }; + storeValue(tokens); + return tokens; + }); +} + +function decodeImpl(value) { + if (typeof value === 'string') { + _logInfo('Found an old-style ID from an earlier version of the module. Refresh is unavailable for this token.'); + const result = { uid2: { id: value } }; + return result; + } + if (Date.now() < value.latestToken.identity_expires) { + return { uid2: { id: value.latestToken.advertising_token } }; + } + return null; +} + +function getIdImpl(config, consentData) { + let suppliedToken = null; + const uid2BaseUrl = config?.params?.uid2ApiBase ?? UID2_BASE_URL; + if (config && config.params) { + if (config.params.uid2Token) { + suppliedToken = config.params.uid2Token; + _logInfo('Read token from params', suppliedToken); + } else if (config.params.uid2ServerCookie) { + suppliedToken = readJsonCookie(config.params.uid2ServerCookie); + _logInfo('Read token from server-supplied cookie', suppliedToken); + } + } + let storedTokens = readModuleCookie() || readFromLocalStorage(); + _logInfo('Loaded module-stored tokens:', storedTokens); + + if (storedTokens && typeof storedTokens === 'string') { + // Legacy value stored, this must be from an old integration. If no token supplied, just use the legacy value. + + if (!suppliedToken) { + _logInfo('Returning legacy cookie value.'); + return { id: storedTokens }; + } + // Otherwise, ignore the legacy value - it should get over-written later anyway. + _logInfo('Discarding superseded legacy cookie.'); + storedTokens = null; + } + + if (suppliedToken && storedTokens) { + if (storedTokens.originalToken?.advertising_token !== suppliedToken.advertising_token) { + _logInfo('Server supplied new token - ignoring stored value.', storedTokens.originalToken?.advertising_token, suppliedToken.advertising_token); + // Stored token wasn't originally sourced from the provided token - ignore the stored value. A new user has logged in? + storedTokens = null; + } + } + // At this point, any legacy values or superseded stored tokens have been nulled out. + const useSuppliedToken = !(storedTokens?.latestToken) || (suppliedToken && suppliedToken.identity_expires > storedTokens.latestToken.identity_expires); + const newestAvailableToken = useSuppliedToken ? suppliedToken : storedTokens.latestToken; + _logInfo('UID2 module selected latest token', useSuppliedToken, newestAvailableToken); + if (!newestAvailableToken || Date.now() > newestAvailableToken.refresh_expires) { + _logInfo('Newest available token is expired and not refreshable.'); + return { id: null }; + } + if (Date.now() > newestAvailableToken.identity_expires) { + const promise = refreshTokenAndStore(uid2BaseUrl, newestAvailableToken); + _logInfo('Token is expired but can be refreshed, attempting refresh.'); + return { callback: (cb) => { + promise.then((result) => { + _logInfo('Refresh reponded, passing the updated token on.', result); + cb(result); + }); + } }; + } + // If should refresh (but don't need to), refresh in the background. + if (Date.now() > newestAvailableToken.refresh_from) { + _logInfo(`Refreshing token in background with low priority.`); + refreshTokenAndStore(uid2BaseUrl, newestAvailableToken); + } + const tokens = { + originalToken: suppliedToken ?? storedTokens?.originalToken, + latestToken: newestAvailableToken, + }; + storeValue(tokens); + return { id: tokens }; +} + // Register submodule for userId submodule('userId', uid2IdSubmodule); diff --git a/modules/uid2IdSystem.md b/modules/uid2IdSystem.md index fa596b17584..82ff1b3cb68 100644 --- a/modules/uid2IdSystem.md +++ b/modules/uid2IdSystem.md @@ -6,11 +6,23 @@ UID 2.0 ID Module. Individual params may be set for the UID 2.0 Submodule. At least one identifier must be set in the params. +The module will handle refreshing the token periodically and storing the updated token using the Prebid.js storage manager. If you provide an expired identity and the module has a valid identity which was refreshed from the identity you provide, it will use the refreshed identity. The module stores the original token used for refreshing the token, and it will use the refreshed tokens as long as the original token matches the one supplied. + ``` pbjs.setConfig({ userSync: { userIds: [{ - name: 'uid2' + name: 'uid2', + params: { + // Either: + uid2ServerCookie: 'your_UID2_server_set_cookie_name' + // Or: + uid2Token: { + 'advertising_token': '...', + 'refresh_token': '...', + // etc. - see the Sample Token below for contents of this object + } + } }] } }); @@ -18,7 +30,25 @@ pbjs.setConfig({ ## Parameter Descriptions for the `usersync` Configuration Section The below parameters apply only to the UID 2.0 User ID Module integration. +You should supply either `uid2Token` or `uid2ServerCookie`. + +If you provide `uid2Token`, the value should be a JavaScript/JSON object with the decrypted `body` payload response from a call to either `/token/generate` or `/token/refresh`. + +If you provide `uid2ServerCookie`, the module will expect that same JSON object to be stored in the cookie - i.e. it will pass the cookie value to `JSON.parse` and expect to receive an object containing similar to what you see in the `Sample token` section below. + +The module will make calls to the `/token/refresh` endpoint to update the token it stores internally, so bids may contain an updated token. + +If neither of `uid2Token` or `uid2ServerCookie` are supplied, and the module has stored a token using the Prebid.js storage system (typically in a cookie named `__uid2_advertising_token`), it will use that token. This cookie is internal to the module and should not be set directly. + +If a new token is supplied which does not match the original token used to generate any refreshed tokens, all stored tokens will be discarded and the new token used instead (refreshed if necessary). + +### Sample token + +`{`
  `"advertising_token": "...",`
  `"refresh_token": "...",`
  `"identity_expires": 1633643601000,`
  `"refresh_from": 1633643001000,`
  `"refresh_expires": 1636322000000,`
  `"refresh_response_key": "wR5t6HKMfJ2r4J7fEGX9Gw=="`
`}` + | Param under userSync.userIds[] | Scope | Type | Description | Example | | --- | --- | --- | --- | --- | | name | Required | String | ID value for the UID20 module - `"uid2"` | `"uid2"` | -| value | Optional | Object | Used only if the page has a separate mechanism for storing the UID 2.0 ID. The value is an object containing the values to be sent to the adapters. In this scenario, no URL is called and nothing is added to local storage | `{"uid2": { "id": "eb33b0cb-8d35-4722-b9c0-1a31d4064888"}}` | +| params.uid2Token | Optional | Object | The initial UID2 token. This should be `body` element of the decrypted response from a call to the `/token/generate` or `/token/refresh` endpoint. | See the sample token above. | +| params.uid2ServerCookie | Optional | String | The name of a cookie which holds the initial UID2 token, set by the server. The cookie should contain JSON in the same format as the alternative uid2Token param. **If uid2Token is supplied, this param is ignored.** | See the sample token above. | +| params.uid2ApiBase | Optional | String | Overrides the default UID2 API endpoint. | `https://prod.uidapi.com` _(default)_ | diff --git a/test/mocks/timers.js b/test/mocks/timers.js new file mode 100644 index 00000000000..6efd798c881 --- /dev/null +++ b/test/mocks/timers.js @@ -0,0 +1,84 @@ +/* + * Provides wrappers for timers to allow easy cancelling and/or awaiting of outstanding timers. + * This helps avoid functionality leaking from one test to the next. + */ + +let wrappersActive = false; + +export function configureTimerInterceptors(debugLog = function() {}, generateStackTraces = false) { + if (wrappersActive) throw new Error(`Timer wrappers are already in place.`); + wrappersActive = true; + let theseWrappersActive = true; + + let originalSetTimeout = setTimeout, originalSetInterval = setInterval, originalClearTimeout = clearTimeout, originalClearInterval = clearInterval; + + let timerId = -1; + let timers = []; + + const waitOnTimersResolves = []; + function checkWaits() { + if (timers.length === 0) waitOnTimersResolves.forEach((r) => r()); + } + const waitAllActiveTimers = () => timers.length === 0 ? Promise.resolve() : new Promise((resolve) => waitOnTimersResolves.push(resolve)); + const clearAllActiveTimers = () => timers.forEach((timer) => timer.type === 'timeout' ? clearTimeout(timer.handle) : clearInterval(timer.handle)); + + const generateInterceptor = (type, originalFunctionWrapper) => (fn, delay, ...args) => { + timerId++; + debugLog(`Setting wrapped timeout ${timerId} for ${delay ?? 0}`); + const info = { timerId, type }; + if (generateStackTraces) { + try { + throw new Error(); + } catch (ex) { + info.stack = ex.stack; + } + } + info.handle = originalFunctionWrapper(info, fn, delay, ...args); + timers.push(info); + return info.handle; + }; + const setTimeoutInterceptor = generateInterceptor('timeout', (info, fn, delay, ...args) => originalSetTimeout(() => { + try { + debugLog(`Running timeout ${info.timerId}`); + fn(...args); + } finally { + const infoIndex = timers.indexOf(info); + if (infoIndex > -1) timers.splice(infoIndex, 1); + checkWaits(); + } + }, delay)); + + const setIntervalInterceptor = generateInterceptor('interval', (info, fn, interval, ...args) => originalSetInterval(() => { + debugLog(`Running interval ${info.timerId}`); + fn(...args); + }, interval)); + + const generateClearInterceptor = (type, originalClearFunction) => (handle) => { + originalClearFunction(handle); + const infoIndex = timers.findIndex((i) => i.handle === handle && i.type === type); + if (infoIndex > -1) timers.splice(infoIndex, 1); + checkWaits(); + } + const clearTimeoutInterceptor = generateClearInterceptor('timeout', originalClearTimeout); + const clearIntervalInterceptor = generateClearInterceptor('interval', originalClearInterval); + + setTimeout = setTimeoutInterceptor; + setInterval = setIntervalInterceptor; + clearTimeout = clearTimeoutInterceptor; + clearInterval = clearIntervalInterceptor; + + return { + waitAllActiveTimers, + clearAllActiveTimers, + timers, + restore: () => { + if (theseWrappersActive) { + theseWrappersActive = false; + setTimeout = originalSetTimeout; + setInterval = originalSetInterval; + clearTimeout = originalClearTimeout; + clearInterval = originalClearInterval; + } + } + } +} diff --git a/test/spec/modules/uid2IdSystem_spec.js b/test/spec/modules/uid2IdSystem_spec.js new file mode 100644 index 00000000000..b33e8cda501 --- /dev/null +++ b/test/spec/modules/uid2IdSystem_spec.js @@ -0,0 +1,310 @@ +import {coreStorage, init, setSubmoduleRegistry, requestBidsHook} from 'modules/userId/index.js'; +import {config} from 'src/config.js'; +import * as utils from 'src/utils.js'; +import { uid2IdSubmodule } from 'modules/uid2IdSystem.js'; +import 'src/prebid.js'; +import { getGlobal } from 'src/prebidGlobal.js'; +import { server } from 'test/mocks/xhr.js'; +import { configureTimerInterceptors } from 'test/mocks/timers.js'; +import {hook} from 'src/hook.js'; +import {uninstall as uninstallGdprEnforcement} from 'modules/gdprEnforcement.js'; + +let expect = require('chai').expect; + +const clearTimersAfterEachTest = true; +const debugOutput = () => {}; + +const expireCookieDate = 'Thu, 01 Jan 1970 00:00:01 GMT'; +const msIn12Hours = 60 * 60 * 12 * 1000; +const moduleCookieName = '__uid2_advertising_token'; +const publisherCookieName = '__UID2_SERVER_COOKIE'; +const auctionDelayMs = 10; +const legacyConfigParams = null; +const serverCookieConfigParams = { uid2ServerCookie: publisherCookieName } +const getFutureCookieExpiry = () => new Date(Date.now() + msIn12Hours).toUTCString(); +const setPublisherCookie = (token) => coreStorage.setCookie(publisherCookieName, JSON.stringify(token), getFutureCookieExpiry()); + +const makePrebidIdentityContainer = (token) => ({uid2: {id: token}}); +const makePrebidConfig = (params = null, extraSettings = {}, debug = false) => ({ + userSync: { auctionDelay: auctionDelayMs, userIds: [{name: 'uid2', params}] }, debug, ...extraSettings +}); + +const initialToken = `initial-advertising-token`; +const legacyToken = 'legacy-advertising-token'; +const refreshedToken = 'refreshed-advertising-token'; +const makeUid2Token = (token = initialToken, shouldRefresh = false, expired = false) => ({ + advertising_token: token, + refresh_token: 'fake-refresh-token', + identity_expires: expired ? Date.now() - 1000 : Date.now() + 60 * 60 * 1000, + refresh_from: shouldRefresh ? Date.now() - 1000 : Date.now() + 60 * 1000, + refresh_expires: Date.now() + 24 * 60 * 60 * 1000, // 24 hours + refresh_response_key: 'wR5t6HKMfJ2r4J7fEGX9Gw==', +}); +const expectInitialToken = (bid) => expect(bid?.userId ?? {}).to.deep.include(makePrebidIdentityContainer(initialToken)); +const expectRefreshedToken = (bid) => expect(bid?.userId ?? {}).to.deep.include(makePrebidIdentityContainer(refreshedToken)); +const expectNoIdentity = (bid) => expect(bid).to.not.haveOwnProperty('userId'); +const expectGlobalToHaveRefreshedIdentity = () => expect(getGlobal().getUserIds()).to.deep.include(makePrebidIdentityContainer(refreshedToken)); +const expectGlobalToHaveNoUid2 = () => expect(getGlobal().getUserIds()).to.not.haveOwnProperty('uid2'); +const expectLegacyToken = (bid) => expect(bid.userId).to.deep.include(makePrebidIdentityContainer(legacyToken)); +const expectNoLegacyToken = (bid) => expect(bid.userId).to.not.deep.include(makePrebidIdentityContainer(legacyToken)); +const expectModuleCookieEmptyOrMissing = () => expect(coreStorage.getCookie(moduleCookieName)).to.be.null; +const expectModuleCookieToContain = (initialIdentity, latestIdentity) => { + const cookie = JSON.parse(coreStorage.getCookie(moduleCookieName)); + if (initialIdentity) expect(cookie.originalToken.advertising_token).to.equal(initialIdentity); + if (latestIdentity) expect(cookie.latestToken.advertising_token).to.equal(latestIdentity); +} + +const apiUrl = 'https://prod.uidapi.com/v2/token/refresh'; +const headers = { 'Content-Type': 'application/json' }; +const makeSuccessResponseBody = () => btoa(JSON.stringify({ status: 'success', body: { ...makeUid2Token(), advertising_token: refreshedToken } })); +const configureUid2Response = (httpStatus, response) => server.respondWith('POST', apiUrl, (xhr) => xhr.respond(httpStatus, headers, response)); +const configureUid2ApiSuccessResponse = () => configureUid2Response(200, makeSuccessResponseBody()); +const configureUid2ApiFailResponse = () => configureUid2Response(500, 'Error'); + +const respondAfterDelay = (delay) => new Promise((resolve) => setTimeout(() => { + server.respond(); + setTimeout(() => resolve()); +}, delay)); + +const runAuction = async () => { + const adUnits = [{ + code: 'adUnit-code', + mediaTypes: {banner: {}, native: {}}, + sizes: [[300, 200], [300, 600]], + bids: [{bidder: 'sampleBidder', params: {placementId: 'banner-only-bidder'}}] + }]; + return new Promise(function(resolve) { + requestBidsHook(function() { + resolve(adUnits[0].bids[0]); + }, {adUnits}); + }); +} + +// Runs the provided test twice - once with a successful API mock, once with one which returns a server error +const testApiSuccessAndFailure = (act, testDescription, failTestDescription, only = false) => { + const testFn = only ? it.only : it; + testFn(`API responds successfully: ${testDescription}`, async function() { + configureUid2ApiSuccessResponse(); + await act(true); + }); + testFn(`API responds with an error: ${failTestDescription ?? testDescription}`, async function() { + configureUid2ApiFailResponse(); + await act(false); + }); +} +describe(`UID2 module`, function () { + let suiteSandbox, testSandbox, timerSpy, fullTestTitle, restoreSubtleToUndefined = false; + before(function () { + timerSpy = configureTimerInterceptors(debugOutput); + hook.ready(); + uninstallGdprEnforcement(); + + suiteSandbox = sinon.sandbox.create(); + // I'm unable to find an authoritative source, but apparently subtle isn't available in some test stacks for security reasons. + // I've confirmed it's available in Firefox since v34 (it seems to be unavailable on BrowserStack in Firefox v106). + if (typeof window.crypto.subtle === 'undefined') { + restoreSubtleToUndefined = true; + window.crypto.subtle = { importKey: () => {}, decrypt: () => {} }; + } + suiteSandbox.stub(window.crypto.subtle, 'importKey').callsFake(() => Promise.resolve()); + suiteSandbox.stub(window.crypto.subtle, 'decrypt').callsFake((settings, key, data) => Promise.resolve(new Uint8Array([...settings.iv, ...data]))); + }); + + after(function () { + suiteSandbox.restore(); + timerSpy.restore(); + if (restoreSubtleToUndefined) window.crypto.subtle = undefined; + }); + + const getFullTestTitle = (test) => `${test.parent.title ? getFullTestTitle(test.parent) + ' | ' : ''}${test.title}`; + beforeEach(function () { + debugOutput(`----------------- START TEST ------------------`); + fullTestTitle = getFullTestTitle(this.test.ctx.currentTest); + debugOutput(fullTestTitle); + testSandbox = sinon.sandbox.create(); + testSandbox.stub(utils, 'logWarn'); + + init(config); + setSubmoduleRegistry([uid2IdSubmodule]); + }); + + afterEach(async function() { + $$PREBID_GLOBAL$$.requestBids.removeAll(); + config.resetConfig(); + testSandbox.restore(); + if (timerSpy.timers.length > 0) { + if (clearTimersAfterEachTest) { + debugOutput(`Cancelling ${timerSpy.timers.length} still-active timers.`); + timerSpy.clearAllActiveTimers(); + } else { + debugOutput(`Waiting on ${timerSpy.timers.length} still-active timers...`, timerSpy.timers); + await timerSpy.waitAllActiveTimers(); + } + } + coreStorage.setCookie(moduleCookieName, '', expireCookieDate); + coreStorage.setCookie(publisherCookieName, '', expireCookieDate); + + debugOutput('----------------- END TEST ------------------'); + }); + + describe('Configuration', function() { + it('When no baseUrl is provided in config, the module calls the production endpoint', function() { + const uid2Token = makeUid2Token(initialToken, true, true); + config.setConfig(makePrebidConfig({uid2Token})); + expect(server.requests[0]?.url).to.have.string('https://prod.uidapi.com/'); + }); + + it('When a baseUrl is provided in config, the module calls the provided endpoint', function() { + const uid2Token = makeUid2Token(initialToken, true, true); + config.setConfig(makePrebidConfig({uid2Token, uid2ApiBase: 'https://operator-integ.uidapi.com'})); + expect(server.requests[0]?.url).to.have.string('https://operator-integ.uidapi.com/'); + }); + }); + + it('When a legacy value is provided directly in configuration, it is passed on', async function() { + const valueConfig = makePrebidConfig(); + valueConfig.userSync.userIds[0].value = {uid2: {id: legacyToken}} + config.setConfig(valueConfig); + const bid = await runAuction(); + + expectLegacyToken(bid); + }); + + // These tests cover 'legacy' cookies - i.e. cookies set with just the uid2 advertising token, which was how some previous integrations worked. + // Some users might still have this cookie, and the module should use that token if a newer one isn't provided. + // This should cover older integrations where the server is setting this legacy cookie and expecting the module to pass it on. + describe('When a legacy cookie exists', function () { + // Creates a test which sets the legacy cookie, configures the UID2 module with provided params, runs an + const createLegacyTest = function(params, bidAssertions) { + return async function() { + coreStorage.setCookie(moduleCookieName, legacyToken, getFutureCookieExpiry()); + config.setConfig(makePrebidConfig(params)); + + const bid = await runAuction(); + bidAssertions.forEach(function(assertion) { assertion(bid); }); + } + }; + + it('and a legacy config is used, it should provide the legacy cookie', + createLegacyTest(legacyConfigParams, [expectLegacyToken])); + it('and a server cookie config is used without a valid server cookie, it should provide the legacy cookie', + createLegacyTest(serverCookieConfigParams, [expectLegacyToken])); + it('and a server cookie is used with a valid server cookie, it should provide the server cookie', + async function() { setPublisherCookie(makeUid2Token()); await createLegacyTest(serverCookieConfigParams, [expectInitialToken, expectNoLegacyToken])(); }); + it('and a token is provided in config, it should provide the config token', + createLegacyTest({uid2Token: makeUid2Token()}, [expectInitialToken, expectNoLegacyToken])); + }); + + // This setup runs all of the functional tests with both types of config - the full token response in params, or a server cookie with the cookie name provided + let scenarios = [ + { + name: 'Token provided in config call', + setConfig: (token, extraConfig = {}) => config.setConfig(makePrebidConfig({uid2Token: token}, extraConfig)), + }, + { + name: 'Token provided in server-set cookie', + setConfig: (token, extraConfig) => { + setPublisherCookie(token); + config.setConfig(makePrebidConfig(serverCookieConfigParams, extraConfig)); + }, + } + ] + + scenarios.forEach(function(scenario) { + describe(scenario.name, function() { + describe(`When an expired token which can be refreshed is provided`, function() { + describe('When the refresh is available in time', function() { + testApiSuccessAndFailure(async function(apiSucceeds) { + scenario.setConfig(makeUid2Token(initialToken, true, true)); + respondAfterDelay(auctionDelayMs / 10); + const bid = await runAuction(); + + if (apiSucceeds) expectRefreshedToken(bid); + else expectNoIdentity(bid); + }, 'it should be used in the auction', 'the auction should have no uid2'); + + testApiSuccessAndFailure(async function(apiSucceeds) { + scenario.setConfig(makeUid2Token(initialToken, true, true)); + respondAfterDelay(auctionDelayMs / 10); + + await runAuction(); + if (apiSucceeds) { + expectModuleCookieToContain(initialToken, refreshedToken); + } else { + expectModuleCookieEmptyOrMissing(); + } + }, 'the refreshed token should be stored in the module cookie', 'the module cookie should not be set'); + }); + describe(`when the response doesn't arrive before the auction timer`, function() { + testApiSuccessAndFailure(async function() { + scenario.setConfig(makeUid2Token(initialToken, true, true)); + const bid = await runAuction(); + expectNoIdentity(bid); + }, 'it should run the auction'); + + testApiSuccessAndFailure(async function(apiSucceeds) { + scenario.setConfig(makeUid2Token(initialToken, true, true)); + const promise = respondAfterDelay(auctionDelayMs * 2); + + const bid = await runAuction(); + expectNoIdentity(bid); + expectGlobalToHaveNoUid2(); + await promise; + if (apiSucceeds) expectGlobalToHaveRefreshedIdentity(); + else expectGlobalToHaveNoUid2(); + }, 'it should update the userId after the auction', 'there should be no global identity'); + }) + describe('and there is a refreshed token in the module cookie', function() { + it('the refreshed value from the cookie is used', async function() { + const initialIdentity = makeUid2Token(initialToken, true, true); + const refreshedIdentity = makeUid2Token(refreshedToken); + const moduleCookie = {originalToken: initialIdentity, latestToken: refreshedIdentity}; + coreStorage.setCookie(moduleCookieName, JSON.stringify(moduleCookie), getFutureCookieExpiry()); + scenario.setConfig(initialIdentity); + + const bid = await runAuction(); + expectRefreshedToken(bid); + }); + }) + }); + + describe(`When a current token is provided`, function() { + beforeEach(function() { + scenario.setConfig(makeUid2Token()); + }); + + it('it should use the token in the auction', async function() { + const bid = await runAuction(); + expectInitialToken(bid); + }); + }); + + describe(`When a current token which should be refreshed is provided, and the auction is set to run immediately`, function() { + beforeEach(function() { + scenario.setConfig(makeUid2Token(initialToken, true), {auctionDelay: 0, syncDelay: 1}); + }); + testApiSuccessAndFailure(async function() { + respondAfterDelay(10); + const bid = await runAuction(); + expectInitialToken(bid); + }, 'it should not be refreshed before the auction runs'); + + testApiSuccessAndFailure(async function(success) { + const promise = respondAfterDelay(1); + await runAuction(); + await promise; + if (success) { + expectModuleCookieToContain(initialToken, refreshedToken); + } else { + expectModuleCookieToContain(initialToken, initialToken); + } + }, 'the refreshed token should be stored in the module cookie after the auction runs', 'the module cookie should only have the original token'); + + it('it should use the current token in the auction', async function() { + const bid = await runAuction(); + expectInitialToken(bid); + }); + }); + }); + }); +});