Bug 1277627 - Added module for working with attribution codes, including tests; r?mconley draft
authorMatt Howell <mhowell@mozilla.com>
Tue, 09 Aug 2016 15:13:00 -0700
changeset 398873 0e3462cb58b4a915410dd8ef4861da6c6a2044ac
parent 398604 6cf0089510fad8deb866136f5b92bbced9498447
child 527778 24ec623518c5354369dd8fd5654422f725850019
push id25662
push usermhowell@mozilla.com
push dateTue, 09 Aug 2016 22:18:46 +0000
reviewersmconley
bugs1277627
milestone51.0a1
Bug 1277627 - Added module for working with attribution codes, including tests; r?mconley 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,102 @@
+/* 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_VALUE_CHARS = "[a-zA-Z0-9_%\\-\\.\\(\\)]";
+const ATTR_CODE_REGEX = new RegExp(
+  "^source%3D(" + ATTR_CODE_VALUE_CHARS + "*)%26" +
+   "medium%3D(" + ATTR_CODE_VALUE_CHARS + "*)%26" +
+   "campaign%3D(" + ATTR_CODE_VALUE_CHARS + "*)%26" +
+   "content%3D(" + ATTR_CODE_VALUE_CHARS + "*)$", "i");
+const ATTR_CODE_REGEX_NUM_GROUPS = 4;
+
+let gCachedCode = 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 true if the passed-in attribution code string fits the expected format,
+// or false otherwise.
+function attributionCodeIsValid(code) {
+  let matches = code.match(ATTR_CODE_REGEX);
+  return code.length <= ATTR_CODE_MAX_LENGTH &&
+         matches != null &&
+         matches.length == (ATTR_CODE_REGEX_NUM_GROUPS + 1);
+}
+
+var AttributionCode = {
+  // Reads the attribution code, either from disk or a cached version.
+  // Returns a promise that fulfills with the attribution code
+  // if the code could be read and is valid, or an empty string otherwise.
+  getCodeAsync() {
+    return Task.spawn(function*() {
+      if (gCachedCode != null) {
+        return gCachedCode;
+      }
+
+      try {
+        let bytes = yield OS.File.read(getAttributionFile().path);
+        let decoder = new TextDecoder();
+        gCachedCode = 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.
+        gCachedCode = "";
+      }
+
+      if (!attributionCodeIsValid(gCachedCode)) {
+        gCachedCode = "";
+      }
+      return gCachedCode;
+    });
+  },
+
+  // 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")) {
+      gCachedCode = 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,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource:///modules/AttributionCode.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+function getAttributionFile() {
+  let directoryService = Cc["@mozilla.org/file/directory_service;1"].
+                           getService(Ci.nsIProperties);
+  let file = directoryService.get("LocalAppData", Ci.nsIFile);
+  file.append(Services.appinfo.vendor || "mozilla");
+  file.append(AppConstants.MOZ_APP_NAME);
+  file.append("postSigningData");
+  return file;
+}
+
+function writeAttributionFile(data) {
+  let stream = Cc["@mozilla.org/network/file-output-stream;1"].
+                 createInstance(Ci.nsIFileOutputStream);
+  stream.init(getAttributionFile(), -1, -1, 0);
+  stream.write(data, data.length);
+  stream.close();
+}
+
+let attrCodeList = [
+  {code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set)",
+   valid: true},
+  {code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D%26content%3D",
+   valid: true},
+  {code: "",
+   valid: false},
+  {code: "source=google.com&medium=organic&campaign=(not set)&content=(not set)",
+   valid: false},
+  {code: "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)",
+   valid: false},
+  {code: "source%3Dgoogle.com%26medium%3Dorganic",
+   valid: false},
+  {code: "source%3Dgoogle.com",
+   valid: false},
+  {code: "source%reallyreallyreallyreallyreallyreallyreallyreallyreallylongdomain.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3Dalmostexactlyenoughcontenttomakethisstringlongerthanthe200characterlimit",
+   valid: false},
+];
+
+// Test validation of attribution codes,
+// to make sure we reject bad ones and accept good ones.
+add_task(function* testAttrCodes() {
+  for (let entry of attrCodeList) {
+    AttributionCode._clearCache();
+    writeAttributionFile(entry.code);
+    let result = yield AttributionCode.getCodeAsync();
+    if (entry.valid) {
+      Assert.equal(result, entry.code, "Code should be valid");
+    } else {
+      Assert.equal(result, "", "Code should not be valid");
+    }
+  }
+  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.
+  const code =
+    "source%3Dgoogle.com%26medium%3Dorganic%26campaign%3D(not%20set)%26content%3D(not%20set)";
+  writeAttributionFile(code);
+  let result = yield AttributionCode.getCodeAsync();
+  Assert.equal(result, code,
+    "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.getCodeAsync();
+  Assert.equal(result, code,
+    "The code should be readable from the cache");
+
+  // Clear the cache and check we can't read anything.
+  AttributionCode._clearCache();
+  result = yield AttributionCode.getCodeAsync();
+  Assert.equal(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]