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

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Luca Greco 2019-05-06 18:45:01 +00:00
Родитель 707e8a73dc
Коммит c5e820dd3d
5 изменённых файлов: 663 добавлений и 1 удалений

Просмотреть файл

@ -2730,6 +2730,8 @@ 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,

Просмотреть файл

@ -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;
}
}

Просмотреть файл

@ -51,6 +51,7 @@ EXTRA_PP_COMPONENTS += [
]
EXTRA_JS_MODULES += [
'AbuseReporter.jsm',
'addonManager.js',
'AddonManager.jsm',
'amContentHandler.jsm',

Просмотреть файл

@ -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();
});

Просмотреть файл

@ -7,7 +7,7 @@ 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
@ -17,6 +17,7 @@ skip-if = os == "android"
[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]