Bug 1277627 - Added module for working with attribution codes, including tests draft
authorMatt Howell <mhowell@mozilla.com>
Tue, 09 Aug 2016 10:22:20 -0700
changeset 398746 cae2ee91f62d2b774cdbce4c3886472694f3f5a5
parent 398604 6cf0089510fad8deb866136f5b92bbced9498447
child 527733 b9441f6645f143064891f9377b92464417b7aae7
push id25614
push usermhowell@mozilla.com
push dateTue, 09 Aug 2016 17:23:35 +0000
bugs1277627
milestone51.0a1
Bug 1277627 - Added module for working with attribution codes, including tests 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,81 @@
+/* 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/AppConstants.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("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");
+
+let cachedCode = null;
+
+function getAttributionFile() {
+  let directoryService = Cc["@mozilla.org/file/directory_service;1"].
+                         getService(Ci.nsIProperties);
+  let file = directoryService.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;
+}
+
+function validateAttributionCode(code) {
+  let matches = code.match(ATTR_CODE_REGEX);
+  return code.length <= ATTR_CODE_MAX_LENGTH &&
+         matches != null && matches.length == 5;
+}
+
+var AttributionCode = {
+  getCodeAsync() {
+    return Task.spawn(function*() {
+      if (cachedCode != null) {
+        return cachedCode;
+      }
+
+      try {
+        let bytes = yield OS.File.read(getAttributionFile().path);
+        let decoder = new TextDecoder();
+        cachedCode = 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.
+        cachedCode = "";
+      }
+
+      if (!validateAttributionCode(cachedCode)) {
+        cachedCode = "";
+      }
+      return cachedCode;
+    });
+  },
+
+  deleteFile() {
+    try {
+      getAttributionFile().remove(false);
+    } 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.
+    }
+  },
+
+  // This function should probably only be used by tests.
+  _clearCache() {
+    cachedCode = 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,93 @@
+/* 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},
+];
+
+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");
+    }
+  }
+});
+
+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)";
+  AttributionCode._clearCache();
+  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.
+  AttributionCode.deleteFile();
+  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");
+});
+
+function cleanup() {
+  // Make sure we don't leave a file hanging around.
+  AttributionCode.deleteFile();
+}
+
+function run_test() {
+  do_register_cleanup(cleanup);
+  run_next_test();
+}
--- 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]