diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/attribution | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/attribution')
15 files changed, 1436 insertions, 0 deletions
diff --git a/browser/components/attribution/AttributionCode.jsm b/browser/components/attribution/AttributionCode.jsm new file mode 100644 index 0000000000..59fe86920f --- /dev/null +++ b/browser/components/attribution/AttributionCode.jsm @@ -0,0 +1,402 @@ +/* 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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["AttributionCode"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "AppConstants", + "resource://gre/modules/AppConstants.jsm" +); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); +ChromeUtils.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "MacAttribution", + "resource:///modules/MacAttribution.jsm" +); +XPCOMUtils.defineLazyGetter(this, "log", () => { + let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {}) + .ConsoleAPI; + let consoleOptions = { + // tip: set maxLogLevel to "debug" and use log.debug() to create detailed + // messages during development. See LOG_LEVELS in Console.jsm for details. + maxLogLevel: "error", + maxLogLevelPref: "browser.attribution.loglevel", + prefix: "AttributionCode", + }; + return new ConsoleAPI(consoleOptions); +}); +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +const ATTR_CODE_MAX_LENGTH = 1010; +const ATTR_CODE_VALUE_REGEX = /[a-zA-Z0-9_%\\-\\.\\(\\)]*/; +const ATTR_CODE_FIELD_SEPARATOR = "%26"; // URL-encoded & +const ATTR_CODE_KEY_VALUE_SEPARATOR = "%3D"; // URL-encoded = +const ATTR_CODE_KEYS = [ + "source", + "medium", + "campaign", + "content", + "experiment", + "variation", + "ua", + "dltoken", +]; + +let gCachedAttrData = null; + +var AttributionCode = { + /** + * Returns a platform-specific nsIFile for the file containing the attribution + * data, or null if the current platform does not support (caching) + * attribution data. + */ + get attributionFile() { + if (AppConstants.platform == "win") { + let file = Services.dirsvc.get("LocalAppData", Ci.nsIFile); + // appinfo does not exist in xpcshell, so we need defaults. + file.append(Services.appinfo.vendor || "mozilla"); + file.append(AppConstants.MOZ_APP_NAME); + file.append("postSigningData"); + return file; + } else if (AppConstants.platform == "macosx") { + // There's no `UpdRootD` in xpcshell tests. Some existing tests override + // it, which is onerous and difficult to share across tests. When testing, + // if it's not defined, fallback to a nested subdirectory of the xpcshell + // temp directory. Nesting more closely replicates the situation where the + // update directory does not (yet) exist, testing a scenario witnessed in + // development. + let file; + try { + file = Services.dirsvc.get("UpdRootD", Ci.nsIFile); + } catch (ex) { + let env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + // It's most common to test for the profile dir, even though we actually + // are using the temp dir. + if ( + ex instanceof Ci.nsIException && + ex.result == Cr.NS_ERROR_FAILURE && + env.exists("XPCSHELL_TEST_PROFILE_DIR") + ) { + let path = env.get("XPCSHELL_TEST_TEMP_DIR"); + file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(path); + file.append("nested_UpdRootD_1"); + file.append("nested_UpdRootD_2"); + } else { + throw ex; + } + } + file.append("macAttributionData"); + return file; + } + + return null; + }, + + /** + * Write the given attribution code to the attribution file. + * @param {String} code to write. + */ + async writeAttributionFile(code) { + let file = AttributionCode.attributionFile; + let dir = file.parent; + try { + // This is a simple way to create the entire directory tree. + // `OS.File.makeDir` has an awkward API for our situation. + dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) { + throw ex; + } + // Ignore the exception due to a directory that already exists. + } + let bytes = new TextEncoder().encode(code); + await OS.File.writeAtomic(file.path, bytes); + }, + + /** + * Returns an array of allowed attribution code keys. + */ + get allowedCodeKeys() { + return [...ATTR_CODE_KEYS]; + }, + + /** + * Returns an object containing a key-value pair for each piece of attribution + * data included in the passed-in attribution code string. + * If the string isn't a valid attribution code, returns an empty object. + */ + parseAttributionCode(code) { + if (code.length > ATTR_CODE_MAX_LENGTH) { + return {}; + } + + let isValid = true; + let parsed = {}; + for (let param of code.split(ATTR_CODE_FIELD_SEPARATOR)) { + let [key, value] = param.split(ATTR_CODE_KEY_VALUE_SEPARATOR, 2); + if (key && ATTR_CODE_KEYS.includes(key)) { + if (value && ATTR_CODE_VALUE_REGEX.test(value)) { + parsed[key] = value; + } + } else { + log.debug( + `parseAttributionCode: "${code}" => isValid = false: "${key}", "${value}"` + ); + isValid = false; + break; + } + } + + if (isValid) { + return parsed; + } + + Services.telemetry + .getHistogramById("BROWSER_ATTRIBUTION_ERRORS") + .add("decode_error"); + + return {}; + }, + + /** + * Returns an object containing a key-value pair for each piece of attribution + * data included in the passed-in URL containing a query string encoding an + * attribution code. + * + * We have less control of the attribution codes on macOS so we accept more + * URLs than we accept attribution codes on Windows. + * + * If the URL is empty, returns an empty object. + * + * If the URL doesn't parse, throws. + */ + parseAttributionCodeFromUrl(url) { + if (!url) { + return {}; + } + + let parsed = {}; + + let params = new URL(url).searchParams; + for (let key of ATTR_CODE_KEYS) { + // We support the key prefixed with utm_ or not, but intentionally + // choose non-utm params over utm params. + for (let paramKey of [`utm_${key}`, `funnel_${key}`, key]) { + if (params.has(paramKey)) { + // We expect URI-encoded components in our attribution codes. + let value = encodeURIComponent(params.get(paramKey)); + if (value && ATTR_CODE_VALUE_REGEX.test(value)) { + parsed[key] = value; + } + } + } + } + + return parsed; + }, + + /** + * Returns a string serializing the given attribution data. + * + * It is expected that the given values are already URL-encoded. + */ + serializeAttributionData(data) { + // Iterating in this way makes the order deterministic. + let s = ""; + for (let key of ATTR_CODE_KEYS) { + if (key in data) { + let value = data[key]; + if (s) { + s += ATTR_CODE_FIELD_SEPARATOR; // URL-encoded & + } + s += `${key}${ATTR_CODE_KEY_VALUE_SEPARATOR}${value}`; // URL-encoded = + } + } + return s; + }, + + /** + * Reads the attribution code, either from disk or a cached version. + * Returns a promise that fulfills with an object containing the parsed + * attribution data if the code could be read and is valid, + * or an empty object otherwise. + * + * On windows the attribution service converts utm_* keys, removing "utm_". + * On OSX the attributions are set directly on download and retain "utm_". We + * strip "utm_" while retrieving the params. + */ + async getAttrDataAsync() { + if (gCachedAttrData != null) { + log.debug( + `getAttrDataAsync: attribution is cached: ${JSON.stringify( + gCachedAttrData + )}` + ); + return gCachedAttrData; + } + + gCachedAttrData = {}; + let attributionFile = this.attributionFile; + if (!attributionFile) { + // This platform doesn't support attribution. + log.debug(`getAttrDataAsync: no attribution (attributionFile is null)`); + return gCachedAttrData; + } + + if ( + AppConstants.platform == "macosx" && + !(await OS.File.exists(attributionFile.path)) + ) { + log.debug( + `getAttrDataAsync: macOS && !exists("${attributionFile.path}")` + ); + + // On macOS, we fish the attribution data from the system quarantine DB. + try { + let referrer = await MacAttribution.getReferrerUrl(); + log.debug( + `getAttrDataAsync: macOS attribution getReferrerUrl: "${referrer}"` + ); + + gCachedAttrData = this.parseAttributionCodeFromUrl(referrer); + } catch (ex) { + // Avoid partial attribution data. + gCachedAttrData = {}; + + // No attributions. Just `warn` 'cuz this isn't necessarily an error. + log.warn("Caught exception fetching macOS attribution codes!", ex); + + if ( + ex instanceof Ci.nsIException && + ex.result == Cr.NS_ERROR_UNEXPECTED + ) { + // Bad quarantine data. + Services.telemetry + .getHistogramById("BROWSER_ATTRIBUTION_ERRORS") + .add("quarantine_error"); + } + } + + log.debug(`macOS attribution data is ${JSON.stringify(gCachedAttrData)}`); + + // We only want to try to fetch the referrer from the quarantine + // database once on macOS. + try { + let code = this.serializeAttributionData(gCachedAttrData); + log.debug(`macOS attribution data serializes as "${code}"`); + await this.writeAttributionFile(code); + } catch (ex) { + log.debug(`Caught exception writing "${attributionFile.path}"`, ex); + Services.telemetry + .getHistogramById("BROWSER_ATTRIBUTION_ERRORS") + .add("write_error"); + return gCachedAttrData; + } + + log.debug( + `Returning after successfully writing "${attributionFile.path}"` + ); + return gCachedAttrData; + } + + log.debug(`getAttrDataAsync: !macOS || !exists("${attributionFile.path}")`); + + let bytes; + try { + bytes = await OS.File.read(attributionFile.path); + } catch (ex) { + if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) { + log.debug( + `getAttrDataAsync: !exists("${ + attributionFile.path + }"), returning ${JSON.stringify(gCachedAttrData)}` + ); + return gCachedAttrData; + } + Services.telemetry + .getHistogramById("BROWSER_ATTRIBUTION_ERRORS") + .add("read_error"); + } + if (bytes) { + try { + let decoder = new TextDecoder(); + let code = decoder.decode(bytes); + log.debug( + `getAttrDataAsync: ${attributionFile.path} deserializes to ${code}` + ); + if (AppConstants.platform == "macosx" && !code) { + // On macOS, an empty attribution code is fine. (On Windows, that + // means the stub/full installer has been incorrectly attributed, + // which is an error.) + return gCachedAttrData; + } + + gCachedAttrData = this.parseAttributionCode(code); + log.debug( + `getAttrDataAsync: ${code} parses to ${JSON.stringify( + gCachedAttrData + )}` + ); + } catch (ex) { + // TextDecoder can throw an error + Services.telemetry + .getHistogramById("BROWSER_ATTRIBUTION_ERRORS") + .add("decode_error"); + } + } + + return gCachedAttrData; + }, + + /** + * Return the cached attribution data synchronously without hitting + * the disk. + * @returns A dictionary with the attribution data if it's available, + * null otherwise. + */ + getCachedAttributionData() { + return gCachedAttrData; + }, + + /** + * Deletes the attribution data file. + * Returns a promise that resolves when the file is deleted, + * or if the file couldn't be deleted (the promise is never rejected). + */ + async deleteFileAsync() { + try { + await OS.File.remove(this.attributionFile.path); + } catch (ex) { + // The attribution file may already have been deleted, + // or it may have never been installed at all; + // failure to delete it isn't an error. + } + }, + + /** + * Clears the cached attribution code value, if any. + * Does nothing if called from outside of an xpcshell test. + */ + _clearCache() { + let env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + if (env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + gCachedAttrData = null; + } + }, +}; diff --git a/browser/components/attribution/MacAttribution.jsm b/browser/components/attribution/MacAttribution.jsm new file mode 100644 index 0000000000..e42c5b0209 --- /dev/null +++ b/browser/components/attribution/MacAttribution.jsm @@ -0,0 +1,186 @@ +/* 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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["MacAttribution"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyGetter(this, "log", () => { + let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {}) + .ConsoleAPI; + let consoleOptions = { + // tip: set maxLogLevel to "debug" and use log.debug() to create detailed + // messages during development. See LOG_LEVELS in Console.jsm for details. + maxLogLevel: "error", + maxLogLevelPref: "browser.attribution.mac.loglevel", + prefix: "MacAttribution", + }; + return new ConsoleAPI(consoleOptions); +}); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); +ChromeUtils.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "Subprocess", + "resource://gre/modules/Subprocess.jsm" +); + +/** + * Get the location of the user's macOS quarantine database. + * @return {String} path. + */ +function getQuarantineDatabasePath() { + let file = Services.dirsvc.get("Home", Ci.nsIFile); + file.append("Library"); + file.append("Preferences"); + file.append("com.apple.LaunchServices.QuarantineEventsV2"); + return file.path; +} + +/** + * Query given path for quarantine extended attributes. + * @param {String} path of the file to query. + * @return {[String, String]} pair of the quarantine data GUID and remaining + * quarantine data (usually, Gatekeeper flags). + * @throws NS_ERROR_NOT_AVAILABLE if there is no quarantine GUID for the given path. + * @throws NS_ERROR_UNEXPECTED if there is a quarantine GUID, but it is malformed. + */ +async function getQuarantineAttributes(path) { + let bytes = await OS.File.macGetXAttr(path, "com.apple.quarantine"); + if (!bytes) { + throw new Components.Exception( + `No macOS quarantine xattrs found for ${path}`, + Cr.NS_ERROR_NOT_AVAILABLE + ); + } + + let string = new TextDecoder("utf-8").decode(bytes); + let parts = string.split(";"); + if (!parts.length) { + throw new Components.Exception( + `macOS quarantine data is not ; separated`, + Cr.NS_ERROR_UNEXPECTED + ); + } + let guid = parts[parts.length - 1]; + if (guid.length != 36) { + // Like "12345678-90AB-CDEF-1234-567890ABCDEF". + throw new Components.Exception( + `macOS quarantine data guid is not length 36: ${guid.length}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + return { guid, parts }; +} + +/** + * Invoke system SQLite binary to extract the referrer URL corresponding to + * the given GUID from the given macOS quarantine database. + * @param {String} path of the user's macOS quarantine database. + * @param {String} guid to query. + * @return {String} referrer URL. + */ +async function queryQuarantineDatabase( + guid, + path = getQuarantineDatabasePath() +) { + let query = `SELECT COUNT(*), LSQuarantineOriginURLString + FROM LSQuarantineEvent + WHERE LSQuarantineEventIdentifier = '${guid}' + ORDER BY LSQuarantineTimeStamp DESC LIMIT 1`; + + let proc = await Subprocess.call({ + command: "/usr/bin/sqlite3", + arguments: [path, query], + environment: {}, + stderr: "stdout", + }); + + let stdout = await proc.stdout.readString(); + + let { exitCode } = await proc.wait(); + if (exitCode != 0) { + throw new Components.Exception( + "Failed to run sqlite3", + Cr.NS_ERROR_UNEXPECTED + ); + } + + // Output is like "integer|url". + let parts = stdout.split("|", 2); + if (parts.length != 2) { + throw new Components.Exception( + "Failed to parse sqlite3 output", + Cr.NS_ERROR_UNEXPECTED + ); + } + + if (parts[0].trim() == "0") { + throw new Components.Exception( + `Quarantine database does not contain URL for guid ${guid}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + return parts[1].trim(); +} + +var MacAttribution = { + /** + * The file path to the `.app` directory. + */ + get applicationPath() { + // On macOS, `GreD` is like "App.app/Contents/macOS". Return "App.app". + return Services.dirsvc.get("GreD", Ci.nsIFile).parent.parent.path; + }, + + /** + * Used by the Attributions system to get the download referrer. + * + * @param {String} path to get the quarantine data from. + * Usually this is a `.app` directory but can be any + * (existing) file or directory. Default: `this.applicationPath`. + * @return {String} referrer URL. + * @throws NS_ERROR_NOT_AVAILABLE if there is no quarantine GUID for the given path. + * @throws NS_ERROR_UNEXPECTED if there is a quarantine GUID, but no corresponding referrer URL is known. + */ + async getReferrerUrl(path = this.applicationPath) { + log.debug(`getReferrerUrl(${JSON.stringify(path)})`); + + // First, determine the quarantine GUID assigned by macOS to the given path. + let guid; + try { + guid = (await getQuarantineAttributes(path)).guid; + } catch (ex) { + throw new Components.Exception( + `No macOS quarantine GUID found for ${path}`, + Cr.NS_ERROR_NOT_AVAILABLE + ); + } + log.debug(`getReferrerUrl: guid: ${guid}`); + + // Second, fish the relevant record from the quarantine database. + let url = ""; + try { + url = await queryQuarantineDatabase(guid); + log.debug(`getReferrerUrl: url: ${url}`); + } catch (ex) { + // This path is known to macOS but we failed to extract a referrer -- be noisy. + throw new Components.Exception( + `No macOS quarantine referrer URL found for ${path} with GUID ${guid}`, + Cr.NS_ERROR_UNEXPECTED + ); + } + + return url; + }, +}; diff --git a/browser/components/attribution/moz.build b/browser/components/attribution/moz.build new file mode 100644 index 0000000000..9d8a560eda --- /dev/null +++ b/browser/components/attribution/moz.build @@ -0,0 +1,37 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Telemetry") + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"] + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"] + +EXTRA_JS_MODULES += [ + "AttributionCode.jsm", +] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa": + XPIDL_SOURCES += [ + "nsIMacAttribution.idl", + ] + + XPIDL_MODULE = "attribution" + + EXPORTS += [ + "nsMacAttribution.h", + ] + + SOURCES += [ + "nsMacAttribution.cpp", + ] + + FINAL_LIBRARY = "browsercomps" + + EXTRA_JS_MODULES += [ + "MacAttribution.jsm", + ] diff --git a/browser/components/attribution/nsIMacAttribution.idl b/browser/components/attribution/nsIMacAttribution.idl new file mode 100644 index 0000000000..5bd1818817 --- /dev/null +++ b/browser/components/attribution/nsIMacAttribution.idl @@ -0,0 +1,23 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +[scriptable, uuid(6FC66A78-6CBC-4B3F-B7BA-379289B29276)] +interface nsIMacAttributionService : nsISupports +{ + /** + * Set the referrer URL on a given path. + * + * @param aFilePath A path to the file to set the quarantine data on. + * @param aReferrer A url to set as the referrer for the download. + * @param aCreate If true, creates new quarantine properties, overwriting + * any existing properties. If false, the referrer is only + * set if quarantine properties already exist on the file. + */ + void setReferrerUrl(in AUTF8String aFilePath, + in AUTF8String aReferrer, + in boolean aCreate); +}; diff --git a/browser/components/attribution/nsMacAttribution.cpp b/browser/components/attribution/nsMacAttribution.cpp new file mode 100644 index 0000000000..7397285794 --- /dev/null +++ b/browser/components/attribution/nsMacAttribution.cpp @@ -0,0 +1,51 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsMacAttribution.h" + +#include <CoreFoundation/CoreFoundation.h> +#include <ApplicationServices/ApplicationServices.h> + +#include "../../../xpcom/io/CocoaFileUtils.h" +#include "nsCocoaFeatures.h" +#include "nsString.h" + +using namespace mozilla; + +NS_IMPL_ISUPPORTS(nsMacAttributionService, nsIMacAttributionService) + +NS_IMETHODIMP +nsMacAttributionService::SetReferrerUrl(const nsACString& aFilePath, + const nsACString& aReferrerUrl, + const bool aCreate) { + const nsCString& flat = PromiseFlatCString(aFilePath); + CFStringRef filePath = ::CFStringCreateWithCString( + kCFAllocatorDefault, flat.get(), kCFStringEncodingUTF8); + + if (!filePath) { + return NS_ERROR_UNEXPECTED; + } + + const nsCString& flatReferrer = PromiseFlatCString(aReferrerUrl); + CFStringRef referrer = ::CFStringCreateWithCString( + kCFAllocatorDefault, flatReferrer.get(), kCFStringEncodingUTF8); + if (!referrer) { + ::CFRelease(filePath); + return NS_ERROR_UNEXPECTED; + } + CFURLRef referrerURL = + ::CFURLCreateWithString(kCFAllocatorDefault, referrer, nullptr); + + CocoaFileUtils::AddQuarantineMetadataToFile(filePath, NULL, referrerURL, true, + aCreate); + + ::CFRelease(filePath); + ::CFRelease(referrer); + if (referrerURL) { + ::CFRelease(referrerURL); + } + + return NS_OK; +} diff --git a/browser/components/attribution/nsMacAttribution.h b/browser/components/attribution/nsMacAttribution.h new file mode 100644 index 0000000000..9ccbee4ea4 --- /dev/null +++ b/browser/components/attribution/nsMacAttribution.h @@ -0,0 +1,22 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#ifndef nsmacattribution_h____ +#define nsmacattribution_h____ + +#include "nsIMacAttribution.h" + +class nsMacAttributionService : public nsIMacAttributionService { + public: + nsMacAttributionService(){}; + + NS_DECL_ISUPPORTS + NS_DECL_NSIMACATTRIBUTIONSERVICE + + protected: + virtual ~nsMacAttributionService(){}; +}; + +#endif // nsmacattribution_h____ diff --git a/browser/components/attribution/test/browser/browser.ini b/browser/components/attribution/test/browser/browser.ini new file mode 100644 index 0000000000..55050ac799 --- /dev/null +++ b/browser/components/attribution/test/browser/browser.ini @@ -0,0 +1,8 @@ +[DEFAULT] +support-files = + head.js + +[browser_AttributionCode_telemetry.js] +skip-if = (os != "win" && toolkit != "cocoa") # Windows and macOS only telemetry. +[browser_AttributionCode_Mac_telemetry.js] +skip-if = toolkit != "cocoa" # 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..bc5a87e585 --- /dev/null +++ b/browser/components/attribution/test/browser/browser_AttributionCode_Mac_telemetry.js @@ -0,0 +1,249 @@ +ChromeUtils.defineModuleGetter( + this, + "TelemetryTestUtils", + "resource://testing-common/TelemetryTestUtils.jsm" +); +const { MacAttribution } = ChromeUtils.import( + "resource:///modules/MacAttribution.jsm" +); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + +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 OS.File.exists(AttributionCode.attributionFile.path)); + Assert.deepEqual( + "", + new TextDecoder().decode( + await OS.File.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" + ); + + 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 `OS.File.writeAtomic` while + // this test is running. Be careful to only stub the one call. + const writeAtomic = sandbox.stub(OS.File, "writeAtomic"); + writeAtomic + .withArgs( + sinon.match(AttributionCode.attributionFile.path), + sinon.match.any + ) + .throws(() => new Error("write_error")); + OS.File.writeAtomic.callThrough(); + + // 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 { + 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 = Cc["@mozilla.org/uuid-generator;1"] + .getService(Ci.nsIUUIDGenerator) + .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 OS.File.macSetXAttr( + 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..1bd997d4fd --- /dev/null +++ b/browser/components/attribution/test/browser/browser_AttributionCode_telemetry.js @@ -0,0 +1,86 @@ +ChromeUtils.defineModuleGetter( + this, + "TelemetryTestUtils", + "resource://testing-common/TelemetryTestUtils.jsm" +); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + +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.import( + "resource:///modules/MacAttribution.jsm" + ); + 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 + 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" + ); + const sandbox = sinon.createSandbox(); + // 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 + const exists = sandbox.stub(OS.File, "exists"); + exists.resolves(true); + const read = sandbox.stub(OS.File, "read"); + read.throws(() => new Error("read_error")); + // 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(); + sandbox.restore(); +}); diff --git a/browser/components/attribution/test/browser/head.js b/browser/components/attribution/test/browser/head.js new file mode 100644 index 0000000000..f7f54a1aeb --- /dev/null +++ b/browser/components/attribution/test/browser/head.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +const { AttributionCode } = ChromeUtils.import( + "resource:///modules/AttributionCode.jsm" +); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); + +// 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_task(function setup() { + // AttributionCode._clearCache is only possible in a testing environment + let env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + env.set("XPCSHELL_TEST_PROFILE_DIR", "testing"); + + registerCleanupFunction(() => { + 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..2e6c46e722 --- /dev/null +++ b/browser/components/attribution/test/xpcshell/head.js @@ -0,0 +1,121 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +const { AttributionCode } = ChromeUtils.import( + "resource:///modules/AttributionCode.jsm" +); + +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%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" }, + }, +]; + +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.import( + "resource://gre/modules/AppConstants.jsm" + ); + const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + + // 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.import( + "resource:///modules/MacAttribution.jsm" + ); + 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 OS.File.makeDir(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..f222c70a98 --- /dev/null +++ b/browser/components/attribution/test/xpcshell/test_AttributionCode.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +"use strict"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +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() { + for (let entry of validAttrCodes) { + AttributionCode._clearCache(); + await AttributionCode.writeAttributionFile(entry.code); + let result = await AttributionCode.getAttrDataAsync(); + Assert.deepEqual( + result, + entry.parsed, + "Parsed code should match expected value, code was: " + entry.code + ); + } + AttributionCode._clearCache(); +}); + +/** + * Make sure codes with various formatting errors are not seen as valid. + */ +add_task(async function testInvalidAttrCodes() { + for (let code of invalidAttrCodes) { + AttributionCode._clearCache(); + await AttributionCode.writeAttributionFile(code); + let result = await AttributionCode.getAttrDataAsync(); + Assert.deepEqual(result, {}, "Code should have failed to parse: " + code); + } + AttributionCode._clearCache(); +}); + +/** + * Test the cache by deleting the attribution data file + * and making sure we still get the expected code. + */ +add_task(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..4fb773264c --- /dev/null +++ b/browser/components/attribution/test/xpcshell/test_MacAttribution.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +"use strict"; + +const { MacAttribution } = ChromeUtils.import( + "resource:///modules/MacAttribution.jsm" +); + +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) { + // 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/xpcshell.ini b/browser/components/attribution/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..f88924e216 --- /dev/null +++ b/browser/components/attribution/test/xpcshell/xpcshell.ini @@ -0,0 +1,9 @@ +[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] |