/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/
 */

const { AbuseReporter, AbuseReportError } = ChromeUtils.importESModule(
  "resource://gre/modules/AbuseReporter.sys.mjs"
);

const { ClientID } = ChromeUtils.importESModule(
  "resource://gre/modules/ClientID.sys.mjs"
);

const APPVERSION = "1";
const ADDON_ID = "test-addon@tests.mozilla.org";
const FAKE_INSTALL_INFO = {
  source: "fake-Install:Source",
  method: "fake:install method",
};
const EXPECTED_API_RESPONSE = {
  id: ADDON_ID,
  some: "other-props",
};

async function installTestExtension(overrideOptions = {}) {
  const extOptions = {
    manifest: {
      browser_specific_settings: { 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 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_source,
    "fake_install_source",
    "Got expected 'addon_install_source'"
  );
  equal(
    reportData.addon_install_method,
    "fake_install_method",
    "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,
    AppConstants.platform === "android" ? "android" : "firefox",
    "Got expected 'app'"
  );
  equal(reportData.appversion, APPVERSION, "Got expected 'appversion'");
  equal(
    reportData.lang,
    Services.locale.appLocaleAsBCP47,
    "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'"
  );
}

async function assertRejectsAbuseReportError(promise, errorType, errorInfo) {
  let error;

  await Assert.rejects(
    promise,
    err => {
      error = err;
      return err instanceof AbuseReportError;
    },
    `Got an AbuseReportError`
  );

  equal(error.errorType, errorType, "Got the expected errorType");
  equal(error.errorInfo, errorInfo, "Got the expected errorInfo");
  ok(
    error.message.includes(errorType),
    "errorType should be included in the error message"
  );
  if (errorInfo) {
    ok(
      error.message.includes(errorInfo),
      "errorInfo should be included in the error message"
    );
  }
}

function handleSubmitRequest({ request, response }) {
  response.setStatusLine(request.httpVersion, 200, "OK");
  response.setHeader("Content-Type", "application/json", false);
  response.write(JSON.stringify(EXPECTED_API_RESPONSE));
}

createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");

const server = createHttpServer({ hosts: ["test.addons.org"] });

// Mock abuse report API endpoint.
let apiRequestHandler;
server.registerPathHandler("/api/abuse/report/addon/", (request, response) => {
  const stream = request.bodyInputStream;
  const buffer = NetUtil.readInputStream(stream, stream.available());
  const data = new TextDecoder().decode(buffer);
  apiRequestHandler({ data, request, response });
});

add_setup(async () => {
  Services.prefs.setCharPref(
    "extensions.addonAbuseReport.url",
    "http://test.addons.org/api/abuse/report/addon/"
  );

  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_source,
    "temporary_addon",
    "Got expected 'addon_install_method' on temporary install"
  );
  await extension3.unload();
});

add_task(
  {
    skip_if: () => AppConstants.platform === "android",
  },
  async function test_dictionary_report_data() {
    info("Verify 'addon_signature' report property for a dictionary");
    const dict = await promiseInstallWebExtension({
      manifest: {
        name: "Dictionary",
        browser_specific_settings: { gecko: { id: "dictionary@mozilla.com" } },
        dictionaries: {
          und: "und.dic",
        },
      },
      files: {
        "und.dic": "",
        "und.aff": "",
      },
    });
    const dataDict = await AbuseReporter.getReportData(dict);
    equal(
      dataDict.addon_signature,
      "not_required",
      "Got expected 'addon_signature' for dictionary"
    );
    await dict.uninstall();
  }
);

// This tests verifies how the addon installTelemetryInfo values are being
// normalized into the addon_install_source and addon_install_method
// expected by the API endpoint.
add_task(async function test_normalized_addon_install_source_and_method() {
  async function assertAddonInstallMethod(amInstallTelemetryInfo, expected) {
    const { addon, extension } = await installTestExtension({
      amInstallTelemetryInfo,
    });
    const {
      addon_install_method,
      addon_install_source,
      addon_install_source_url,
    } = await AbuseReporter.getReportData(addon);

    Assert.deepEqual(
      {
        addon_install_method,
        addon_install_source,
        addon_install_source_url,
      },
      {
        addon_install_method: expected.method,
        addon_install_source: expected.source,
        addon_install_source_url: expected.sourceURL,
      },
      `Got the expected report data for ${JSON.stringify(
        amInstallTelemetryInfo
      )}`
    );
    await extension.unload();
  }

  // Array of testcases: the `test` property contains the installTelemetryInfo value
  // and the `expect` contains the expected normalized values.
  const TEST_CASES = [
    // Explicitly verify normalized values on missing telemetry info.
    {
      test: null,
      expect: { source: null, method: null },
    },

    // Verify expected normalized values for some common install telemetry info.
    {
      test: { source: "about:addons", method: "drag-and-drop" },
      expect: { source: "about_addons", method: "drag_and_drop" },
    },
    {
      test: { source: "amo", method: "amWebAPI" },
      expect: { source: "amo", method: "amwebapi" },
    },
    {
      test: { source: "app-profile", method: "sideload" },
      expect: { source: "app_profile", method: "sideload" },
    },
    {
      test: { source: "distribution" },
      expect: { source: "distribution", method: null },
    },
    {
      test: {
        method: "installTrigger",
        source: "test-host",
        sourceURL: "http://host.triggered.install/example?test=1",
      },
      expect: {
        method: "installtrigger",
        source: "test_host",
        sourceURL: "http://host.triggered.install/example?test=1",
      },
    },
    {
      test: {
        method: "link",
        source: "unknown",
        sourceURL: "https://another.host/installExtension?name=ext1",
      },
      expect: {
        method: "link",
        source: "unknown",
        sourceURL: "https://another.host/installExtension?name=ext1",
      },
    },
  ];

  for (const { expect, test } of TEST_CASES) {
    await assertAddonInstallMethod(test, expect);
  }
});

add_task(async function test_sendAbuseReport() {
  const { addon, extension } = await installTestExtension();
  // Data passed by the caller.
  const formData = { "some-data-from-the-caller": true };
  // Metadata stored by Gecko, only passed when the add-on is installed, which
  // is what this test case verifies.
  //
  // NOTE: We JSON stringify + parse to get rid of the undefined values, which
  // we do not send to the server.
  const metadata = JSON.parse(
    JSON.stringify(await AbuseReporter.getReportData(addon))
  );
  // Register a request handler to (1) access the data submitted and (2) return
  // a 200 response.
  let dataSubmitted;
  apiRequestHandler = ({ data, request, response }) => {
    Assert.equal(
      request.getHeader("content-type"),
      "application/json",
      "expected content-type header"
    );
    Assert.ok(
      !request.hasHeader("authorization"),
      "expected no authorization header"
    );

    dataSubmitted = JSON.parse(data);
    handleSubmitRequest({ request, response });
  };

  const response = await AbuseReporter.sendAbuseReport(ADDON_ID, formData);

  Assert.deepEqual(
    response,
    EXPECTED_API_RESPONSE,
    "expected successful response"
  );
  Assert.deepEqual(
    dataSubmitted,
    {
      ...formData,
      ...metadata,
      // The add-on ID is unconditionally passed as `addon` on purpose. See:
      // https://mozilla.github.io/addons-server/topics/api/abuse.html#submitting-an-add-on-abuse-report
      addon: ADDON_ID,
    },
    "expected the right data to be sent to the server"
  );

  await extension.unload();
});

add_task(async function test_sendAbuseReport_addon_not_installed() {
  const formData = { "some-data-from-the-caller": true };
  // Register a request handler to (1) access the data submitted and (2) return
  // a 200 response.
  let dataSubmitted;
  apiRequestHandler = ({ data, request, response }) => {
    Assert.equal(
      request.getHeader("content-type"),
      "application/json",
      "expected content-type header"
    );
    Assert.ok(
      !request.hasHeader("authorization"),
      "expected no authorization header"
    );

    dataSubmitted = JSON.parse(data);
    handleSubmitRequest({ request, response });
  };

  const response = await AbuseReporter.sendAbuseReport(ADDON_ID, formData);

  Assert.deepEqual(
    response,
    EXPECTED_API_RESPONSE,
    "expected successful response"
  );
  Assert.deepEqual(
    dataSubmitted,
    {
      ...formData,
      // The add-on ID is unconditionally passed as `addon` on purpose. See:
      // https://mozilla.github.io/addons-server/topics/api/abuse.html#submitting-an-add-on-abuse-report
      addon: ADDON_ID,
    },
    "expected the right data to be sent to the server"
  );
});

add_task(async function test_sendAbuseReport_with_authorization() {
  const { addon, extension } = await installTestExtension();
  // Data passed by the caller.
  const formData = { "some-data-from-the-caller": true };
  // Metadata stored by Gecko, only passed when the add-on is installed, which
  // is what this test case verifies.
  //
  // NOTE: We JSON stringify + parse to get rid of the undefined values, which
  // we do not send to the server.
  const metadata = JSON.parse(
    JSON.stringify(await AbuseReporter.getReportData(addon))
  );
  const authorization = "some authorization header";
  // Register a request handler to (1) access the data submitted and (2) return
  // a 200 response.
  let dataSubmitted;
  apiRequestHandler = ({ data, request, response }) => {
    Assert.equal(
      request.getHeader("content-type"),
      "application/json",
      "expected content-type header"
    );
    Assert.equal(
      request.getHeader("authorization"),
      authorization,
      "expected authorization header"
    );

    dataSubmitted = JSON.parse(data);
    handleSubmitRequest({ request, response });
  };

  const response = await AbuseReporter.sendAbuseReport(ADDON_ID, formData, {
    authorization,
  });

  Assert.deepEqual(
    response,
    EXPECTED_API_RESPONSE,
    "expected successful response"
  );
  Assert.deepEqual(
    dataSubmitted,
    {
      ...formData,
      ...metadata,
      // The add-on ID is unconditionally passed as `addon` on purpose. See:
      // https://mozilla.github.io/addons-server/topics/api/abuse.html#submitting-an-add-on-abuse-report
      addon: ADDON_ID,
    },
    "expected the right data to be sent to the server"
  );

  await extension.unload();
});

add_task(async function test_sendAbuseReport_errors() {
  const { extension } = await installTestExtension();

  async function testErrorCode({
    responseStatus,
    responseText = "",
    expectedErrorType,
    expectedErrorInfo,
    expectRequest = true,
  }) {
    info(
      `Test expected AbuseReportError on response status "${responseStatus}"`
    );

    let requestReceived = false;
    apiRequestHandler = ({ request, response }) => {
      requestReceived = true;
      response.setStatusLine(request.httpVersion, responseStatus, "Error");
      response.write(responseText);
    };

    const promise = AbuseReporter.sendAbuseReport(ADDON_ID, {});
    if (typeof expectedErrorType === "string") {
      // Assert a specific AbuseReportError errorType.
      await assertRejectsAbuseReportError(
        promise,
        expectedErrorType,
        expectedErrorInfo
      );
    } else {
      // Assert on a given Error class.
      await Assert.rejects(
        promise,
        expectedErrorType,
        "expected correct Error class"
      );
    }
    equal(
      requestReceived,
      expectRequest,
      `${expectRequest ? "" : "Not "}received a request as expected`
    );
  }

  await testErrorCode({
    responseStatus: 500,
    responseText: "A server error",
    expectedErrorType: "ERROR_SERVER",
    expectedErrorInfo: JSON.stringify({
      status: 500,
      responseText: "A server error",
    }),
  });
  await testErrorCode({
    responseStatus: 404,
    responseText: "Not found error",
    expectedErrorType: "ERROR_CLIENT",
    expectedErrorInfo: JSON.stringify({
      status: 404,
      responseText: "Not found error",
    }),
  });
  // Test response with unexpected status code.
  await testErrorCode({
    responseStatus: 604,
    responseText: "An unexpected status code",
    expectedErrorType: "ERROR_UNKNOWN",
    expectedErrorInfo: JSON.stringify({
      status: 604,
      responseText: "An unexpected status code",
    }),
  });
  // Test response status 200 with invalid json data.
  await testErrorCode({
    responseStatus: 200,
    expectedErrorType: /SyntaxError: JSON.parse/,
  });
  // Test on invalid url.
  Services.prefs.setCharPref(
    "extensions.addonAbuseReport.url",
    "invalid-protocol://abuse-report"
  );
  await testErrorCode({
    expectedErrorType: "ERROR_NETWORK",
    expectRequest: false,
  });

  await extension.unload();
});
