Bug 1474413 - Update addon studies in place r=mythmon,glasserc
authorrdalal <rdalal@mozilla.com>
Sun, 03 Mar 2019 08:25:33 +0000
changeset 520053 a920a704ff6bf2811f717be3c437239b6257046b
parent 520052 4f78b47f52b2f138e472b6fd54b83b4b8031a967
child 520054 b5dec9e96f42cd166b4cdaf70b6765be34454aae
push id10862
push userffxbld-merge
push dateMon, 11 Mar 2019 13:01:11 +0000
treeherdermozilla-beta@a2e7f5c935da [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmythmon, glasserc
bugs1474413
milestone67.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 1474413 - Update addon studies in place r=mythmon,glasserc Differential Revision: https://phabricator.services.mozilla.com/D19405
toolkit/components/normandy/actions/AddonStudyAction.jsm
toolkit/components/normandy/actions/schemas/index.js
toolkit/components/normandy/docs/data-collection.rst
toolkit/components/normandy/lib/AddonStudies.jsm
toolkit/components/normandy/lib/NormandyApi.jsm
toolkit/components/normandy/moz.build
toolkit/components/normandy/test/browser/addons/normandydriver-1.0/manifest.json
toolkit/components/normandy/test/browser/addons/normandydriver-2.0/manifest.json
toolkit/components/normandy/test/browser/browser.ini
toolkit/components/normandy/test/browser/browser_AddonStudies.js
toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
toolkit/components/normandy/test/browser/fixtures/addon-fixture/manifest.json
toolkit/components/normandy/test/browser/fixtures/normandy.xpi
toolkit/components/normandy/test/browser/head.js
toolkit/components/normandy/test/browser/moz.build
toolkit/components/normandy/test/create_xpi.py
toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json
toolkit/components/normandy/test/unit/mock_api/api/v1/extension/index.json
toolkit/components/normandy/test/unit/mock_api/api/v1/index.json
toolkit/components/normandy/test/unit/test_NormandyApi.js
toolkit/components/normandy/test/unit/test_addon_unenroll.js
toolkit/components/normandy/test/unit/utils.js
toolkit/components/telemetry/Events.yaml
--- a/toolkit/components/normandy/actions/AddonStudyAction.jsm
+++ b/toolkit/components/normandy/actions/AddonStudyAction.jsm
@@ -14,16 +14,17 @@ const {XPCOMUtils} = ChromeUtils.import(
 const {BaseAction} = ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   Services: "resource://gre/modules/Services.jsm",
   PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   ActionSchemas: "resource://normandy/actions/schemas/index.js",
   AddonStudies: "resource://normandy/lib/AddonStudies.jsm",
+  NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
   TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
 });
 
 var EXPORTED_SYMBOLS = ["AddonStudyAction"];
 
 const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled";
 
 class AddonStudyEnrollError extends Error {
@@ -39,21 +40,73 @@ class AddonStudyEnrollError extends Erro
       case "conflicting-addon-id": {
         message = "an add-on with this ID is already installed";
         break;
       }
       case "download-failure": {
         message = "the add-on failed to download";
         break;
       }
+      case "metadata-mismatch": {
+        message = "the server metadata does not match the downloaded add-on";
+        break;
+      }
+      case "install-failure": {
+        message = "the add-on failed to install";
+        break;
+      }
       default: {
         throw new Error(`Unexpected AddonStudyEnrollError reason: ${reason}`);
       }
     }
-    super(new Error(`Cannot install study add-on for ${studyName}: ${message}.`));
+    super(`Cannot install study add-on for ${studyName}: ${message}.`);
+    this.studyName = studyName;
+    this.extra = extra;
+  }
+}
+
+class AddonStudyUpdateError extends Error {
+  /**
+   * @param {string} studyName
+   * @param {object} extra Extra details to include when reporting the error to telemetry.
+   * @param {string} extra.reason The specific reason for the failure.
+   */
+  constructor(studyName, extra) {
+    let message;
+    let { reason } = extra;
+    switch (reason) {
+      case "addon-id-mismatch": {
+        message = "new add-on ID does not match old add-on ID";
+        break;
+      }
+      case "addon-does-not-exist": {
+        message = "an add-on with this ID does not exist";
+        break;
+      }
+      case "no-downgrade": {
+        message = "the add-on was an older version than is installed";
+        break;
+      }
+      case "metadata-mismatch": {
+        message = "the server metadata does not match the downloaded add-on";
+        break;
+      }
+      case "download-failure": {
+        message = "the add-on failed to download";
+        break;
+      }
+      case "install-failure": {
+        message = "the add-on failed to install";
+        break;
+      }
+      default: {
+        throw new Error(`Unexpected AddonStudyUpdateError reason: ${reason}`);
+      }
+    }
+    super(`Cannot update study add-on for ${studyName}: ${message}.`);
     this.studyName = studyName;
     this.extra = extra;
   }
 }
 
 class AddonStudyAction extends BaseAction {
   get schema() {
     return ActionSchemas["addon-study"];
@@ -79,30 +132,33 @@ class AddonStudyAction extends BaseActio
     }
   }
 
   /**
    * This hook is executed once for each recipe that currently applies to this
    * client. It is responsible for:
    *
    *   - Enrolling studies the first time they are seen.
+   *   - Updating studies that have upgraded addons.
    *   - Marking studies as having been seen in this session.
    *
-   * If the recipe fails to enroll, it should throw to properly report its status.
+   * If the recipe fails to enroll or update, it should throw to properly report its status.
    */
   async _run(recipe) {
     this.seenRecipeIds.add(recipe.id);
 
     const hasStudy = await AddonStudies.has(recipe.id);
-    if (recipe.arguments.isEnrollmentPaused || hasStudy) {
-      // Recipe does not need anything done
-      return;
+    const { extensionApiId } = recipe.arguments;
+    const extensionDetails = await NormandyApi.fetchExtensionDetails(extensionApiId);
+
+    if (hasStudy) {
+      await this.update(recipe, extensionDetails);
+    } else {
+      await this.enroll(recipe, extensionDetails);
     }
-
-    await this.enroll(recipe);
   }
 
   /**
    * This hook is executed once after all recipes that apply to this client
    * have been processed. It is responsible for unenrolling the client from any
    * studies that no longer apply, based on this.seenRecipeIds.
    */
   async _finalize() {
@@ -116,20 +172,101 @@ class AddonStudyAction extends BaseActio
         } catch (err) {
           Cu.reportError(err);
         }
       }
     }
   }
 
   /**
+   * Download and install the addon for a given recipe
+   *
+   * @param recipe Object describing the study to enroll in.
+   * @param extensionDetails Object describing the addon to be installed.
+   * @param onInstallStarted A function that returns a callback for the install listener.
+   * @param onComplete A callback function that is run on completion of the download.
+   * @param onFailedInstall A callback function that is run if the installation fails.
+   * @param errorClass The class of error to be thrown when exceptions occur.
+   * @param reportError A function that reports errors to Telemetry.
+   */
+  async downloadAndInstall(recipe, extensionDetails, onInstallStarted, onComplete, onFailedInstall, errorClass, reportError) {
+    const { name } = recipe.arguments;
+    const { hash, hash_algorithm } = extensionDetails;
+
+    const downloadDeferred = PromiseUtils.defer();
+    const installDeferred = PromiseUtils.defer();
+
+    const install = await AddonManager.getInstallForURL(extensionDetails.xpi, {
+      hash: `${hash_algorithm}:${hash}`,
+      telemetryInfo: {source: "internal"},
+    });
+
+    const listener = {
+      onDownloadFailed() {
+        downloadDeferred.reject(new errorClass(name, {
+          reason: "download-failure",
+          detail: AddonManager.errorToString(install.error),
+        }));
+      },
+
+      onDownloadEnded() {
+        downloadDeferred.resolve();
+        return false; // temporarily pause installation for Normandy bookkeeping
+      },
+
+      onInstallFailed() {
+        installDeferred.reject(new errorClass(name, {
+          reason: "install-failure",
+          detail: AddonManager.errorToString(install.error),
+        }));
+      },
+
+      onInstallEnded() {
+        installDeferred.resolve();
+      },
+    };
+
+    listener.onInstallStarted = onInstallStarted(installDeferred);
+
+    install.addListener(listener);
+
+    // Download the add-on
+    try {
+      install.install();
+      await downloadDeferred.promise;
+    } catch (err) {
+      reportError(err);
+      install.removeListener(listener);
+      throw err;
+    }
+
+    await onComplete(install, listener);
+
+    // Finish paused installation
+    try {
+      install.install();
+      await installDeferred.promise;
+    } catch (err) {
+      reportError(err);
+      install.removeListener(listener);
+      await onFailedInstall();
+      throw err;
+    }
+
+    install.removeListener(listener);
+
+    return [install.addon.id, install.addon.version];
+  }
+
+  /**
    * Enroll in the study represented by the given recipe.
    * @param recipe Object describing the study to enroll in.
+   * @param extensionDetails Object describing the addon to be installed.
    */
-  async enroll(recipe) {
+  async enroll(recipe, extensionDetails) {
     // This function first downloads the add-on to get its metadata. Then it
     // uses that metadata to record a study in `AddonStudies`. Then, it finishes
     // installing the add-on, and finally sends telemetry. If any of these steps
     // fails, the previous ones are undone, as needed.
     //
     // This ordering is important because the only intermediate states we can be
     // in are:
     //   1. The add-on is only downloaded, in which case AddonManager will clean it up.
@@ -139,110 +276,176 @@ class AddonStudyAction extends BaseActio
     //   3. After installation is complete, but before telemetry, in which case we
     //      lose an enroll event. This is acceptable.
     //
     // This way we a shutdown, crash or unexpected error can't leave Normandy in
     // a long term inconsistent state. The main thing avoided is having a study
     // add-on installed but no record of it, which would leave it permanently
     // installed.
 
-    const { addonUrl, name, description } = recipe.arguments;
-
-    const downloadDeferred = PromiseUtils.defer();
-    const installDeferred = PromiseUtils.defer();
-
-    const install = await AddonManager.getInstallForURL(addonUrl, {
-      telemetryInfo: {source: "internal"},
-    });
+    if (recipe.arguments.isEnrollmentPaused) {
+      // Recipe does not need anything done
+      return;
+    }
 
-    const listener = {
-      onDownloadFailed() {
-        downloadDeferred.reject(new AddonStudyEnrollError(name, {
-          reason: "download-failure",
-          detail: AddonManager.errorToString(install.error),
-        }));
-      },
+    const { extensionApiId, name, description } = recipe.arguments;
 
-      onDownloadEnded() {
-        downloadDeferred.resolve();
-        return false; // temporarily pause installation for Normandy bookkeeping
-      },
+    const onInstallStarted = installDeferred => {
+      return cbInstall => {
+        const versionMatches = cbInstall.addon.version === extensionDetails.version;
+        const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
 
-      onInstallStarted(cbInstall) {
         if (cbInstall.existingAddon) {
           installDeferred.reject(new AddonStudyEnrollError(name, {reason: "conflicting-addon-id"}));
           return false; // cancel the installation, no upgrades allowed
+        } else if (!versionMatches || !idMatches) {
+          installDeferred.reject(new AddonStudyEnrollError(name, {
+            reason: "metadata-mismatch",
+          }));
+          return false; // cancel the installation, server metadata do not match downloaded add-on
         }
         return true;
-      },
+      };
+    };
 
-      onInstallFailed() {
-        installDeferred.reject(new AddonStudyEnrollError(name, {
-          reason: "failed-to-install",
-          detail: AddonManager.errorToString(install.error),
-        }));
-      },
+    const onComplete = async (install, listener) => {
+      const study = {
+        recipeId: recipe.id,
+        name,
+        description,
+        addonId: install.addon.id,
+        addonVersion: install.addon.version,
+        addonUrl: extensionDetails.xpi,
+        extensionApiId,
+        extensionHash: extensionDetails.hash,
+        extensionHashAlgorithm: extensionDetails.hash_algorithm,
+        active: true,
+        studyStartDate: new Date(),
+      };
 
-      onInstallEnded() {
-        installDeferred.resolve();
-      },
+      try {
+        await AddonStudies.add(study);
+      } catch (err) {
+        this.reportEnrollError(err);
+        install.removeListener(listener);
+        install.cancel();
+        throw err;
+      }
+    };
+
+    const onFailedInstall = async () => {
+      await AddonStudies.delete(recipe.id);
     };
 
-    install.addListener(listener);
+    const [installedId, installedVersion] = await this.downloadAndInstall(
+      recipe,
+      extensionDetails,
+      onInstallStarted,
+      onComplete,
+      onFailedInstall,
+      AddonStudyEnrollError,
+      this.reportEnrollError,
+    );
 
-    // Download the add-on
-    try {
-      install.install();
-      await downloadDeferred.promise;
-    } catch (err) {
-      this.reportEnrollError(err);
-      install.removeListener(listener);
-      return;
+    // All done, report success to Telemetry
+    TelemetryEvents.sendEvent("enroll", "addon_study", name, {
+      addonId: installedId,
+      addonVersion: installedVersion,
+    });
+  }
+
+  /**
+   * Update the study represented by the given recipe.
+   * @param recipe Object describing the study to be updated.
+   * @param extensionDetails Object describing the addon to be installed.
+   */
+  async update(recipe, extensionDetails) {
+    const study = await AddonStudies.get(recipe.id);
+    const { extensionApiId, name } = recipe.arguments;
+
+    let error;
+
+    if (study.addonId !== extensionDetails.extension_id) {
+      error = new AddonStudyUpdateError(name, {
+        reason: "addon-id-mismatch",
+      });
     }
 
-    const addonId = install.addon.id;
+    const versionCompare = Services.vc.compare(study.addonVersion, extensionDetails.version);
+    if (versionCompare > 0) {
+      error = new AddonStudyUpdateError(name, {
+        reason: "no-downgrade",
+      });
+    } else if (versionCompare === 0) {
+      return; // Unchanged, do nothing
+    }
+
+    if (error) {
+      this.reportUpdateError(error);
+      throw error;
+    }
 
-    const study = {
-      recipeId: recipe.id,
-      name,
-      description,
-      addonId,
-      addonVersion: install.addon.version,
-      addonUrl,
-      active: true,
-      studyStartDate: new Date(),
+    const onInstallStarted = installDeferred => {
+      return cbInstall => {
+        const versionMatches = cbInstall.addon.version === extensionDetails.version;
+        const idMatches = cbInstall.addon.id === extensionDetails.extension_id;
+
+        if (!cbInstall.existingAddon) {
+          installDeferred.reject(new AddonStudyUpdateError(name, {
+            reason: "addon-does-not-exist",
+          }));
+          return false; // cancel the installation, must upgrade an existing add-on
+        } else if (!versionMatches || !idMatches) {
+          installDeferred.reject(new AddonStudyUpdateError(name, {
+            reason: "metadata-mismatch",
+          }));
+          return false; // cancel the installation, server metadata do not match downloaded add-on
+        }
+
+        return true;
+      };
     };
 
-    try {
-      await AddonStudies.add(study);
-    } catch (err) {
-      this.reportEnrollError(err);
-      install.removeListener(listener);
-      install.cancel();
-      throw err;
-    }
+    const onComplete = async (install, listener) => {
+      try {
+        await AddonStudies.update({
+          ...study,
+          addonVersion: install.addon.version,
+          addonUrl: extensionDetails.xpi,
+          extensionHash: extensionDetails.hash,
+          extensionHashAlgorithm: extensionDetails.hash_algorithm,
+          extensionApiId,
+        });
+      } catch (err) {
+        this.reportUpdateError(err);
+        install.removeListener(listener);
+        install.cancel();
+        throw err;
+      }
+    };
 
-    // finish paused installation
-    try {
-      install.install();
-      await installDeferred.promise;
-    } catch (err) {
-      this.reportEnrollError(err);
-      install.removeListener(listener);
-      await AddonStudies.delete(recipe.id);
-      throw err;
-    }
+    const onFailedInstall = () => {
+      AddonStudies.update(study);
+    };
 
-    // All done, report success to Telemetry and cleanup
-    TelemetryEvents.sendEvent("enroll", "addon_study", name, {
-      addonId: install.addon.id,
-      addonVersion: install.addon.version,
+    const [installedId, installedVersion] = await this.downloadAndInstall(
+      recipe,
+      extensionDetails,
+      onInstallStarted,
+      onComplete,
+      onFailedInstall,
+      AddonStudyUpdateError,
+      this.reportUpdateError,
+    );
+
+    // All done, report success to Telemetry
+    TelemetryEvents.sendEvent("update", "addon_study", name, {
+      addonId: installedId,
+      addonVersion: installedVersion,
     });
-
-    install.removeListener(listener);
   }
 
   reportEnrollError(error) {
     if (error instanceof AddonStudyEnrollError) {
       // One of our known errors. Report it nicely to telemetry
       TelemetryEvents.sendEvent("enrollFailed", "addon_study", error.studyName, error.extra);
     } else {
       /*
@@ -254,16 +457,35 @@ class AddonStudyAction extends BaseActio
         */
       const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
       TelemetryEvents.sendEvent("enrollFailed", "addon_study", error.studyName, {
         reason: safeErrorMessage.slice(0, 80),  // max length is 80 chars
       });
     }
   }
 
+  reportUpdateError(error) {
+    if (error instanceof AddonStudyUpdateError) {
+      // One of our known errors. Report it nicely to telemetry
+      TelemetryEvents.sendEvent("updateFailed", "addon_study", error.studyName, error.extra);
+    } else {
+      /*
+        * Some unknown error. Add some helpful details, and report it to
+        * telemetry. The actual stack trace and error message could possibly
+        * contain PII, so we don't include them here. Instead include some
+        * information that should still be helpful, and is less likely to be
+        * unsafe.
+        */
+      const safeErrorMessage = `${error.fileName}:${error.lineNumber}:${error.columnNumber} ${error.name}`;
+      TelemetryEvents.sendEvent("updateFailed", "addon_study", error.studyName, {
+        reason: safeErrorMessage.slice(0, 80),  // max length is 80 chars
+      });
+    }
+  }
+
   /**
    * Unenrolls the client from the study with a given recipe ID.
    * @param recipeId The recipe ID of an enrolled study
    * @param reason The reason for this unenrollment, to be used in Telemetry
    * @throws If the specified study does not exist, or if it is already inactive.
    */
   async unenroll(recipeId, reason = "unknown") {
     const study = await AddonStudies.get(recipeId);
--- a/toolkit/components/normandy/actions/schemas/index.js
+++ b/toolkit/components/normandy/actions/schemas/index.js
@@ -65,16 +65,17 @@ const ActionSchemas = {
   "addon-study": {
     $schema: "http://json-schema.org/draft-04/schema#",
     title: "Enroll a user in an opt-out SHIELD study",
     type: "object",
     required: [
       "name",
       "description",
       "addonUrl",
+      "extensionApiId",
     ],
     properties: {
       name: {
         description: "User-facing name of the study",
         type: "string",
         minLength: 1,
       },
       description: {
@@ -83,16 +84,20 @@ const ActionSchemas = {
         minLength: 1,
       },
       addonUrl: {
         description: "URL of the add-on XPI file",
         type: "string",
         format: "uri",
         minLength: 1,
       },
+      extensionApiId: {
+        description: "The record ID of the extension used for Normandy API calls.",
+        type: "integer",
+      },
       isEnrollmentPaused: {
         description: "If true, new users will not be enrolled in the study.",
         type: "boolean",
         default: false,
       },
     },
   },
 
--- a/toolkit/components/normandy/docs/data-collection.rst
+++ b/toolkit/components/normandy/docs/data-collection.rst
@@ -163,16 +163,42 @@ Enroll Failure
    value
       The name of the study (``recipe.arguments.name``).
    reason
       A string containing the filename and line number of the code
       that failed, and the name of the error thrown. This information
       is purposely limited to avoid leaking personally identifiable
       information. This should be considered a bug.
 
+Update
+   method
+      The string ``"update"``,
+   object
+      The string ``"addon_study"``,
+   value
+      The name of the study (``recipe.arguments.name``).
+   extra
+      addonId
+         The add-on's ID (example: ``"feature-study@shield.mozilla.com"``).
+      addonVersion
+         The add-on's version (example: ``"1.2.3"``).
+
+Update Failure
+   method
+      The string ``"updateFailed"``
+   object
+      The string ``"addon_study"``
+   value
+      The name of the study (``recipe.arguments.name``).
+   reason
+      A string containing the filename and line number of the code
+      that failed, and the name of the error thrown. This information
+      is purposely limited to avoid leaking personally identifiable
+      information. This should be considered a bug.
+
 Unenrollment
    method
       The string ``"unenroll"``.
    object
       The string ``"addon_study"``.
    value
       The name of the study (``recipe.arguments.name``).
    extra
--- a/toolkit/components/normandy/lib/AddonStudies.jsm
+++ b/toolkit/components/normandy/lib/AddonStudies.jsm
@@ -13,16 +13,22 @@
  * @property {string} description
  *   Description of the study and its intent.
  * @property {boolean} active
  *   Is the study still running?
  * @property {string} addonId
  *   Add-on ID for this particular study.
  * @property {string} addonUrl
  *   URL that the study add-on was installed from.
+ * @property {int} extensionApiId
+ *   The ID used to look up the extension in Normandy's API.
+ * @property {string} extensionHash
+ *   The hash of the XPI file.
+ * @property {string} extensionHashAlgorithm
+ *   The algorithm used to hash the XPI file.
  * @property {string} addonVersion
  *   Study add-on version number
  * @property {string} studyStartDate
  *   Date when the study was started.
  * @property {Date} studyEndDate
  *   Date when the study was ended.
  */
 
@@ -192,16 +198,25 @@ var AddonStudies = {
    * @return {Promise<void, Error>} Resolves when the study is stored, or rejects with an error.
    */
   async add(study) {
     const db = await getDatabase();
     return getStore(db, "readwrite").add(study);
   },
 
   /**
+   * Update a study in storage.
+   * @return {Promise<void, Error>} Resolves when the study is updated, or rejects with an error.
+   */
+  async update(study) {
+    const db = await getDatabase();
+    return getStore(db, "readwrite").put(study);
+  },
+
+  /**
    * Remove a study from storage
    * @param recipeId The recipeId of the study to delete
    * @return {Promise<void, Error>} Resolves when the study is deleted, or rejects with an error.
    */
   async delete(recipeId) {
     const db = await getDatabase();
     return getStore(db, "readwrite").delete(recipeId);
   },
--- a/toolkit/components/normandy/lib/NormandyApi.jsm
+++ b/toolkit/components/normandy/lib/NormandyApi.jsm
@@ -159,16 +159,28 @@ var NormandyApi = {
   /**
    * Fetch an array of available actions from the server.
    * @resolves {Array}
    */
   async fetchActions(filters = {}) {
     return this.fetchSignedObjects("action", filters);
   },
 
+  /**
+   * Fetch details for an extension from the server.
+   * @param extensionId {integer} The ID of the extension to look up
+   * @resolves {Object}
+   */
+  async fetchExtensionDetails(extensionId) {
+    const baseUrl = await this.getApiUrl("extension-list");
+    const extensionDetailsUrl = `${baseUrl}${extensionId}/`;
+    const response = await this.get(extensionDetailsUrl);
+    return response.json();
+  },
+
   async fetchImplementation(action) {
     const implementationUrl = new URL(this.absolutify(action.implementation_url));
 
     // fetch implementation
     const response = await fetch(implementationUrl);
     if (!response.ok) {
       throw new Error(
         `Failed to fetch action implementation for ${action.name}: ${response.status}`
--- a/toolkit/components/normandy/moz.build
+++ b/toolkit/components/normandy/moz.build
@@ -14,10 +14,11 @@ EXTRA_JS_MODULES += [
 ]
 
 XPCOM_MANIFESTS += [
     'components.conf',
 ]
 
 SPHINX_TREES['normandy'] = 'docs'
 
-BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
+TEST_DIRS += ['test/browser']
+
 XPCSHELL_TESTS_MANIFESTS += ['test/unit/xpcshell.ini']
rename from toolkit/components/normandy/test/browser/fixtures/addon-fixture/manifest.json
rename to toolkit/components/normandy/test/browser/addons/normandydriver-1.0/manifest.json
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/addons/normandydriver-2.0/manifest.json
@@ -0,0 +1,11 @@
+{
+    "manifest_version": 2,
+    "name": "normandy_fixture",
+    "version": "2.0",
+    "description": "Dummy test fixture that's a webextension",
+    "applications": {
+        "gecko": {
+            "id": "normandydriver@example.com"
+        }
+    }
+}
--- a/toolkit/components/normandy/test/browser/browser.ini
+++ b/toolkit/components/normandy/test/browser/browser.ini
@@ -1,12 +1,17 @@
 [DEFAULT]
+tags = addons
 support-files =
   action_server.sjs
-  fixtures/normandy.xpi
+  addons/normandydriver-1.0.xpi
+  addons/normandydriver-2.0.xpi
+generated-files =
+  addons/normandydriver-1.0.xpi
+  addons/normandydriver-2.0.xpi
 head = head.js
 [browser_about_preferences.js]
 # Skip this test when FHR/Telemetry aren't available.
 skip-if = !healthreport || !telemetry
 [browser_about_studies.js]
 [browser_actions_AddonStudyAction.js]
 [browser_actions_ConsoleLogAction.js]
 [browser_actions_PreferenceExperimentAction.js]
--- a/toolkit/components/normandy/test/browser/browser_AddonStudies.js
+++ b/toolkit/components/normandy/test/browser/browser_AddonStudies.js
@@ -5,26 +5,16 @@ ChromeUtils.import("resource://gre/modul
 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);
 
 // Initialize test utils
 AddonTestUtils.initMochitest(this);
 
-let _startArgsFactoryId = 1;
-function startArgsFactory(args) {
-  return Object.assign({
-    recipeId: _startArgsFactoryId++,
-    name: "Test",
-    description: "Test",
-    addonUrl: "http://test/addon.xpi",
-  }, args);
-}
-
 decorate_task(
   AddonStudies.withStudies(),
   async function testGetMissing() {
     is(
       await AddonStudies.get("does-not-exist"),
       null,
       "get returns null when the requested study does not exist"
     );
@@ -87,23 +77,40 @@ decorate_task(
       (await AddonStudies.has(study2.recipeId))
     );
     ok(!hasAny, "After calling clear, all studies are removed from storage.");
   }
 );
 
 decorate_task(
   AddonStudies.withStudies([
+    addonStudyFactory({name: "foo"}),
+  ]),
+  async function testUpdate([study]) {
+    Assert.deepEqual(await AddonStudies.get(study.recipeId), study);
+
+    const updatedStudy = {
+      ...study,
+      name: "bar",
+    };
+    await AddonStudies.update(updatedStudy);
+
+    Assert.deepEqual(await AddonStudies.get(study.recipeId), updatedStudy);
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
     addonStudyFactory({active: true, addonId: "does.not.exist@example.com", studyEndDate: null}),
     addonStudyFactory({active: true, addonId: "installed@example.com"}),
     addonStudyFactory({active: false, addonId: "already.gone@example.com", studyEndDate: new Date(2012, 1)}),
   ]),
   withSendEventStub,
-  withInstalledWebExtension({id: "installed@example.com"}),
-  async function testInit([activeUninstalledStudy, activeInstalledStudy, inactiveStudy], sendEventStub) {
+  withInstalledWebExtension({id: "installed@example.com"}, /* expectUninstall: */ true),
+  async function testInit([activeUninstalledStudy, activeInstalledStudy, inactiveStudy], sendEventStub, [addonId, addonFile]) {
     await AddonStudies.init();
 
     const newActiveStudy = await AddonStudies.get(activeUninstalledStudy.recipeId);
     ok(!newActiveStudy.active, "init marks studies as inactive if their add-on is not installed.");
     ok(
       newActiveStudy.studyEndDate,
       "init sets the study end date if a study's add-on is not installed."
     );
@@ -130,28 +137,37 @@ decorate_task(
     Assert.deepEqual(
       activeInstalledStudy,
       newActiveInstalledStudy,
       "init does not modify studies whose add-on is still installed."
     );
 
     // Only activeUninstalledStudy should have generated any events
     ok(sendEventStub.calledOnce, "no extra events should be generated");
+
+    // Clean up
+    const addon = await AddonManager.getAddonByID(addonId);
+    await addon.uninstall();
+    await TestUtils.topicObserved("shield-study-ended", (subject, message) => {
+      return message === `${activeInstalledStudy.recipeId}`;
+    });
   }
 );
 
 decorate_task(
   AddonStudies.withStudies([
     addonStudyFactory({active: true, addonId: "installed@example.com", studyEndDate: null}),
   ]),
   withInstalledWebExtension({id: "installed@example.com"}, /* expectUninstall: */ true),
   async function testInit([study], [id, addonFile]) {
     const addon = await AddonManager.getAddonByID(id);
     await addon.uninstall();
-    await TestUtils.topicObserved("shield-study-ended");
+    await TestUtils.topicObserved("shield-study-ended", (subject, message) => {
+      return message === `${study.recipeId}`;
+    });
 
     const newStudy = await AddonStudies.get(study.recipeId);
     ok(!newStudy.active, "Studies are marked as inactive when their add-on is uninstalled.");
     ok(
       newStudy.studyEndDate,
       "The study end date is set when the add-on for the study is uninstalled."
     );
   }
--- a/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
+++ b/toolkit/components/normandy/test/browser/browser_actions_AddonStudyAction.js
@@ -1,31 +1,144 @@
 "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 FIXTURE_ADDON_ID = "normandydriver@example.com";
-const FIXTURE_ADDON_URL = "http://example.com/browser/toolkit/components/normandy/test/browser/fixtures/normandy.xpi";
+const FIXTURE_ADDON_BASE_URL = "http://example.com/browser/toolkit/components/normandy/test/browser/addons/";
+
+const FIXTURE_ADDONS = [
+  "normandydriver-1.0",
+  "normandydriver-2.0",
+];
+
+// Generate fixture add-on details
+const FIXTURE_ADDON_DETAILS = {};
+FIXTURE_ADDONS.forEach(addon => {
+  const filename = `${addon}.xpi`;
+  const dir = getChromeDir(getResolvedURI(gTestPath));
+  dir.append("addons");
+  dir.append(filename);
+  const xpiFile = Services.io.newFileURI(dir).QueryInterface(Ci.nsIFileURL).file;
+
+  FIXTURE_ADDON_DETAILS[addon] = {
+    url: `${FIXTURE_ADDON_BASE_URL}${filename}`,
+    hash: CryptoUtils.getFileHash(xpiFile, "sha256"),
+  };
+});
 
 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")) {
     args = Object.assign(args, overrides.arguments);
     delete overrides.arguments;
   }
   return recipeFactory(Object.assign({ action: "addon-study", arguments: args }, overrides));
 }
 
+function extensionDetailsFactory(overrides = {}) {
+  return Object.assign({
+    id: 1,
+    name: "Normandy Fixture",
+    xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+    extension_id: FIXTURE_ADDON_ID,
+    version: "1.0",
+    hash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    hash_algorithm: "sha256",
+  }, overrides);
+}
+
+/**
+ * Utility function to uninstall addons safely. Preventing the issue mentioned
+ * in bug 1485569.
+ *
+ * addon.uninstall is async, but it also triggers the AddonStudies onUninstall
+ * listener, which is not awaited. Wrap it here and trigger a promise once it's
+ * done so we can wait until AddonStudies cleanup is finished.
+ */
+async function safeUninstallAddon(addon) {
+  const activeStudies = (await AddonStudies.getAll()).filter(study => study.active);
+  const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
+
+  let studyEndedPromise;
+  if (matchingStudy) {
+    studyEndedPromise = TestUtils.topicObserved("shield-study-ended", (subject, message) => {
+      return message === `${matchingStudy.recipeId}`;
+    });
+  }
+
+  const addonUninstallPromise = addon.uninstall();
+
+  return Promise.all([studyEndedPromise, addonUninstallPromise]);
+}
+
+/**
+ * Test decorator that is a modified version of the withInstalledWebExtension
+ * decorator that safely uninstalls the created addon.
+ */
+function withInstalledWebExtensionSafe(manifestOverrides = {}) {
+  return testFunction => {
+    return async function wrappedTestFunction(...args) {
+      const decorated = withInstalledWebExtension(manifestOverrides, true)(async ([id, file]) => {
+        try {
+          await testFunction(...args, [id, file]);
+        } finally {
+          let addon = await AddonManager.getAddonByID(id);
+          if (addon) {
+            await safeUninstallAddon(addon);
+            addon = await AddonManager.getAddonByID(id);
+            ok(!addon, "add-on should be uninstalled");
+          }
+        }
+      });
+      await decorated();
+    };
+  };
+}
+
+/**
+ * Test decorator to provide a web extension installed from a URL.
+ */
+function withInstalledWebExtensionFromURL(url) {
+  return function wrapper(testFunction) {
+    return async function wrappedTestFunction(...args) {
+      let startupPromise;
+      let addonId;
+
+      const install = await AddonManager.getInstallForURL(url);
+      const listener = {
+        onInstallStarted(cbInstall) {
+          addonId = cbInstall.addon.id;
+          startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId);
+        },
+      };
+      install.addListener(listener);
+
+      await install.install();
+      await startupPromise;
+
+      try {
+        await testFunction(...args, [addonId, url]);
+      } finally {
+        const addonToUninstall = await AddonManager.getAddonByID(addonId);
+        await safeUninstallAddon(addonToUninstall);
+      }
+    };
+  };
+}
+
 /**
  * Test decorator that checks that the test cleans up all add-ons installed
  * during the test. Likely needs to be the first decorator used.
  */
 function ensureAddonCleanup(testFunction) {
   return async function wrappedTestFunction(...args) {
     const beforeAddons = new Set(await AddonManager.getAllAddons());
 
@@ -33,70 +146,179 @@ function ensureAddonCleanup(testFunction
       await testFunction(...args);
     } finally {
       const afterAddons = new Set(await AddonManager.getAllAddons());
       Assert.deepEqual(beforeAddons, afterAddons, "The add-ons should be same before and after the test");
     }
   };
 }
 
-// Test that enroll is not called if recipe is already enrolled
+// Test that enroll is not called if recipe is already enrolled and update does nothing
+// if recipe is unchanged
 decorate_task(
   ensureAddonCleanup,
+  withMockNormandyApi,
   AddonStudies.withStudies([addonStudyFactory()]),
   withSendEventStub,
-  async function enrollTwiceFail([study], sendEventStub) {
+  withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}),
+  async function enrollTwiceFail(mockApi, [study], sendEventStub, installedAddon) {
     const recipe = recipeFactory({
       id: study.recipeId,
       type: "addon-study",
       arguments: {
         name: study.name,
         description: study.description,
         addonUrl: study.addonUrl,
+        extensionApiId: study.extensionApiId,
       },
     });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: study.addonId,
+        hash: study.extensionHash,
+      }),
+    };
     const action = new AddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
+    const updateSpy = sinon.spy(action, "update");
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
-    Assert.deepEqual(enrollSpy.args, [], "enroll should not be called");
+    ok(!enrollSpy.called, "enroll should not be called");
+    ok(updateSpy.called, "update should be called");
     sendEventStub.assertEvents([]);
   },
 );
 
 // Test that if the add-on fails to install, the database is cleaned up and the
 // error is correctly reported.
 decorate_task(
   ensureAddonCleanup,
+  withMockNormandyApi,
   withSendEventStub,
-  AddonStudies.withStudies([]),
-  async function enrollFailInstall(sendEventStub) {
-    const recipe = addonStudyRecipeFactory({ arguments: { addonUrl: "https://example.com/404.xpi" }});
+  AddonStudies.withStudies(),
+  async function enrollDownloadFail(mockApi, sendEventStub) {
+    const recipe = addonStudyRecipeFactory({
+      arguments: {
+        addonUrl: "https://example.com/404.xpi",
+        extensionApiId: 404,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        xpi: recipe.arguments.addonUrl,
+      }),
+    };
     const action = new AddonStudyAction();
-    await action.enroll(recipe);
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
 
     const studies = await AddonStudies.getAll();
     Assert.deepEqual(studies, [], "the study should not be in the database");
 
     sendEventStub.assertEvents(
       [["enrollFailed", "addon_study", recipe.arguments.name, {reason: "download-failure", detail: "ERROR_NETWORK_FAILURE"}]]
     );
   }
 );
 
+// Ensure that the database is clean and error correctly reported if hash check fails
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  AddonStudies.withStudies(),
+  async function enrollHashCheckFails(mockApi, sendEventStub) {
+    const recipe = addonStudyRecipeFactory({
+      arguments: {
+        extensionApiId: 1,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        hash: "badhash",
+      }),
+    };
+    const action = new AddonStudyAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    const studies = await AddonStudies.getAll();
+    Assert.deepEqual(studies, [], "the study should not be in the database");
+
+    sendEventStub.assertEvents(
+      [["enrollFailed", "addon_study", recipe.arguments.name, {
+        reason: "download-failure",
+        detail: "ERROR_INCORRECT_HASH",
+      }]],
+    );
+  }
+);
+
+// Ensure that the database is clean and error correctly reported if there is a metadata mismatch
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  withSendEventStub,
+  AddonStudies.withStudies(),
+  async function enrollFailsMetadataMismatch(mockApi, sendEventStub) {
+    const recipe = addonStudyRecipeFactory({
+      arguments: {
+        extensionApiId: 1,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        xpi: recipe.arguments.addonUrl,
+        version: "1.5",
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+      }),
+    };
+    const action = new AddonStudyAction();
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+
+    const studies = await AddonStudies.getAll();
+    Assert.deepEqual(studies, [], "the study should not be in the database");
+
+    sendEventStub.assertEvents(
+      [["enrollFailed", "addon_study", recipe.arguments.name, {
+        reason: "metadata-mismatch",
+      }]],
+    );
+  }
+);
+
 // Test that in the case of a study add-on conflicting with a non-study add-on, the study does not enroll
 decorate_task(
   ensureAddonCleanup,
-  AddonStudies.withStudies([]),
+  withMockNormandyApi,
   withSendEventStub,
-  withInstalledWebExtension({ version: "0.1", id: FIXTURE_ADDON_ID }),
-  async function conflictingEnrollment(studies, sendEventStub, [installedAddonId, installedAddonFile]) {
+  withInstalledWebExtensionSafe({ version: "0.1", id: FIXTURE_ADDON_ID }),
+  AddonStudies.withStudies(),
+  async function conflictingEnrollment(mockApi, sendEventStub, [installedAddonId, installedAddonFile]) {
     is(installedAddonId, FIXTURE_ADDON_ID, "Generated, installed add-on should have the same ID as the fixture");
-    const addonUrl = FIXTURE_ADDON_URL;
-    const recipe = addonStudyRecipeFactory({ arguments: { name: "conflicting", addonUrl } });
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url;
+    const recipe = addonStudyRecipeFactory({
+      arguments: {
+        name: "conflicting",
+        extensionApiId: 1,
+        addonUrl,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+      }),
+    };
     const action = new AddonStudyAction();
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon.version, "0.1", "The installed add-on should not be replaced");
 
     Assert.deepEqual(await AddonStudies.getAll(), [], "There should be no enrolled studies");
@@ -106,25 +328,38 @@ decorate_task(
     );
   },
 );
 
 // Test a successful enrollment
 decorate_task(
   ensureAddonCleanup,
   withSendEventStub,
+  withMockNormandyApi,
   AddonStudies.withStudies(),
-  async function successfulEnroll(sendEventStub, studies) {
+  async function successfulEnroll(mockApi, sendEventStub, studies) {
     const webExtStartupPromise = AddonTestUtils.promiseWebExtensionStartup(FIXTURE_ADDON_ID);
-    const addonUrl = FIXTURE_ADDON_URL;
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url;
 
     let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     is(addon, null, "Before enroll, the add-on is not installed");
 
-    const recipe = addonStudyRecipeFactory({ arguments: { name: "success", addonUrl } });
+    const recipe = addonStudyRecipeFactory({
+      arguments: {
+        name: "success",
+        extensionApiId: 1,
+        addonUrl,
+      },
+    });
+    const extensionDetails = extensionDetailsFactory({
+      id: recipe.arguments.extensionApiId,
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetails,
+    };
     const action = new AddonStudyAction();
     await action.runRecipe(recipe);
     is(action.lastError, null, "lastError should be null");
 
     await webExtStartupPromise;
     addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
     ok(addon, "After start is called, the add-on is installed");
 
@@ -138,28 +373,408 @@ decorate_task(
         recipeId: recipe.id,
         name: recipe.arguments.name,
         description: recipe.arguments.description,
         addonId: FIXTURE_ADDON_ID,
         addonVersion: "1.0",
         addonUrl,
         active: true,
         studyStartDate: study.studyStartDate,
+        extensionApiId: recipe.arguments.extensionApiId,
+        extensionHash: extensionDetails.hash,
+        extensionHashAlgorithm: extensionDetails.hash_algorithm,
       },
       "study data should be stored",
     );
     ok(study.studyStartDate, "a start date should be assigned");
     is(study.studyEndDate, null, "an end date should not be assigned");
 
     sendEventStub.assertEvents(
       [["enroll", "addon_study", recipe.arguments.name, { addonId: FIXTURE_ADDON_ID, addonVersion: "1.0" }]]
     );
 
     // cleanup
-    await addon.uninstall();
+    await safeUninstallAddon(addon);
+    Assert.deepEqual(AddonManager.getAllAddons(), [], "add-on should be uninstalled.");
+  },
+);
+
+// Test a successful update
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([addonStudyFactory({
+    addonId: FIXTURE_ADDON_ID,
+    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    extensionHashAlgorithm: "sha256",
+    addonVersion: "1.0",
+  })]),
+  withSendEventStub,
+  withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}),
+  async function successfulUpdate(mockApi, [study], sendEventStub, installedAddon) {
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url;
+    const recipe = recipeFactory({
+      id: study.recipeId,
+      type: "addon-study",
+      arguments: {
+        name: study.name,
+        description: study.description,
+        extensionApiId: study.extensionApiId,
+        addonUrl,
+      },
+    });
+    const hash = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash;
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: study.addonId,
+        xpi: addonUrl,
+        hash,
+        version: "2.0",
+      }),
+    };
+    const action = new AddonStudyAction();
+    const enrollSpy = sinon.spy(action, "enroll");
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+    ok(!enrollSpy.called, "enroll should not be called");
+    ok(updateSpy.called, "update should be called");
+    sendEventStub.assertEvents(
+      [["update", "addon_study", recipe.arguments.name, {
+        addonId: FIXTURE_ADDON_ID,
+        addonVersion: "2.0",
+      }]],
+    );
+
+    const updatedStudy = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(
+      updatedStudy,
+      {
+        ...study,
+        addonVersion: "2.0",
+        addonUrl,
+        extensionApiId: recipe.arguments.extensionApiId,
+        extensionHash: hash,
+      },
+      "study data should be updated",
+    );
+
+    const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    ok(addon.version === "2.0", "add-on should be updated");
+  },
+);
+
+// Test update fails when addon ID does not match
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([addonStudyFactory({
+    addonId: "test@example.com",
+    extensionHash: "01d",
+    extensionHashAlgorithm: "sha256",
+    addonVersion: "0.1",
+  })]),
+  withSendEventStub,
+  withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "0.1"}),
+  async function updateFailsAddonIdMismatch(mockApi, [study], sendEventStub, installedAddon) {
+    const recipe = recipeFactory({
+      id: study.recipeId,
+      type: "addon-study",
+      arguments: {
+        name: study.name,
+        description: study.description,
+        extensionApiId: study.extensionApiId,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: FIXTURE_ADDON_ID,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+      }),
+    };
+    const action = new AddonStudyAction();
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+    ok(updateSpy.called, "update should be called");
+    sendEventStub.assertEvents(
+      [["updateFailed", "addon_study", recipe.arguments.name, {
+        reason: "addon-id-mismatch",
+      }]],
+    );
+
+    const updatedStudy = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
+
+    let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    ok(addon.version === "0.1", "add-on should be unchanged");
+  },
+);
+
+// Test update fails when original addon does not exist
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([addonStudyFactory({
+    extensionHash: "01d",
+    extensionHashAlgorithm: "sha256",
+    addonVersion: "0.1",
+  })]),
+  withSendEventStub,
+  withInstalledWebExtensionSafe({id: "test@example.com", version: "0.1"}),
+  async function updateFailsAddonDoesNotExist(mockApi, [study], sendEventStub, installedAddon) {
+    const recipe = recipeFactory({
+      id: study.recipeId,
+      type: "addon-study",
+      arguments: {
+        name: study.name,
+        description: study.description,
+        extensionApiId: study.extensionApiId,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: study.addonId,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+      }),
+    };
+    const action = new AddonStudyAction();
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+    ok(updateSpy.called, "update should be called");
+    sendEventStub.assertEvents(
+      [["updateFailed", "addon_study", recipe.arguments.name, {
+        reason: "addon-does-not-exist",
+      }]],
+    );
+
+    const updatedStudy = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
+
+    let addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    ok(!addon, "new add-on should not be installed");
+
+    addon = await AddonManager.getAddonByID("test@example.com");
+    ok(addon, "old add-on should still be installed");
+  },
+);
+
+// Test update fails when download fails
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([addonStudyFactory({
+    addonId: FIXTURE_ADDON_ID,
+    extensionHash: "01d",
+    extensionHashAlgorithm: "sha256",
+    addonVersion: "0.1",
+  })]),
+  withSendEventStub,
+  withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "0.1"}),
+  async function updateDownloadFailure(mockApi, [study], sendEventStub, installedAddon) {
+    const recipe = recipeFactory({
+      id: study.recipeId,
+      type: "addon-study",
+      arguments: {
+        name: study.name,
+        description: study.description,
+        extensionApiId: study.extensionApiId,
+        addonUrl: "https://example.com/404.xpi",
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: study.addonId,
+        xpi: "https://example.com/404.xpi",
+      }),
+    };
+    const action = new AddonStudyAction();
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+    ok(updateSpy.called, "update should be called");
+    sendEventStub.assertEvents(
+      [["updateFailed", "addon_study", recipe.arguments.name, {
+        reason: "download-failure",
+        detail: "ERROR_NETWORK_FAILURE",
+      }]],
+    );
+
+    const updatedStudy = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
+
+    const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    ok(addon.version === "0.1", "add-on should be unchanged");
+  },
+);
+
+// Test update fails when hash check fails
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([addonStudyFactory({
+    addonId: FIXTURE_ADDON_ID,
+    extensionHash: "01d",
+    extensionHashAlgorithm: "sha256",
+    addonVersion: "0.1",
+  })]),
+  withSendEventStub,
+  withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "0.1"}),
+  async function updateFailsHashCheckFail(mockApi, [study], sendEventStub, installedAddon) {
+    const recipe = recipeFactory({
+      id: study.recipeId,
+      type: "addon-study",
+      arguments: {
+        name: study.name,
+        description: study.description,
+        extensionApiId: study.extensionApiId,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: study.addonId,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        hash: "badhash",
+      }),
+    };
+    const action = new AddonStudyAction();
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+    ok(updateSpy.called, "update should be called");
+    sendEventStub.assertEvents(
+      [["updateFailed", "addon_study", recipe.arguments.name, {
+        reason: "download-failure",
+        detail: "ERROR_INCORRECT_HASH",
+      }]],
+    );
+
+    const updatedStudy = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
+
+    const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    ok(addon.version === "0.1", "add-on should be unchanged");
+  },
+);
+
+// Test update fails on downgrade when study version is greater than extension version
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([addonStudyFactory({
+    addonId: FIXTURE_ADDON_ID,
+    extensionHash: "01d",
+    extensionHashAlgorithm: "sha256",
+    addonVersion: "2.0",
+  })]),
+  withSendEventStub,
+  withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "2.0"}),
+  async function upgradeFailsNoDowngrades(mockApi, [study], sendEventStub, installedAddon) {
+    const recipe = recipeFactory({
+      id: study.recipeId,
+      type: "addon-study",
+      arguments: {
+        name: study.name,
+        description: study.description,
+        extensionApiId: study.extensionApiId,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: study.addonId,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+        version: "1.0",
+      }),
+    };
+    const action = new AddonStudyAction();
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+    ok(updateSpy.called, "update should be called");
+    sendEventStub.assertEvents(
+      [["updateFailed", "addon_study", recipe.arguments.name, {
+        reason: "no-downgrade",
+      }]],
+    );
+
+    const updatedStudy = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
+
+    const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    ok(addon.version === "2.0", "add-on should be unchanged");
+  },
+);
+
+// Test update fails when there is a version mismatch with metadata
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([addonStudyFactory({
+    addonId: FIXTURE_ADDON_ID,
+    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    extensionHashAlgorithm: "sha256",
+    addonVersion: "1.0",
+  })]),
+  withSendEventStub,
+  withInstalledWebExtensionFromURL(FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url),
+  async function upgradeFailsMetadataMismatchVersion(mockApi, [study], sendEventStub, installedAddon) {
+    const recipe = recipeFactory({
+      id: study.recipeId,
+      type: "addon-study",
+      arguments: {
+        name: study.name,
+        description: study.description,
+        extensionApiId: study.extensionApiId,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: study.addonId,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+        version: "3.0",
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash,
+      }),
+    };
+    const action = new AddonStudyAction();
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+    ok(updateSpy.called, "update should be called");
+    sendEventStub.assertEvents(
+      [["updateFailed", "addon_study", recipe.arguments.name, {
+        reason: "metadata-mismatch",
+      }]],
+    );
+
+    const updatedStudy = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(updatedStudy, study, "study data should be unchanged");
+
+    const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    ok(addon.version === "1.0", "add-on should be unchanged");
+
+    let addonSourceURI = addon.getResourceURI();
+    if (addonSourceURI instanceof Ci.nsIJARURI) {
+      addonSourceURI = addonSourceURI.JARFile;
+    }
+    const xpiFile = addonSourceURI.QueryInterface(Ci.nsIFileURL).file;
+    const installedHash = CryptoUtils.getFileHash(xpiFile, study.extensionHashAlgorithm);
+    ok(installedHash === study.extensionHash, "add-on should be unchanged");
   },
 );
 
 // Test that unenrolling fails if the study doesn't exist
 decorate_task(
   ensureAddonCleanup,
   AddonStudies.withStudies(),
   async function unenrollNonexistent(studies) {
@@ -224,17 +839,17 @@ decorate_task(
 
 // If the add-on for a study isn't installed, a warning should be logged, but the action is still successful
 decorate_task(
   ensureAddonCleanup,
   AddonStudies.withStudies([
     addonStudyFactory({active: true, addonId: "missingAddon@example.com", studyEndDate: null}),
   ]),
   withSendEventStub,
-  async function unenrollTest([study], sendEventStub) {
+  async function unenrollMissingAddonTest([study], sendEventStub) {
     const action = new AddonStudyAction();
 
     SimpleTest.waitForExplicitFinish();
     SimpleTest.monitorConsole(() => SimpleTest.finish(), [{message: /could not uninstall addon/i}]);
     await action.unenroll(study.recipeId);
 
     sendEventStub.assertEvents(
       [["unenroll", "addon_study", study.name, {
@@ -246,50 +861,187 @@ decorate_task(
 
     SimpleTest.endMonitorConsole();
   },
 );
 
 // Test that the action respects the study opt-out
 decorate_task(
   ensureAddonCleanup,
+  withMockNormandyApi,
   withSendEventStub,
   withMockPreferences,
-  AddonStudies.withStudies([]),
-  async function testOptOut(sendEventStub, mockPreferences) {
+  AddonStudies.withStudies(),
+  async function testOptOut(mockApi, sendEventStub, mockPreferences) {
     mockPreferences.set("app.shield.optoutstudies.enabled", false);
     const action = new AddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
     const recipe = addonStudyRecipeFactory();
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+      }),
+    };
     await action.runRecipe(recipe);
     is(action.state, AddonStudyAction.STATE_DISABLED, "the action should be disabled");
     await action.finalize();
     is(action.state, AddonStudyAction.STATE_FINALIZED, "the action should be finalized");
     is(action.lastError, null, "lastError should be null");
     Assert.deepEqual(enrollSpy.args, [], "enroll should not be called");
     sendEventStub.assertEvents([]);
   },
 );
 
-// Test that the action does not execute paused recipes
+// Test that the action does not enroll paused recipes
 decorate_task(
   ensureAddonCleanup,
+  withMockNormandyApi,
   withSendEventStub,
-  AddonStudies.withStudies([]),
-  async function testOptOut(sendEventStub) {
+  AddonStudies.withStudies(),
+  async function testEnrollmentPaused(mockApi, sendEventStub) {
     const action = new AddonStudyAction();
     const enrollSpy = sinon.spy(action, "enroll");
     const recipe = addonStudyRecipeFactory({arguments: {isEnrollmentPaused: true}});
+    const extensionDetails = extensionDetailsFactory({
+      id: recipe.arguments.extensionApiId,
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetails,
+    };
     await action.runRecipe(recipe);
+    const addon = await AddonManager.getAddonByID(extensionDetails.extension_id);
+    is(addon, null, "the add-on should not have been installed");
     await action.finalize();
-    Assert.deepEqual(enrollSpy.args, [], "enroll should not be called");
+    ok(enrollSpy.called, "enroll should be called");
     sendEventStub.assertEvents([]);
   },
 );
 
+// Test that the action updates paused recipes
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([addonStudyFactory({
+    addonId: FIXTURE_ADDON_ID,
+    extensionHash: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].hash,
+    extensionHashAlgorithm: "sha256",
+    addonVersion: "1.0",
+  })]),
+  withSendEventStub,
+  withInstalledWebExtensionSafe({id: FIXTURE_ADDON_ID, version: "1.0"}),
+  async function testUpdateEnrollmentPaused(mockApi, [study], sendEventStub, installedAddon) {
+    const addonUrl = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url;
+    const recipe = recipeFactory({
+      id: study.recipeId,
+      type: "addon-study",
+      arguments: {
+        isEnrollmentPaused: true,
+        name: study.name,
+        description: study.description,
+        extensionApiId: study.extensionApiId,
+        addonUrl,
+      },
+    });
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: study.addonId,
+        xpi: addonUrl,
+        hash: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash,
+        version: "2.0",
+      }),
+    };
+    const action = new AddonStudyAction();
+    const enrollSpy = sinon.spy(action, "enroll");
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+    is(action.lastError, null, "lastError should be null");
+    ok(!enrollSpy.called, "enroll should not be called");
+    ok(updateSpy.called, "update should be called");
+    sendEventStub.assertEvents(
+      [["update", "addon_study", recipe.arguments.name, {
+        addonId: FIXTURE_ADDON_ID,
+        addonVersion: "2.0",
+      }]],
+    );
+
+    const addon = await AddonManager.getAddonByID(FIXTURE_ADDON_ID);
+    ok(addon.version === "2.0", "add-on should be updated");
+  },
+);
+
+// Test that update method works for legacy studies with no hash
+decorate_task(
+  ensureAddonCleanup,
+  withMockNormandyApi,
+  AddonStudies.withStudies([addonStudyFactory({
+    addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url,
+    addonId: FIXTURE_ADDON_ID,
+    addonVersion: "1.0",
+  })]),
+  withSendEventStub,
+  withInstalledWebExtensionFromURL(FIXTURE_ADDON_DETAILS["normandydriver-1.0"].url),
+  async function updateWorksLegacyStudy(mockApi, [study], sendEventStub, installedAddon) {
+    delete study.extensionHash;
+    delete study.extensionHashAlgorithm;
+    await AddonStudies.update(study);
+
+    const recipe = recipeFactory({
+      id: study.recipeId,
+      type: "addon-study",
+      arguments: {
+        name: study.name,
+        description: study.description,
+        extensionApiId: study.extensionApiId,
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+      },
+    });
+
+    const hash = FIXTURE_ADDON_DETAILS["normandydriver-2.0"].hash;
+    mockApi.extensionDetails = {
+      [recipe.arguments.extensionApiId]: extensionDetailsFactory({
+        id: recipe.arguments.extensionApiId,
+        extension_id: study.addonId,
+        xpi: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+        hash,
+        version: "2.0",
+      }),
+    };
+
+    const action = new AddonStudyAction();
+    const enrollSpy = sinon.spy(action, "enroll");
+    const updateSpy = sinon.spy(action, "update");
+    await action.runRecipe(recipe);
+
+    is(action.lastError, null, "lastError should be null");
+    ok(!enrollSpy.called, "enroll should not be called");
+    ok(updateSpy.called, "update should be called");
+    sendEventStub.assertEvents(
+      [["update", "addon_study", "Test study", {
+        addonId: "normandydriver@example.com",
+        addonVersion: "2.0",
+      }]],
+    );
+
+    const updatedStudy = await AddonStudies.get(recipe.id);
+    Assert.deepEqual(
+      updatedStudy,
+      {
+        ...study,
+        addonVersion: "2.0",
+        addonUrl: FIXTURE_ADDON_DETAILS["normandydriver-2.0"].url,
+        extensionApiId: recipe.arguments.extensionApiId,
+        extensionHash: hash,
+        extensionHashAlgorithm: "sha256",
+      },
+      "study data should be updated",
+    );
+  },
+);
+
 // Test that enroll is not called if recipe is already enrolled
 decorate_task(
   ensureAddonCleanup,
   AddonStudies.withStudies([addonStudyFactory()]),
   async function enrollTwiceFail([study]) {
     const action = new AddonStudyAction();
     const unenrollSpy = sinon.stub(action, "unenroll");
     await action.finalize();
deleted file mode 100644
index 71a6f8fe7cd90427ca710599e7125f986e8dd24d..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
GIT binary patch
literal 0
Hc$@<O00001
--- a/toolkit/components/normandy/test/browser/head.js
+++ b/toolkit/components/normandy/test/browser/head.js
@@ -4,16 +4,21 @@ ChromeUtils.import("resource://testing-c
 ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
 ChromeUtils.import("resource://normandy/lib/SandboxManager.jsm", this);
 ChromeUtils.import("resource://normandy/lib/NormandyDriver.jsm", this);
 ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
 ChromeUtils.defineModuleGetter(this, "TelemetryTestUtils",
                                "resource://testing-common/TelemetryTestUtils.jsm");
 
+const CryptoHash = Components.Constructor("@mozilla.org/security/hash;1",
+                                          "nsICryptoHash", "initWithString");
+const FileInputStream = Components.Constructor("@mozilla.org/network/file-input-stream;1",
+                                               "nsIFileInputStream", "init");
+
 // Load mocking/stubbing library, sinon
 // docs: http://sinonjs.org/docs/
 /* global sinon */
 Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
 
 // Make sinon assertions fail in a way that mochitest understands
 sinon.assert.fail = function(message) {
   ok(false, message);
@@ -26,18 +31,18 @@ registerCleanupFunction(async function()
 
 // Prep Telemetry to receive events from tests
 TelemetryEvents.init();
 
 this.UUID_REGEX = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
 
 this.TEST_XPI_URL = (function() {
   const dir = getChromeDir(getResolvedURI(gTestPath));
-  dir.append("fixtures");
-  dir.append("normandy.xpi");
+  dir.append("addons");
+  dir.append("normandydriver-1.0.xpi");
   return Services.io.newFileURI(dir).spec;
 })();
 
 this.withWebExtension = function(manifestOverrides = {}) {
   return function wrapper(testFunction) {
     return async function wrappedTestFunction(...args) {
       const random = Math.random().toString(36).replace(/0./, "").substr(-3);
       let id = `normandydriver_${random}@example.com`;
@@ -124,37 +129,47 @@ this.withDriver = function(Assert, testF
     const sandboxManager = args[args.length - 1];
     const driver = new NormandyDriver(sandboxManager);
     await testFunction(driver, ...args);
   });
 };
 
 this.withMockNormandyApi = function(testFunction) {
   return async function inner(...args) {
-    const mockApi = {actions: [], recipes: [], implementations: {}};
+    const mockApi = {actions: [], recipes: [], implementations: {}, extensionDetails: {}};
 
     // Use callsFake instead of resolves so that the current values in mockApi are used.
     mockApi.fetchActions = sinon.stub(NormandyApi, "fetchActions").callsFake(async () => mockApi.actions);
     mockApi.fetchRecipes = sinon.stub(NormandyApi, "fetchRecipes").callsFake(async () => mockApi.recipes);
     mockApi.fetchImplementation = sinon.stub(NormandyApi, "fetchImplementation").callsFake(
       async action => {
         const impl = mockApi.implementations[action.name];
         if (!impl) {
           throw new Error(`Missing implementation for ${action.name}`);
         }
         return impl;
       }
     );
+    mockApi.fetchExtensionDetails = sinon.stub(NormandyApi, "fetchExtensionDetails").callsFake(
+      async extensionId => {
+        const details = mockApi.extensionDetails[extensionId];
+        if (!details) {
+          throw new Error(`Missing extension details for ${extensionId}`);
+        }
+        return details;
+      }
+    );
 
     try {
       await testFunction(mockApi, ...args);
     } finally {
       mockApi.fetchActions.restore();
       mockApi.fetchRecipes.restore();
       mockApi.fetchImplementation.restore();
+      mockApi.fetchExtensionDetails.restore();
     }
   };
 };
 
 const preferenceBranches = {
   user: Preferences,
   default: new Preferences({defaultBranch: true}),
 };
@@ -290,16 +305,19 @@ this.addonStudyFactory = function(attrs)
     recipeId: _addonStudyFactoryId++,
     name: "Test study",
     description: "fake",
     active: true,
     addonId: "fake@example.com",
     addonUrl: "http://test/addon.xpi",
     addonVersion: "1.0.0",
     studyStartDate: new Date(),
+    extensionApiId: 1,
+    extensionHash: "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
+    extensionHashAlgorithm: "sha256",
   }, attrs);
 };
 
 let _preferenceStudyFactoryId = 0;
 this.preferenceStudyFactory = function(attrs) {
   return Object.assign({
     name: `Test study ${_preferenceStudyFactoryId++}`,
     branch: "control",
@@ -378,8 +396,34 @@ function mockLogger() {
   logStub.error = sinon.stub();
   logStub.warn = sinon.stub();
   logStub.info = sinon.stub();
   logStub.config = sinon.stub();
   logStub.debug = sinon.stub();
   logStub.trace = sinon.stub();
   return logStub;
 }
+
+this.CryptoUtils = {
+  _getHashStringForCrypto(aCrypto) {
+    // return the two-digit hexadecimal code for a byte
+    let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+    // convert the binary hash data to a hex string.
+    let binary = aCrypto.finish(false);
+    let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+    return hash.join("").toLowerCase();
+  },
+
+  /**
+   * Get the computed hash for a given file
+   * @param {nsIFile} file The file to be hashed
+   * @param {string} [algorithm] The hashing algorithm to use
+   */
+  getFileHash(file, algorithm = "sha256") {
+    const crypto = CryptoHash(algorithm);
+    const fis = new FileInputStream(file, -1, -1, false);
+    crypto.updateFromStream(fis, file.fileSize);
+    const hash = this._getHashStringForCrypto(crypto);
+    fis.close();
+    return hash;
+  },
+};
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/browser/moz.build
@@ -0,0 +1,24 @@
+# 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/.
+
+BROWSER_CHROME_MANIFESTS += [
+  'browser.ini',
+]
+
+addons = [
+  'normandydriver-1.0',
+  'normandydriver-2.0',
+]
+
+output_dir = OBJDIR_FILES._tests.testing.mochitest.browser.toolkit.components.normandy.test.browser.addons
+
+for addon in addons:
+    indir = 'addons/%s' % addon
+    xpi = '%s.xpi' % indir
+
+    GENERATED_FILES += [xpi]
+    GENERATED_FILES[xpi].script = '../create_xpi.py'
+    GENERATED_FILES[xpi].inputs = [indir]
+
+    output_dir += ['!%s' % xpi]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/create_xpi.py
@@ -0,0 +1,12 @@
+# 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/.
+
+from os.path import abspath
+
+from mozbuild.action.zip import main as create_zip
+
+
+def main(output, input_dir):
+    output.close()
+    return create_zip(['-C', input_dir, abspath(output.name), '**'])
new file mode 100644
--- /dev/null
+++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/extension/1/index.json
@@ -0,0 +1,9 @@
+{
+  "id": 1,
+  "name": "Normandy Fixture",
+  "xpi": "http://example.com/browser/toolkit/components/normandy/test/browser/fixtures/normandy.xpi",
+  "extension_id": "normandydriver@example.com",
+  "version": "1.0",
+  "hash": "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
+  "hash_algorithm": "sha256"
+}
new file mode 100644
--- a/toolkit/components/normandy/test/unit/mock_api/api/v1/index.json
+++ b/toolkit/components/normandy/test/unit/mock_api/api/v1/index.json
@@ -1,9 +1,10 @@
 {
   "action-list": "/api/v1/action/",
   "action-signed": "/api/v1/action/signed/",
   "classify-client": "/api/v1/classify_client/",
+  "extension-list": "/api/v1/extension/",
   "filters": "/api/v1/filters/",
   "recipe-list": "/api/v1/recipe/",
   "recipe-signed": "/api/v1/recipe/signed/",
   "reciperevision-list": "/api/v1/recipe_revision/"
 }
--- a/toolkit/components/normandy/test/unit/test_NormandyApi.js
+++ b/toolkit/components/normandy/test/unit/test_NormandyApi.js
@@ -1,98 +1,16 @@
 /* globals sinon */
 "use strict";
 
-const {HttpServer} = ChromeUtils.import("resource://testing-common/httpd.js");
 ChromeUtils.import("resource://gre/modules/CanonicalJSON.jsm", this);
 ChromeUtils.import("resource://gre/modules/osfile.jsm", this);
 ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
 
-load("utils.js"); /* globals withMockPreferences */
-
-class MockResponse {
-  constructor(content) {
-    this.content = content;
-  }
-
-  async text() {
-    return this.content;
-  }
-
-  async json() {
-    return JSON.parse(this.content);
-  }
-}
-
-function withServer(server, task) {
-  return withMockPreferences(async function inner(preferences) {
-    const serverUrl = `http://localhost:${server.identity.primaryPort}`;
-    preferences.set("app.normandy.api_url", `${serverUrl}/api/v1`);
-    preferences.set(
-      "security.content.signature.root_hash",
-      // Hash of the key that signs the normandy dev certificates
-      "4C:35:B1:C3:E3:12:D9:55:E7:78:ED:D0:A7:E7:8A:38:83:04:EF:01:BF:FA:03:29:B2:46:9F:3C:C5:EC:36:04"
-    );
-    NormandyApi.clearIndexCache();
-
-    try {
-      await task(serverUrl, preferences);
-    } finally {
-      await new Promise(resolve => server.stop(resolve));
-    }
-  });
-}
-
-function makeScriptServer(scriptPath) {
-  const server = new HttpServer();
-  server.registerContentType("sjs", "sjs");
-  server.registerFile("/", do_get_file(scriptPath));
-  server.start(-1);
-  return server;
-}
-
-function withScriptServer(scriptPath, task) {
-  return withServer(makeScriptServer(scriptPath), task);
-}
-
-function makeMockApiServer(directory) {
-  const server = new HttpServer();
-  server.registerDirectory("/", directory);
-
-  server.setIndexHandler(async function(request, response) {
-    response.processAsync();
-    const dir = request.getProperty("directory");
-    const index = dir.clone();
-    index.append("index.json");
-
-    if (!index.exists()) {
-      response.setStatusLine("1.1", 404, "Not Found");
-      response.write(`Cannot find path ${index.path}`);
-      response.finish();
-      return;
-    }
-
-    try {
-      const contents = await OS.File.read(index.path, {encoding: "utf-8"});
-      response.write(contents);
-    } catch (e) {
-      response.setStatusLine("1.1", 500, "Server error");
-      response.write(e.toString());
-    } finally {
-      response.finish();
-    }
-  });
-
-  server.start(-1);
-  return server;
-}
-
-function withMockApiServer(task) {
-  return withServer(makeMockApiServer(do_get_file("mock_api")), task);
-}
+load("utils.js"); /* globals withMockApiServer, MockResponse, withScriptServer, withServer, makeMockApiServer */
 
 add_task(withMockApiServer(async function test_get(serverUrl) {
   // Test that NormandyApi can fetch from the test server.
   const response = await NormandyApi.get(`${serverUrl}/api/v1/`);
   const data = await response.json();
   equal(data["recipe-list"], "/api/v1/recipe/", "Expected data in response");
 }));
 
@@ -221,16 +139,29 @@ add_task(withMockApiServer(async functio
   equal(actions.length, 4);
   const actionNames = actions.map(a => a.name);
   ok(actionNames.includes("console-log"));
   ok(actionNames.includes("opt-out-study"));
   ok(actionNames.includes("show-heartbeat"));
   ok(actionNames.includes("preference-experiment"));
 }));
 
+add_task(withMockApiServer(async function test_fetchExtensionDetails() {
+  const extensionDetails = await NormandyApi.fetchExtensionDetails(1);
+  deepEqual(extensionDetails, {
+    "id": 1,
+    "name": "Normandy Fixture",
+    "xpi": "http://example.com/browser/toolkit/components/normandy/test/browser/fixtures/normandy.xpi",
+    "extension_id": "normandydriver@example.com",
+    "version": "1.0",
+    "hash": "ade1c14196ec4fe0aa0a6ba40ac433d7c8d1ec985581a8a94d43dc58991b5171",
+    "hash_algorithm": "sha256",
+  });
+}));
+
 add_task(withScriptServer("query_server.sjs", async function test_getTestServer(serverUrl) {
   // Test that NormandyApi can fetch from the test server.
   const response = await NormandyApi.get(serverUrl);
   const data = await response.json();
   Assert.deepEqual(data, {queryString: {}, body: {}}, "NormandyApi returned incorrect server data.");
 }));
 
 add_task(withScriptServer("query_server.sjs", async function test_getQueryString(serverUrl) {
--- a/toolkit/components/normandy/test/unit/test_addon_unenroll.js
+++ b/toolkit/components/normandy/test/unit/test_addon_unenroll.js
@@ -1,32 +1,37 @@
-
 const {AddonTestUtils} = ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm");
 const {ExtensionTestUtils} = ChromeUtils.import("resource://testing-common/ExtensionXPCShellUtils.jsm");
 const {AddonStudyAction} = ChromeUtils.import("resource://normandy/actions/AddonStudyAction.jsm");
 const {TelemetryEvents} = ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm");
 const {AddonManager} = ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
 
 const global = this;
 
-add_task(async function test_addon_unenroll() {
+load("utils.js"); /* globals withMockApiServer, CryptoUtils */
+
+add_task(withMockApiServer(async function test_addon_unenroll(...args) {
+  const apiServer = args[args.length - 1];
+
   ExtensionTestUtils.init(global);
   AddonTestUtils.init(global);
   AddonTestUtils.createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
   AddonTestUtils.overrideCertDB();
   await AddonTestUtils.promiseStartupManager();
 
   TelemetryEvents.init();
 
   const ID = "study@tests.mozilla.org";
 
   // Create a test extension that uses webextension experiments to install
   // an unenroll listener.
   let xpi = AddonTestUtils.createTempWebExtensionFile({
     manifest: {
+      version: "1.0",
+
       applications: {
         gecko: { id: ID },
       },
 
       experiment_apis: {
         study: {
           schema: "schema.json",
           parent: {
@@ -86,31 +91,46 @@ add_task(async function test_addon_unenr
         });
       });
     },
   });
 
   const server = AddonTestUtils.createHttpServer({hosts: ["example.com"]});
   server.registerFile("/study.xpi", xpi);
 
+  const API_ID = 999;
+  apiServer.registerPathHandler(`/api/v1/extension/${API_ID}/`, (request, response) => {
+    response.setStatusLine(request.httpVersion, 200, "OK");
+    response.write(JSON.stringify({
+      id: API_ID,
+      name: "Addon Unenroll Fixture",
+      xpi: "http://example.com/study.xpi",
+      extension_id: ID,
+      version: "1.0",
+      hash: CryptoUtils.getFileHash(xpi, "sha256"),
+      hash_algorithm: "sha256",
+    }));
+  });
+
   // Begin by telling Normandy to install the test extension above
   // that uses a webextension experiment to register a blocking callback
   // to be invoked when the study ends.
   let extension = ExtensionTestUtils.expectExtension(ID);
 
   const RECIPE_ID = 1;
   const UNENROLL_REASON = "test-ending";
   let action = new AddonStudyAction();
   await action.runRecipe({
     id: RECIPE_ID,
     type: "addon-study",
     arguments: {
       name: "addon unenroll test",
       description: "testing",
       addonUrl: "http://example.com/study.xpi",
+      extensionApiId: API_ID,
     },
   });
 
   await extension.awaitStartup();
 
   let addon = await AddonManager.getAddonByID(ID);
   ok(addon, "Extension is installed");
 
@@ -128,9 +148,9 @@ add_task(async function test_addon_unenr
 
   // Once the extension does resolve the promise returned from the
   // event listener, the uninstall can proceed.
   extension.sendMessage("resolve");
   await unenrollPromise;
 
   addon = await AddonManager.getAddonByID(ID);
   equal(addon, null, "After resolving studyEnded promise, extension is uninstalled");
-});
+}));
--- a/toolkit/components/normandy/test/unit/utils.js
+++ b/toolkit/components/normandy/test/unit/utils.js
@@ -1,15 +1,24 @@
 "use strict";
 /* eslint-disable no-unused-vars */
 
 // Loaded into the same scope as head_xpc.js
 /* import-globals-from head_xpc.js */
 
 const {Preferences} = ChromeUtils.import("resource://gre/modules/Preferences.jsm");
+const {HttpServer} = ChromeUtils.import("resource://testing-common/httpd.js");
+
+ChromeUtils.import("resource://gre/modules/osfile.jsm", this);
+ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
+
+const CryptoHash = Components.Constructor("@mozilla.org/security/hash;1",
+                                          "nsICryptoHash", "initWithString");
+const FileInputStream = Components.Constructor("@mozilla.org/network/file-input-stream;1",
+                                               "nsIFileInputStream", "init");
 
 const preferenceBranches = {
   user: Preferences,
   default: new Preferences({defaultBranch: true}),
 };
 
 // duplicated from test/browser/head.js until we move everything over to mochitests.
 function withMockPreferences(testFunction) {
@@ -47,8 +56,116 @@ class MockPreferences {
           preferenceBranch.set(name, value);
         } else {
           preferenceBranch.reset(name);
         }
       }
     }
   }
 }
+
+class MockResponse {
+  constructor(content) {
+    this.content = content;
+  }
+
+  async text() {
+    return this.content;
+  }
+
+  async json() {
+    return JSON.parse(this.content);
+  }
+}
+
+function withServer(server, task) {
+  return withMockPreferences(async function inner(preferences) {
+    const serverUrl = `http://localhost:${server.identity.primaryPort}`;
+    preferences.set("app.normandy.api_url", `${serverUrl}/api/v1`);
+    preferences.set(
+      "security.content.signature.root_hash",
+      // Hash of the key that signs the normandy dev certificates
+      "4C:35:B1:C3:E3:12:D9:55:E7:78:ED:D0:A7:E7:8A:38:83:04:EF:01:BF:FA:03:29:B2:46:9F:3C:C5:EC:36:04"
+    );
+    NormandyApi.clearIndexCache();
+
+    try {
+      await task(serverUrl, preferences, server);
+    } finally {
+      await new Promise(resolve => server.stop(resolve));
+    }
+  });
+}
+
+function makeScriptServer(scriptPath) {
+  const server = new HttpServer();
+  server.registerContentType("sjs", "sjs");
+  server.registerFile("/", do_get_file(scriptPath));
+  server.start(-1);
+  return server;
+}
+
+function withScriptServer(scriptPath, task) {
+  return withServer(makeScriptServer(scriptPath), task);
+}
+
+function makeMockApiServer(directory) {
+  const server = new HttpServer();
+  server.registerDirectory("/", directory);
+
+  server.setIndexHandler(async function(request, response) {
+    response.processAsync();
+    const dir = request.getProperty("directory");
+    const index = dir.clone();
+    index.append("index.json");
+
+    if (!index.exists()) {
+      response.setStatusLine("1.1", 404, "Not Found");
+      response.write(`Cannot find path ${index.path}`);
+      response.finish();
+      return;
+    }
+
+    try {
+      const contents = await OS.File.read(index.path, {encoding: "utf-8"});
+      response.write(contents);
+    } catch (e) {
+      response.setStatusLine("1.1", 500, "Server error");
+      response.write(e.toString());
+    } finally {
+      response.finish();
+    }
+  });
+
+  server.start(-1);
+  return server;
+}
+
+function withMockApiServer(task) {
+  return withServer(makeMockApiServer(do_get_file("mock_api")), task);
+}
+
+const CryptoUtils = {
+  _getHashStringForCrypto(aCrypto) {
+    // return the two-digit hexadecimal code for a byte
+    let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+    // convert the binary hash data to a hex string.
+    let binary = aCrypto.finish(false);
+    let hash = Array.from(binary, c => toHexString(c.charCodeAt(0)));
+    return hash.join("").toLowerCase();
+  },
+
+  /**
+   * Get the computed hash for a given file
+   * @param {nsIFile} file The file to be hashed
+   * @param {string} [algorithm] The hashing algorithm to use
+   */
+  getFileHash(file, algorithm = "sha256") {
+    const crypto = CryptoHash(algorithm);
+    const fis = new FileInputStream(file, -1, -1, false);
+    crypto.updateFromStream(fis, file.fileSize);
+    const hash = this._getHashStringForCrypto(crypto);
+    fis.close();
+    return hash;
+  },
+};
+
--- a/toolkit/components/telemetry/Events.yaml
+++ b/toolkit/components/telemetry/Events.yaml
@@ -255,26 +255,46 @@ normandy:
         Currently only provided for addon_study.
     bug_numbers: [1443560]
     notification_emails: ["normandy-notifications@mozilla.com"]
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   update:
-    objects: ["preference_rollout"]
+    objects: ["addon_study", "preference_rollout"]
     description: >
-      This event is fired when a client detects that a preference
-      rollout has changed on the server, and the new version of the
-      preference rollout is being applied over an existing, older
-      version previously fetched from the server.
+      This event is fired when a client detects that a recipe of the
+      ahove types has changed on the server, and the new version of the
+      recipe is being applied over an existing, older version previously
+      fetched from the server.
     extra_keys:
       previousState: >
-        This is the state of the rollout that had been applied previously.
-    bug_numbers: [1443560]
+        For preference_rollout recipes, the state of the rollout that had been applied
+        previously.
+      addonId: For addon_study recipes, the ID of the addon that was updated.
+      addonVersion: For addon_study recipes, the version of the addon that was installed.
+    bug_numbers: [1443560, 1474413]
+    notification_emails: ["normandy-notifications@mozilla.com"]
+    record_in_processes: [main]
+    release_channel_collection: opt-out
+    expiry_version: never
+
+  update_failed:
+    methods: ["updateFailed"]
+    objects: ["addon_study"]
+    description: >
+      Sent when applying a new version of a Normandy recipe of the above types (over an
+      existing, older version previously fetched from the server) has failed.
+    extra_keys:
+      reason: An error code describing the failure.
+      detail: >
+        Extra text describing the failure.
+        Currently only provided for addon_study.
+    bug_numbers: [1474413]
     notification_emails: ["normandy-notifications@mozilla.com"]
     record_in_processes: [main]
     release_channel_collection: opt-out
     expiry_version: never
 
   unenroll:
     objects: ["preference_study", "addon_study", "preference_rollback"]
     description: >