Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

dsaControl module: Reject bids without meta.dsa when required #10982

Merged
merged 3 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions modules/dsaControl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {BID_RESPONSE, registerOrtbProcessor} from '../src/pbjsORTB.js';
import {config} from '../src/config.js';
import {auctionManager} from '../src/auctionManager.js';
import {timedBidResponseHook} from '../src/utils/perfMetrics.js';
import CONSTANTS from '../src/constants.json';
import {getHook} from '../src/hook.js';
import {logInfo, logWarn} from '../src/utils.js';

let expiryHandle;
let dsaAuctions = {};

export const addBidResponseHook = timedBidResponseHook('dsa', function (fn, adUnitCode, bid, reject) {
if (!dsaAuctions.hasOwnProperty(bid.auctionId)) {
dsaAuctions[bid.auctionId] = auctionManager.index.getAuction(bid)?.getFPD?.()?.global?.regs?.ext?.dsa?.required
}
const required = dsaAuctions[bid.auctionId];
if (!bid.meta?.dsa) {
if (required === 1) {
logWarn(`dsaControl: ${CONSTANTS.REJECTION_REASON.DSA_REQUIRED}; will still be accepted as regs.ext.dsa.required = 1`, bid);
} else if ([2, 3].includes(required)) {
reject(CONSTANTS.REJECTION_REASON.DSA_REQUIRED);
return;
}
}
return fn.call(this, adUnitCode, bid, reject);
});

function toggleHooks(enabled) {
if (enabled && expiryHandle == null) {
getHook('addBidResponse').before(addBidResponseHook);
expiryHandle = auctionManager.onExpiry(auction => {
delete dsaAuctions[auction.getAuctionId()];
});
logInfo('dsaControl: DSA bid validation is enabled')
} else if (!enabled && expiryHandle != null) {
getHook('addBidResponse').getHooks({hook: addBidResponseHook}).remove();
expiryHandle();
expiryHandle = null;
logInfo('dsaControl: DSA bid validation is disabled')
}
}

export function reset() {
toggleHooks(false);
dsaAuctions = {};
}

toggleHooks(true);

config.getConfig('consentManagement', (cfg) => {
toggleHooks(cfg.consentManagement?.dsa?.validateBids ?? true);
});

export function setMetaDsa(bidResponse, bid) {
if (bid.ext?.dsa) {
bidResponse.meta.dsa = bidResponse.meta.dsa ?? bid.ext.dsa;
}
}

registerOrtbProcessor({
Copy link
Collaborator

Choose a reason for hiding this comment

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

should this just be in core? It seems if not we'll be in a world where dsa info being in meta or not will be conditional on if you install this module for only a subset of your partners

name: 'metaDsa',
type: BID_RESPONSE,
fn: setMetaDsa,
priority: -1 // run after 'meta' init
})
4 changes: 3 additions & 1 deletion src/auctionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ export function newAuctionManager() {
}
})

const auctionManager = {};
const auctionManager = {
onExpiry: _auctions.onExpiry
};

function getAuction(auctionId) {
for (const auction of _auctions) {
Expand Down
3 changes: 2 additions & 1 deletion src/constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@
"INVALID_REQUEST_ID": "Invalid request ID",
"BIDDER_DISALLOWED": "Bidder code is not allowed by allowedAlternateBidderCodes / allowUnknownBidderCodes",
"FLOOR_NOT_MET": "Bid does not meet price floor",
"CANNOT_CONVERT_CURRENCY": "Unable to convert currency"
"CANNOT_CONVERT_CURRENCY": "Unable to convert currency",
"DSA_REQUIRED": "Bid does not provide required DSA transparency info"
},
"PREBID_NATIVE_DATA_KEYS_TO_ORTB": {
"body": "desc",
Expand Down
25 changes: 24 additions & 1 deletion src/utils/ttlCollection.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {GreedyPromise} from './promise.js';
import {binarySearch, timestamp} from '../utils.js';
import {binarySearch, logError, timestamp} from '../utils.js';

/**
* Create a set-like collection that automatically forgets items after a certain time.
Expand Down Expand Up @@ -27,6 +27,7 @@ export function ttlCollection(
} = {}
) {
const items = new Map();
const callbacks = [];
const pendingPurge = [];
const markForPurge = monotonic
? (entry) => pendingPurge.push(entry)
Expand All @@ -43,6 +44,13 @@ export function ttlCollection(
let cnt = 0;
for (const entry of pendingPurge) {
if (entry.expiry > now) break;
callbacks.forEach(cb => {
try {
cb(entry.item)
} catch (e) {
logError(e);
}
});
items.delete(entry.item)
cnt++;
}
Expand Down Expand Up @@ -135,5 +143,20 @@ export function ttlCollection(
entry.refresh();
}
},
/**
* Register a callback to be run when an item has expired and is about to be
* removed the from the collection.
* @param cb a callback that takes the expired item as argument
* @return an unregistration function.
*/
onExpiry(cb) {
callbacks.push(cb);
return () => {
const idx = callbacks.indexOf(cb);
if (idx >= 0) {
callbacks.splice(idx, 1);
}
}
}
};
}
89 changes: 89 additions & 0 deletions test/spec/modules/dsaControl_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {addBidResponseHook, setMetaDsa, reset} from '../../../modules/dsaControl.js';
import CONSTANTS from 'src/constants.json';
import {auctionManager} from '../../../src/auctionManager.js';
import {AuctionIndex} from '../../../src/auctionIndex.js';

describe('DSA transparency', () => {
let sandbox;
beforeEach(() => {
sandbox = sinon.sandbox.create();
});
afterEach(() => {
sandbox.restore();
reset();
});

describe('addBidResponseHook', () => {
const auctionId = 'auction-id';
let bid, auction, fpd, next, reject;
beforeEach(() => {
next = sinon.stub();
reject = sinon.stub();
fpd = {};
bid = {
auctionId
}
auction = {
getAuctionId: () => auctionId,
getFPD: () => ({global: fpd})
}
sandbox.stub(auctionManager, 'index').get(() => new AuctionIndex(() => [auction]));
});

[2, 3].forEach(required => {
describe(`when regs.ext.dsa.required is ${required} (required)`, () => {
beforeEach(() => {
fpd = {
regs: {ext: {dsa: {required}}}
};
});

it('should reject bids that have no meta.dsa', () => {
addBidResponseHook(next, 'adUnit', bid, reject);
sinon.assert.calledWith(reject, CONSTANTS.REJECTION_REASON.DSA_REQUIRED);
sinon.assert.notCalled(next);
});

it('should accept bids that do', () => {
bid.meta = {dsa: {}};
addBidResponseHook(next, 'adUnit', bid, reject);
sinon.assert.notCalled(reject);
sinon.assert.calledWith(next, 'adUnit', bid, reject);
});
});
});
[undefined, 'garbage', 0, 1].forEach(required => {
describe(`when regs.ext.dsa is ${required}`, () => {
beforeEach(() => {
if (required != null) {
fpd = {
regs: {ext: {dsa: {required}}}
}
}
});

it('should accept bids regardless of their meta.dsa', () => {
addBidResponseHook(next, 'adUnit', bid, reject);
sinon.assert.notCalled(reject);
sinon.assert.calledWith(next, 'adUnit', bid, reject);
})
})
})
it('should accept bids regardless of dsa when "required" any other value')
});

describe('setMetaDsa', () => {
it('does nothing if bid has no ext.dsa', () => {
const resp = {};
setMetaDsa(resp, {});
expect(resp).to.eql({});
});

it('carries over ext.dsa into meta.dsa', () => {
const dsa = {transparency: 'info'};
const resp = {meta: {}};
setMetaDsa(resp, {ext: {dsa}});
expect(resp.meta.dsa).to.eql(dsa);
})
})
});
27 changes: 27 additions & 0 deletions test/spec/unit/utils/ttlCollection_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,33 @@ describe('ttlCollection', () => {
});
});

it('should run onExpiry when items are cleared', () => {
const i1 = {ttl: 1000, some: 'data'};
const i2 = {ttl: 2000, some: 'data'};
coll.add(i1);
coll.add(i2);
const cb = sinon.stub();
coll.onExpiry(cb);
return waitForPromises().then(() => {
clock.tick(500);
sinon.assert.notCalled(cb);
clock.tick(SLACK + 500);
sinon.assert.calledWith(cb, i1);
clock.tick(3000);
sinon.assert.calledWith(cb, i2);
})
});

it('should allow unregistration of onExpiry callbacks', () => {
const cb = sinon.stub();
coll.add({ttl: 500});
coll.onExpiry(cb)();
return waitForPromises().then(() => {
clock.tick(500 + SLACK);
sinon.assert.notCalled(cb);
})
})

it('should not wait too long if a shorter ttl shows up', () => {
coll.add({ttl: 4000});
coll.add({ttl: 1000});
Expand Down