Bug 1277627 - Added module for working with attribution codes, including tests; r?mconley
MozReview-Commit-ID: E83Hs7QDlLJ
new file mode 100644
--- /dev/null
+++ b/browser/modules/AttributionCode.jsm
@@ -0,0 +1,123 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["AttributionCode"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, 'AppConstants',
+ 'resource://gre/modules/AppConstants.jsm');
+XPCOMUtils.defineLazyModuleGetter(this, 'OS',
+ 'resource://gre/modules/osfile.jsm');
+XPCOMUtils.defineLazyModuleGetter(this, 'Services',
+ 'resource://gre/modules/Services.jsm');
+XPCOMUtils.defineLazyModuleGetter(this, 'Task',
+ 'resource://gre/modules/Task.jsm');
+
+const ATTR_CODE_MAX_LENGTH = 200;
+const ATTR_CODE_KEYS_REGEX = /^source|medium|campaign|content$/;
+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 =
+
+let gCachedAttrData = null;
+
+/**
+ * Returns an nsIFile for the file containing the attribution data.
+ */
+function getAttributionFile() {
+ 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;
+}
+
+/**
+ * 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.
+ */
+function 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_REGEX.test(key)) {
+ if (value && ATTR_CODE_VALUE_REGEX.test(value)) {
+ parsed[key] = value;
+ }
+ } else {
+ isValid = false;
+ break;
+ }
+ }
+ return isValid ? parsed : {};
+}
+
+var AttributionCode = {
+ /**
+ * 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.
+ */
+ getAttrDataAsync() {
+ return Task.spawn(function*() {
+ if (gCachedAttrData != null) {
+ return gCachedAttrData;
+ }
+
+ let code = "";
+ try {
+ let bytes = yield OS.File.read(getAttributionFile().path);
+ let decoder = new TextDecoder();
+ code = decoder.decode(bytes);
+ } catch (ex) {
+ // The attribution file may already have been deleted,
+ // or it may have never been installed at all;
+ // failure to open or read it isn't an error.
+ }
+
+ gCachedAttrData = parseAttributionCode(code);
+ 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).
+ */
+ deleteFileAsync() {
+ return Task.spawn(function*() {
+ try {
+ yield OS.File.remove(getAttributionFile().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;
+ }
+ },
+};
--- a/browser/modules/moz.build
+++ b/browser/modules/moz.build
@@ -8,16 +8,17 @@ BROWSER_CHROME_MANIFESTS += ['test/brows
XPCSHELL_TESTS_MANIFESTS += [
'test/unit/social/xpcshell.ini',
'test/xpcshell/xpcshell.ini',
]
EXTRA_JS_MODULES += [
'AboutHome.jsm',
'AboutNewTab.jsm',
+ 'AttributionCode.jsm',
'BrowserUITelemetry.jsm',
'BrowserUsageTelemetry.jsm',
'CaptivePortalWatcher.jsm',
'CastingApps.jsm',
'Chat.jsm',
'ContentClick.jsm',
'ContentCrashHandlers.jsm',
'ContentLinkHandler.jsm',
new file mode 100644
--- /dev/null
+++ b/browser/modules/test/xpcshell/test_AttributionCode.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const {interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource:///modules/AttributionCode.jsm");
+Cu.import('resource://gre/modules/osfile.jsm');
+Cu.import("resource://gre/modules/Services.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"}},
+ {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"}}
+];
+
+let invalidAttrCodes = [
+ // Empty string
+ "",
+ // Not escaped
+ "source=google.com&medium=organic&campaign=(not set)&content=(not set)",
+ // Too long
+ "source%3Dreallyreallyreallyreallyreallyreallyreallyreallyreallylongdomain.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3Dalmostexactlyenoughcontenttomakethisstringlongerthanthe200characterlimit",
+ // Unknown key name
+ "source%3Dgoogle.com%26medium%3Dorganic%26large%3Dgeneticallymodified",
+ // Empty key name
+ "source%3Dgoogle.com%26medium%3Dorganic%26%3Dgeneticallymodified"
+];
+
+function* writeAttributionFile(data) {
+ let appDir = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
+ let file = appDir.clone();
+ file.append(Services.appinfo.vendor || "mozilla");
+ file.append(AppConstants.MOZ_APP_NAME);
+
+ yield OS.File.makeDir(file.path,
+ {from: appDir.path, ignoreExisting: true});
+
+ file.append("postSigningData");
+ yield OS.File.writeAtomic(file.path, data);
+}
+
+/**
+ * Test validation of attribution codes,
+ * to make sure we reject bad ones and accept good ones.
+ */
+add_task(function* testValidAttrCodes() {
+ for (let entry of validAttrCodes) {
+ AttributionCode._clearCache();
+ yield writeAttributionFile(entry.code);
+ let result = yield 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(function* testInvalidAttrCodes() {
+ for (let code of invalidAttrCodes) {
+ AttributionCode._clearCache();
+ yield writeAttributionFile(code);
+ let result = yield 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(function* testDeletedFile() {
+ // Set up the test by clearing the cache and writing a valid file.
+ yield writeAttributionFile(validAttrCodes[0].code);
+ let result = yield 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.
+ yield AttributionCode.deleteFileAsync();
+ result = yield 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 = yield AttributionCode.getAttrDataAsync();
+ Assert.deepEqual(result, {},
+ "Shouldn't be able to get a code after file is deleted and cache is cleared");
+});
--- a/browser/modules/test/xpcshell/xpcshell.ini
+++ b/browser/modules/test/xpcshell/xpcshell.ini
@@ -1,10 +1,12 @@
[DEFAULT]
head =
tail =
firefox-appdir = browser
skip-if = toolkit == 'android' || toolkit == 'gonk'
+[test_AttributionCode.js]
+skip-if = os != 'win'
[test_DirectoryLinksProvider.js]
[test_SitePermissions.js]
[test_TabGroupsMigrator.js]
[test_LaterRun.js]