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

"use strict";

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

ChromeUtils.defineESModuleGetters(this, {
  ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
  Management: "resource://gre/modules/Extension.sys.mjs",
});

AddonTestUtils.init(this);
AddonTestUtils.overrideCertDB();
AddonTestUtils.usePrivilegedSignatures = false;

const testStartTime = Date.now();
const not_before = new Date(testStartTime - 3600000).toISOString();
const not_after = new Date(testStartTime + 3600000).toISOString();
const RECOMMENDATION_FILE_NAME = "mozilla-recommendation.json";

const server = AddonTestUtils.createHttpServer();
const SERVER_BASE_URL = `http://localhost:${server.identity.primaryPort}`;
// Allow the test extensions to be updated from an insecure update url.
Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false);
Services.prefs.setCharPref(
  "extensions.update.background.url",
  `${SERVER_BASE_URL}/upgrade.json`
);

function createFileWithRecommendations(id, recommendation, version = "1.0.0") {
  let files = {};
  if (recommendation) {
    files[RECOMMENDATION_FILE_NAME] = recommendation;
  }
  return AddonTestUtils.createTempWebExtensionFile({
    manifest: {
      version,
      browser_specific_settings: { gecko: { id } },
    },
    files,
  });
}

async function installAddonWithRecommendations(id, recommendation) {
  let xpi = createFileWithRecommendations(id, recommendation);
  let install = await AddonTestUtils.promiseInstallFile(xpi);
  return install.addon;
}

function checkRecommended(addon, recommended = true) {
  equal(
    addon.isRecommended,
    recommended,
    "The add-on isRecommended state is correct"
  );
  equal(
    addon.recommendationStates.includes("recommended"),
    recommended,
    "The add-on recommendationStates is correct"
  );
}

function waitForPendingExtension(extId) {
  return new Promise(resolve => {
    Management.on("startup", function startupListener() {
      const pendingExtensionsMap =
        Services.ppmm.sharedData.get("extensions/pending");
      if (pendingExtensionsMap.has(extId)) {
        Management.off("startup", startupListener);
        resolve(pendingExtensionsMap.get(extId));
      }
    });
  });
}

async function assertPendingExtensionIgnoreQuarantined({
  addonId,
  expectedIgnoreQuarantined,
}) {
  info(
    `Reload ${addonId} and verify ignoreQuarantine in extensions/pending sharedData`
  );
  const promisePendingExtension = waitForPendingExtension(addonId);
  const addon = await AddonManager.getAddonByID(addonId);
  await addon.disable();
  await addon.enable();
  Assert.deepEqual(
    (await promisePendingExtension).ignoreQuarantine,
    expectedIgnoreQuarantined,
    `Expect ignoreQuarantine to be true in pending/extensions details for ${addon.id}`
  );
}

function assertQuarantinedFromURI({ domain, expected }) {
  const { processType, PROCESS_TYPE_DEFAULT } = Services.appinfo;
  const processTypeStr =
    processType === PROCESS_TYPE_DEFAULT ? "Main Process" : "Child Process";
  const testURI = Services.io.newURI(`https://${domain}/`);
  for (const [addonId, expectedQuarantinedFromURI] of Object.entries(
    expected
  )) {
    Assert.equal(
      WebExtensionPolicy.getByID(addonId).quarantinedFromURI(testURI),
      expectedQuarantinedFromURI,
      `Expect ${addonId} to ${
        expectedQuarantinedFromURI ? "not be" : "be"
      } quarantined from ${domain} in ${processTypeStr}`
    );
  }
}

async function assertQuarantinedFromURIInChildProcessAsync({
  domain,
  expected,
}) {
  // Doesn't matter what content url we us here, as long as we are
  // using a content url to be able to run the assertions from a
  // child process.
  const testUrl = SERVER_BASE_URL;
  const page = await ExtensionTestUtils.loadContentPage(testUrl);
  // TODO(rpl): look into Bug 1648545 changes and determine what
  // would need to change to use page.spawn instead.
  await page.legacySpawn({ domain, expected }, assertQuarantinedFromURI);
  await page.close();
}

function getUpdatesJSONFor(id, version) {
  return {
    updates: [
      {
        version,
        update_link: `${SERVER_BASE_URL}/addons/${id}.xpi`,
      },
    ],
  };
}

function registerUpdateXPIFile({ id, version, recommendationStates }) {
  const recommendation = {
    addon_id: id,
    states: recommendationStates,
    validity: { not_before, not_after },
  };
  let xpi = createFileWithRecommendations(id, recommendation, version);
  server.registerFile(`/addons/${id}.xpi`, xpi);
}

function waitForBootstrapUpdateMethod(addonId, newVersion) {
  return new Promise(resolve => {
    function listener(_evt, { method, params }) {
      if (
        method === "update" &&
        params.id === addonId &&
        params.newVersion === newVersion
      ) {
        AddonTestUtils.off("bootstrap-method", listener);
        info(`Update bootstrap method called for ${addonId} ${newVersion}`);
        resolve({ addonId, method, params });
      }
    }
    AddonTestUtils.on("bootstrap-method", listener);
  });
}

function assertUpdateBootstrapCall(detailsBootstrapUpdates, expected) {
  const actualPerAddonId = detailsBootstrapUpdates
    .map(({ addonId, params }) => {
      return [addonId, params.recommendationState?.states];
    })
    .reduce((acc, [addonId, states]) => {
      acc[addonId] = states;
      return acc;
    }, {});
  Assert.deepEqual(
    actualPerAddonId,
    expected,
    `Got the expected recommendation states in the update bootstrap calls`
  );
}

add_setup(async () => {
  await ExtensionTestUtils.startAddonManager();
});

add_task(async function text_no_file() {
  const id = "no-recommendations-file@test.web.extension";
  let addon = await installAddonWithRecommendations(id, null);

  checkRecommended(addon, false);

  await addon.uninstall();
});

add_task(async function text_malformed_file() {
  const id = "no-recommendations-file@test.web.extension";
  let addon = await installAddonWithRecommendations(id, "This is not JSON");

  checkRecommended(addon, false);

  await addon.uninstall();
});

add_task(async function test_valid_recommendation_file() {
  const id = "recommended@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: ["recommended"],
    validity: { not_before, not_after },
  });

  checkRecommended(addon);

  await addon.uninstall();
});

add_task(async function test_multiple_valid_recommendation_file() {
  const id = "recommended@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: ["recommended", "something"],
    validity: { not_before, not_after },
  });

  checkRecommended(addon);
  ok(
    addon.recommendationStates.includes("something"),
    "The add-on recommendationStates contains something"
  );

  await addon.uninstall();
});

add_task(async function test_unsigned() {
  // Don't override the certificate, so that the test add-on is unsigned.
  AddonTestUtils.useRealCertChecks = true;
  // Allow unsigned add-on to be installed.
  Services.prefs.setBoolPref("xpinstall.signatures.required", false);

  const id = "unsigned@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: ["recommended"],
    validity: { not_before, not_after },
  });

  checkRecommended(addon, false);

  await addon.uninstall();
  AddonTestUtils.useRealCertChecks = false;
  Services.prefs.setBoolPref("xpinstall.signatures.required", true);
});

add_task(async function test_temporary() {
  const id = "temporary@test.web.extension";
  let xpi = createFileWithRecommendations(id, {
    addon_id: id,
    states: ["recommended"],
    validity: { not_before, not_after },
  });
  let addon = await XPIExports.XPIInstall.installTemporaryAddon(xpi);

  checkRecommended(addon, false);

  await addon.uninstall();
});

// Tests that unpacked temporary add-ons are not recommended.
add_task(async function test_temporary_directory() {
  const id = "temporary-dir@test.web.extension";
  let files = ExtensionTestCommon.generateFiles({
    manifest: {
      browser_specific_settings: { gecko: { id } },
    },
    files: {
      [RECOMMENDATION_FILE_NAME]: {
        addon_id: id,
        states: ["recommended"],
        validity: { not_before, not_after },
      },
    },
  });
  let extDir = await AddonTestUtils.promiseWriteFilesToExtension(
    gTmpD.path,
    id,
    files,
    true
  );

  let addon = await XPIExports.XPIInstall.installTemporaryAddon(extDir);

  checkRecommended(addon, false);

  await addon.uninstall();
  extDir.remove(true);
});

add_task(async function test_builtin() {
  const id = "builtin@test.web.extension";
  let extension = await installBuiltinExtension({
    manifest: {
      browser_specific_settings: { gecko: { id } },
    },
    background: `browser.test.sendMessage("started");`,
    files: {
      [RECOMMENDATION_FILE_NAME]: {
        addon_id: id,
        states: ["recommended"],
        validity: { not_before, not_after },
      },
    },
  });
  await extension.awaitMessage("started");

  checkRecommended(extension.addon, false);

  await extension.unload();
});

add_task(
  // Non-extension add-ons are not supported on Android, but test installs static theme.
  { skip_if: () => AppConstants.platform == "android" },
  async function test_theme() {
    const id = "theme@test.web.extension";
    let xpi = AddonTestUtils.createTempWebExtensionFile({
      manifest: {
        browser_specific_settings: { gecko: { id } },
        theme: {},
      },
      files: {
        [RECOMMENDATION_FILE_NAME]: {
          addon_id: id,
          states: ["recommended"],
          validity: { not_before, not_after },
        },
      },
    });
    let { addon } = await AddonTestUtils.promiseInstallFile(xpi);

    checkRecommended(addon, false);

    await addon.uninstall();
  }
);

add_task(async function test_not_recommended() {
  const id = "not-recommended@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: ["something"],
    validity: { not_before, not_after },
  });

  checkRecommended(addon, false);
  ok(
    addon.recommendationStates.includes("something"),
    "The add-on recommendationStates contains something"
  );

  await addon.uninstall();
});

add_task(async function test_id_missing() {
  const id = "no-id@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    states: ["recommended"],
    validity: { not_before, not_after },
  });

  checkRecommended(addon, false);

  await addon.uninstall();
});

add_task(async function test_expired() {
  const id = "expired@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: ["recommended", "something"],
    validity: { not_before, not_after: not_before },
  });

  checkRecommended(addon, false);
  ok(
    !addon.recommendationStates.length,
    "The add-on recommendationStates does not contain anything"
  );

  await addon.uninstall();
});

add_task(async function test_not_valid_yet() {
  const id = "expired@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: ["recommended"],
    validity: { not_before: not_after, not_after },
  });

  checkRecommended(addon, false);

  await addon.uninstall();
});

add_task(async function test_states_missing() {
  const id = "states-missing@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    validity: { not_before, not_after },
  });

  checkRecommended(addon, false);

  await addon.uninstall();
});

add_task(async function test_validity_missing() {
  const id = "validity-missing@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: ["recommended"],
  });

  checkRecommended(addon, false);

  await addon.uninstall();
});

add_task(async function test_not_before_missing() {
  const id = "not-before-missing@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: ["recommended"],
    validity: { not_after },
  });

  checkRecommended(addon, false);

  await addon.uninstall();
});

add_task(async function test_bad_states() {
  const id = "bad-states@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: { recommended: true },
    validity: { not_before, not_after },
  });

  checkRecommended(addon, false);

  await addon.uninstall();
});

add_task(async function test_recommendation_persist_restart() {
  const id = "persisted-recommendation@test.web.extension";
  let addon = await installAddonWithRecommendations(id, {
    addon_id: id,
    states: ["recommended"],
    validity: { not_before, not_after },
  });

  checkRecommended(addon);

  await AddonTestUtils.promiseRestartManager();

  addon = await AddonManager.getAddonByID(id);

  checkRecommended(addon);

  await addon.uninstall();
});

add_task(async function test_isLineExtension_internal_svg_permission() {
  async function assertLineExtensionStateAndPermission(
    addonId,
    expectLineExtension,
    isRestart
  ) {
    const { extension } = WebExtensionPolicy.getByID(addonId);

    const msgShould = expectLineExtension ? "should" : "should not";

    equal(
      extension.hasPermission("internal:svgContextPropertiesAllowed"),
      expectLineExtension,
      `"${addonId}" ${msgShould} have permission internal:svgContextPropertiesAllowed`
    );
    if (isRestart) {
      const { permissions } = await ExtensionPermissions.get(addonId);
      Assert.deepEqual(
        permissions,
        expectLineExtension ? ["internal:svgContextPropertiesAllowed"] : [],
        `ExtensionPermission.get("${addonId}") result ${msgShould} include internal:svgContextPropertiesAllowed permission`
      );
    }
  }

  const idLineExt = "line-extension@test.web.extension";
  await installAddonWithRecommendations(idLineExt, {
    addon_id: idLineExt,
    states: ["line"],
    validity: { not_before, not_after },
  });

  info(`Test line extension ${idLineExt}`);
  await assertLineExtensionStateAndPermission(idLineExt, true, false);
  await AddonTestUtils.promiseRestartManager();
  info(`Test ${idLineExt} again after AOM restart`);
  await assertLineExtensionStateAndPermission(idLineExt, true, true);
  let addon = await AddonManager.getAddonByID(idLineExt);
  await addon.uninstall();

  const idNonLineExt = "non-line-extension@test.web.extension";
  await installAddonWithRecommendations(idNonLineExt, {
    addon_id: idNonLineExt,
    states: ["recommended"],
    validity: { not_before, not_after },
  });

  info(`Test non line extension: ${idNonLineExt}`);
  await assertLineExtensionStateAndPermission(idNonLineExt, false, false);
  await AddonTestUtils.promiseRestartManager();
  info(`Test ${idNonLineExt} again after AOM restart`);
  await assertLineExtensionStateAndPermission(idNonLineExt, false, true);
  addon = await AddonManager.getAddonByID(idNonLineExt);
  await addon.uninstall();
});

add_task(
  {
    pref_set: [
      ["extensions.quarantinedDomains.enabled", true],
      ["extensions.quarantinedDomains.list", "quarantined.example.org"],
    ],
  },
  async function test_recommended_exempt_from_quarantined() {
    const invalidRecommendedId = "invalid-recommended@test.web.extension";
    const validRecommendedId = "recommended@test.web.extension";
    const validAndroidRecommendedId = "recommended-android@test.web.extension";
    const lineExtensionId = "line@test.web.extension";
    const validMultiRecommendedId = "recommended-multi@test.web.extension";
    // NOTE: confirm that any future recommendation state that was considered
    // valid and signed by AMO is also going to be exempt, which does also include
    // recommendation states that we are not using anymore but are still technically
    // supported by autograph (e.g. verified), see:
    // https://github.com/mozilla-services/autograph/blob/8a34847a/autograph.yaml#L1456-L1460
    const validFutureRecStateId = "fake-future-valid-state@test.web.extension";

    const recommendationStatesPerId = {
      [invalidRecommendedId]: null,
      [validRecommendedId]: ["recommended"],
      [validAndroidRecommendedId]: ["recommended-android"],
      [lineExtensionId]: ["line"],
      [validFutureRecStateId]: ["fake-future-valid-state"],
      [validMultiRecommendedId]: ["recommended", "recommended-android"],
    };

    for (const [extId, expectedRecStates] of Object.entries(
      recommendationStatesPerId
    )) {
      const recommendationData = expectedRecStates
        ? {
            addon_id: extId,
            states: expectedRecStates,
            validity: { not_before, not_after },
          }
        : null;
      await installAddonWithRecommendations(extId, recommendationData);
      // Check that the expected recommendation states are reflected by the
      // value returned by the AddonWrapper.recommendationStates getter.
      const addon = await AddonManager.getAddonByID(extId);
      Assert.deepEqual(
        addon.recommendationStates,
        expectedRecStates ?? [],
        `Addon ${extId} has the expected recommendation states`
      );
    }

    assertQuarantinedFromURI({
      domain: "quarantined.example.org",
      expected: {
        [invalidRecommendedId]: true,
        [validRecommendedId]: false,
        [validAndroidRecommendedId]: false,
        [lineExtensionId]: false,
        [validFutureRecStateId]: false,
        [validMultiRecommendedId]: false,
      },
    });

    await assertQuarantinedFromURIInChildProcessAsync({
      domain: "quarantined.example.org",
      expected: {
        [invalidRecommendedId]: true,
        [validRecommendedId]: false,
        [validAndroidRecommendedId]: false,
        [lineExtensionId]: false,
        [validFutureRecStateId]: false,
        [validMultiRecommendedId]: false,
      },
    });

    // NOTE: we only cover the 3 basic cases in the rest of this test case
    // (we have verified that ignoreQuarantine is being set to the expected
    // value and so the other cases shouldn't matter for the behaviors being
    // explicitly covered by the remaining part of this test task).

    // Make sure the ignoreQuarantine property is also propagated in the child
    // processes while the extensions may still be not fully initialized (and
    // so listed in the `extensions/pending` sharedData entry).
    await assertPendingExtensionIgnoreQuarantined({
      addonId: validRecommendedId,
      expectedIgnoreQuarantined: true,
    });
    await assertPendingExtensionIgnoreQuarantined({
      addonId: lineExtensionId,
      expectedIgnoreQuarantined: true,
    });
    await assertPendingExtensionIgnoreQuarantined({
      addonId: invalidRecommendedId,
      expectedIgnoreQuarantined: false,
    });

    info("Verify ignoreQuarantine again after application restart");

    await AddonTestUtils.promiseRestartManager();
    assertQuarantinedFromURI({
      domain: "quarantined.example.org",
      expected: {
        [invalidRecommendedId]: true,
        [validRecommendedId]: false,
        [lineExtensionId]: false,
      },
    });

    info("Verify ignoreQuarantine again after addon updates");

    AddonTestUtils.registerJSON(server, "/upgrade.json", {
      addons: {
        [invalidRecommendedId]: getUpdatesJSONFor(
          invalidRecommendedId,
          "2.0.0"
        ),
        [validRecommendedId]: getUpdatesJSONFor(validRecommendedId, "2.0.0"),
        [lineExtensionId]: getUpdatesJSONFor(lineExtensionId, "2.0.0"),
      },
    });
    registerUpdateXPIFile({
      id: invalidRecommendedId,
      version: "2.0.0",
      recommendationStates: recommendationStatesPerId[invalidRecommendedId],
    });
    registerUpdateXPIFile({
      id: validRecommendedId,
      version: "2.0.0",
      recommendationStates: recommendationStatesPerId[validRecommendedId],
    });
    registerUpdateXPIFile({
      id: lineExtensionId,
      version: "2.0.0",
      recommendationStates: recommendationStatesPerId[lineExtensionId],
    });

    const promiseUpdatesInstalled = Promise.all([
      waitForBootstrapUpdateMethod(invalidRecommendedId, "2.0.0"),
      waitForBootstrapUpdateMethod(validRecommendedId, "2.0.0"),
      waitForBootstrapUpdateMethod(lineExtensionId, "2.0.0"),
    ]);

    const promiseBackgroundUpdatesFound = TestUtils.topicObserved(
      "addons-background-updates-found"
    );
    let [
      extensionInvalidRecommended,
      extensionValidRecommended,
      extensionLine,
    ] = [
      ExtensionTestUtils.expectExtension(invalidRecommendedId),
      ExtensionTestUtils.expectExtension(validRecommendedId),
      ExtensionTestUtils.expectExtension(lineExtensionId),
    ];

    await AddonManagerPrivate.backgroundUpdateCheck();
    await promiseBackgroundUpdatesFound;

    assertUpdateBootstrapCall(await promiseUpdatesInstalled, {
      [invalidRecommendedId]: null,
      [validRecommendedId]: ["recommended"],
      [lineExtensionId]: ["line"],
    });

    // Wait the test extension to be fully started (prevents logspam
    // due to the AOM trying to uninstall them while being started).
    await Promise.all([
      extensionInvalidRecommended.awaitStartup(),
      extensionValidRecommended.awaitStartup(),
      extensionLine.awaitStartup(),
    ]);

    Assert.equal(
      WebExtensionPolicy.getByID(extensionInvalidRecommended.id)
        .hasRecommendedState,
      false
    );
    Assert.equal(
      WebExtensionPolicy.getByID(extensionValidRecommended.id)
        .hasRecommendedState,
      true
    );
    Assert.equal(
      WebExtensionPolicy.getByID(extensionLine.id).hasRecommendedState,
      true
    );

    // Uninstall all test extensions.
    await Promise.all(
      Object.keys(recommendationStatesPerId).map(async addonId => {
        const addon = await AddonManager.getAddonByID(addonId);
        await addon.uninstall();
      })
    );
  }
);
