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

"use strict";

/**
 * @file Verifies that the MLBF dump of the addons blocklist is
 * correctly registered.
 */

Services.prefs.setBoolPref("extensions.blocklist.useMLBF", true);

const ExtensionBlocklistMLBF = getExtensionBlocklistMLBF();

// A known blocked version from bug 1626602.
const blockedAddon = {
  id: "{6f62927a-e380-401a-8c9e-c485b7d87f0d}",
  version: "9.2.0",
  // The following date is the date of the first checked in MLBF. Any MLBF
  // generated in the future should be generated after this date, to be useful.
  signedDate: new Date(1588098908496), // 2020-04-28 (dummy date)
  signedState: AddonManager.SIGNEDSTATE_SIGNED,
};

// A known add-on that is not blocked, as of writing. It is likely not going
// to be blocked because it does not have any executable code.
const nonBlockedAddon = {
  id: "disable-ctrl-q-and-cmd-q@robwu.nl",
  version: "1",
  signedDate: new Date(1482430349000), // 2016-12-22 (actual signing time).
  signedState: AddonManager.SIGNEDSTATE_SIGNED,
};

async function sha256(arrayBuffer) {
  let hash = await crypto.subtle.digest("SHA-256", arrayBuffer);
  const toHex = b => b.toString(16).padStart(2, "0");
  return Array.from(new Uint8Array(hash), toHex).join("");
}

// A list of { inputRecord, downloadPromise }:
// - inputRecord is the record that was used for looking up the MLBF.
// - downloadPromise is the result of trying to download it.
const observed = [];

add_task(async function setup() {
  createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1");
  ExtensionBlocklistMLBF.ensureInitialized();

  // Tapping into the internals of ExtensionBlocklistMLBF._fetchMLBF to observe
  // MLBF request details.

  // Despite being called "download", this does not actually access the network
  // when there is a valid dump.
  const originalImpl = ExtensionBlocklistMLBF._client.attachments.download;
  ExtensionBlocklistMLBF._client.attachments.download = function (record) {
    let downloadPromise = originalImpl.apply(this, arguments);
    observed.push({ inputRecord: record, downloadPromise });
    return downloadPromise;
  };

  await promiseStartupManager();
});

async function verifyBlocklistWorksWithDump() {
  Assert.equal(
    await Blocklist.getAddonBlocklistState(blockedAddon),
    Ci.nsIBlocklistService.STATE_BLOCKED,
    "A add-on that is known to be on the blocklist should be blocked"
  );
  Assert.equal(
    await Blocklist.getAddonBlocklistState(nonBlockedAddon),
    Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
    "A known non-blocked add-on should not be blocked"
  );
}

add_task(async function verify_dump_first_run() {
  await verifyBlocklistWorksWithDump();
  Assert.equal(
    observed.length,
    Blocklist.isSoftBlockEnabled ? 2 : 1,
    "expected number of MLBF download requests"
  );

  const observedSoftBlock = Blocklist.isSoftBlockEnabled
    ? observed.pop()
    : undefined;
  const observedHardBlock = observed.pop();

  const verifyMLBFAttachment = async (
    { inputRecord, downloadPromise },
    expectedAttachmentType,
    message
  ) => {
    Assert.ok(
      inputRecord,
      `addons-bloomfilters collection bloomfilter attachment dump exists (${message})`
    );

    Assert.equal(
      inputRecord.attachment_type,
      expectedAttachmentType,
      `attachment record has the expected attachment_type (${message})`
    );

    const downloadResult = await downloadPromise;

    // Verify that the "download" result really originates from the local dump.
    // "dump_match" means that the record exists in the collection and that an
    // attachment was found.
    //
    // If this fails:
    // - "dump_fallback" means that the MLBF attachment is out of sync with the
    //   collection data.
    // - undefined could mean that the implementation of Attachments.sys.mjs changed.
    Assert.equal(
      downloadResult._source,
      "dump_match",
      `MLBF attachment should match the RemoteSettings collection (${message})`
    );

    Assert.equal(
      await sha256(downloadResult.buffer),
      inputRecord.attachment.hash,
      `The content of the attachment should actually matches the record (${message})`
    );
  };

  const { RS_ATTACHMENT_TYPE, RS_SOFTBLOCKS_ATTACHMENT_TYPE } =
    ExtensionBlocklistMLBF;

  info("Verify the hard blocks mlbf dump");
  // Hard blocks are enabled by default and packaged with desktop.
  // On Android, dumps are not included, see test_android_blocklist_dump.js instead.
  await verifyMLBFAttachment(
    observedHardBlock,
    RS_ATTACHMENT_TYPE,
    "hard blocks"
  );

  if (Blocklist.isSoftBlockEnabled) {
    info("Verify the soft blocks mlbf dump");
    await verifyMLBFAttachment(
      observedSoftBlock,
      RS_SOFTBLOCKS_ATTACHMENT_TYPE,
      "soft blocks"
    );
  }
});

add_task(async function use_dump_fallback_when_collection_is_out_of_sync() {
  await AddonTestUtils.loadBlocklistRawData({
    // last_modified higher than any value in addons-bloomfilters.json.
    extensionsMLBF: [{ last_modified: Date.now() }],
  });
  Assert.equal(
    observed.length,
    Blocklist.isSoftBlockEnabled ? 2 : 1,
    "Expected new download on update"
  );

  if (Blocklist.isSoftBlockEnabled) {
    observed.pop(); // soft-blocks dump.
  }

  const { inputRecord, downloadPromise } = observed.pop();
  Assert.equal(inputRecord, null, "No MLBF record found");

  const downloadResult = await downloadPromise;
  Assert.equal(
    downloadResult._source,
    "dump_fallback",
    "should have used fallback despite the absence of a MLBF record"
  );

  await verifyBlocklistWorksWithDump();
  Assert.equal(observed.length, 0, "Blocklist uses cached result");
});

// Verifies that the dump would supersede local data. This can happen after an
// application upgrade, where the local database contains outdated records from
// a previous application version.
add_task(async function verify_dump_supersedes_old_dump() {
  // Delete in-memory value; otherwise the cached record from the previous test
  // task would be re-used and nothing would be downloaded.
  delete ExtensionBlocklistMLBF._mlbfData;
  delete ExtensionBlocklistMLBF._mlbfDataSoftBlocks;

  await AddonTestUtils.loadBlocklistRawData({
    // last_modified lower than any value in addons-bloomfilters.json.
    extensionsMLBF: [{ last_modified: 2 }],
  });

  Assert.equal(
    observed.length,
    Blocklist.isSoftBlockEnabled ? 2 : 1,
    "Expected new download on update"
  );

  if (Blocklist.isSoftBlockEnabled) {
    observed.pop(); // soft-blocks dump.
  }

  const { inputRecord, downloadPromise } = observed.pop();
  Assert.ok(inputRecord, "should have read from addons-bloomfilters dump");

  const downloadResult = await downloadPromise;
  Assert.equal(
    downloadResult._source,
    "dump_match",
    "Should have replaced outdated collection records with dump"
  );

  await verifyBlocklistWorksWithDump();
  Assert.equal(observed.length, 0, "Blocklist uses cached result");
});
