tor-browser

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

commit 5696e84aa1bf6a80d96866b5a3548421630e84f5
parent 6cbd287424755e3f5f8f4e9b7a62be569d476be5
Author: Nathan Barrett <nbarrett@mozilla.com>
Date:   Thu, 13 Nov 2025 19:25:42 +0000

Bug 1997664 - Connect allow-list from RS to Actor pair r=thecount,home-newtab-reviewers

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

Diffstat:
Mbrowser/extensions/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx | 18++++++++++++++++++
Mbrowser/extensions/newtab/data/content/activity-stream.bundle.js | 19++++++++++++++++++-
Mbrowser/extensions/newtab/lib/NewTabActorRegistry.sys.mjs | 2+-
Mbrowser/extensions/newtab/lib/actors/NewTabAttributionParent.sys.mjs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mbrowser/extensions/newtab/test/browser/browser_attribution_actor.js | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 227 insertions(+), 4 deletions(-)

diff --git a/browser/extensions/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx b/browser/extensions/newtab/content-src/components/DiscoveryStreamAdmin/DiscoveryStreamAdmin.jsx @@ -149,6 +149,7 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { this.refreshTopicSelectionCache.bind(this); this.handleSectionsToggle = this.handleSectionsToggle.bind(this); this.toggleIABBanners = this.toggleIABBanners.bind(this); + this.sendConversionEvent = this.sendConversionEvent.bind(this); this.state = { toggledStories: {}, weatherQuery: "", @@ -357,6 +358,20 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { ); } + sendConversionEvent() { + const detail = { + partnerId: "demo-partner", + lookbackDays: 7, + impressionType: "default", + }; + const event = new CustomEvent("FirefoxConversionNotification", { + detail, + bubbles: true, + composed: true, + }); + window?.dispatchEvent(event); + } + renderComponent(width, component) { return ( <table> @@ -706,6 +721,9 @@ export class DiscoveryStreamAdminUI extends React.PureComponent { /> </div> </details> + <button className="button" onClick={this.sendConversionEvent}> + Send conversion event + </button> <table> <tbody> {prefToggles.map(pref => ( diff --git a/browser/extensions/newtab/data/content/activity-stream.bundle.js b/browser/extensions/newtab/data/content/activity-stream.bundle.js @@ -725,6 +725,7 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { this.refreshTopicSelectionCache = this.refreshTopicSelectionCache.bind(this); this.handleSectionsToggle = this.handleSectionsToggle.bind(this); this.toggleIABBanners = this.toggleIABBanners.bind(this); + this.sendConversionEvent = this.sendConversionEvent.bind(this); this.state = { toggledStories: {}, weatherQuery: "" @@ -889,6 +890,19 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { this.props.dispatch(actionCreators.SetPref("discoverystream.sections.cards.enabled", pressed)); this.props.dispatch(actionCreators.SetPref("discoverystream.sections.cards.thumbsUpDown.enabled", pressed)); } + sendConversionEvent() { + const detail = { + partnerId: "demo-partner", + lookbackDays: 7, + impressionType: "default" + }; + const event = new CustomEvent("FirefoxConversionNotification", { + detail, + bubbles: true, + composed: true + }); + window?.dispatchEvent(event); + } renderComponent(width, component) { return /*#__PURE__*/external_React_default().createElement("table", null, /*#__PURE__*/external_React_default().createElement("tbody", null, /*#__PURE__*/external_React_default().createElement(Row, null, /*#__PURE__*/external_React_default().createElement("td", { className: "min" @@ -1113,7 +1127,10 @@ class DiscoveryStreamAdminUI extends (external_React_default()).PureComponent { pressed: mediumRectangleEnabledPressed || null, onToggle: this.toggleIABBanners, label: "Enable IAB Medium Rectangle (MREC)" - }))), /*#__PURE__*/external_React_default().createElement("table", null, /*#__PURE__*/external_React_default().createElement("tbody", null, prefToggles.map(pref => /*#__PURE__*/external_React_default().createElement(Row, { + }))), /*#__PURE__*/external_React_default().createElement("button", { + className: "button", + onClick: this.sendConversionEvent + }, "Send conversion event"), /*#__PURE__*/external_React_default().createElement("table", null, /*#__PURE__*/external_React_default().createElement("tbody", null, prefToggles.map(pref => /*#__PURE__*/external_React_default().createElement(Row, { key: pref }, /*#__PURE__*/external_React_default().createElement("td", null, /*#__PURE__*/external_React_default().createElement(TogglePrefCheckbox, { checked: config[pref], diff --git a/browser/extensions/newtab/lib/NewTabActorRegistry.sys.mjs b/browser/extensions/newtab/lib/NewTabActorRegistry.sys.mjs @@ -40,7 +40,7 @@ export const NewTabActorRegistry = { }, /** - * Registers the Attribution actor to handle conversion events from advertiser websites. + * Registers the Attribution actor. * Called by NewTabAttributionFeed when attribution is enabled. */ registerAttributionActor() { diff --git a/browser/extensions/newtab/lib/actors/NewTabAttributionParent.sys.mjs b/browser/extensions/newtab/lib/actors/NewTabAttributionParent.sys.mjs @@ -16,6 +16,10 @@ ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { }); }); +ChromeUtils.defineESModuleGetters(lazy, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", +}); + /** * Allowed fields in the conversion event payload from advertisers. * - partnerId: Mozilla-generated UUID associated with the advertiser @@ -45,9 +49,10 @@ function isPlainObject(obj) { ); } -// RS collection: https://bugzilla.mozilla.org/show_bug.cgi?id=1994040 -// TODO: connect to RS collection when created +const ATTRIBUTION_ALLOWLIST_COLLECTION = "newtab-attribution-allowlist"; + let gAllowList = new Set([]); +let gAllowListClient = null; /** * Parent-side JSWindowActor for handling attribution conversion events. @@ -57,6 +62,11 @@ let gAllowList = new Set([]); * Upon successful validation, the conversion data is passed to NewTabAttributionService */ export class AttributionParent extends JSWindowActorParent { + constructor() { + super(); + this._onSync = this.onSync.bind(this); + } + /** * TEST-ONLY: Override the allowlist from a test. * @@ -67,6 +77,73 @@ export class AttributionParent extends JSWindowActorParent { } /** + * TEST-ONLY: Reset the Remote Settings client. + */ + resetRemoteSettingsClientForTest() { + gAllowListClient = null; + } + + /** + * This thin wrapper around lazy.RemoteSettings makes it easier for us to write + * automated tests that simulate responses from this fetch. + */ + RemoteSettings(...args) { + return lazy.RemoteSettings(...args); + } + + /** + * Updates the global allowlist with the provided records. + * + * @param {Array} records - Array of Remote Settings records containing domain fields. + */ + updateAllowList(records) { + if (records?.length) { + const domains = records.map(record => record.domain); + gAllowList = new Set(domains); + } else { + gAllowList = new Set([]); + } + } + + /** + * Retrieves the allow list of advertiser origins from Remote Settings. + * Populates the internal gAllowList set with the retrieved origins. + */ + async retrieveAllowList() { + try { + if (!gAllowListClient) { + gAllowListClient = this.RemoteSettings( + ATTRIBUTION_ALLOWLIST_COLLECTION + ); + gAllowListClient.on("sync", this._onSync); + const records = await gAllowListClient.get(); + this.updateAllowList(records); + } + } catch (error) { + lazy.logConsole.error( + `AttributionParent: failed to retrieve allow list: ${error}` + ); + } + } + + /** + * Handles Remote Settings sync events. + * Updates the allow list when the collection changes. + * + * @param {object} event - The sync event object. + * @param {Array} event.data.current - The current records after sync. + */ + onSync({ data: { current } }) { + this.updateAllowList(current); + } + + didDestroy() { + if (gAllowListClient) { + gAllowListClient.off("sync", this._onSync); + } + } + + /** * Validates a conversion event payload from an advertiser. * Ensures all required fields are present, correctly typed, and within valid ranges. * @@ -140,6 +217,10 @@ export class AttributionParent extends JSWindowActorParent { return; } + if (!gAllowList.size) { + await this.retrieveAllowList(); + } + // Only accept conversion events from allowlisted origins if (!gAllowList.has(principal.originNoSuffix)) { lazy.logConsole.error( diff --git a/browser/extensions/newtab/test/browser/browser_attribution_actor.js b/browser/extensions/newtab/test/browser/browser_attribution_actor.js @@ -11,10 +11,18 @@ ChromeUtils.defineESModuleGetters(this, { "resource://newtab/lib/NewTabAttributionService.sys.mjs", }); +const { AttributionParent } = ChromeUtils.importESModule( + "resource://newtab/lib/actors/NewTabAttributionParent.sys.mjs" +); + const { DAPSender } = ChromeUtils.importESModule( "resource://gre/modules/DAPSender.sys.mjs" ); +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + let sandbox; let dapStub; let conversionStub; @@ -250,3 +258,102 @@ add_task(async function test_parent_blocks_missing_detail() { Assert.ok(!conversionStub.called, "onAttributionConversion was not called"); }); }); + +/** + * Test that Remote Settings client uses get() and registers onSync handler. + */ +add_task(async function test_remote_settings_sync_and_handler() { + await resetTestState(); + + const mockClient = { + get: sandbox + .stub() + .resolves([ + { domain: "https://example.com" }, + { domain: "https://partner.com" }, + ]), + on: sandbox.stub(), + off: sandbox.stub(), + }; + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + const parent = await getParentActor(browser); + parent.resetRemoteSettingsClientForTest(); + sandbox.stub(parent, "RemoteSettings").returns(mockClient); + + await parent.retrieveAllowList(); + + Assert.ok(mockClient.get.calledOnce, "get() was called once"); + Assert.ok(mockClient.on.calledOnce, "on() was called once"); + Assert.equal( + mockClient.on.firstCall.args[0], + "sync", + "on() was called with 'sync' event" + ); + }); +}); + +/** + * Test that onSync updates the allowlist when Remote Settings syncs. + */ +add_task(async function test_onSync_updates_allowlist() { + await resetTestState(); + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + const parent = await getParentActor(browser); + const testOrigin = "https://test-partner.com"; + + parent.setAllowListForTest([]); + + parent.onSync({ + data: { + current: [{ domain: "https://example.com" }, { domain: testOrigin }], + }, + }); + + const origin = parent.manager.documentPrincipal.originNoSuffix; + parent.setAllowListForTest([origin]); + + await dispatchAttributionEvent(browser, { + partnerId: "test-partner", + lookbackDays: 7, + impressionType: "view", + }); + + await BrowserTestUtils.waitForCondition(() => conversionStub.calledOnce); + Assert.ok( + conversionStub.calledOnce, + "onAttributionConversion was called after onSync updated allowlist" + ); + }); +}); + +/** + * Test that didDestroy removes the onSync event listener. + */ +add_task(async function test_didDestroy_removes_listener() { + await resetTestState(); + + const mockClient = { + get: sandbox.stub().resolves([]), + on: sandbox.stub(), + off: sandbox.stub(), + }; + + await BrowserTestUtils.withNewTab(TEST_URL, async browser => { + const parent = await getParentActor(browser); + parent.resetRemoteSettingsClientForTest(); + sandbox.stub(parent, "RemoteSettings").returns(mockClient); + + await parent.retrieveAllowList(); + + parent.didDestroy(); + + Assert.ok(mockClient.off.calledOnce, "off() was called once"); + Assert.equal( + mockClient.off.firstCall.args[0], + "sync", + "off() was called with 'sync' event" + ); + }); +});