summaryrefslogtreecommitdiffstats
path: root/browser/components/attribution/test
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/attribution/test/browser/browser.ini9
-rw-r--r--browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js248
-rw-r--r--browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js101
-rw-r--r--browser/components/attribution/test/browser/head.js23
-rw-r--r--browser/components/attribution/test/xpcshell/head.js136
-rw-r--r--browser/components/attribution/test/xpcshell/test_AttributionCode.js131
-rw-r--r--browser/components/attribution/test/xpcshell/test_MacAttribution.js97
-rw-r--r--browser/components/attribution/test/xpcshell/test_attribution_parsing.js44
-rw-r--r--browser/components/attribution/test/xpcshell/test_zoneId_parsing.js538
-rw-r--r--browser/components/attribution/test/xpcshell/xpcshell.ini11
10 files changed, 1338 insertions, 0 deletions
diff --git a/browser/components/attribution/test/browser/browser.ini b/browser/components/attribution/test/browser/browser.ini
new file mode 100644
index 0000000000..080df0afef
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_AttributionCode_Mac_telemetry.js]
+skip-if = toolkit != "cocoa" # macOS only telemetry.
+[browser_AttributionCode_telemetry.js]
+skip-if = (os != "win" && toolkit != "cocoa") # Windows and macOS only telemetry.
+
diff --git a/browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js b/browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js
new file mode 100644
index 0000000000..7523f0f930
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js
@@ -0,0 +1,248 @@
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+);
+const { AttributionIOUtils } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+async function assertCacheExistsAndIsEmpty() {
+ // We should have written to the cache, and be able to read back
+ // with no errors.
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ histogram.clear();
+
+ ok(await AttributionIOUtils.exists(AttributionCode.attributionFile.path));
+ Assert.deepEqual(
+ "",
+ new TextDecoder().decode(
+ await AttributionIOUtils.read(AttributionCode.attributionFile.path)
+ )
+ );
+
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should be able to get cached result");
+
+ Assert.deepEqual({}, histogram.snapshot().values || {});
+}
+
+add_task(async function test_write_error() {
+ const sandbox = sinon.createSandbox();
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(
+ MacAttribution.applicationPath,
+ "https://example.com?content=content",
+ true
+ );
+
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+
+ let oldExists = AttributionIOUtils.exists;
+ let oldWrite = AttributionIOUtils.write;
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Force the file to not exist and then cause a write error. This is delicate
+ // because various background tasks may invoke `IOUtils.writeAtomic` while
+ // this test is running. Be careful to only stub the one call.
+ AttributionIOUtils.exists = () => false;
+ AttributionIOUtils.write = () => {
+ throw new Error("write_error");
+ };
+
+ // Try to read the attribution code.
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ { content: "content" },
+ "Should be able to get a result even if the file doesn't write"
+ );
+
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_WRITE_ERROR, 1);
+ } finally {
+ AttributionIOUtils.exists = oldExists;
+ AttributionIOUtils.write = oldWrite;
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ sandbox.restore();
+ }
+});
+
+add_task(async function test_unusual_referrer() {
+ // This referrer URL looks malformed, but the malformed bits are dropped, so
+ // it's actually ok. This is what allows extraneous bits like `fbclid` tags
+ // to be ignored.
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(
+ MacAttribution.applicationPath,
+ "https://example.com?content=&=campaign",
+ true
+ );
+
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Try to read the attribution code
+ await AttributionCode.getAttrDataAsync();
+
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should be able to get empty result");
+
+ Assert.deepEqual({}, histogram.snapshot().values || {});
+
+ await assertCacheExistsAndIsEmpty();
+ } finally {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ }
+});
+
+add_task(async function test_blank_referrer() {
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(MacAttribution.applicationPath, "", true);
+
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Try to read the attribution code
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should be able to get empty result");
+
+ Assert.deepEqual({}, histogram.snapshot().values || {});
+
+ await assertCacheExistsAndIsEmpty();
+ } finally {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ }
+});
+
+add_task(async function test_no_referrer() {
+ const sandbox = sinon.createSandbox();
+ let newApplicationPath = MacAttribution.applicationPath + ".test";
+ sandbox.stub(MacAttribution, "applicationPath").get(() => newApplicationPath);
+
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Try to read the attribution code
+ await AttributionCode.getAttrDataAsync();
+
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should be able to get empty result");
+
+ Assert.deepEqual({}, histogram.snapshot().values || {});
+
+ await assertCacheExistsAndIsEmpty();
+ } finally {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ sandbox.restore();
+ }
+});
+
+add_task(async function test_broken_referrer() {
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(
+ MacAttribution.applicationPath,
+ "https://example.com?content=content",
+ true
+ );
+
+ // This uses macOS internals to change the GUID so that it will look like the
+ // application has quarantine data but nothing will be pressent in the
+ // quarantine database. This shouldn't happen in the wild.
+ function generateQuarantineGUID() {
+ let str = Services.uuid.generateUUID().toString().toUpperCase();
+ // Strip {}.
+ return str.substring(1, str.length - 1);
+ }
+
+ // These magic constants are macOS GateKeeper flags.
+ let string = [
+ "01c1",
+ "5991b778",
+ "Safari.app",
+ generateQuarantineGUID(),
+ ].join(";");
+ let bytes = new TextEncoder().encode(string);
+ await IOUtils.setMacXAttr(
+ MacAttribution.applicationPath,
+ "com.apple.quarantine",
+ bytes
+ );
+
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ try {
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Try to read the attribution code
+ await AttributionCode.getAttrDataAsync();
+
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should be able to get empty result");
+
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_QUARANTINE_ERROR, 1);
+ histogram.clear();
+
+ await assertCacheExistsAndIsEmpty();
+ } finally {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ histogram.clear();
+ }
+});
diff --git a/browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js b/browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js
new file mode 100644
index 0000000000..43888a1e74
--- /dev/null
+++ b/browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js
@@ -0,0 +1,101 @@
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+const { AttributionIOUtils } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+
+add_task(async function test_parse_error() {
+ if (AppConstants.platform == "macosx") {
+ // On macOS, the underlying data is the OS-level quarantine
+ // database. We need to start from nothing to isolate the cache.
+ const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+ );
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+ attributionSvc.setReferrerUrl(MacAttribution.applicationPath, "", true);
+ }
+
+ registerCleanupFunction(async () => {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ });
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+ // Delete the file to trigger a read error
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ // Clear any existing telemetry
+ histogram.clear();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ {},
+ "Shouldn't be able to get a result if the file doesn't exist"
+ );
+
+ // Write an invalid file to trigger a decode error
+ // Skip this for MSIX packages though - we can't write or delete
+ // the attribution file there, everything happens in memory instead.
+ if (
+ AppConstants.platform === "win" &&
+ !Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ // Empty string is valid on macOS.
+ await AttributionCode.writeAttributionFile(
+ AppConstants.platform == "macosx" ? "invalid" : ""
+ );
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Should have failed to parse");
+
+ // `assertHistogram` also ensures that `read_error` index 0 is 0
+ // as we should not have recorded telemetry from the previous `getAttrDataAsync` call
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_DECODE_ERROR, 1);
+ // Reset
+ histogram.clear();
+ }
+});
+
+add_task(async function test_read_error() {
+ registerCleanupFunction(async () => {
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ });
+ const histogram = Services.telemetry.getHistogramById(
+ "BROWSER_ATTRIBUTION_ERRORS"
+ );
+
+ // Delete the file to trigger a read error
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ // Clear any existing telemetry
+ histogram.clear();
+
+ // Force the file to exist but then cause a read error
+ let oldExists = AttributionIOUtils.exists;
+ AttributionIOUtils.exists = () => true;
+
+ let oldRead = AttributionIOUtils.read;
+ AttributionIOUtils.read = () => {
+ throw new Error("read_error");
+ };
+
+ registerCleanupFunction(() => {
+ AttributionIOUtils.exists = oldExists;
+ AttributionIOUtils.read = oldRead;
+ });
+
+ // Try to read the file
+ await AttributionCode.getAttrDataAsync();
+
+ // It should record the read error
+ TelemetryTestUtils.assertHistogram(histogram, INDEX_READ_ERROR, 1);
+
+ // Clear any existing telemetry
+ histogram.clear();
+});
diff --git a/browser/components/attribution/test/browser/head.js b/browser/components/attribution/test/browser/head.js
new file mode 100644
index 0000000000..61b24284ee
--- /dev/null
+++ b/browser/components/attribution/test/browser/head.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AttributionCode } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+
+// Keep in sync with `BROWSER_ATTRIBUTION_ERRORS` in Histograms.json.
+const INDEX_READ_ERROR = 0;
+const INDEX_DECODE_ERROR = 1;
+const INDEX_WRITE_ERROR = 2;
+const INDEX_QUARANTINE_ERROR = 3;
+
+add_setup(function () {
+ // AttributionCode._clearCache is only possible in a testing environment
+ Services.env.set("XPCSHELL_TEST_PROFILE_DIR", "testing");
+
+ registerCleanupFunction(() => {
+ Services.env.set("XPCSHELL_TEST_PROFILE_DIR", null);
+ });
+});
diff --git a/browser/components/attribution/test/xpcshell/head.js b/browser/components/attribution/test/xpcshell/head.js
new file mode 100644
index 0000000000..67a93563d4
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/head.js
@@ -0,0 +1,136 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AttributionCode } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+
+let validAttrCodes = [
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set)",
+ parsed: {
+ source: "google.com",
+ medium: "organic",
+ campaign: "(not%20set)",
+ content: "(not%20set)",
+ },
+ },
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set)%26msstoresignedin%3Dtrue",
+ parsed: {
+ source: "google.com",
+ medium: "organic",
+ campaign: "(not%20set)",
+ content: "(not%20set)",
+ msstoresignedin: true,
+ },
+ platforms: ["win"],
+ },
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D%26content%3D",
+ parsed: { source: "google.com", medium: "organic" },
+ doesNotRoundtrip: true, // `campaign=` and `=content` are dropped.
+ },
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)",
+ parsed: {
+ source: "google.com",
+ medium: "organic",
+ campaign: "(not%20set)",
+ },
+ },
+ {
+ code: "source%3Dgoogle.com%26medium%3Dorganic",
+ parsed: { source: "google.com", medium: "organic" },
+ },
+ { code: "source%3Dgoogle.com", parsed: { source: "google.com" } },
+ { code: "medium%3Dgoogle.com", parsed: { medium: "google.com" } },
+ { code: "campaign%3Dgoogle.com", parsed: { campaign: "google.com" } },
+ { code: "content%3Dgoogle.com", parsed: { content: "google.com" } },
+ {
+ code: "experiment%3Dexperimental",
+ parsed: { experiment: "experimental" },
+ },
+ { code: "variation%3Dvaried", parsed: { variation: "varied" } },
+ {
+ code: "ua%3DGoogle%20Chrome%20123",
+ parsed: { ua: "Google%20Chrome%20123" },
+ },
+ {
+ code: "dltoken%3Dc18f86a3-f228-4d98-91bb-f90135c0aa9c",
+ parsed: { dltoken: "c18f86a3-f228-4d98-91bb-f90135c0aa9c" },
+ },
+ {
+ code: "dlsource%3Dsome-dl-source",
+ parsed: {
+ dlsource: "some-dl-source",
+ },
+ },
+];
+
+let invalidAttrCodes = [
+ // Empty string
+ "",
+ // Not escaped
+ "source=google.com&medium=organic&campaign=(not set)&content=(not set)",
+ // Too long
+ "campaign%3D" + "a".repeat(1000),
+ // Unknown key name
+ "source%3Dgoogle.com%26medium%3Dorganic%26large%3Dgeneticallymodified",
+ // Empty key name
+ "source%3Dgoogle.com%26medium%3Dorganic%26%3Dgeneticallymodified",
+];
+
+/**
+ * Arrange for each test to have a unique application path for storing
+ * quarantine data.
+ *
+ * The quarantine data is necessarily a shared system resource, managed by the
+ * OS, so we need to avoid polluting it during tests.
+ *
+ * There are at least two ways to achieve this. Here we use Sinon to stub the
+ * relevant accessors: this has the advantage of being local and relatively easy
+ * to follow. In the App Update Service tests, an `nsIDirectoryServiceProvider`
+ * is installed, which is global and much harder to extract for re-use.
+ */
+async function setupStubs() {
+ // Local imports to avoid polluting the global namespace.
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+
+ // This depends on the caller to invoke it by name. We do try to
+ // prevent the most obvious incorrect invocation, namely
+ // `add_task(setupStubs)`.
+ let caller = Components.stack.caller;
+ const testID = caller.filename.toString().split("/").pop().split(".")[0];
+ notEqual(testID, "head");
+
+ let applicationFile = do_get_tempdir();
+ applicationFile.append(testID);
+ applicationFile.append("App.app");
+
+ if (AppConstants.platform == "macosx") {
+ // We're implicitly using the fact that modules are shared between importers here.
+ const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+ );
+ sinon
+ .stub(MacAttribution, "applicationPath")
+ .get(() => applicationFile.path);
+ }
+
+ // The macOS quarantine database applies to existing paths only, so make
+ // sure our mock application path exists. This also creates the parent
+ // directory for the attribution file, needed on both macOS and Windows. We
+ // don't ignore existing paths because we're inside a temporary directory:
+ // this should never be invoked twice for the same test.
+ await IOUtils.makeDirectory(applicationFile.path, {
+ from: do_get_tempdir().path,
+ });
+}
diff --git a/browser/components/attribution/test/xpcshell/test_AttributionCode.js b/browser/components/attribution/test/xpcshell/test_AttributionCode.js
new file mode 100644
index 0000000000..1296420cd7
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_AttributionCode.js
@@ -0,0 +1,131 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+add_task(async () => {
+ await setupStubs();
+});
+
+/**
+ * Test validation of attribution codes,
+ * to make sure we reject bad ones and accept good ones.
+ */
+add_task(async function testValidAttrCodes() {
+ let currentCode = null;
+ for (let entry of validAttrCodes) {
+ currentCode = entry.code;
+ // Attribution for MSIX builds works quite differently than regular Windows
+ // builds: the attribution codes come from a Windows API that we've wrapped
+ // with an XPCOM class. We don't have a way to inject codes into the build,
+ // so instead we mock out our XPCOM class and return the desired values from
+ // there. (A potential alternative is to have MSIX tests rely on attribution
+ // files, but that more or less invalidates the tests.)
+ if (
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ // In real life, the attribution codes returned from Microsoft APIs
+ // are not URI encoded, and the AttributionCode code that deals with
+ // them expects that - so we have to simulate that as well.
+ sinon
+ .stub(AttributionCode, "msixCampaignId")
+ .get(() => decodeURIComponent(currentCode));
+ } else {
+ await AttributionCode.writeAttributionFile(currentCode);
+ }
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + currentCode
+ );
+ }
+ AttributionCode._clearCache();
+});
+
+/**
+ * Make sure codes with various formatting errors are not seen as valid.
+ */
+add_task(async function testInvalidAttrCodes() {
+ let currentCode = null;
+ for (let code of invalidAttrCodes) {
+ currentCode = code;
+
+ if (
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ if (code.includes("not set")) {
+ // One of the "invalid" codes that we test is an unescaped one.
+ // This is valid for most platforms, but we actually _expect_
+ // unescaped codes for MSIX builds, so that particular test is not
+ // valid for this case.
+ continue;
+ }
+
+ sinon
+ .stub(AttributionCode, "msixCampaignId")
+ .get(() => decodeURIComponent(currentCode));
+ } else {
+ await AttributionCode.writeAttributionFile(currentCode);
+ }
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ {},
+ "Code should have failed to parse: " + currentCode
+ );
+ }
+ AttributionCode._clearCache();
+});
+
+/**
+ * Test the cache by deleting the attribution data file
+ * and making sure we still get the expected code.
+ */
+let condition = {
+ // MSIX attribution codes are not cached by us, thus this test is
+ // unnecessary for those builds.
+ skip_if: () =>
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId"),
+};
+add_task(condition, async function testDeletedFile() {
+ // Set up the test by clearing the cache and writing a valid file.
+ await AttributionCode.writeAttributionFile(validAttrCodes[0].code);
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ validAttrCodes[0].parsed,
+ "The code should be readable directly from the file"
+ );
+
+ // Delete the file and make sure we can still read the value back from cache.
+ await AttributionCode.deleteFileAsync();
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ validAttrCodes[0].parsed,
+ "The code should be readable from the cache"
+ );
+
+ // Clear the cache and check we can't read anything.
+ AttributionCode._clearCache();
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ {},
+ "Shouldn't be able to get a code after file is deleted and cache is cleared"
+ );
+});
diff --git a/browser/components/attribution/test/xpcshell/test_MacAttribution.js b/browser/components/attribution/test/xpcshell/test_MacAttribution.js
new file mode 100644
index 0000000000..078b056dac
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_MacAttribution.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { MacAttribution } = ChromeUtils.importESModule(
+ "resource:///modules/MacAttribution.sys.mjs"
+);
+
+add_task(async () => {
+ await setupStubs();
+});
+
+add_task(async function testValidAttrCodes() {
+ let appPath = MacAttribution.applicationPath;
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+
+ for (let entry of validAttrCodes) {
+ if (entry.platforms && !entry.platforms.includes("mac")) {
+ continue;
+ }
+
+ // Set a url referrer. In the macOS quarantine database, the
+ // referrer URL has components that areURI-encoded. Our test data
+ // URI-encodes the components and also the separators (?, &, =).
+ // So we decode it and re-encode it to leave just the components
+ // URI-encoded.
+ let url = `http://example.com?${encodeURI(decodeURIComponent(entry.code))}`;
+ attributionSvc.setReferrerUrl(appPath, url, true);
+ let referrer = await MacAttribution.getReferrerUrl(appPath);
+ equal(referrer, url, "overwrite referrer url");
+
+ // Read attribution code from referrer.
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+
+ // Read attribution code from file.
+ AttributionCode._clearCache();
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+
+ // Does not overwrite cached existing attribution code.
+ attributionSvc.setReferrerUrl(appPath, "http://test.com", false);
+ referrer = await MacAttribution.getReferrerUrl(appPath);
+ equal(referrer, url, "update referrer url");
+
+ result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+ }
+});
+
+add_task(async function testInvalidAttrCodes() {
+ let appPath = MacAttribution.applicationPath;
+ let attributionSvc = Cc["@mozilla.org/mac-attribution;1"].getService(
+ Ci.nsIMacAttributionService
+ );
+
+ for (let code of invalidAttrCodes) {
+ // Set a url referrer. Not all of these invalid codes can be represented
+ // in the quarantine database; skip those ones.
+ let url = `http://example.com?${code}`;
+ let referrer;
+ try {
+ attributionSvc.setReferrerUrl(appPath, url, true);
+ referrer = await MacAttribution.getReferrerUrl(appPath);
+ } catch (ex) {
+ continue;
+ }
+ if (!referrer) {
+ continue;
+ }
+ equal(referrer, url, "overwrite referrer url");
+
+ // Read attribution code from referrer.
+ await AttributionCode.deleteFileAsync();
+ AttributionCode._clearCache();
+ let result = await AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {}, "Code should have failed to parse: " + code);
+ }
+});
diff --git a/browser/components/attribution/test/xpcshell/test_attribution_parsing.js b/browser/components/attribution/test/xpcshell/test_attribution_parsing.js
new file mode 100644
index 0000000000..abf2456ce4
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_attribution_parsing.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+/**
+ * This test file exists to be run on any platform during development,
+ * whereas the test_AttributionCode.js will test the attribution file
+ * in the app local data dir on Windows. It will only run under
+ * Windows on try.
+ */
+
+/**
+ * Test validation of attribution codes.
+ */
+add_task(async function testValidAttrCodes() {
+ for (let entry of validAttrCodes) {
+ let result = AttributionCode.parseAttributionCode(entry.code);
+ Assert.deepEqual(
+ result,
+ entry.parsed,
+ "Parsed code should match expected value, code was: " + entry.code
+ );
+
+ result = AttributionCode.serializeAttributionData(entry.parsed);
+ if (!entry.doesNotRoundtrip) {
+ Assert.deepEqual(
+ result,
+ entry.code,
+ "Serialized data should match expected value, code was: " + entry.code
+ );
+ }
+ }
+});
+
+/**
+ * Make sure codes with various formatting errors are not seen as valid.
+ */
+add_task(async function testInvalidAttrCodes() {
+ for (let code of invalidAttrCodes) {
+ let result = AttributionCode.parseAttributionCode(code);
+ Assert.deepEqual(result, {}, "Code should have failed to parse: " + code);
+ }
+});
diff --git a/browser/components/attribution/test/xpcshell/test_zoneId_parsing.js b/browser/components/attribution/test/xpcshell/test_zoneId_parsing.js
new file mode 100644
index 0000000000..2cab8678fd
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/test_zoneId_parsing.js
@@ -0,0 +1,538 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+/**
+ * Test that we can properly parse and validate `zoneIdProvenanceData`.
+ */
+
+const { AttributionIOUtils } = ChromeUtils.importESModule(
+ "resource:///modules/AttributionCode.sys.mjs"
+);
+const { ProvenanceData } = ChromeUtils.importESModule(
+ "resource:///modules/ProvenanceData.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_setup(function setup() {
+ let origReadUTF8 = AttributionIOUtils.readUTF8;
+ registerCleanupFunction(() => {
+ AttributionIOUtils.readUTF8 = origReadUTF8;
+ });
+});
+
+// If `iniFileContents` is passed as `null`, we will simulate an error reading
+// the INI.
+async function testProvenance(iniFileContents, testFn, telemetryTestFn) {
+ // The extra data returned by `ProvenanceData.submitProvenanceTelemetry` uses
+ // names that don't actually match the scalar names due to name length
+ // restrictions.
+ let scalarToExtraKeyMap = {
+ "attribution.provenance.data_exists": "data_exists",
+ "attribution.provenance.file_system": "file_system",
+ "attribution.provenance.ads_exists": "ads_exists",
+ "attribution.provenance.security_zone": "security_zone",
+ "attribution.provenance.referrer_url_exists": "refer_url_exist",
+ "attribution.provenance.referrer_url_is_mozilla": "refer_url_moz",
+ "attribution.provenance.host_url_exists": "host_url_exist",
+ "attribution.provenance.host_url_is_mozilla": "host_url_moz",
+ };
+
+ if (iniFileContents == null) {
+ AttributionIOUtils.readUTF8 = async path => {
+ throw new Error("test error: simulating provenance file read error");
+ };
+ } else {
+ AttributionIOUtils.readUTF8 = async path => iniFileContents;
+ }
+ let provenance = await ProvenanceData.readZoneIdProvenanceFile();
+ if (testFn) {
+ await testFn(provenance);
+ }
+ if (telemetryTestFn) {
+ Services.telemetry.clearScalars();
+ let extras = await ProvenanceData.submitProvenanceTelemetry();
+ let scalars = Services.telemetry.getSnapshotForScalars(
+ "new-profile",
+ false /* aClear */
+ ).parent;
+ let checkScalar = (scalarName, expectedValue) => {
+ TelemetryTestUtils.assertScalar(scalars, scalarName, expectedValue);
+ let extraKey = scalarToExtraKeyMap[scalarName];
+ Assert.equal(extras[extraKey], expectedValue.toString());
+ };
+ telemetryTestFn(checkScalar);
+ }
+ ProvenanceData._clearCache();
+}
+
+add_task(async function unableToReadFile() {
+ await testProvenance(
+ null,
+ provenance => {
+ Assert.ok("readProvenanceError" in provenance);
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.data_exists", false);
+ }
+ );
+});
+
+add_task(async function expectedMozilla() {
+ await testProvenance(
+ `
+[Mozilla]
+fileSystem=NTFS
+zoneIdFileSize=194
+zoneIdBufferLargeEnough=true
+zoneIdTruncated=false
+
+[MozillaZoneIdentifierStartSentinel]
+[ZoneTransfer]
+ZoneId=3
+ReferrerUrl=https://mozilla.org/
+HostUrl=https://download-installer.cdn.mozilla.net/pub/firefox/nightly/latest-mozilla-central-l10n/Firefox%20Installer.en-US.exe
+`,
+ provenance => {
+ Assert.deepEqual(provenance, {
+ fileSystem: "NTFS",
+ zoneIdFileSize: 194,
+ zoneIdBufferLargeEnough: true,
+ zoneIdTruncated: false,
+ zoneId: 3,
+ referrerUrl: "https://mozilla.org/",
+ hostUrl:
+ "https://download-installer.cdn.mozilla.net/pub/firefox/nightly/latest-mozilla-central-l10n/Firefox%20Installer.en-US.exe",
+ referrerUrlIsMozilla: true,
+ hostUrlIsMozilla: true,
+ });
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.data_exists", true);
+ checkScalar("attribution.provenance.file_system", "NTFS");
+ checkScalar("attribution.provenance.ads_exists", true);
+ checkScalar("attribution.provenance.security_zone", "3");
+ checkScalar("attribution.provenance.referrer_url_exists", true);
+ checkScalar("attribution.provenance.referrer_url_is_mozilla", true);
+ checkScalar("attribution.provenance.host_url_exists", true);
+ checkScalar("attribution.provenance.host_url_is_mozilla", true);
+ }
+ );
+});
+
+add_task(async function expectedNonMozilla() {
+ await testProvenance(
+ `
+[ZoneTransfer]
+ReferrerUrl=https://mozilla.foobar.org/
+HostUrl=https://download-installer.cdn.mozilla.foobar.net/pub/firefox/nightly/latest-mozilla-central-l10n/Firefox%20Installer.en-US.exe
+`,
+ provenance => {
+ Assert.equal(provenance.referrerUrlIsMozilla, false);
+ Assert.equal(provenance.hostUrlIsMozilla, false);
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.referrer_url_exists", true);
+ checkScalar("attribution.provenance.referrer_url_is_mozilla", false);
+ checkScalar("attribution.provenance.host_url_exists", true);
+ checkScalar("attribution.provenance.host_url_is_mozilla", false);
+ }
+ );
+});
+
+add_task(async function readFsError() {
+ await testProvenance(
+ `
+[Mozilla]
+readFsError=openFile
+readFsErrorCode=1234
+`,
+ provenance => {
+ Assert.equal(provenance.readFsError, "openFile");
+ Assert.equal(provenance.readFsErrorCode, 1234);
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.file_system", "error");
+ }
+ );
+});
+
+add_task(async function unexpectedReadFsError() {
+ await testProvenance(
+ `
+[Mozilla]
+readFsError=openFile
+readFsErrorCode=bazqux
+`,
+ provenance => {
+ Assert.equal(provenance.readFsError, "openFile");
+ Assert.ok(!("readFsErrorCode" in provenance));
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.file_system", "error");
+ }
+ );
+});
+
+add_task(async function unexpectedReadFsError() {
+ await testProvenance(
+ `
+[Mozilla]
+readFsError=foobar
+`,
+ provenance => {
+ Assert.equal(provenance.readFsError, "unexpected");
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.file_system", "error");
+ }
+ );
+});
+
+add_task(async function missingFileSystem() {
+ await testProvenance(
+ ``,
+ provenance => {
+ Assert.equal(provenance.fileSystem, "missing");
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.file_system", "missing");
+ }
+ );
+});
+
+add_task(async function fileSystem() {
+ await testProvenance(
+ `
+[Mozilla]
+fileSystem=ntfs
+`,
+ provenance => {
+ Assert.equal(provenance.fileSystem, "NTFS");
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.file_system", "NTFS");
+ }
+ );
+});
+
+add_task(async function unexpectedFileSystem() {
+ await testProvenance(
+ `
+[Mozilla]
+fileSystem=foobar
+`,
+ provenance => {
+ Assert.equal(provenance.fileSystem, "other");
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.file_system", "other");
+ }
+ );
+});
+
+add_task(async function zoneIdFileSize() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdFileSize=1234
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdFileSize, 1234);
+ },
+ null
+ );
+});
+
+add_task(async function unknownZoneIdFileSize() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdFileSize=unknown
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdFileSize, "unknown");
+ },
+ null
+ );
+});
+
+add_task(async function unexpectedZoneIdFileSize() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdFileSize=foobar
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdFileSize, "unexpected");
+ },
+ null
+ );
+});
+
+add_task(async function missingZoneIdFileSize() {
+ await testProvenance(
+ ``,
+ provenance => {
+ Assert.ok(!("zoneIdFileSize" in provenance));
+ },
+ null
+ );
+});
+
+add_task(async function zoneIdBufferLargeEnoughTrue() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdBufferLargeEnough=TrUe
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdBufferLargeEnough, true);
+ },
+ null
+ );
+});
+
+add_task(async function zoneIdBufferLargeEnoughFalse() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdBufferLargeEnough=FaLsE
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdBufferLargeEnough, false);
+ },
+ null
+ );
+});
+
+add_task(async function unknownZoneIdBufferLargeEnough() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdBufferLargeEnough=unknown
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdBufferLargeEnough, "unknown");
+ },
+ null
+ );
+});
+
+add_task(async function unknownZoneIdBufferLargeEnough() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdBufferLargeEnough=foobar
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdBufferLargeEnough, "unexpected");
+ },
+ null
+ );
+});
+
+add_task(async function missingZoneIdBufferLargeEnough() {
+ await testProvenance(``, provenance => {
+ Assert.ok(!("zoneIdBufferLargeEnough" in provenance));
+ });
+});
+
+add_task(async function zoneIdTruncatedTrue() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdTruncated=TrUe
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdTruncated, true);
+ },
+ null
+ );
+});
+
+add_task(async function zoneIdTruncatedFalse() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdTruncated=FaLsE
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdTruncated, false);
+ },
+ null
+ );
+});
+
+add_task(async function unknownZoneIdTruncated() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdTruncated=unknown
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdTruncated, "unknown");
+ },
+ null
+ );
+});
+
+add_task(async function unexpectedZoneIdTruncated() {
+ await testProvenance(
+ `
+[Mozilla]
+zoneIdTruncated=foobar
+`,
+ provenance => {
+ Assert.equal(provenance.zoneIdTruncated, "unexpected");
+ },
+ null
+ );
+});
+
+add_task(async function missingZoneIdTruncated() {
+ await testProvenance(``, provenance => {
+ Assert.ok(!("zoneIdTruncated" in provenance));
+ });
+});
+
+add_task(async function readZoneIdError() {
+ await testProvenance(
+ `
+[Mozilla]
+readZoneIdError=openFile
+readZoneIdErrorCode=1234
+`,
+ provenance => {
+ Assert.equal(provenance.readZoneIdError, "openFile");
+ Assert.equal(provenance.readZoneIdErrorCode, 1234);
+ },
+ null
+ );
+});
+
+add_task(async function unexpectedReadZoneIdErrorCode() {
+ await testProvenance(
+ `
+[Mozilla]
+readZoneIdError=openFile
+readZoneIdErrorCode=bazqux
+`,
+ provenance => {
+ Assert.equal(provenance.readZoneIdError, "openFile");
+ Assert.ok(!("readZoneIdErrorCode" in provenance));
+ },
+ null
+ );
+});
+
+add_task(async function noAdsOnInstaller() {
+ await testProvenance(
+ `
+[Mozilla]
+readZoneIdError=openFile
+readZoneIdErrorCode=2
+`,
+ provenance => {
+ Assert.equal(provenance.readZoneIdError, "openFile");
+ Assert.equal(provenance.readZoneIdErrorCode, 2);
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.ads_exists", false);
+ }
+ );
+});
+
+add_task(async function unexpectedReadZoneIdError() {
+ await testProvenance(
+ `
+[Mozilla]
+readZoneIdError=foobar
+`,
+ provenance => {
+ Assert.equal(provenance.readZoneIdError, "unexpected");
+ },
+ null
+ );
+});
+
+add_task(async function missingZoneId() {
+ await testProvenance(
+ ``,
+ provenance => {
+ Assert.equal(provenance.zoneId, "missing");
+ },
+ null
+ );
+});
+
+add_task(async function unexpectedZoneId() {
+ await testProvenance(
+ `
+[ZoneTransfer]
+ZoneId=9999999999
+`,
+ provenance => {
+ Assert.equal(provenance.zoneId, "unexpected");
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.ads_exists", true);
+ checkScalar("attribution.provenance.security_zone", "unexpected");
+ }
+ );
+});
+
+add_task(async function missingReferrerUrl() {
+ await testProvenance(
+ ``,
+ provenance => {
+ Assert.equal(provenance.referrerUrl, "missing");
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.ads_exists", true);
+ checkScalar("attribution.provenance.referrer_url_exists", false);
+ }
+ );
+});
+
+add_task(async function unexpectedReferrerUrl() {
+ await testProvenance(
+ `
+[ZoneTransfer]
+ReferrerUrl=foobar
+`,
+ provenance => {
+ Assert.equal(provenance.referrerUrl, "unexpected");
+ },
+ null
+ );
+});
+
+add_task(async function missingHostUrl() {
+ await testProvenance(
+ ``,
+ provenance => {
+ Assert.equal(provenance.hostUrl, "missing");
+ },
+ checkScalar => {
+ checkScalar("attribution.provenance.ads_exists", true);
+ checkScalar("attribution.provenance.referrer_url_exists", false);
+ }
+ );
+});
+
+add_task(async function unexpectedHostUrl() {
+ await testProvenance(
+ `
+[ZoneTransfer]
+HostUrl=foobar
+`,
+ provenance => {
+ Assert.equal(provenance.hostUrl, "unexpected");
+ },
+ null
+ );
+});
diff --git a/browser/components/attribution/test/xpcshell/xpcshell.ini b/browser/components/attribution/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..0ab5f6b0cb
--- /dev/null
+++ b/browser/components/attribution/test/xpcshell/xpcshell.ini
@@ -0,0 +1,11 @@
+[DEFAULT]
+firefox-appdir = browser
+skip-if = (os != "win" && toolkit != "cocoa") # Only available on Windows and macOS
+head = head.js
+
+[test_AttributionCode.js]
+[test_MacAttribution.js]
+skip-if = toolkit != "cocoa" # osx specific tests
+[test_attribution_parsing.js]
+[test_zoneId_parsing.js]
+run-if = os == 'win' # Feature only available on Windows