Skip to content

Commit

Permalink
dsaControl module: Reject bids without meta.dsa when required (#10982)
Browse files Browse the repository at this point in the history
* dsaControl - reject bids without meta.dsa when required

* ortbConverter: always set meta.dsa

* dsaControl: reject bids whose DSA rendering method disagrees with the request
  • Loading branch information
dgirardi authored Feb 8, 2024
1 parent dbbbccd commit 43e3980
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 3 deletions.
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 @@ -134,7 +134,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

0 comments on commit 43e3980

Please sign in to comment.