Bug 1543377 - Add abuse report submission helpers. r=janerik,aswan
authorLuca Greco <lgreco@mozilla.com>
Mon, 06 May 2019 18:45:01 +0000
changeset 472746 9bee4151da91814b2369971779cb8a248e25dadc
parent 472745 2d8bdc083e77da5b7b84589130d041d580319701
child 472747 e70459131e5a6186b3e08995b42a08c754a2229e
push id35978
push usershindli@mozilla.com
push dateTue, 07 May 2019 09:44:39 +0000
treeherdermozilla-central@7aee5a30dd15 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjanerik, aswan
bugs1543377
milestone68.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 1543377 - Add abuse report submission helpers. r=janerik,aswan This patch contains a new jsm file which provides some helpers to be used for the abuse report submission in the UI components related to abuse reporting, and a new xpcshell test that unit test these helpers. Differential Revision: https://phabricator.services.mozilla.com/D27938
modules/libpref/init/all.js
toolkit/mozapps/extensions/AbuseReporter.jsm
toolkit/mozapps/extensions/moz.build
toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -2725,16 +2725,18 @@ pref("services.settings.default_signer",
 pref("services.common.uptake.sampleRate", 1);   // 1%
 
 // Security state OneCRL.
 pref("services.settings.security.onecrl.bucket", "security-state");
 pref("services.settings.security.onecrl.collection", "onecrl");
 pref("services.settings.security.onecrl.signer", "onecrl.content-signature.mozilla.org");
 pref("services.settings.security.onecrl.checked", 0);
 
+pref("extensions.abuseReport.url", "https://addons.mozilla.org/api/v4/abuse/report/addon/");
+
 // Blocklist preferences
 pref("extensions.blocklist.enabled", true);
 // OneCRL freshness checking depends on this value, so if you change it,
 // please also update security.onecrl.maximum_staleness_in_seconds.
 pref("extensions.blocklist.interval", 86400);
 // Required blocklist freshness for OneCRL OCSP bypass
 // (default is 1.25x extensions.blocklist.interval, or 30 hours)
 pref("security.onecrl.maximum_staleness_in_seconds", 108000);
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/AbuseReporter.jsm
@@ -0,0 +1,327 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = [ "AbuseReporter", "AbuseReportError" ];
+
+const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+Cu.importGlobalProperties(["fetch"]);
+
+const PREF_ABUSE_REPORT_URL  = "extensions.abuseReport.url";
+// Minimum time between report submissions (in ms).
+const MIN_MS_BETWEEN_SUBMITS = 30000;
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  AddonManager: "resource://gre/modules/AddonManager.jsm",
+  AppConstants: "resource://gre/modules/AppConstants.jsm",
+  ClientID: "resource://gre/modules/ClientID.jsm",
+  Services: "resource://gre/modules/Services.jsm",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(this, "ABUSE_REPORT_URL", PREF_ABUSE_REPORT_URL);
+
+const PRIVATE_REPORT_PROPS = Symbol("privateReportProps");
+
+const ERROR_TYPES = Object.freeze([
+  "ERROR_ABORTED_SUBMIT",
+  "ERROR_ADDON_NOTFOUND",
+  "ERROR_CLIENT",
+  "ERROR_NETWORK",
+  "ERROR_UNKNOWN",
+  "ERROR_RECENT_SUBMIT",
+  "ERROR_SERVER",
+]);
+
+class AbuseReportError extends Error {
+  constructor(errorType) {
+    if (!ERROR_TYPES.includes(errorType)) {
+      throw new Error(`Unknown AbuseReportError type "${errorType}"`);
+    }
+    super(errorType);
+    this.name = "AbuseReportError";
+    this.errorType = errorType;
+  }
+}
+
+/**
+ * A singleton object used to create new AbuseReport instances for a given addonId
+ * and enforce a minium amount of time between two report submissions .
+ */
+const AbuseReporter = {
+  _lastReportTimestamp: null,
+
+  // Error types.
+  updateLastReportTimestamp() {
+    this._lastReportTimestamp = Date.now();
+  },
+
+  getTimeFromLastReport() {
+    const currentTimestamp = Date.now();
+    if (this._lastReportTimestamp > currentTimestamp) {
+      // Reset the last report timestamp if it is in the future.
+      this._lastReportTimestamp = null;
+    }
+
+    if (!this._lastReportTimestamp) {
+      return Infinity;
+    }
+
+    return currentTimestamp - this._lastReportTimestamp;
+  },
+
+  /**
+   * Create an AbuseReport instance, given the addonId and a reportEntryPoint.
+   *
+   * @param {string} addonId
+   *        The id of the addon to create the report instance for.
+   * @param {object} options
+   * @param {string} options.reportEntryPoint
+   *        An identifier that represent the entry point for the report flow.
+   *
+   * @returns {AbuseReport}
+   *          An instance of the AbuseReport class, which represent an ongoing
+   *          report.
+   */
+  async createAbuseReport(addonId, {reportEntryPoint} = {}) {
+    const addon = await AddonManager.getAddonByID(addonId);
+
+    if (!addon) {
+      throw new AbuseReportError("ERROR_ADDON_NOTFOUND");
+    }
+
+    const reportData = await this.getReportData(addon);
+
+    return new AbuseReport({
+      addon,
+      reportData,
+      reportEntryPoint,
+    });
+  },
+
+  /**
+   * Helper function that retrieves from an addon object all the data to send
+   * as part of the submission request, besides the `reason`, `message` which are
+   * going to be received from the submit method of the report object returned
+   * by `createAbuseReport`.
+   * (See https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html)
+   *
+   * @param {AddonWrapper} addon
+   *        The addon object to collect the detail from.
+   *
+   * @return {object}
+   *         An object that contains the collected details.
+   */
+  async getReportData(addon) {
+    const data = {
+      addon: addon.id,
+      addon_version: addon.version,
+      addon_summary: addon.description,
+      addon_install_origin: addon.sourceURI && addon.sourceURI.spec,
+      install_date: addon.installDate && addon.installDate.toISOString(),
+    };
+
+    // Map addon.installTelemetryInfo values to the supported addon_install_method
+    // values supported by the API endpoint (See API endpoint docs at
+    // https://addons-server.readthedocs.io/en/latest/topics/api/abuse.html).
+    let install_method = "other";
+    if (addon.installTelemetryInfo) {
+      const {source, method} = addon.installTelemetryInfo;
+      switch (source) {
+        case "enterprise-policy":
+        case "file-uri":
+        case "system-addon":
+        case "temporary-addon":
+          install_method = source.replace(/-/g, "_");
+          break;
+        case "distribution":
+        case "sideload":
+        case "sync":
+          install_method = source;
+          break;
+        default:
+          install_method = "other";
+      }
+
+      switch (method) {
+        case "link":
+          install_method = method;
+          break;
+        case "amWebAPI":
+        case "installTrigger":
+          install_method = method.toLowerCase();
+          break;
+        case "drag-and-drop":
+        case "install-from-file":
+        case "management-webext-api":
+          install_method = method.replace(/-/g, "_");
+          break;
+      }
+    }
+    data.addon_install_method = install_method;
+
+    // TODO: Add support for addon_signature "curated" in AbuseReport
+    // (Bug 1549290).
+    switch (addon.signedState) {
+      case AddonManager.SIGNEDSTATE_BROKEN:
+        data.addon_signature = "broken";
+        break;
+      case AddonManager.SIGNEDSTATE_UNKNOWN:
+        data.addon_signature = "unknown";
+        break;
+      case AddonManager.SIGNEDSTATE_MISSING:
+        data.addon_signature = "missing";
+        break;
+      case AddonManager.SIGNEDSTATE_PRELIMINARY:
+        data.addon_signature = "preliminary";
+        break;
+      case AddonManager.SIGNEDSTATE_SIGNED:
+        data.addon_signature = "signed";
+        break;
+      case AddonManager.SIGNEDSTATE_SYSTEM:
+        data.addon_signature = "system";
+        break;
+      case AddonManager.SIGNEDSTATE_PRIVILEGED:
+        data.addon_signature = "privileged";
+        break;
+      default:
+        data.addon_signature = `unknown: ${addon.signedState}`;
+    }
+
+    data.client_id = await ClientID.getClientIdHash();
+
+    data.app = Services.appinfo.name.toLowerCase();
+    data.appversion = Services.appinfo.version;
+    data.lang = Services.locale.appLocaleAsLangTag;
+    data.operating_system = AppConstants.platform;
+    data.operating_system_version = Services.sysinfo.getProperty("version");
+
+    return data;
+  },
+};
+
+/**
+ * Represents an ongoing abuse report. Instances of this class are created
+ * by the `AbuseReporter.createAbuseReport` method.
+ *
+ * This object is used by the reporting UI panel and message bars to:
+ *
+ * - get an errorType in case of a report creation error (e.g. because of a
+ *   previously submitted report)
+ * - get the addon details used inside the reporting panel
+ * - submit the abuse report (and re-submit if a previous submission failed
+ *   and the user choose to retry to submit it again)
+ * - abort an ongoing submission
+ *
+ * @param {object}            options
+ * @param {AddonWrapper|null} options.addon
+ *        AddonWrapper instance for the extension/theme being reported.
+ *        (May be null if the extension has not been found).
+ * @param {object|null}       options.reportData
+ *        An object which contains addon and environment details to send as part of a submission
+ *        (may be null if the report has a createErrorType).
+ * @param {string}            options.reportEntryPoint
+ *        A string that identify how the report has been triggered.
+ */
+class AbuseReport {
+  constructor({addon, createErrorType, reportData, reportEntryPoint}) {
+    this[PRIVATE_REPORT_PROPS] = {
+      aborted: false,
+      abortController: new AbortController(),
+      addon,
+      reportData,
+      reportEntryPoint,
+    };
+  }
+
+  /**
+   * Submit the current report, given a reason and a message.
+   *
+   * @params {object} options
+   * @params {string} options.reason
+   *         String identifier for the report reason.
+   * @params {string} [options.message]
+   *         An optional string which contains a description for the reported issue.
+   *
+   * @returns {Promise<void>}
+   *          Resolves once the report has been successfully submitted.
+   *          It rejects with an AbuseReportError if the report couldn't be
+   *          submitted for a known reason (or another Error type otherwise).
+   */
+  async submit({reason, message}) {
+    const {
+      aborted, abortController,
+      reportData,
+      reportEntryPoint,
+    } = this[PRIVATE_REPORT_PROPS];
+
+    if (aborted) {
+      // Report aborted before being actually submitted.
+      throw new AbuseReportError("ERROR_ABORTED_SUBMIT");
+    }
+
+    // Prevent submit of a new abuse report in less than MIN_MS_BETWEEN_SUBMITS.
+    let msFromLastReport = AbuseReporter.getTimeFromLastReport();
+    if (msFromLastReport < MIN_MS_BETWEEN_SUBMITS) {
+      throw new AbuseReportError("ERROR_RECENT_SUBMIT");
+    }
+
+    let response;
+    try {
+      response = await fetch(ABUSE_REPORT_URL, {
+        signal: abortController.signal,
+        method: "POST",
+        credentials: "omit",
+        referrerPolicy: "no-referrer",
+        headers: {"Content-Type": "application/json"},
+        body: JSON.stringify({
+          ...reportData,
+          report_entry_point: reportEntryPoint,
+          message,
+          reason,
+        }),
+      });
+    } catch (err) {
+      if (err.name === "AbortError") {
+        throw new AbuseReportError("ERROR_ABORTED_SUBMIT");
+      }
+      Cu.reportError(err);
+      throw new AbuseReportError("ERROR_NETWORK");
+    }
+
+    if (response.ok && response.status >= 200 && response.status < 400) {
+      // Ensure that the response is also a valid json format.
+      await response.json();
+      AbuseReporter.updateLastReportTimestamp();
+      return;
+    }
+
+    if (response.status >= 400 && response.status < 500) {
+      throw new AbuseReportError("ERROR_CLIENT");
+    }
+
+    if (response.status >= 500 && response.status < 600) {
+      throw new AbuseReportError("ERROR_SERVER");
+    }
+
+    // We got an unexpected HTTP status code.
+    throw new AbuseReportError("ERROR_UNKNOWN");
+  }
+
+  /**
+   * Abort the report submission.
+   */
+  abort() {
+    const {abortController} = this[PRIVATE_REPORT_PROPS];
+    abortController.abort();
+    this[PRIVATE_REPORT_PROPS].aborted = true;
+  }
+
+  get addon() {
+    return this[PRIVATE_REPORT_PROPS].addon;
+  }
+
+  get reportEntryPoint() {
+    return this[PRIVATE_REPORT_PROPS].reportEntryPoint;
+  }
+}
--- a/toolkit/mozapps/extensions/moz.build
+++ b/toolkit/mozapps/extensions/moz.build
@@ -46,16 +46,17 @@ elif CONFIG['MOZ_BUILD_APP'] == 'mobile/
         '!%s' % built_in_addons,
     ]
 
 EXTRA_PP_COMPONENTS += [
     'extensions.manifest',
 ]
 
 EXTRA_JS_MODULES += [
+    'AbuseReporter.jsm',
     'addonManager.js',
     'AddonManager.jsm',
     'amContentHandler.jsm',
     'amInstallTrigger.jsm',
     'amWebAPI.jsm',
     'Blocklist.jsm',
     'LightweightThemeManager.jsm',
 ]
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_AbuseReporter.js
@@ -0,0 +1,331 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const {
+  AbuseReporter,
+  AbuseReportError,
+} = ChromeUtils.import("resource://gre/modules/AbuseReporter.jsm");
+
+const {ClientID} = ChromeUtils.import("resource://gre/modules/ClientID.jsm");
+
+const APPNAME = "XPCShell";
+const APPVERSION = "1";
+const ADDON_ID = "test-addon@tests.mozilla.org";
+const ADDON_ID2 = "test-addon2@tests.mozilla.org";
+const FAKE_INSTALL_INFO = {source: "fake-install-method"};
+const REPORT_OPTIONS = {reportEntryPoint: "menu"};
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+let apiRequestHandler;
+const server = createHttpServer({hosts: ["test.addons.org"]});
+server.registerPathHandler("/api/report/", (request, response) => {
+  const stream = request.bodyInputStream;
+  const buffer = NetUtil.readInputStream(stream, stream.available());
+  const data = new TextDecoder().decode(buffer);
+  apiRequestHandler({data, request, response});
+});
+
+function handleSubmitRequest({request, response}) {
+  response.setStatusLine(request.httpVersion, 200, "OK");
+  response.setHeader("Content-Type", "application/json", false);
+  response.write("{}");
+}
+
+async function clearAbuseReportState() {
+  // Clear the timestamp of the last submission.
+  AbuseReporter._lastReportTimestamp = null;
+}
+
+async function installTestExtension(overrideOptions = {}) {
+  const extOptions = {
+    manifest: {
+      applications: {gecko: {id: ADDON_ID}},
+      name: "Test Extension",
+    },
+    useAddonManager: "permanent",
+    amInstallTelemetryInfo: FAKE_INSTALL_INFO,
+    ...overrideOptions,
+  };
+
+  const extension = ExtensionTestUtils.loadExtension(extOptions);
+  await extension.startup();
+
+  const addon = await AddonManager.getAddonByID(ADDON_ID);
+
+  return {extension, addon};
+}
+
+async function assertRejectsAbuseReportError(promise, errorType) {
+  await Assert.rejects(promise, error => {
+    ok(error instanceof AbuseReportError);
+    return error.errorType === errorType;
+  });
+}
+
+async function assertBaseReportData({reportData, addon}) {
+  // Report properties related to addon metadata.
+  equal(reportData.addon, ADDON_ID, "Got expected 'addon'");
+  equal(reportData.addon_version, addon.version, "Got expected 'addon_version'");
+  equal(reportData.install_date, addon.installDate.toISOString(),
+        "Got expected 'install_date' in ISO format");
+  equal(reportData.addon_install_origin, addon.sourceURI.spec,
+        "Got expected 'addon_install_origin'");
+  equal(reportData.addon_install_method, "other",
+        "Got expected 'addon_install_method'");
+  equal(reportData.addon_signature, "privileged", "Got expected 'addon_signature'");
+
+  // Report properties related to the environment.
+  equal(reportData.client_id, await ClientID.getClientIdHash(),
+        "Got the expected 'client_id'");
+  equal(reportData.app, APPNAME.toLowerCase(), "Got expected 'app'");
+  equal(reportData.appversion, APPVERSION, "Got expected 'appversion'");
+  equal(reportData.lang, Services.locale.appLocaleAsLangTag, "Got expected 'lang'");
+  equal(reportData.operating_system, AppConstants.platform, "Got expected 'operating_system'");
+  equal(reportData.operating_system_version, Services.sysinfo.getProperty("version"),
+        "Got expected 'operating_system_version'");
+}
+
+add_task(async function test_setup() {
+  Services.prefs.setCharPref("extensions.abuseReport.url", "http://test.addons.org/api/report/");
+  await promiseStartupManager();
+});
+
+add_task(async function test_addon_report_data() {
+  info("Verify report property for a privileged extension");
+  const {addon, extension} = await installTestExtension();
+  const data = await AbuseReporter.getReportData(addon);
+  await assertBaseReportData({reportData: data, addon});
+  await extension.unload();
+
+  info("Verify 'addon_signature' report property for non privileged extension");
+  AddonTestUtils.usePrivilegedSignatures = false;
+  const {
+    addon: addon2,
+    extension: extension2,
+  } = await installTestExtension();
+  const data2 = await AbuseReporter.getReportData(addon2);
+  equal(data2.addon_signature, "signed",
+        "Got expected 'addon_signature' for non privileged extension");
+  await extension2.unload();
+
+  info("Verify 'addon_install_method' report property on temporary install");
+  const {
+    addon: addon3,
+    extension: extension3,
+  } = await installTestExtension({useAddonManager: "temporary"});
+  const data3 = await AbuseReporter.getReportData(addon3);
+  equal(data3.addon_install_method, "temporary_addon",
+        "Got expected 'addon_install_method' on temporary install");
+  await extension3.unload();
+});
+
+add_task(async function test_report_on_not_installed_addon() {
+  await assertRejectsAbuseReportError(
+    AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS),
+    "ERROR_ADDON_NOTFOUND");
+});
+
+// This tests verifies the mapping between the addon installTelemetryInfo
+// values and the addon_install_method expected by the API endpoint.
+add_task(async function test_addon_install_method_mapping() {
+  async function assertAddonInstallMethod(amInstallTelemetryInfo, expected) {
+    const {addon, extension} = await installTestExtension({amInstallTelemetryInfo});
+    const {addon_install_method} = await AbuseReporter.getReportData(addon);
+    equal(addon_install_method, expected,
+          `Got the expected addon_install_method for ${JSON.stringify(amInstallTelemetryInfo)}`);
+    await extension.unload();
+  }
+
+  // Array of [ expected, amInstallTelemetryInfo ]
+  const TEST_CASES = [
+    ["amwebapi", {source: "amo", method: "amWebAPI"}],
+    ["amwebapi", {source: "disco", method: "amWebAPI"}],
+    ["distribution", {source: "distribution"}],
+    ["drag_and_drop", {source: "about:addons", method: "drag-and-drop"}],
+    ["enterprise_policy", {source: "enterprise-policy"}],
+    ["file_uri", {source: "file-uri"}],
+    ["install_from_file", {source: "about:addons", method: "install-from-file"}],
+    ["installtrigger", {source: "test-host", method: "installTrigger"}],
+    ["link", {source: "unknown", method: "link"}],
+    ["management_webext_api", {source: "extension", method: "management-webext-api"}],
+    ["sideload", {source: "sideload"}],
+    ["sync", {source: "sync"}],
+    ["system_addon", {source: "system-addon"}],
+    ["temporary_addon", {source: "temporary-addon"}],
+    ["other", {source: "internal"}],
+    ["other", {source: "about:debugging"}],
+    ["other", {source: "webide"}],
+  ];
+
+  for (const [expected, telemetryInfo] of TEST_CASES) {
+    await assertAddonInstallMethod(telemetryInfo, expected);
+  }
+});
+
+add_task(async function test_report_create_and_submit() {
+  // Override the test api server request handler, to be able to
+  // intercept the submittions to the test api server.
+  let reportSubmitted;
+  apiRequestHandler = ({data, request, response}) => {
+    reportSubmitted = JSON.parse(data);
+    handleSubmitRequest({request, response});
+  };
+
+  const {addon, extension} = await installTestExtension();
+
+  const reportEntryPoint = "menu";
+  const report = await AbuseReporter.createAbuseReport(ADDON_ID, {reportEntryPoint});
+
+  equal(report.addon, addon, "Got the expected addon property");
+  equal(report.reportEntryPoint, reportEntryPoint, "Got the expected reportEntryPoint");
+
+  const baseReportData = await AbuseReporter.getReportData(addon);
+  const reportProperties = {
+    message: "test message",
+    reason: "test-reason",
+  };
+
+  info("Submitting report");
+  await report.submit(reportProperties);
+
+  const expectedEntries = Object.entries({
+    report_entry_point: reportEntryPoint,
+    ...baseReportData,
+    ...reportProperties,
+  });
+
+  for (const [expectedKey, expectedValue] of expectedEntries) {
+    equal(reportSubmitted[expectedKey], expectedValue,
+          `Got the expected submitted value for "${expectedKey}"`);
+  }
+
+  await extension.unload();
+});
+
+add_task(async function test_error_recent_submit() {
+  await clearAbuseReportState();
+
+  let reportSubmitted;
+  apiRequestHandler = ({data, request, response}) => {
+    reportSubmitted = JSON.parse(data);
+    handleSubmitRequest({request, response});
+  };
+
+  const {extension} = await installTestExtension();
+  const report = await AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS);
+
+  const {extension: extension2} = await installTestExtension({
+    manifest: {
+      applications: {gecko: {id: ADDON_ID2}},
+      name: "Test Extension2",
+    },
+  });
+  const report2 = await AbuseReporter.createAbuseReport(ADDON_ID2, REPORT_OPTIONS);
+
+  // Submit the two reports in fast sequence.
+  await report.submit({reason: "reason1"});
+  await assertRejectsAbuseReportError(report2.submit({reason: "reason2"}),
+                                      "ERROR_RECENT_SUBMIT");
+  equal(reportSubmitted.reason, "reason1",
+        "Server only received the data from the first submission");
+
+  await extension.unload();
+  await extension2.unload();
+});
+
+add_task(async function test_submission_server_error() {
+  const {extension} = await installTestExtension();
+
+  async function testErrorCode(
+    responseStatus, expectedErrorType, expectRequest = true
+  ) {
+    info(`Test expected AbuseReportError on response status "${responseStatus}"`);
+    await clearAbuseReportState();
+
+    let requestReceived = false;
+    apiRequestHandler = ({request, response}) => {
+      requestReceived = true;
+      response.setStatusLine(request.httpVersion, responseStatus, "Error");
+      response.write("");
+    };
+
+    const report = await AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS);
+    const promiseSubmit = report.submit({reason: "a-reason"});
+    if (typeof expectedErrorType === "string") {
+      // Assert a specific AbuseReportError errorType.
+      await assertRejectsAbuseReportError(promiseSubmit, expectedErrorType);
+    } else {
+      // Assert on a given Error class.
+      await Assert.rejects(promiseSubmit, expectedErrorType);
+    }
+    equal(requestReceived, expectRequest,
+          `${expectRequest ? "" : "Not "}received a request as expected`);
+  }
+
+  await testErrorCode(500, "ERROR_SERVER");
+  await testErrorCode(404, "ERROR_CLIENT");
+  // Test response with unexpected status code.
+  await testErrorCode(604, "ERROR_UNKNOWN");
+  // Test response status 200 with invalid json data.
+  await testErrorCode(200, /SyntaxError: JSON.parse/);
+
+  // Test on invalid url.
+  Services.prefs.setCharPref("extensions.abuseReport.url",
+                             "invalid-protocol://abuse-report");
+  await testErrorCode(200, "ERROR_NETWORK", false);
+
+  await extension.unload();
+});
+
+add_task(async function set_test_abusereport_url() {
+  Services.prefs.setCharPref("extensions.abuseReport.url",
+                             "http://test.addons.org/api/report/");
+});
+
+add_task(async function test_submission_aborting() {
+  await clearAbuseReportState();
+
+  const {extension} = await installTestExtension();
+
+  // override the api request handler with one that is never going to reply.
+  let receivedRequestsCount = 0;
+  let resolvePendingResponses;
+  const waitToReply = new Promise(resolve => resolvePendingResponses = resolve);
+
+  const onRequestReceived = new Promise(resolve => {
+    apiRequestHandler = ({request, response}) => {
+      response.processAsync();
+      response.setStatusLine(request.httpVersion, 200, "OK");
+      receivedRequestsCount++;
+      resolve();
+
+      // Keep the request pending until resolvePendingResponses have been
+      // called.
+      waitToReply.then(() => {
+        response.finish();
+      });
+    };
+  });
+
+  const report = await AbuseReporter.createAbuseReport(ADDON_ID, REPORT_OPTIONS);
+  const promiseResult = report.submit({reason: "a-reason"});
+
+  await onRequestReceived;
+
+  ok(receivedRequestsCount > 0, "Got the expected number of requests");
+  ok(await Promise.race([promiseResult, Promise.resolve("pending")]) === "pending",
+    "Submission fetch request should still be pending");
+
+  report.abort();
+
+  await assertRejectsAbuseReportError(promiseResult, "ERROR_ABORTED_SUBMIT");
+
+  await extension.unload();
+
+  // Unblock pending requests on the server request handler side, so that the
+  // test file can shutdown (otherwise the test run will be stuck after this
+  // task completed).
+  resolvePendingResponses();
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini
@@ -2,26 +2,27 @@
 skip-if = toolkit == 'android'
 tags = addons
 head = head_addons.js
 firefox-appdir = browser
 dupe-manifest =
 support-files =
   data/**
 
-[test_addon_manager_telemetry_events.js]
+[test_AbuseReporter.js]
 [test_AddonRepository.js]
 [test_AddonRepository_cache.js]
 # Bug 676992: test consistently hangs on Android
 skip-if = os == "android"
 [test_AddonRepository_langpacks.js]
 [test_AddonRepository_paging.js]
 [test_ProductAddonChecker.js]
 [test_XPIStates.js]
 [test_XPIcancel.js]
+[test_addon_manager_telemetry_events.js]
 [test_addonStartup.js]
 [test_bad_json.js]
 [test_badschema.js]
 [test_blocklist_appversion.js]
 # Bug 676992: test consistently hangs on Android
 skip-if = os == "android"
 tags = blocklist
 [test_blocklist_gfx.js]