Skip to content

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

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

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions libraries/ortbConverter/processors/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ export const DEFAULT_PROCESSORS = {
if (bid.adomain) {
bidResponse.meta.advertiserDomains = bid.adomain;
}
if (bid.ext?.dsa) {
bidResponse.meta.dsa = bid.ext.dsa;
}
}
}
}
Expand Down
67 changes: 67 additions & 0 deletions modules/dsaControl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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;
}
const dsaRequest = dsaAuctions[bid.auctionId];
let rejectReason;
if (dsaRequest) {
if (!bid.meta?.dsa) {
if (dsaRequest.dsarequired === 1) {
// request says dsa is supported; response does not have dsa info; warn about it
logWarn(`dsaControl: ${CONSTANTS.REJECTION_REASON.DSA_REQUIRED}; will still be accepted as regs.ext.dsa.dsarequired = 1`, bid);
} else if ([2, 3].includes(dsaRequest.dsarequired)) {
// request says dsa is required; response does not have dsa info; reject it
rejectReason = CONSTANTS.REJECTION_REASON.DSA_REQUIRED;
}
} else {
if (dsaRequest.pubrender === 0 && bid.meta.dsa.adrender === 0) {
// request says publisher can't render; response says advertiser won't; reject it
rejectReason = CONSTANTS.REJECTION_REASON.DSA_MISMATCH;
} else if (dsaRequest.pubrender === 2 && bid.meta.dsa.adrender === 1) {
// request says publisher will render; response says advertiser will; reject it
rejectReason = CONSTANTS.REJECTION_REASON.DSA_MISMATCH;
}
}
}
if (rejectReason) {
reject(rejectReason);
} else {
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);
});
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
4 changes: 3 additions & 1 deletion src/constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@
"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",
"DSA_MISMATCH": "Bid indicates inappropriate DSA rendering method"
},
"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);
}
}
}
};
}
113 changes: 113 additions & 0 deletions test/spec/modules/dsaControl_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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]));
});

function expectRejection(reason) {
addBidResponseHook(next, 'adUnit', bid, reject);
sinon.assert.calledWith(reject, reason);
sinon.assert.notCalled(next);
}

function expectAcceptance() {
addBidResponseHook(next, 'adUnit', bid, reject);
sinon.assert.notCalled(reject);
sinon.assert.calledWith(next, 'adUnit', bid, reject);
}

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

it('should reject bids that have no meta.dsa', () => {
expectRejection(CONSTANTS.REJECTION_REASON.DSA_REQUIRED);
});

it('should accept bids that do', () => {
bid.meta = {dsa: {}};
expectAcceptance();
});

describe('and pubrender = 0 (rendering by publisher not supported)', () => {
beforeEach(() => {
fpd.regs.ext.dsa.pubrender = 0;
});

it('should reject bids with adrender = 0 (advertiser will not render)', () => {
bid.meta = {dsa: {adrender: 0}};
expectRejection(CONSTANTS.REJECTION_REASON.DSA_MISMATCH);
});

it('should accept bids with adrender = 1 (advertiser will render)', () => {
bid.meta = {dsa: {adrender: 1}};
expectAcceptance();
});
});
describe('and pubrender = 2 (publisher will render)', () => {
beforeEach(() => {
fpd.regs.ext.dsa.pubrender = 2;
});

it('should reject bids with adrender = 1 (advertiser will render)', () => {
bid.meta = {dsa: {adrender: 1}};
expectRejection(CONSTANTS.REJECTION_REASON.DSA_MISMATCH);
});

it('should accept bids with adrender = 0 (advertiser will not render)', () => {
bid.meta = {dsa: {adrender: 0}};
expectAcceptance();
})
})
});
});
[undefined, 'garbage', 0, 1].forEach(required => {
describe(`when regs.ext.dsa.dsarequired is ${required}`, () => {
beforeEach(() => {
if (required != null) {
fpd = {
regs: {ext: {dsa: {dsarequired: 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')
});
});
29 changes: 29 additions & 0 deletions test/spec/ortbConverter/common_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {DEFAULT_PROCESSORS} from '../../../libraries/ortbConverter/processors/default.js';
import {BID_RESPONSE} from '../../../src/pbjsORTB.js';

describe('common processors', () => {
describe('bid response properties', () => {
const responseProps = DEFAULT_PROCESSORS[BID_RESPONSE].props.fn;
let context;

beforeEach(() => {
context = {
ortbResponse: {}
}
})

describe('meta.dsa', () => {
const MOCK_DSA = {transparency: 'info'};
it('is not set if bid has no meta.dsa', () => {
const resp = {};
responseProps(resp, {}, context);
expect(resp.meta?.dsa).to.not.exist;
});
it('is set to ext.dsa otherwise', () => {
const resp = {};
responseProps(resp, {ext: {dsa: MOCK_DSA}}, context);
expect(resp.meta.dsa).to.eql(MOCK_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