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:
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"
+ );
+ });
+});