tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit e79b03ef1a42878332895a48e501649ce6725030
parent bc30450405eaae2370961abeb74277dd2de7346d
Author: Daniel Mueller <dmueller@mozilla.com>
Date:   Thu,  2 Oct 2025 17:56:16 +0000

Bug 1974059 - Attribution service with specific to Newtab r=mconley,home-newtab-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D255015

Diffstat:
Abrowser/extensions/newtab/lib/NewtabAttributionService.sys.mjs | 379+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/extensions/newtab/test/xpcshell/test_NewtabAttributionService.js | 612+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/extensions/newtab/test/xpcshell/xpcshell.toml | 2++
3 files changed, 993 insertions(+), 0 deletions(-)

diff --git a/browser/extensions/newtab/lib/NewtabAttributionService.sys.mjs b/browser/extensions/newtab/lib/NewtabAttributionService.sys.mjs @@ -0,0 +1,379 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + DAPTelemetrySender: "resource://gre/modules/DAPTelemetrySender.sys.mjs", +}); + +const MAX_CONVERSIONS = 5; +const MAX_LOOKBACK_DAYS = 30; +const DAY_IN_MILLI = 1000 * 60 * 60 * 24; +const CONVERSION_RESET_MILLI = 7 * DAY_IN_MILLI; + +/** + * + */ +export class NewTabAttributionService { + /** + * @typedef { 'view' | 'click' | 'default' } matchType - Available matching methodologies for conversion events. + * + * @typedef { 'view' | 'click' } eventType - A subset of matchType values that Newtab will register events. + * + * @typedef {object} task - DAP task settings. + * @property {string} id - task id. + * @property {string} vdaf - vdaf type. + * @property {number} bits - datatype size. + * @property {number} length - number of buckets. + * @property {number} time_precision - time precision. + * + * @typedef {object} allocatedTask + * @property {task} task - DAP task settings. + * @property {number} defaultMeasurement - Measurement value used if budget is exceeded. + * @property {number} index - Measurement value used if budget is not exceeded. + * + * @typedef {object} impression - stored event. + * @property {allocatedTask} conversion - DAP task settings for conversion attribution. + * @property {number} lastImpression - Timestamp in milliseconds for last touch matching. + * @property {number} lastView - Timestamp in milliseconds for last view matching. + * @property {number} lastClick - Timestamp in milliseconds for last click matching. + * + * @typedef {object} budget - stored budget. + * @property {number} conversions - Number of conversions that have occurred in the budget period. + * @property {number} nextReset - Timestamp in milliseconds for the end of the period this budget applies to. + */ + #dapTelemetrySenderInternal; + #dateProvider; + // eslint-disable-next-line no-unused-private-class-members + #testDapOptions; + + constructor({ dapTelemetrySender, dateProvider, testDapOptions } = {}) { + this.#dapTelemetrySenderInternal = dapTelemetrySender; + this.#dateProvider = dateProvider ?? Date; + this.#testDapOptions = testDapOptions; + + this.dbName = "NewTabAttribution"; + this.impressionStoreName = "impressions"; + this.budgetStoreName = "budgets"; + this.storeNames = [this.impressionStoreName, this.budgetStoreName]; + this.dbVersion = 1; + this.models = { + default: "lastImpression", + view: "lastView", + click: "lastClick", + }; + } + + get #dapTelemetrySender() { + return this.#dapTelemetrySenderInternal || lazy.DAPTelemetrySender; + } + + #now() { + return this.#dateProvider.now(); + } + + /** + * onAttributionEvent stores an event locally for an attributable interaction on Newtab. + * + * @param {eventType} type - The type of event. + * @param {*} params - Attribution task details & partner, to enable attribution matching + * with this event and submission to DAP. + */ + async onAttributionEvent(type, params) { + try { + const now = this.#now(); + + const impressionStore = await this.#getImpressionStore(); + + if (!params || !params.conversion) { + return; + } + + const impression = await this.#getImpression( + impressionStore, + params.partner_id, + { + conversion: { + task: { + id: params.conversion.task_id, + vdaf: params.conversion.vdaf, + bits: params.conversion.bits, + length: params.conversion.length, + time_precision: params.conversion.time_precision, + }, + defaultMeasurement: params.conversion.default_measurement, + index: params.conversion.index, + }, + } + ); + + const prop = this.#getModelProp(type); + impression.lastImpression = now; + impression[prop] = now; + + await this.#updateImpression( + impressionStore, + params.partner_id, + impression + ); + } catch (e) { + console.error(e); + } + } + + /** + * onAttributionClear + */ + async onAttributionClear() {} + + /** + * onAttributionReset + */ + async onAttributionReset() {} + + /** + * onAttributionConversion checks for eligible Newtab events and submits + * a DAP report. + * + * @param {string} partnerId - The partner that the conversion occured for. Compared against + * local events to see if any of them are eligible. + * @param {number} lookbackDays - The number of days prior to now that an event can be for it + * to be eligible. + * @param {matchType} impressionType - How the matching of events is determined. + * 'view': attributes the most recent eligible view event. + * 'click': attributes the most recent eligible click event. + * 'default': attributes the most recent eligible event of any type. + */ + async onAttributionConversion(partnerId, lookbackDays, impressionType) { + try { + if (lookbackDays > MAX_LOOKBACK_DAYS) { + return; + } + + const now = this.#now(); + + const budget = await this.#getBudget(partnerId, now); + const impression = await this.#findImpression( + partnerId, + lookbackDays, + impressionType, + now + ); + + let conversion = impression?.conversion; + if (!conversion) { + // retreive "conversion" for conversions with no found impression + // conversion = await this.#getUnattributedTask(partnerId); + if (!conversion) { + return; + } + } + + let measurement = conversion.defaultMeasurement; + let budgetSpend = 0; + if (budget.conversions < MAX_CONVERSIONS && conversion) { + budgetSpend = 1; + if (conversion.task && conversion.task.length > conversion.index) { + measurement = conversion.index; + } + } + + await this.#updateBudget(budget, budgetSpend, partnerId); + await this.#dapTelemetrySender.sendDAPMeasurement( + conversion.task, + measurement, + {} + ); + } catch (e) { + console.error(e); + } + } + + /** + * findImpression queries the local events to find an attributable event. + * @param {string} partnerId - Partner the event must be associated with. + * @param {number} lookbackDays - Maximum number of days ago that the event occurred for it to + * be eligible. + * @param {matchType} impressionType - How the matching of events is determined. Determines what + * timestamp property to compare against. + * @param {number} now - Timestamp in milliseconds when the conversion event was triggered + * @returns {Promise<impression|undefined>} - The impression that most recently occurred matching the + * search criteria. + */ + async #findImpression(partnerId, lookbackDays, impressionType, now) { + // Get impressions for the partner + const impressionStore = await this.#getImpressionStore(); + const impressions = await this.#getPartnerImpressions( + impressionStore, + partnerId + ); + + // Determine what timestamp to compare against for the matching methodology + const prop = this.#getModelProp(impressionType); + + // Find the most relevant impression + const lookbackWindow = now - lookbackDays * DAY_IN_MILLI; + return ( + impressions + // Filter by lookback days + .filter(impression => impression[prop] >= lookbackWindow) + // Get the impression with the most recent interaction + .reduce( + (cur, impression) => + !cur || impression[prop] > cur[prop] ? impression : cur, + null + ) + ); + } + + /** + * getImpression searches existing events for the partner and retuns the event + * if it is found, defaulting to the passed in impression if there are none. This + * enables timestamp fields of the stored event to be updated or carried forward. + * @param {ObjectStore} impressionStore - Promise-based wrapped IDBObjectStore. + * @param {string} partnerId - partner this event is associated with. + * @param {impression} defaultImpression - event to use if it has not been seen previously. + * @returns {Promise<impression>} + */ + async #getImpression(impressionStore, partnerId, defaultImpression) { + const impressions = await this.#getPartnerImpressions( + impressionStore, + partnerId + ); + const impression = impressions.find(r => + this.#compareImpression(r, defaultImpression) + ); + + return impression ?? defaultImpression; + } + + /** + * updateImpression stores the passed event, either updating the record + * if this event was already seen, or appending to the list of events if it is new. + * @param {ObjectStore} impressionStore - Promise-based wrapped IDBObjectStore. + * @param {string} partnerId - partner this event is associated with. + * @param {impression} impression - event to update. + */ + async #updateImpression(impressionStore, partnerId, impression) { + let impressions = await this.#getPartnerImpressions( + impressionStore, + partnerId + ); + + const i = impressions.findIndex(r => + this.#compareImpression(r, impression) + ); + if (i < 0) { + impressions.push(impression); + } else { + impressions[i] = impression; + } + + await impressionStore.put(impressions, partnerId); + } + + /** + * @param {impression} cur + * @param {impression} impression + * @returns {boolean} true if cur and impression have the same DAP allocation, else false. + */ + #compareImpression(cur, impression) { + return ( + cur.conversion.task.id === impression.conversion.task.id && + cur.conversion.index === impression.conversion.index + ); + } + + /** + * getBudget returns the current budget available for the partner. + * + * @param {string} partnerId - partner to look up budget for. + * @param {number} now - Timestamp in milliseconds. + * @returns {Promise<budget>} the current budget for the partner. + */ + async #getBudget(partnerId, now) { + const budgetStore = await this.#getBudgetStore(); + const budget = await budgetStore.get(partnerId); + + if (!budget || now > budget.nextReset) { + return { + conversions: 0, + nextReset: now + CONVERSION_RESET_MILLI, + }; + } + + return budget; + } + + /** + * updateBudget updates the stored budget to indicate some has been used. + * @param {budget} budget - current budget to be modified. + * @param {number} value - amount of budget that has been used. + * @param {string} partnerId - partner this budget is for. + */ + async #updateBudget(budget, value, partnerId) { + const budgetStore = await this.#getBudgetStore(); + budget.conversions += value; + await budgetStore.put(budget, partnerId); + } + + /** + * @param {ObjectStore} impressionStore - Promise-based wrapped IDBObjectStore. + * @param {string} partnerId - partner to look up impressions for. + * @returns {Promise<Array<impression>>} impressions associated with the partner. + */ + async #getPartnerImpressions(impressionStore, partnerId) { + const impressions = (await impressionStore.get(partnerId)) ?? []; + return impressions; + } + + async #getImpressionStore() { + return await this.#getStore(this.impressionStoreName); + } + + async #getBudgetStore() { + return await this.#getStore(this.budgetStoreName); + } + + async #getStore(storeName) { + return (await this.#db).objectStore(storeName, "readwrite"); + } + + get #db() { + return this._db || (this._db = this.#createOrOpenDb()); + } + + async #createOrOpenDb() { + try { + return await this.#openDatabase(); + } catch { + await lazy.IndexedDB.deleteDatabase(this.dbName); + return this.#openDatabase(); + } + } + + async #openDatabase() { + return await lazy.IndexedDB.open(this.dbName, this.dbVersion, db => { + this.storeNames.forEach(store => { + if (!db.objectStoreNames.contains(store)) { + db.createObjectStore(store); + } + }); + }); + } + + /** + * getModelProp returns the property name associated with a given matching + * methodology. + * + * @param {matchType} type + * @returns {string} The name of the timestamp property to check against. + */ + #getModelProp(type) { + return this.models[type] ?? this.models.default; + } +} diff --git a/browser/extensions/newtab/test/xpcshell/test_NewtabAttributionService.js b/browser/extensions/newtab/test/xpcshell/test_NewtabAttributionService.js @@ -0,0 +1,612 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + NewTabAttributionService: + "resource://newtab/lib/NewTabAttributionService.sys.mjs", +}); + +const { HttpServer } = ChromeUtils.importESModule( + "resource://testing-common/httpd.sys.mjs" +); + +const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +const PREF_LEADER = "toolkit.telemetry.dap.leader.url"; +const PREF_HELPER = "toolkit.telemetry.dap.helper.url"; +const TASK_ID = "DSZGMFh26hBYXNaKvhL_N4AHA3P5lDn19on1vFPBxJM"; +const MAX_CONVERSIONS = 5; +const DAY_IN_MILLI = 1000 * 60 * 60 * 24; +const LOOKBACK_DAYS = 1; +const MAX_LOOKBACK_DAYS = 30; +const HISTOGRAM_SIZE = 5; + +class MockDateProvider { + constructor() { + this._now = Date.now(); + } + + now() { + return this._now; + } + + add(interval_ms) { + this._now += interval_ms; + } +} + +class MockDAPTelemetrySender { + constructor() { + this.receivedMeasurements = []; + } + + async sendDAPMeasurement(task, measurement, _options) { + this.receivedMeasurements.push({ + task, + measurement, + }); + } +} + +class MockServer { + constructor() { + this.receivedReports = []; + + const server = new HttpServer(); + + server.registerPrefixHandler( + "/leader_endpoint/tasks/", + this.uploadHandler.bind(this) + ); + + this._server = server; + } + + start() { + this._server.start(-1); + + this.orig_leader = Services.prefs.getStringPref(PREF_LEADER); + this.orig_helper = Services.prefs.getStringPref(PREF_HELPER); + + const i = this._server.identity; + const serverAddr = `${i.primaryScheme}://${i.primaryHost}:${i.primaryPort}`; + Services.prefs.setStringPref(PREF_LEADER, `${serverAddr}/leader_endpoint`); + Services.prefs.setStringPref(PREF_HELPER, `${serverAddr}/helper_endpoint`); + } + + async stop() { + Services.prefs.setStringPref(PREF_LEADER, this.orig_leader); + Services.prefs.setStringPref(PREF_HELPER, this.orig_helper); + + await this._server.stop(); + } + + uploadHandler(request, response) { + let body = new BinaryInputStream(request.bodyInputStream); + + this.receivedReports.push({ + contentType: request.getHeader("Content-Type"), + size: body.available(), + }); + + response.setStatusLine(request.httpVersion, 200); + } +} + +add_setup(async function () { + do_get_profile(); +}); + +add_task(async function testSuccessfulConversion() { + const mockSender = new MockDAPTelemetrySender(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + }); + + const partnerIdentifier = "partner_identifier"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + index: 1, + }; + + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: conversionSettings, + }); + + await privateAttribution.onAttributionEvent("click", { + partner_id: partnerIdentifier, + conversion: conversionSettings, + }); + + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "view" + ); + + const expectedMeasurement = { + task: { + id: conversionSettings.task_id, + vdaf: conversionSettings.vdaf, + bits: conversionSettings.bits, + length: conversionSettings.length, + time_precision: conversionSettings.time_precision, + }, + measurement: conversionSettings.index, + }; + + const receivedMeasurement = mockSender.receivedMeasurements.pop(); + Assert.deepEqual(receivedMeasurement, expectedMeasurement); + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testConversionWithoutImpression() { + const mockSender = new MockDAPTelemetrySender(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + }); + + const partnerIdentifier = "partner_identifier_no_impression"; + + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "view" + ); + + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testConversionWithInvalidLookbackDays() { + const mockSender = new MockDAPTelemetrySender(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + }); + + const partnerIdentifier = "partner_identifier"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + index: 1, + }; + + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: conversionSettings, + }); + + await privateAttribution.onAttributionConversion( + partnerIdentifier, + MAX_LOOKBACK_DAYS + 1, + "view" + ); + + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testSelectionByLastView() { + const mockSender = new MockDAPTelemetrySender(); + const mockDateProvider = new MockDateProvider(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + dateProvider: mockDateProvider, + }); + + const partnerIdentifier = "partner_identifier_last_view"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + index: 1, + }; + const selectedViewIndex = 1; + const ignoredViewIndex = 2; + const clickIndex = 3; + + // View event that will be ignored, as a more recent view will exist + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: { + ...conversionSettings, + index: ignoredViewIndex, + }, + }); + + // step forward time + mockDateProvider.add(10); + + // View event that will be selected, as no more recent view exists + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: { + ...conversionSettings, + index: selectedViewIndex, + }, + }); + + // step forward time + mockDateProvider.add(10); + + // Click event that will be ignored because the match type is "view" + await privateAttribution.onAttributionEvent("click", { + partner_id: partnerIdentifier, + conversion: { + ...conversionSettings, + index: clickIndex, + }, + }); + + // Conversion filtering for "view" finds the view event + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "view" + ); + + let receivedMeasurement = mockSender.receivedMeasurements.pop(); + Assert.deepEqual(receivedMeasurement.measurement, selectedViewIndex); + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testSelectionByLastClick() { + const mockSender = new MockDAPTelemetrySender(); + const mockDateProvider = new MockDateProvider(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + dateProvider: mockDateProvider, + }); + + const partnerIdentifier = "partner_identifier_last_click"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + index: 1, + }; + const viewIndex = 1; + const ignoredClickIndex = 2; + const selectedClickIndex = 3; + + // Click event that will be ignored, as a more recent click will exist + await privateAttribution.onAttributionEvent("click", { + partner_id: partnerIdentifier, + conversion: { + ...conversionSettings, + index: ignoredClickIndex, + }, + }); + + // step forward time + mockDateProvider.add(10); + + // Click event that will be selected, as no more recent click exists + await privateAttribution.onAttributionEvent("click", { + partner_id: partnerIdentifier, + conversion: { + ...conversionSettings, + index: selectedClickIndex, + }, + }); + + // step forward time + mockDateProvider.add(10); + + // View event that will be ignored because the match type is "click" + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: { + ...conversionSettings, + index: viewIndex, + }, + }); + + // Conversion filtering for "click" finds the click event + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "click" + ); + + let receivedMeasurement = mockSender.receivedMeasurements.pop(); + Assert.deepEqual(receivedMeasurement.measurement, selectedClickIndex); + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testSelectionByLastTouch() { + const mockSender = new MockDAPTelemetrySender(); + const mockDateProvider = new MockDateProvider(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + dateProvider: mockDateProvider, + }); + + const partnerIdentifier = "partner_identifier_last_touch"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + index: 1, + }; + const viewIndex = 1; + const clickIndex = 2; + + // Click at clickIndex + await privateAttribution.onAttributionEvent("click", { + partner_id: partnerIdentifier, + conversion: { + ...conversionSettings, + index: clickIndex, + }, + }); + + // step forward time so the view event occurs most recently + mockDateProvider.add(10); + + // View at viewIndex + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: { + ...conversionSettings, + index: viewIndex, + }, + }); + + // Conversion filtering for "default" finds the view event + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "default" + ); + + let receivedMeasurement = mockSender.receivedMeasurements.pop(); + Assert.deepEqual(receivedMeasurement.measurement, viewIndex); + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testSelectionByPartnerId() { + const mockSender = new MockDAPTelemetrySender(); + const mockDateProvider = new MockDateProvider(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + dateProvider: mockDateProvider, + }); + + const partnerIdentifier1 = "partner_identifier_1"; + const partnerIdentifier2 = "partner_identifier_2"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + index: 1, + }; + const partner1Index = 1; + const partner2Index = 2; + + // view event associated with partner 1 + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier1, + conversion: { + ...conversionSettings, + index: partner1Index, + }, + }); + + // step forward time so the partner 2 event occurs most recently + mockDateProvider.add(10); + + // view event associated with partner 2 + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier2, + conversion: { + ...conversionSettings, + index: partner2Index, + }, + }); + + // Conversion filtering for "default" finds the correct view event + await privateAttribution.onAttributionConversion( + partnerIdentifier1, + LOOKBACK_DAYS, + "default" + ); + + let receivedMeasurement = mockSender.receivedMeasurements.pop(); + Assert.deepEqual(receivedMeasurement.measurement, partner1Index); + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testExpiredImpressions() { + const mockSender = new MockDAPTelemetrySender(); + const mockDateProvider = new MockDateProvider(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + dateProvider: mockDateProvider, + }); + + const partnerIdentifier = "partner_identifier"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + index: 1, + }; + + // Register impression + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: conversionSettings, + }); + + // Fast-forward time by LOOKBACK_DAYS days + 1 ms + mockDateProvider.add(LOOKBACK_DAYS * DAY_IN_MILLI + 1); + + // Conversion doesn't match expired impression + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "view" + ); + + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testConversionBudget() { + const mockSender = new MockDAPTelemetrySender(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + }); + + const partnerIdentifier = "partner_identifier_budget"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + index: 1, + }; + + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: conversionSettings, + }); + + // Measurements uploaded for conversions up to MAX_CONVERSIONS + for (let i = 0; i < MAX_CONVERSIONS; i++) { + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "view" + ); + + const receivedMeasurement = mockSender.receivedMeasurements.pop(); + Assert.deepEqual(receivedMeasurement.measurement, conversionSettings.index); + Assert.equal(mockSender.receivedMeasurements.length, 0); + } + + // default report uploaded on subsequent conversions + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "view" + ); + + const receivedMeasurement = mockSender.receivedMeasurements.pop(); + Assert.deepEqual( + receivedMeasurement.measurement, + conversionSettings.default_measurement + ); + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testHistogramSize() { + const mockSender = new MockDAPTelemetrySender(); + const privateAttribution = new NewTabAttributionService({ + dapTelemetrySender: mockSender, + }); + + const partnerIdentifier = "partner_identifier_bad_settings"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + // Zero-based index equal to histogram size is out of bounds + index: HISTOGRAM_SIZE, + }; + + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: conversionSettings, + }); + + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "view" + ); + + const receivedMeasurement = mockSender.receivedMeasurements.pop(); + Assert.deepEqual( + receivedMeasurement.measurement, + conversionSettings.default_measurement + ); + Assert.equal(mockSender.receivedMeasurements.length, 0); +}); + +add_task(async function testWithRealDAPSender() { + // Omit mocking DAP telemetry sender in this test to defend against mock + // sender getting out of sync + const mockServer = new MockServer(); + mockServer.start(); + + const privateAttribution = new NewTabAttributionService({}); + + const partnerIdentifier = "partner_identifier_real_dap"; + const conversionSettings = { + task_id: TASK_ID, + vdaf: "histogram", + bits: 1, + length: HISTOGRAM_SIZE, + time_precision: 60, + default_measurement: 0, + index: 1, + }; + + await privateAttribution.onAttributionEvent("view", { + partner_id: partnerIdentifier, + conversion: conversionSettings, + }); + + await privateAttribution.onAttributionConversion( + partnerIdentifier, + LOOKBACK_DAYS, + "view" + ); + + await mockServer.stop(); + + Assert.equal(mockServer.receivedReports.length, 1); + + const expectedReport = { + contentType: "application/dap-report", + size: 502, + }; + + const receivedReport = mockServer.receivedReports.pop(); + Assert.deepEqual(receivedReport, expectedReport); +}); diff --git a/browser/extensions/newtab/test/xpcshell/xpcshell.toml b/browser/extensions/newtab/test/xpcshell/xpcshell.toml @@ -23,6 +23,8 @@ support-files = ["topstories.json"] ["test_LocalInferredRanking.js"] +["test_NewTabAttributionService.js"] + ["test_NewTabContentPing.js"] ["test_NewTabGleanUtils.js"]