Bug 1522214 - Bug 1536658 - Implement privileged web extension API for Normandy addon studies r=mythmon,mixedpuppy
☠☠ backed out by abb8b01b3ed2 ☠ ☠
authorrdalal <rdalal@mozilla.com>
Wed, 26 Jun 2019 22:53:56 +0000
changeset 480311 2a6ee5724361a4988028e7c7d728f91da71f0a35
parent 480310 89fbb2008b608b3b49f71ff5b6d73971d967e8d5
child 480312 7510c8179dffd0bff95bf117ff877995d9f59849
push id88615
push userccoroiu@mozilla.com
push dateThu, 27 Jun 2019 06:19:34 +0000
treeherderautoland@2a6ee5724361 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmythmon, mixedpuppy
bugs1522214, 1536658
milestone69.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1522214 - Bug 1536658 - Implement privileged web extension API for Normandy addon studies r=mythmon,mixedpuppy Differential Revision: https://phabricator.services.mozilla.com/D29913
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/ext-toolkit.json
toolkit/components/extensions/jar.mn
toolkit/components/extensions/parent/ext-normandyAddonStudy.js
toolkit/components/extensions/schemas/jar.mn
toolkit/components/extensions/schemas/normandyAddonStudy.json
toolkit/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js
toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
toolkit/components/normandy/lib/AddonStudies.jsm
toolkit/components/normandy/moz.build
toolkit/components/normandy/test/NormandyTestUtils.jsm
toolkit/components/normandy/test/browser/browser_AddonStudies.js
toolkit/components/normandy/test/browser/browser_ShieldPreferences.js
toolkit/components/normandy/test/browser/browser_about_studies.js
toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js
toolkit/components/normandy/test/browser/head.js
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -117,17 +117,17 @@ const PRIVATE_ALLOWED_PERMISSION = "inte
 // storage used by the browser.storage.local API is not directly accessible from the extension code,
 // it is defined and reserved as "userContextIdInternal.webextStorageLocal" in ContextualIdentityService.jsm).
 const WEBEXT_STORAGE_USER_CONTEXT_ID = -1 >>> 0;
 
 // The maximum time to wait for extension child shutdown blockers to complete.
 const CHILD_SHUTDOWN_TIMEOUT_MS = 8000;
 
 // Permissions that are only available to privileged extensions.
-const PRIVILEGED_PERMS = new Set(["mozillaAddons", "geckoViewAddons", "telemetry", "urlbar"]);
+const PRIVILEGED_PERMS = new Set(["mozillaAddons", "geckoViewAddons", "telemetry", "urlbar", "normandyAddonStudy"]);
 
 /**
  * Classify an individual permission from a webextension manifest
  * as a host/origin permission, an api permission, or a regular permission.
  *
  * @param {string} perm  The permission string to classify
  * @param {boolean} restrictSchemes
  * @param {boolean} isPrivileged whether or not the webextension is privileged
--- a/toolkit/components/extensions/ext-toolkit.json
+++ b/toolkit/components/extensions/ext-toolkit.json
@@ -101,16 +101,24 @@
   "management": {
     "url": "chrome://extensions/content/parent/ext-management.js",
     "schema": "chrome://extensions/content/schemas/management.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["management"]
     ]
   },
+  "normandyAddonStudy": {
+    "url": "chrome://extensions/content/parent/ext-normandyAddonStudy.js",
+    "schema": "chrome://extensions/content/schemas/normandyAddonStudy.json",
+    "scopes": ["addon_parent", "content_parent", "devtools_parent"],
+    "paths": [
+      ["normandyAddonStudy"]
+    ]
+  },
   "notifications": {
     "url": "chrome://extensions/content/parent/ext-notifications.js",
     "schema": "chrome://extensions/content/schemas/notifications.json",
     "scopes": ["addon_parent"],
     "paths": [
       ["notifications"]
     ]
   },
--- a/toolkit/components/extensions/jar.mn
+++ b/toolkit/components/extensions/jar.mn
@@ -24,16 +24,17 @@ toolkit.jar:
     content/extensions/parent/ext-geckoProfiler.js (parent/ext-geckoProfiler.js)
 #endif
     content/extensions/parent/ext-i18n.js (parent/ext-i18n.js)
 #ifndef ANDROID
     content/extensions/parent/ext-identity.js (parent/ext-identity.js)
 #endif
     content/extensions/parent/ext-idle.js (parent/ext-idle.js)
     content/extensions/parent/ext-management.js (parent/ext-management.js)
+    content/extensions/parent/ext-normandyAddonStudy.js (parent/ext-normandyAddonStudy.js)
     content/extensions/parent/ext-notifications.js (parent/ext-notifications.js)
     content/extensions/parent/ext-permissions.js (parent/ext-permissions.js)
     content/extensions/parent/ext-privacy.js (parent/ext-privacy.js)
     content/extensions/parent/ext-protocolHandlers.js (parent/ext-protocolHandlers.js)
     content/extensions/parent/ext-proxy.js (parent/ext-proxy.js)
     content/extensions/parent/ext-runtime.js (parent/ext-runtime.js)
     content/extensions/parent/ext-storage.js (parent/ext-storage.js)
     content/extensions/parent/ext-tabs-base.js (parent/ext-tabs-base.js)
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/parent/ext-normandyAddonStudy.js
@@ -0,0 +1,75 @@
+"use strict";
+
+const {AddonStudies} = ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm");
+const {ClientID} = ChromeUtils.import("resource://gre/modules/ClientID.jsm");
+
+ChromeUtils.defineModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
+
+
+this.normandyAddonStudy = class extends ExtensionAPI {
+  getAPI(context) {
+    let {extension} = context;
+
+    return {
+      normandyAddonStudy: {
+        /**
+         * Returns a study object for the current study.
+         *
+         * @returns {Study}
+         */
+        async getStudy() {
+          const studies = await AddonStudies.getAll();
+          return studies.find(study => study.addonId === extension.id);
+        },
+
+        /**
+         * Marks the study as ended and then uninstalls the addon.
+         *
+         * @param {string} reason Why the study is ending
+         */
+        async endStudy(reason) {
+          const study = await this.getStudy();
+
+          // Mark the study as ended
+          await AddonStudies.markAsEnded(study, reason);
+
+          // Uninstall the addon
+          const addon = await AddonManager.getAddonByID(study.addonId);
+          if (addon) {
+            await addon.uninstall();
+          }
+        },
+
+        /**
+         * Returns an object with metadata about the client which may
+         * be required for constructing survey URLs.
+         *
+         * @returns {Object}
+         */
+        async getClientMetadata() {
+          return {
+            updateChannel: Services.appinfo.defaultUpdateChannel,
+            fxVersion: Services.appinfo.version,
+            clientID: await ClientID.getClientID(),
+          };
+        },
+
+        onUnenroll: new EventManager({
+          context,
+          name: "normandyAddonStudy.onUnenroll",
+          register: (fire) => {
+            const listener = async (reason) => {
+              await fire.async(reason);
+            };
+
+            AddonStudies.addUnenrollListener(extension.id, listener);
+
+            return () => {
+              AddonStudies.removeUnenrollListener(extension.id, listener);
+            };
+          },
+        }).api(),
+      },
+    };
+  }
+};
--- a/toolkit/components/extensions/schemas/jar.mn
+++ b/toolkit/components/extensions/schemas/jar.mn
@@ -26,16 +26,17 @@ toolkit.jar:
     content/extensions/schemas/i18n.json
 #ifndef ANDROID
     content/extensions/schemas/identity.json
 #endif
     content/extensions/schemas/idle.json
     content/extensions/schemas/management.json
     content/extensions/schemas/manifest.json
     content/extensions/schemas/native_manifest.json
+    content/extensions/schemas/normandyAddonStudy.json
     content/extensions/schemas/notifications.json
     content/extensions/schemas/permissions.json
     content/extensions/schemas/proxy.json
     content/extensions/schemas/privacy.json
     content/extensions/schemas/runtime.json
     content/extensions/schemas/storage.json
     content/extensions/schemas/telemetry.json
     content/extensions/schemas/test.json
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/schemas/normandyAddonStudy.json
@@ -0,0 +1,130 @@
+[
+  {
+    "namespace": "manifest",
+    "types": [{
+      "$extend": "Permission",
+      "choices": [{
+        "type": "string",
+        "enum": [
+          "normandyAddonStudy"
+        ]
+      }]
+    }]
+  },
+  {
+    "namespace": "normandyAddonStudy",
+    "description": "Normandy Study API",
+    "allowedContexts": ["content", "devtools"],
+    "defaultContexts": ["content", "devtools"],
+    "permissions": [
+      "normandyAddonStudy"
+    ],
+    "types": [
+      {
+        "id": "Study",
+        "type": "object",
+        "properties": {
+          "recipeId": {
+            "type": "integer",
+            "description": "The ID of the recipe for the study."
+          },
+          "slug": {
+            "type": "string",
+            "description": "A slug to identify the study."
+          },
+          "userFacingName": {
+            "type": "string",
+            "description": "The name presented on about:studies."
+          },
+          "userFacingDescription": {
+            "type": "string",
+            "description": "The description presented on about:studies."
+          },
+          "branch": {
+            "type": "string",
+            "description": "The study branch in which the user is enrolled."
+          },
+          "active": {
+            "type": "boolean",
+            "description": "The state of the study."
+          },
+          "addonId": {
+            "type": "string",
+            "description": "The ID of the extension installed by the study."
+          },
+          "addonUrl": {
+            "type": "string",
+            "description": "The URL of the XPI that was downloaded and installed by the study."
+          },
+          "addonVersion": {
+            "type": "string",
+            "description": "The version of the extension installed by the study."
+          },
+          "studyStartDate": {
+            "$ref": "extensionTypes.Date",
+            "description": "The start date for the study."
+          },
+          "studyEndDate": {
+            "$ref": "extensionTypes.Date",
+            "description": "The end date for the study."
+          },
+          "extensionApiId": {
+            "type": "integer",
+            "description": "The record ID for the extension in Normandy server's database."
+          },
+          "extensionHash": {
+            "type": "string",
+            "description": "A hash of the extension XPI file."
+          },
+          "extensionHashAlgorithm": {
+            "type": "string",
+            "description": "The algorithm used to hash the extension XPI file."
+          }
+        }
+      }
+    ],
+    "functions": [
+      {
+        "name": "getStudy",
+        "type": "function",
+        "description": "Returns a study object for the current study.",
+        "async": true,
+        "parameters": []
+      },
+      {
+        "name": "endStudy",
+        "type": "function",
+        "description": "Marks the study as ended and then uninstalls the addon.",
+        "async": true,
+        "parameters": [
+          {
+            "type": "string",
+            "name": "reason",
+            "description": "The reason why the study is ending."
+          }
+        ]
+      },
+      {
+        "name": "getClientMetadata",
+        "type": "function",
+        "description": "Returns an object with metadata about the client which may be required for constructing survey URLs.",
+        "async": true,
+        "parameters": []
+      }
+    ],
+    "events": [
+      {
+        "name": "onUnenroll",
+        "type": "function",
+        "description": "Fired when a user unenrolls from a study but before the addon is uninstalled.",
+        "parameters": [
+          {
+            "type": "string",
+            "name": "reason",
+            "description": "The reason why the study is ending."
+          }
+        ]
+      }
+    ]
+  }
+]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js
@@ -0,0 +1,176 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
+
+const {AddonStudies} = ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm");
+const {NormandyTestUtils} = ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm");
+const {TestUtils} = ChromeUtils.import("resource://testing-common/TestUtils.jsm");
+
+const {addonStudyFactory} = NormandyTestUtils.factories;
+
+AddonTestUtils.init(this);
+
+// All tests run privileged unless otherwise specified not to.
+function createExtension(backgroundScript, permissions, isPrivileged = true) {
+  let extensionData = {
+    background: backgroundScript,
+    manifest: {
+      applications: {
+        gecko: {
+          id: "test@shield.mozilla.com",
+        },
+      },
+      permissions,
+    },
+    isPrivileged,
+  };
+  return ExtensionTestUtils.loadExtension(extensionData);
+}
+
+async function run(test) {
+  let extension = createExtension(
+    test.backgroundScript,
+    test.permissions || ["normandyAddonStudy"],
+    test.isPrivileged
+  );
+  await extension.startup();
+  if (test.doneSignal) {
+    await extension.awaitFinish(test.doneSignal);
+  } else if (test.validationScript) {
+    await test.validationScript(extension);
+  }
+  await extension.unload();
+}
+
+add_task(async function setup() {
+  await ExtensionTestUtils.startAddonManager();
+});
+
+add_task(async function test_normandyAddonStudy_without_normandyAddonStudy_permission_privileged() {
+  await run({
+    backgroundScript: () => {
+      browser.test.assertTrue(!browser.normandyAddonStudy, "'normandyAddonStudy' permission is required");
+      browser.test.notifyPass("normandyAddonStudy_permission");
+    },
+    permissions: [],
+    doneSignal: "normandyAddonStudy_permission",
+  });
+});
+
+add_task(async function test_normandyAddonStudy_without_privilege() {
+  await run({
+    backgroundScript: () => {
+      browser.test.assertTrue(!browser.normandyAddonStudy, "Extension must be privileged");
+      browser.test.notifyPass("normandyAddonStudy_permission");
+    },
+    isPrivileged: false,
+    doneSignal: "normandyAddonStudy_permission",
+  });
+});
+
+add_task(async function test_getStudy_works() {
+  const study = addonStudyFactory({
+    addonId: "test@shield.mozilla.com",
+  });
+
+  const testWrapper = AddonStudies.withStudies([study]);
+  const test = testWrapper(async () => {
+    await run({
+      backgroundScript: async () => {
+        const result = await browser.normandyAddonStudy.getStudy();
+        browser.test.sendMessage("study", result);
+      },
+      validationScript: async extension => {
+        let studyResult = await extension.awaitMessage("study");
+        deepEqual(studyResult, study, "normandyAddonStudy.getStudy returns the correct study");
+      },
+    });
+  });
+
+  await test();
+});
+
+add_task(async function test_endStudy_works() {
+  const study = addonStudyFactory({
+    addonId: "test@shield.mozilla.com",
+  });
+
+  const testWrapper = AddonStudies.withStudies([study]);
+  const test = testWrapper(async () => {
+    await run({
+      backgroundScript: async () => {
+        await browser.normandyAddonStudy.endStudy("test");
+      },
+      validationScript: async () => {
+        // Check that `AddonStudies.markAsEnded` was called
+        await TestUtils.topicObserved("shield-study-ended", (subject, message) => {
+          return message === `${study.recipeId}`;
+        });
+
+        const addon = await AddonManager.getAddonByID(study.addonId);
+        equal(addon, undefined, "Addon should be uninstalled.");
+      },
+    });
+  });
+
+  await test();
+});
+
+add_task(async function test_getClientMetadata_works() {
+  const study = addonStudyFactory({
+    addonId: "test@shield.mozilla.com",
+    slug: "test-slug",
+    branch: "test-branch",
+  });
+
+  const testWrapper = AddonStudies.withStudies([study]);
+  const test = testWrapper(async () => {
+    await run({
+      backgroundScript: async () => {
+        const metadata = await browser.normandyAddonStudy.getClientMetadata();
+        browser.test.sendMessage("clientMetadata", metadata);
+      },
+      validationScript: async extension => {
+        let clientMetadata = await extension.awaitMessage("clientMetadata");
+
+        ok(
+          clientMetadata.updateChannel === Services.appinfo.defaultUpdateChannel,
+          "clientMetadata contains correct updateChannel",
+        );
+
+        ok(
+          clientMetadata.fxVersion === Services.appinfo.version,
+          "clientMetadata contains correct fxVersion",
+        );
+
+        ok("clientID" in clientMetadata, "clientMetadata contains a clientID");
+      },
+    });
+  });
+
+  await test();
+});
+
+add_task(async function test_onUnenroll_works() {
+  const study = addonStudyFactory({
+    addonId: "test@shield.mozilla.com",
+  });
+
+  const testWrapper = AddonStudies.withStudies([study]);
+  const test = testWrapper(async () => {
+    await run({
+      backgroundScript: async () => {
+        browser.normandyAddonStudy.onUnenroll.addListener(reason => {
+          browser.test.sendMessage("unenrollReason", reason);
+        });
+      },
+      validationScript: async extension => {
+        await AddonStudies.markAsEnded(study, "test");
+        const unenrollReason = await extension.awaitMessage("unenrollReason");
+        equal(unenrollReason, "test", "Unenroll listener should be called.");
+      },
+    });
+  });
+
+  await test();
+});
--- a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js
@@ -434,16 +434,17 @@ const GRANTED_WITHOUT_USER_PROMPT = [
   "contextualIdentities",
   "cookies",
   "geckoProfiler",
   "identity",
   "idle",
   "menus",
   "menus.overrideContext",
   "mozillaAddons",
+  "normandyAddonStudy",
   "search",
   "storage",
   "telemetry",
   "theme",
   "urlbar",
   "webRequest",
   "webRequestBlocking",
 ];
--- a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
+++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini
@@ -71,16 +71,17 @@ skip-if = os == "android" # Not shipped 
 [test_ext_geturl.js]
 [test_ext_idle.js]
 [test_ext_incognito.js]
 [test_ext_localStorage.js]
 [test_ext_management.js]
 skip-if = (os == "win" && !debug) #Bug 1419183 disable on Windows
 [test_ext_management_uninstall_self.js]
 [test_ext_messaging_startup.js]
+[test_ext_normandyAddonStudy.js]
 skip-if = appname == "thunderbird" || (os == "android" && debug)
 [test_ext_onmessage_removelistener.js]
 skip-if = true # This test no longer tests what it is meant to test.
 [test_ext_permission_xhr.js]
 [test_ext_persistent_events.js]
 [test_ext_privacy.js]
 skip-if = appname == "thunderbird" || (os == "android" && debug)
 [test_ext_privacy_disable.js]
--- a/toolkit/components/normandy/lib/AddonStudies.jsm
+++ b/toolkit/components/normandy/lib/AddonStudies.jsm
@@ -340,19 +340,33 @@ var AddonStudies = {
     if (!listeners) {
       listeners = new Set();
       this._unenrollListeners.set(id, listeners);
     }
     listeners.add(listener);
   },
 
   /**
+   * Unregister a callback to be invoked when a given study ends.
+   *
+   * @param {string} id         The extension id
+   * @param {function} listener The callback
+   */
+  removeUnenrollListener(id, listener) {
+    let listeners = this._unenrollListeners.get(id);
+    if (listeners) {
+      listeners.delete(listener);
+    }
+  },
+
+  /**
    * Invoke the unenroll callback (if any) for the given extension
    *
    * @param {string} id The extension id
+   * @param {string} reason Why the study is ending
    *
    * @returns {Promise} A Promise resolved after the unenroll listener
    *                    (if any) has finished its unenroll tasks.
    */
   onUnenroll(id, reason) {
     let callbacks = this._unenrollListeners.get(id);
     let promises = [];
     if (callbacks) {
--- a/toolkit/components/normandy/moz.build
+++ b/toolkit/components/normandy/moz.build
@@ -8,16 +8,20 @@ with Files('**'):
     BUG_COMPONENT = ('Firefox', 'Normandy Client')
 
 JAR_MANIFESTS += ['jar.mn']
 
 EXTRA_JS_MODULES += [
     'ShieldContentProcess.jsm',
 ]
 
+TESTING_JS_MODULES += [
+    'test/NormandyTestUtils.jsm',
+]
+
 XPCOM_MANIFESTS += [
     'components.conf',
 ]
 
 SPHINX_TREES['normandy'] = 'docs'
 
 TEST_DIRS += ['test/browser']
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/NormandyTestUtils.jsm
@@ -0,0 +1,75 @@
+/* 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";
+
+ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
+
+const FIXTURE_ADDON_ID = "normandydriver-a@example.com";
+
+var EXPORTED_SYMBOLS = ["NormandyTestUtils"];
+
+// Factory IDs
+let _addonStudyFactoryId = 0;
+let _preferenceStudyFactoryId = 0;
+
+const NormandyTestUtils = {
+  factories: {
+    addonStudyFactory(attrs) {
+      for (const key of ["name", "description"]) {
+        if (attrs && attrs[key]) {
+          throw new Error(`${key} is no longer a valid key for addon studies, please update to v2 study schema`);
+        }
+      }
+
+      return Object.assign({
+        recipeId: _addonStudyFactoryId++,
+        slug: "test-study",
+        userFacingName: "Test study",
+        userFacingDescription: "test description",
+        branch: AddonStudies.NO_BRANCHES_MARKER,
+        active: true,
+        addonId: FIXTURE_ADDON_ID,
+        addonUrl: "http://test/addon.xpi",
+        addonVersion: "1.0.0",
+        studyStartDate: new Date(),
+        studyEndDate: null,
+        extensionApiId: 1,
+        extensionHash: "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
+        extensionHashAlgorithm: "sha256",
+      }, attrs);
+    },
+
+    branchedAddonStudyFactory(attrs) {
+      return NormandyTestUtils.factories.addonStudyFactory(Object.assign({
+        branch: "a",
+      }, attrs));
+    },
+
+    preferenceStudyFactory(attrs) {
+      const defaultPref = {
+        "test.study": {},
+      };
+      const defaultPrefInfo = {
+        preferenceValue: false,
+        preferenceType: "boolean",
+        previousPreferenceValue: undefined,
+        preferenceBranchType: "default",
+      };
+      const preferences = {};
+      for (const [prefName, prefInfo] of Object.entries(attrs.preferences || defaultPref)) {
+        preferences[prefName] = { ...defaultPrefInfo, ...prefInfo };
+      }
+
+      return Object.assign({
+        name: `Test study ${_preferenceStudyFactoryId++}`,
+        branch: "control",
+        expired: false,
+        lastSeen: new Date().toJSON(),
+        experimentType: "exp",
+      }, attrs, {
+        preferences,
+      });
+    },
+  },
+};
--- a/toolkit/components/normandy/test/browser/browser_AddonStudies.js
+++ b/toolkit/components/normandy/test/browser/browser_AddonStudies.js
@@ -2,16 +2,19 @@
 
 ChromeUtils.import("resource://gre/modules/IndexedDB.jsm", this);
 ChromeUtils.import("resource://gre/modules/AddonManager.jsm", this);
 ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
 ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
 
+const {NormandyTestUtils} = ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm");
+const {addonStudyFactory} = NormandyTestUtils.factories;
+
 // Initialize test utils
 AddonTestUtils.initMochitest(this);
 
 decorate_task(
   AddonStudies.withStudies(),
   async function testGetMissing() {
     is(
       await AddonStudies.get("does-not-exist"),
--- a/toolkit/components/normandy/test/browser/browser_ShieldPreferences.js
+++ b/toolkit/components/normandy/test/browser/browser_ShieldPreferences.js
@@ -2,16 +2,19 @@
 
 ChromeUtils.import("resource://gre/modules/Services.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm", this);
 ChromeUtils.import("resource://normandy/lib/ShieldPreferences.jsm", this);
 
 const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled";
 
+const {NormandyTestUtils} = ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm");
+const {addonStudyFactory, preferenceStudyFactory} = NormandyTestUtils.factories;
+
 ShieldPreferences.init();
 
 decorate_task(
   withMockPreferences,
   AddonStudies.withStudies([
     addonStudyFactory({active: true}),
     addonStudyFactory({active: true}),
   ]),
--- a/toolkit/components/normandy/test/browser/browser_about_studies.js
+++ b/toolkit/components/normandy/test/browser/browser_about_studies.js
@@ -1,15 +1,18 @@
 "use strict";
 
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm", this);
 ChromeUtils.import("resource://normandy/lib/RecipeRunner.jsm", this);
 ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
 
+const {NormandyTestUtils} = ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm");
+const {addonStudyFactory, preferenceStudyFactory} = NormandyTestUtils.factories;
+
 function withAboutStudies(testFunc) {
   return async (...args) => (
     BrowserTestUtils.withNewTab("about:studies", async browser => (
       testFunc(...args, browser)
     ))
   );
 }
 
--- a/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
@@ -1,16 +1,19 @@
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm", this);
 ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
 ChromeUtils.import("resource://normandy/actions/AddonStudyAction.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
 
+const {NormandyTestUtils} = ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm");
+const {addonStudyFactory} = NormandyTestUtils.factories;
+
 function addonStudyRecipeFactory(overrides = {}) {
   let args = {
     name: "Fake name",
     description: "fake description",
     addonUrl: "https://example.com/study.xpi",
     extensionApiId: 1,
   };
   if (Object.hasOwnProperty.call(overrides, "arguments")) {
--- a/toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_BranchedAddonStudyAction.js
@@ -1,16 +1,19 @@
 "use strict";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm", this);
 ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
 ChromeUtils.import("resource://normandy/actions/BranchedAddonStudyAction.jsm", this);
 ChromeUtils.import("resource://normandy/lib/AddonStudies.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
 
+const {NormandyTestUtils} = ChromeUtils.import("resource://testing-common/NormandyTestUtils.jsm");
+const {branchedAddonStudyFactory} = NormandyTestUtils.factories;
+
 function branchedAddonStudyRecipeFactory(overrides = {}) {
   let args = {
     slug: "fake-slug",
     userFacingName: "Fake name",
     userFacingDescription: "fake description",
     branches: [
       {
         slug: "a",
--- a/toolkit/components/normandy/test/browser/head.js
+++ b/toolkit/components/normandy/test/browser/head.js
@@ -249,75 +249,16 @@ this.decorate = function(...args) {
  *       // Do a test
  *     }
  *   );
  */
 this.decorate_task = function(...args) {
   return add_task(decorate(...args));
 };
 
-let _addonStudyFactoryId = 0;
-this.addonStudyFactory = function(attrs = {}) {
-  for (const key of ["name", "description"]) {
-    if (attrs[key]) {
-      throw new Error(`${key} is no longer a valid key for addon studies, please update to v2 study schema`);
-    }
-  }
-
-  return Object.assign({
-    recipeId: _addonStudyFactoryId++,
-    slug: "test-study",
-    userFacingName: "Test study",
-    userFacingDescription: "test description",
-    branch: AddonStudies.NO_BRANCHES_MARKER,
-    active: true,
-    addonId: FIXTURE_ADDON_ID,
-    addonUrl: "http://test/addon.xpi",
-    addonVersion: "1.0.0",
-    studyStartDate: new Date(),
-    studyEndDate: null,
-    extensionApiId: 1,
-    extensionHash: "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
-    extensionHashAlgorithm: "sha256",
-  }, attrs);
-};
-
-this.branchedAddonStudyFactory = function(attrs) {
-  return this.addonStudyFactory(Object.assign({
-    branch: "a",
-  }, attrs));
-};
-
-let _preferenceStudyFactoryId = 0;
-this.preferenceStudyFactory = function(attrs) {
-  const defaultPref = {
-    "test.study": {},
-  };
-  const defaultPrefInfo = {
-    preferenceValue: false,
-    preferenceType: "boolean",
-    previousPreferenceValue: undefined,
-    preferenceBranchType: "default",
-  };
-  const preferences = {};
-  for (const [prefName, prefInfo] of Object.entries(attrs.preferences || defaultPref)) {
-    preferences[prefName] = { ...defaultPrefInfo, ...prefInfo };
-  }
-
-  return Object.assign({
-    name: `Test study ${_preferenceStudyFactoryId++}`,
-    branch: "control",
-    expired: false,
-    lastSeen: new Date().toJSON(),
-    experimentType: "exp",
-  }, attrs, {
-    preferences,
-  });
-};
-
 this.withStub = function(...stubArgs) {
   return function wrapper(testFunction) {
     return async function wrappedTestFunction(...args) {
       const stub = sinon.stub(...stubArgs);
       try {
         await testFunction(...args, stub);
       } finally {
         stub.restore();