Bug 1277627 - Added module for working with attribution codes, including tests; r=mconley a=ritu
authorMatt Howell <mhowell@mozilla.com>
Thu, 18 Aug 2016 14:40:08 -0700
changeset 332944 4bcd5c359498042511695c565e1d4beec1447579
parent 332943 3c133d122ea1f2557c342855529ef54717e77f8b
child 332945 02876d17a0b794ac7c2959a763a92960463d92b0
push id9958
push userkwierso@gmail.com
push dateTue, 30 Aug 2016 17:44:24 +0000
treeherdermozilla-aurora@bac8c738419e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmconley, ritu
bugs1277627
milestone50.0a2
Bug 1277627 - Added module for working with attribution codes, including tests; r=mconley a=ritu MozReview-Commit-ID: E83Hs7QDlLJ
browser/modules/AttributionCode.jsm
browser/modules/moz.build
browser/modules/test/xpcshell/test_AttributionCode.js
browser/modules/test/xpcshell/xpcshell.ini
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]