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

"use strict";

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

ChromeUtils.defineESModuleGetters(this, {
  ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs",
  ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs",
  sinon: "resource://testing-common/Sinon.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetters(this, {
  aomStartup: [
    "@mozilla.org/addons/addon-manager-startup;1",
    Ci.amIAddonManagerStartup,
  ],
});

AddonTestUtils.init(this);
AddonTestUtils.overrideCertDB();

Services.scriptloader.loadSubScriptWithOptions(
  Services.io.newFileURI(do_get_file("head_dnr.js")).spec,
  { target: this, allowUnsafeURL: true }
);

const EXT_ID = "test-dnr-store-startup-cache@test-extension";
const TEMP_EXT_ID = "test-dnr-store-temporarily-installed@test-extension";

// Test rulesets should include fields that are special cased during Rule object deserialization
// from the plain objects loaded from the StartupCache data objects.
// In particular regexFilter are included to make sure that RuleValidator.deserializeRule is
// internally compiling the regexFilter and storing it into the RuleCondition instance as expected
// (implicitly asserted internally by the test helper assertDNRStoreData in head_dnr.js).
const RULESET_1_DATA = [
  getDNRRule({
    id: 1,
    action: { type: "allow" },
    condition: { resourceTypes: ["main_frame"], regexFilter: "http://from/$" },
  }),
  getDNRRule({
    id: 2,
    action: { type: "allow" },
    condition: {
      resourceTypes: ["main_frame"],
      regexFilter: "http://from2/$",
      isUrlFilterCaseSensitive: true,
    },
  }),
];
const RULESET_2_DATA = [
  getDNRRule({
    action: { type: "block" },
    condition: { resourceTypes: ["main_frame", "script"] },
  }),
];

function getDNRExtension({
  id = EXT_ID,
  version = "1.0",
  useAddonManager = "permanent",
  background,
  rule_resources,
  declarative_net_request,
  files,
}) {
  // Omit declarative_net_request if rule_resources isn't defined
  // (because declarative_net_request fails the manifest validation
  // if rule_resources is missing).
  const dnr = rule_resources ? { rule_resources } : undefined;

  return {
    background,
    useAddonManager,
    manifest: {
      manifest_version: 3,
      version,
      permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
      // Needed to make sure the upgraded extension will have the same id and
      // same uuid (which is mapped based on the extension id).
      browser_specific_settings: {
        gecko: { id },
      },
      declarative_net_request: declarative_net_request
        ? { ...declarative_net_request, ...(dnr ?? {}) }
        : dnr,
    },
    files,
  };
}

add_setup(async () => {
  Services.prefs.setBoolPref("extensions.dnr.enabled", true);
  Services.prefs.setBoolPref("extensions.dnr.feedback", true);

  setupTelemetryForTests();

  await ExtensionTestUtils.startAddonManager();
});

add_task(async function test_dnr_startup_cache_save_and_load() {
  Services.fog.testResetFOG();

  const rule_resources = [
    {
      id: "ruleset_1",
      enabled: true,
      path: "ruleset_1.json",
    },
    {
      id: "ruleset_2",
      enabled: false,
      path: "ruleset_2.json",
    },
  ];
  const files = {
    "ruleset_1.json": JSON.stringify(RULESET_1_DATA),
    "ruleset_2.json": JSON.stringify(RULESET_2_DATA),
  };

  let dnrStore = ExtensionDNRStore._getStoreForTesting();
  let sandboxStoreSpies = sinon.createSandbox();
  const spyScheduleCacheDataSave = sandboxStoreSpies.spy(
    dnrStore,
    "scheduleCacheDataSave"
  );

  const extension = ExtensionTestUtils.loadExtension(
    getDNRExtension({ rule_resources, files })
  );

  const temporarilyInstalledExt = ExtensionTestUtils.loadExtension(
    getDNRExtension({
      id: TEMP_EXT_ID,
      useAddonManager: "temporary",
      rule_resources,
      files,
    })
  );

  assertDNRTelemetryMetricsNoSamples(
    [
      {
        metric: "validateRulesTime",
      },
    ],
    "before any test extensions have been loaded"
  );

  await temporarilyInstalledExt.startup();
  await extension.startup();
  info(
    "Wait for DNR initialization completed for the temporarily installed extension"
  );
  await ExtensionDNR.ensureInitialized(temporarilyInstalledExt.extension);
  info(
    "Wait for DNR initialization completed for the permanently installed extension"
  );
  await ExtensionDNR.ensureInitialized(extension.extension);

  assertDNRTelemetryMetricsSamplesCount(
    [
      {
        metric: "validateRulesTime",
        expectedSamplesCount: 2,
      },
    ],
    "after two test extensions have been loaded"
  );

  Assert.equal(
    spyScheduleCacheDataSave.callCount,
    1,
    "Expect ExtensionDNRStore scheduleCacheDataSave method to have been called once"
  );

  sandboxStoreSpies.restore();

  const extUUID = extension.uuid;
  const { cacheFile } = dnrStore.getFilePaths(extUUID);

  await assertDNRStoreData(dnrStore, extension, {
    ruleset_1: getSchemaNormalizedRules(extension, RULESET_1_DATA),
  });

  assertDNRTelemetryMetricsNoSamples(
    [
      {
        metric: "startupCacheWriteTime",
      },
      {
        metric: "startupCacheWriteSize",
      },
      // Expected no startup cache file to be loaded or used for a newly installed extension.
      {
        metric: "startupCacheReadSize",
      },
      {
        metric: "startupCacheReadTime",
      },
      {
        metric: "startupCacheEntries",
        label: "miss",
      },
      {
        metric: "startupCacheEntries",
        label: "hit",
      },
    ],
    "on loading dnr rules for newly installed extension"
  );
  await dnrStore.waitSaveCacheDataForTesting();
  assertDNRTelemetryMetricsSamplesCount(
    [
      {
        metric: "startupCacheWriteTime",
        expectedSamplesCount: 1,
      },
      {
        metric: "startupCacheWriteSize",
        expectedSamplesCount: 1,
      },
    ],
    "after writing DNR startup cache data to disk"
  );

  ok(
    await IOUtils.exists(cacheFile),
    "Expect the DNR store startupCache file exist"
  );

  const assertDNRStoreDataLoadOnStartup = async ({
    expectLoadedFromCache,
    expectClearLastUpdateTagPref,
  }) => {
    info(
      `Mock browser restart and assert DNR rules ${
        expectLoadedFromCache ? "NOT " : ""
      }going through Schemas.normalize`
    );
    await AddonTestUtils.promiseShutdownManager();
    // Recreate the DNR store to more easily mock its initial state after a browser restart.
    dnrStore = ExtensionDNRStore._recreateStoreForTesting();
    const StoreData = ExtensionDNRStore._getStoreDataClassForTesting();

    let sandbox = sinon.createSandbox();
    const schemasNormalizeSpy = sandbox.spy(Schemas, "normalize");
    const ruleValidatorAddRulesSpy = sandbox.spy(
      ExtensionDNR.RuleValidator.prototype,
      "addRules"
    );
    const deserializeRuleSpy = sandbox.spy(
      ExtensionDNR.RuleValidator,
      "deserializeRule"
    );
    const clearLastUpdateTagPrefSpy = sandbox.spy(
      StoreData,
      "clearLastUpdateTagPref"
    );
    const scheduleCacheDataSaveSpy = sandbox.spy(
      dnrStore,
      "scheduleCacheDataSave"
    );

    Services.fog.testResetFOG();
    await AddonTestUtils.promiseStartupManager();
    await extension.awaitStartup();
    await ExtensionDNR.ensureInitialized(extension.extension);

    if (expectLoadedFromCache) {
      assertDNRTelemetryMetricsSamplesCount(
        [
          {
            metric: "startupCacheReadSize",
            expectedSamplesCount: 1,
          },
          {
            metric: "startupCacheReadTime",
            expectedSamplesCount: 1,
          },
        ],
        "after app startup and expected startup cache hit"
      );
      assertDNRTelemetryMetricsGetValueEq(
        [
          {
            metric: "startupCacheEntries",
            label: "hit",
            expectedGetValue: 1,
          },
        ],
        "after app startup and expected startup cache hit"
      );
      assertDNRTelemetryMetricsNoSamples(
        [
          {
            metric: "validateRulesTime",
          },
          {
            metric: "startupCacheEntries",
            label: "miss",
          },
        ],
        "after DNR store loaded startup cache data"
      );
    } else {
      assertDNRTelemetryMetricsSamplesCount(
        [
          {
            metric: "validateRulesTime",
            expectedSamplesCount: 1,
          },
          {
            metric: "startupCacheReadSize",
            expectedSamplesCount: 1,
          },
          {
            metric: "startupCacheReadTime",
            expectedSamplesCount: 1,
          },
        ],
        "after app startup and expected startup cache miss"
      );
      assertDNRTelemetryMetricsGetValueEq(
        [
          {
            metric: "startupCacheEntries",
            label: "miss",
            expectedGetValue: 1,
          },
        ],
        "after app startup and expected startup cache miss"
      );
      assertDNRTelemetryMetricsNoSamples(
        [
          {
            metric: "startupCacheEntries",
            label: "hit",
          },
        ],
        "after DNR store loaded startup cache data"
      );
    }

    Assert.equal(
      scheduleCacheDataSaveSpy.called,
      !expectLoadedFromCache,
      "scheduleCacheDataSave to not be called when the extension DNR rules are initialized from startup cache data"
    );

    Assert.equal(
      clearLastUpdateTagPrefSpy.callCount,
      expectClearLastUpdateTagPref ? 1 : 0,
      "Expect clearLastUpdateTagPrefSpy to have been called the expected number of times"
    );
    if (expectClearLastUpdateTagPref === true) {
      Assert.ok(
        clearLastUpdateTagPrefSpy.calledWith(extension.uuid),
        "Expect clearLastUpdateTagPrefSpy to have been called with the test extension uuid"
      );
    }

    Assert.equal(
      schemasNormalizeSpy.calledWith(
        sinon.match.any,
        sinon.match("declarativeNetRequest.Rule"),
        sinon.match.any
      ),
      !expectLoadedFromCache,
      `Expect DNR rules to ${
        expectLoadedFromCache ? "NOT " : ""
      }be going through Schemas.normalize`
    );

    Assert.equal(
      ruleValidatorAddRulesSpy.called,
      !expectLoadedFromCache,
      `Expect DNR rules to ${
        expectLoadedFromCache ? "NOT " : ""
      }be going through RuleValidator addRules`
    );

    Assert.equal(
      deserializeRuleSpy.called,
      expectLoadedFromCache,
      `Expect RuleValidator.deserializeRule to ${
        expectLoadedFromCache ? "NOT " : ""
      }be called to convert StartupCache data back into Rule class instances`
    );

    await assertDNRStoreData(dnrStore, extension, {
      ruleset_1: getSchemaNormalizedRules(extension, RULESET_1_DATA),
    });

    sandbox.restore();
  };

  const expectedLastUpdateTag = ExtensionDNRStore._getLastUpdateTag(
    extension.uuid
  );

  Assert.ok(
    typeof expectedLastUpdateTag == "string" && !!expectedLastUpdateTag.length,
    `Expect lastUpdateTag for ${extension.id} to be set to a non empty string: ${expectedLastUpdateTag}`
  );

  Assert.equal(
    ExtensionDNRStore._getLastUpdateTag(temporarilyInstalledExt.uuid),
    null,
    `Expect no lastUpdateTag value set for temporarily installed extensions`
  );

  await assertDNRStoreDataLoadOnStartup({
    expectLoadedFromCache: true,
    expectClearLastUpdateTagPref: false,
  });

  {
    const { buffer } = await IOUtils.read(cacheFile);
    const decodedData = aomStartup.decodeBlob(buffer);
    Assert.equal(
      expectedLastUpdateTag,
      decodedData?.cacheData.get(extension.uuid).lastUpdateTag,
      "Expect cacheData entry's lastUpdateTag to match the value stored in the related pref"
    );
    Assert.equal(
      decodedData?.cacheData.has(temporarilyInstalledExt.uuid),
      false,
      "Expect no cache data entry for temporarily installed extensions"
    );

    info("Confirm startupCache data dropped if last tag pref value mismatches");
    ExtensionDNRStore._storeLastUpdateTag(
      extension.uuid,
      "mismatching-tag-value"
    );
    Assert.notEqual(
      ExtensionDNRStore._getLastUpdateTag(extension.uuid),
      decodedData?.cacheData.get(extension.uuid).lastUpdateTag,
      "Expect cacheData.lastDNRStoreUpdateTag to NOT match the tampered value stored in the related pref"
    );
  }

  await assertDNRStoreDataLoadOnStartup({
    expectLoadedFromCache: false,
    expectClearLastUpdateTagPref: true,
  });

  info(
    "Verify that startupCache data mismatching with the StoreData schema version is being dropped"
  );
  await dnrStore.waitSaveCacheDataForTesting();
  await assertDNRStoreDataLoadOnStartup({
    expectLoadedFromCache: true,
    expectClearLastUpdateTagPref: false,
  });

  {
    info("Tamper the StoreData version in the startupCache data");
    const { buffer } = await IOUtils.read(cacheFile);
    const decodedData = aomStartup.decodeBlob(buffer);
    decodedData.cacheData.get(extUUID).schemaVersion = -1;
    await IOUtils.write(
      cacheFile,
      new Uint8Array(aomStartup.encodeBlob(decodedData))
    );
  }

  await assertDNRStoreDataLoadOnStartup({
    expectLoadedFromCache: false,
    expectClearLastUpdateTagPref: true,
  });

  info(
    "Verify that startupCache data mismatching with the extension version is being dropped"
  );
  await dnrStore.waitSaveCacheDataForTesting();
  await assertDNRStoreDataLoadOnStartup({
    expectLoadedFromCache: true,
    expectClearLastUpdateTagPref: false,
  });

  {
    info("Tamper the extension version in the startupCache data");
    const { buffer } = await IOUtils.read(cacheFile);
    const decodedData = aomStartup.decodeBlob(buffer);
    decodedData.cacheData.get(extUUID).extVersion = "0.1";
    await IOUtils.write(
      cacheFile,
      new Uint8Array(aomStartup.encodeBlob(decodedData))
    );
  }
  await assertDNRStoreDataLoadOnStartup({
    expectLoadedFromCache: false,
    expectClearLastUpdateTagPref: true,
  });

  await extension.unload();

  Assert.equal(
    ExtensionDNRStore._getLastUpdateTag(extension.uuid),
    null,
    "LastUpdateTag pref should have been removed after addon uninstall"
  );
});

add_task(async function test_detect_and_reschedule_save_cache_on_new_changes() {
  const rule_resources = [
    {
      id: "ruleset_1",
      enabled: true,
      path: "ruleset_1.json",
    },
  ];
  const files = {
    "ruleset_1.json": JSON.stringify(RULESET_1_DATA),
  };

  let dnrStore = ExtensionDNRStore._getStoreForTesting();
  let sandboxStore = sinon.createSandbox();
  const spyScheduleCacheDataSave = sandboxStore.spy(
    dnrStore,
    "scheduleCacheDataSave"
  );

  let extension;
  const tamperedLastUpdateTag = Services.uuid.generateUUID().toString();
  let resolvePromiseSaveCacheRescheduled;
  let promiseSaveCacheRescheduled = new Promise(resolve => {
    resolvePromiseSaveCacheRescheduled = resolve;
  });
  const realDetectStartupCacheDataChanged =
    dnrStore.detectStartupCacheDataChanged.bind(dnrStore);
  const stubDetectCacheDataChanges = sandboxStore.stub(
    dnrStore,
    "detectStartupCacheDataChanged"
  );

  stubDetectCacheDataChanges.callsFake(seenLastUpdateTags => {
    const extData = dnrStore._data.get(extension.extension.uuid);
    Assert.ok(extData, "Got StoreData instance for the test extension");
    Assert.ok(
      typeof extData.lastUpdateTag === "string" &&
        !!extData.lastUpdateTag.length,
      "Expect a non empty lastUpdateTag assigned to the extension StoreData"
    );
    Assert.deepEqual(
      Array.from(seenLastUpdateTags),
      [extData.lastUpdateTag],
      "Expects the extension storeData lastUpdateTag to have been seen"
    );
    if (stubDetectCacheDataChanges.callCount == 1) {
      Assert.notEqual(
        extData.lastUpdateTag,
        tamperedLastUpdateTag,
        "New tampered lastUpdateTag should not be equal to the one already set"
      );
      extData.lastUpdateTag = tamperedLastUpdateTag;
      Assert.equal(
        realDetectStartupCacheDataChanged(seenLastUpdateTags),
        true,
        "Expect dnrStore.detectStartupCacheDataChanged to detect a change"
      );
      return true;
    }
    Assert.equal(
      realDetectStartupCacheDataChanged(seenLastUpdateTags),
      false,
      "Expect dnrStore.detectStartupCacheDataChanged to NOT have detected any change"
    );

    Promise.resolve().then(resolvePromiseSaveCacheRescheduled);
    return false;
  });

  extension = ExtensionTestUtils.loadExtension(
    getDNRExtension({
      id: "test-reschedule-save-on-detected-changes@test",
      rule_resources,
      files,
    })
  );

  await extension.startup();
  info(
    "Wait for DNR initialization completed for the permanently installed extension"
  );
  await ExtensionDNR.ensureInitialized(extension.extension);
  info("Wait for the saveCacheDataNow task to have been rescheduled");
  await promiseSaveCacheRescheduled;

  Assert.equal(
    spyScheduleCacheDataSave.callCount,
    2,
    "Expect ExtensionDNRStore scheduleCacheDataSave method to have been called twice"
  );
  Assert.equal(
    stubDetectCacheDataChanges.callCount,
    2,
    "Expect ExtensionDNRStore detectStartupCacheDataChanged method to have been called twice"
  );

  sandboxStore.restore();

  await extension.unload();
});
