"use strict";

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

  // Don't turn warnings in errors, to make sure that the parameter validation
  // tests verify real-world behavior, instead of the stricter test-only mode.
  ExtensionTestUtils.failOnSchemaWarnings(false);
});

// This function is serialized and called in the context of the test extension's
// background page. dnrTestUtils is passed to the background function.
function makeDnrTestUtils() {
  const dnrTestUtils = {};
  const dnr = browser.declarativeNetRequest;
  function makeDummyAction(type) {
    switch (type) {
      case "redirect":
        return { type, redirect: { url: "https://example.com/dummy" } };
      case "modifyHeaders":
        return {
          type,
          responseHeaders: [{ operation: "append", header: "x", value: "y" }],
        };
      default:
        return { type };
    }
  }
  function makeDummyRequest() {
    // A value that matches the condition from makeDummyRule().
    return { url: "https://example.com/some-dummy-url", type: "main_frame" };
  }
  function makeDummyRule(id, actionType) {
    return {
      id,
      // condition matches makeDummyRequest().
      condition: { resourceTypes: ["main_frame"] },
      action: makeDummyAction(actionType),
    };
  }
  async function testMatchesRequest(request, ruleIds, description) {
    browser.test.assertDeepEq(
      ruleIds,
      (await dnr.testMatchOutcome(request)).matchedRules.map(mr => mr.ruleId),
      description
    );
  }
  async function testCanMatchAnyBlock({ matchedRequests, nonMatchedRequests }) {
    await dnr.updateSessionRules({
      addRules: [
        {
          // A rule that is supposed to match everything.
          id: 1,
          condition: { excludedResourceTypes: [] },
          action: { type: "block" },
        },
      ],
    });
    for (let request of matchedRequests) {
      await testMatchesRequest(
        request,
        [1],
        `${JSON.stringify(request)} - should match wildcard DNR block rule`
      );
    }
    for (let request of nonMatchedRequests) {
      await testMatchesRequest(
        request,
        [],
        `${JSON.stringify(request)} - should not match any DNR rule`
      );
    }
    await dnr.updateSessionRules({ removeRuleIds: [1] });
  }
  async function testCanUseAction(type, canUse) {
    await dnr.updateSessionRules({ addRules: [makeDummyRule(1, type)] });
    await testMatchesRequest(
      makeDummyRequest(),
      canUse ? [1] : [],
      `${type} - should${canUse ? "" : " not"} match`
    );
    await dnr.updateSessionRules({ removeRuleIds: [1] });
  }
  Object.assign(dnrTestUtils, {
    makeDummyAction,
    makeDummyRequest,
    makeDummyRule,
    testMatchesRequest,
    testCanMatchAnyBlock,
    testCanUseAction,
  });
  return dnrTestUtils;
}

async function runAsDNRExtension({
  background,
  manifest,
  unloadTestAtEnd = true,
}) {
  let extension = ExtensionTestUtils.loadExtension({
    background: `(${background})((${makeDnrTestUtils})())`,
    manifest: {
      manifest_version: 3,
      permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
      host_permissions: ["<all_urls>"],
      granted_host_permissions: true,
      ...manifest,
    },
    temporarilyInstalled: true, // <-- for granted_host_permissions
  });
  await extension.startup();
  await extension.awaitFinish();
  if (unloadTestAtEnd) {
    await extension.unload();
  }
  return extension;
}

add_task(async function validate_required_params() {
  await runAsDNRExtension({
    background: async () => {
      const testMatchOutcome = browser.declarativeNetRequest.testMatchOutcome;

      browser.test.assertThrows(
        () => testMatchOutcome({ type: "image" }),
        /Type error for parameter request \(Property "url" is required\)/,
        "url is required"
      );
      browser.test.assertThrows(
        () => testMatchOutcome({ url: "https://example.com/" }),
        /Type error for parameter request \(Property "type" is required\)/,
        "resource type is required"
      );

      browser.test.assertDeepEq(
        { matchedRules: [] },
        await testMatchOutcome({ url: "https://example.com/", type: "image" }),
        "testMatchOutcome with url and type succeeds"
      );

      browser.test.notifyPass();
    },
  });
});

add_task(async function resource_type_validation() {
  await runAsDNRExtension({
    background: async () => {
      const testMatchOutcome = browser.declarativeNetRequest.testMatchOutcome;

      const url = "https://example.com/some-dummy-url";

      browser.test.assertThrows(
        () => testMatchOutcome({ url, type: "MAIN_FRAME" }),
        /Error processing type: Invalid enumeration value "MAIN_FRAME"/,
        "testMatchOutcome should expects a lowercase type"
      );

      // Check that at least one ResourceType exists.
      browser.test.assertEq(
        "main_frame",
        browser.declarativeNetRequest.ResourceType.MAIN_FRAME,
        "ResourceType.MAIN_FRAME exists"
      );

      for (let type of Object.values(
        browser.declarativeNetRequest.ResourceType
      )) {
        browser.test.assertDeepEq(
          { matchedRules: [] },
          await testMatchOutcome({ url, type }),
          `testMatchOutcome for type=${type} is allowed`
        );
      }

      browser.test.notifyPass();
    },
  });
});

add_task(async function url_validation() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { testMatchesRequest } = dnrTestUtils;

      const type = "other"; // Dummy resource type.
      await dnr.updateSessionRules({
        addRules: [{ id: 1, condition: {}, action: { type: "block" } }],
      });

      const supportedUrls = [
        // All schemes that are potentially hooked up to the network are here.
        "http://example.com/",
        "https://example.com/",
        // While host permissions permits more (e.g. file:, moz-extension:),
        // we don't list them here since they are not hooked up to the network.
        // Trying to match such URLs is undefined behavior for now.
      ];
      const supportedInitiators = [
        // Supported URLs are also supported initiators.
        ...supportedUrls,
        // Note: moz-extension: has more tests in match_initiator_moz_extension.
        `moz-extension://${location.host}`,
        "file:///tmp/",
        // data:-URIs have a null principal.
        "data:text/plain,",
      ];
      const disallowedUrlsOrInitiators = [
        // about:-URI with system principal:
        "about:config",
        // Unprivileged about:-URL:
        "about:logo",
        "chrome://extensions/content/dummy.xhtml",
        "resource://pdf.js/web/viewer.html",
        // Extensions cannot see "view-source", only the result: bug 1683646.
        "view-source:http://example.com/",
        "view-source:about:config",
        // blob:-URLs do not go through the network. An actual network request
        // will never have a blob-URI as initiator, always the actual principal
        // URI. We don't try to extract the actual principal from the blob:-URI
        // because that is expensive and also performs a validation that the
        // blob:-URI is still valid, so testMatchOutcome could then return
        // inconsistent results.
        URL.createObjectURL(new Blob([])),
      ];
      const disallowedUrls = [
        ...disallowedUrlsOrInitiators,
        // data:-URIs are not hooked up to the network (bug 1631933), so we do
        // not support it in the testMatchOutcome API, even though the URL
        // matches "<all_urls>".
        "data:text/plain,",
      ];
      const disallowedInitiator = [
        ...disallowedUrlsOrInitiators,
        // "about:blank" inherits the principal or is null. testMatchOutcome
        // does not offer a way to specify it more precisely.
        "about:blank",
        // This is bogus: A principal URL can never be about:srcdoc. It is
        // always inherit from something.
        "about:srcdoc",
        "moz-extension://someone-elses-extension-here",
      ];

      for (let url of supportedUrls) {
        await testMatchesRequest({ url, type }, [1], `Supported url: ${url}`);
      }
      for (let initiator of supportedInitiators) {
        await testMatchesRequest(
          { url: "http://example.com/", type, initiator },
          [1],
          `Supported initiator: ${initiator}`
        );
      }
      for (let url of disallowedUrls) {
        await testMatchesRequest({ type, url }, [], `Disallowed url: ${url}`);
      }
      for (let initiator of disallowedInitiator) {
        await testMatchesRequest(
          { url: "http://example.com/", type, initiator },
          [],
          `Disallowed initiator: ${initiator}`
        );
      }

      browser.test.notifyPass();
    },
  });
});

add_task(async function rule_priority_and_action_type_precedence() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyRule, makeDummyRequest } = dnrTestUtils;

      await dnr.updateSessionRules({
        addRules: [
          makeDummyRule(1, "allow"),
          makeDummyRule(2, "allowAllRequests"),
          makeDummyRule(3, "block"),
          makeDummyRule(4, "upgradeScheme"),
          makeDummyRule(5, "redirect"),
          makeDummyRule(6, "modifyHeaders"),
          { ...makeDummyRule(7, "modifyHeaders"), priority: 2 },
          { ...makeDummyRule(8, "allow"), priority: 2 },
          { ...makeDummyRule(9, "block"), priority: 2 },
          // Repeat rules so that we can verify that the outcome is due to the
          // rule action, instead of the rule ID / input order.
          makeDummyRule(11, "allow"),
          makeDummyRule(12, "allowAllRequests"),
          makeDummyRule(13, "block"),
          makeDummyRule(14, "upgradeScheme"),
          makeDummyRule(15, "redirect"),
          makeDummyRule(16, "modifyHeaders"),
          { ...makeDummyRule(17, "modifyHeaders"), priority: 2 },
        ],
      });
      async function testAndRemove(ruleId, expectedRuleIds, description) {
        browser.test.assertDeepEq(
          expectedRuleIds.map(ruleId => ({ ruleId, rulesetId: "_session" })),
          (await dnr.testMatchOutcome(makeDummyRequest())).matchedRules,
          description
        );
        await dnr.updateSessionRules({ removeRuleIds: [ruleId] });
      }

      await testAndRemove(8, [8], "highest-prio allow wins");
      await testAndRemove(9, [9], "highest-prio block wins");
      // after this point, we only have same-prio rules and two higher-prio
      // modifyHeaders rules (7 & 17).

      await testAndRemove(
        1,
        [1, 7, 17],
        "1st allow ignores other rules, except for higher-prio modifyHeaders"
      );
      await testAndRemove(
        11,
        [11, 7, 17],
        "2nd allow ignores other rules, except for higher-prio modifyHeaders"
      );

      await testAndRemove(
        2,
        [2, 7, 17],
        "1st allowAllRequests ignores other rules, except for higher-prio modifyHeaders"
      );
      await testAndRemove(
        12,
        [12, 7, 17],
        "2nd allowAllRequests ignores other rules, except for higher-prio modifyHeaders"
      );

      await testAndRemove(3, [3], "1st block > all other actions");
      await testAndRemove(13, [13], "2nd block > all other actions");

      await testAndRemove(4, [4], "1st upgradeScheme > redirect");
      await testAndRemove(14, [14], "2nd upgradeScheme > redirect");

      await testAndRemove(5, [5], "1st redirect > modifyHeaders");
      await testAndRemove(15, [15], "2nd redirect > modifyHeaders");

      await testAndRemove(
        6,
        [7, 17, 6, 16],
        "All modifyHeaders match if there is no other action"
      );

      // Verify that a new rule takes precedence again.
      await dnr.updateSessionRules({
        addRules: [makeDummyRule(11, "allow")],
      });
      await testAndRemove(
        11,
        [11, 7, 17],
        "After adding an allow rule, only higher-prio modifyHeaders are shown"
      );

      browser.test.assertDeepEq(
        [7, 16, 17],
        (await dnr.getSessionRules()).map(r => r.id),
        "Remaining rules at end of test"
      );

      browser.test.notifyPass();
    },
  });
});

add_task(async function declarativeNetRequest_and_host_permissions() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const { testCanUseAction, testCanMatchAnyBlock } = dnrTestUtils;

      // Unlocked by declarativeNetRequest permission:
      await testCanUseAction("allow", true);
      await testCanUseAction("allowAllRequests", true);
      await testCanUseAction("block", true);
      await testCanUseAction("upgradeScheme", true);
      // Unlocked by host permissions:
      await testCanUseAction("redirect", true);
      await testCanUseAction("modifyHeaders", true);

      const url = "https://example.com/";
      await testCanMatchAnyBlock({
        matchedRequests: [
          { url, type: "other" },
          { url, type: "main_frame" },
          { url, type: "sub_frame" },
          { url, initiator: url, type: "other" },
          { url, initiator: url, type: "main_frame" },
          { url, initiator: url, type: "sub_frame" },
        ],
        nonMatchedRequests: [],
      });

      browser.test.notifyPass();
    },
  });
});

add_task(async function declarativeNetRequest_permission_only() {
  await runAsDNRExtension({
    manifest: {
      host_permissions: [],
    },
    background: async dnrTestUtils => {
      const { testCanUseAction, testCanMatchAnyBlock } = dnrTestUtils;

      // Unlocked by declarativeNetRequest permission:
      await testCanUseAction("allow", true);
      await testCanUseAction("allowAllRequests", true);
      await testCanUseAction("block", true);
      await testCanUseAction("upgradeScheme", true);
      // These require host permissions, which we don't have:
      await testCanUseAction("redirect", false);
      await testCanUseAction("modifyHeaders", false);

      const url = "https://example.com/";
      await testCanMatchAnyBlock({
        matchedRequests: [
          { url, type: "other" },
          { url, type: "main_frame" },
          { url, type: "sub_frame" },
          { url, initiator: url, type: "other" },
          { url, initiator: url, type: "main_frame" },
          { url, initiator: url, type: "sub_frame" },
        ],
        nonMatchedRequests: [],
      });

      browser.test.notifyPass();
    },
  });
});

add_task(async function declarativeNetRequestWithHostAccess_only() {
  await runAsDNRExtension({
    manifest: {
      permissions: [
        "declarativeNetRequestWithHostAccess",
        "declarativeNetRequestFeedback",
      ],
      host_permissions: [],
    },
    background: async dnrTestUtils => {
      const { testCanUseAction } = dnrTestUtils;

      // declarativeNetRequestWithHostAccess requires host permissions,
      // which we don't have. So none of the rules should match:
      await testCanUseAction("allow", false);
      await testCanUseAction("allowAllRequests", false);
      await testCanUseAction("block", false);
      await testCanUseAction("upgradeScheme", false);
      await testCanUseAction("redirect", false);
      await testCanUseAction("modifyHeaders", false);

      browser.test.notifyPass();
    },
  });
});

add_task(async function declarativeNetRequestWithHostAccess_and_host_perm() {
  await runAsDNRExtension({
    manifest: {
      permissions: [
        "declarativeNetRequestWithHostAccess",
        "declarativeNetRequestFeedback",
      ],
      // Origin used by makeDummyRequest() & makeDummyRule():
      host_permissions: ["https://example.com/"],
    },
    background: async dnrTestUtils => {
      const { testCanUseAction, testCanMatchAnyBlock } = dnrTestUtils;

      // declarativeNetRequestWithHostAccess + host permissions allows all:
      await testCanUseAction("allow", true);
      await testCanUseAction("allowAllRequests", true);
      await testCanUseAction("block", true);
      await testCanUseAction("upgradeScheme", true);
      await testCanUseAction("redirect", true);
      await testCanUseAction("modifyHeaders", true);

      const url = "https://example.com/";
      const urlNoPerm = "https://example.net/?not_in:host_permissions";
      await testCanMatchAnyBlock({
        matchedRequests: [
          { url, type: "other" },
          { url, type: "main_frame" },
          { url, type: "sub_frame" },
          // Navigations do no require host permissions for initiator.
          { url, initiator: urlNoPerm, type: "main_frame" },
          { url, initiator: urlNoPerm, type: "sub_frame" },
        ],
        nonMatchedRequests: [
          // url always requires declarativeNetRequest or host permissions.
          { url: urlNoPerm, type: "other" },
          // Non-navigations require host permissions for initiator.
          { url, initiator: urlNoPerm, type: "other" },
        ],
      });

      browser.test.notifyPass();
    },
  });
});

// Tests: resourceTypes, excludedResourceTypes
// Tests: requestMethods, excludedRequestMethods
add_task(async function match_condition_types_and_methods() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyAction, testMatchesRequest } = dnrTestUtils;

      // "modifyHeaders" is the only action that allows multiple rule matches.
      const action = makeDummyAction("modifyHeaders");

      await dnr.updateSessionRules({
        addRules: [
          {
            id: 1,
            condition: {
              resourceTypes: ["xmlhttprequest"],
              requestMethods: ["put"],
            },
            action,
          },
          {
            id: 2,
            condition: {
              excludedResourceTypes: ["sub_frame"],
              excludedRequestMethods: ["post"],
            },
            action,
          },
          {
            id: 3,
            condition: {
              // resourceTypes not specified should imply all-minus-main_frame.
              requestMethods: ["get", "post"],
            },
            action,
          },
          {
            id: 4,
            condition: {
              resourceTypes: ["main_frame", "xmlhttprequest"],
              excludedRequestMethods: ["get"],
            },
            action,
          },
        ],
      });

      const url = "https://example.com/some-dummy-url";
      await testMatchesRequest(
        { url, type: "main_frame" },
        [2],
        "main_frame + GET"
      );

      await testMatchesRequest(
        { url, type: "xmlhttprequest" },
        [2, 3],
        "xmlhttprequest + GET"
      );

      await testMatchesRequest(
        { url, type: "xmlhttprequest", method: "put" },
        [1, 2, 4],
        "xmlhttprequest + PUT"
      );

      await testMatchesRequest(
        { url, type: "sub_frame", method: "post" },
        [3],
        "sub_frame + POST"
      );

      await testMatchesRequest(
        { url, type: "sub_frame", method: "post" },
        [3],
        "sub_frame + POST"
      );

      browser.test.notifyPass();
    },
  });
});

// Tests: requestDomains, excludedRequestDomains
add_task(async function match_request_domains() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyAction, testMatchesRequest } = dnrTestUtils;

      // "modifyHeaders" is the only action that allows multiple rule matches.
      const action = makeDummyAction("modifyHeaders");

      await dnr.updateSessionRules({
        addRules: [
          {
            id: 1,
            condition: {
              requestDomains: ["a.com", "www.b.com"],
            },
            action,
          },
          {
            id: 2,
            condition: {
              excludedRequestDomains: ["a.com", "www.b.com", "127.0.0.1"],
            },
            action,
          },
          {
            id: 3,
            condition: {
              requestDomains: ["one.net"],
              excludedRequestDomains: ["sub.one.net"],
            },
            action,
          },
          {
            id: 4,
            condition: {
              // This can never match.
              requestDomains: ["sub.one.net"],
              excludedRequestDomains: ["one.net"],
            },
            action,
          },
          {
            id: 5,
            condition: {
              requestDomains: ["127.0.0.1", "[::1]"],
            },
            action,
          },
          {
            id: 6,
            condition: {
              requestDomains: [
                "~b.com", // "~" should not be interpreted as pattern negation.
              ],
            },
            action,
          },
          {
            id: 7,
            condition: {
              // A canonical domain does not start with a ".". Domains filters
              // starting with a "." are therefore not matching anything.
              requestDomains: [".a.com"],
            },
            action,
          },
        ],
      });

      const type = "sub_frame";
      // Tests related to a.com:
      await testMatchesRequest(
        { url: "https://a.com:1234/path", type },
        [1],
        "a.com: url's domain is equal to a.com"
      );
      await testMatchesRequest(
        { url: "http://sub.a.com/", type },
        [1],
        "sub.a.com: url is subdomain of a.com"
      );
      await testMatchesRequest(
        { url: "http://nota.com/a.com?a.com#a.com", type },
        [2],
        "nota.com: url's domain does not match a.com"
      );
      await testMatchesRequest(
        { url: "http://a.com.not/a.com?a.com#a.com", type },
        [2],
        "a.com.not: url's domain does not match a.com"
      );
      await testMatchesRequest(
        { url: "http://a.com./a.com?a.com#a.com", type },
        [2],
        "a.com.: url's domain (ending with dot) does not match a.com"
      );

      // Tests related to www.b.com:
      await testMatchesRequest(
        { url: "http://www.b.com/", type },
        [1],
        "www.b.com: url's domain is equal to www.b.com"
      );
      await testMatchesRequest(
        { url: "http://sub.www.b.com", type },
        [1],
        "sub.www.b.com: url's domain is a subdomain of www.b.com"
      );
      await testMatchesRequest(
        { url: "http://b.com/", type },
        [2],
        "b.com: url's domain is a superdomain, NOT a subdomain of www.b.com"
      );

      // Tests related to sub.one.net / one.net
      await testMatchesRequest(
        { url: "http://one.net/", type },
        [2, 3],
        "one.net: url's domain matches one.net, but not sub.one.net"
      );
      await testMatchesRequest(
        { url: "http://sub.one.net/", type },
        [2], // Rule 4 was a candidate, but excluded anyway.
        "sub.one.net: url's domain matches sub.one.net, but excluded by one.net"
      );

      // Tests related to IP addresses
      await testMatchesRequest(
        { url: "http://127.0.0.1:8080/", type },
        [5],
        "127.0.0.1: IP address is exact match for 127.0.0.1"
      );
      await testMatchesRequest(
        { url: "http://8.8.8.8/", type },
        [2],
        "8.8.8.8: not matched by any of the domains"
      );
      await testMatchesRequest(
        { url: "http://[::1]/", type },
        [2, 5],
        "[::1]: IPv6 matches with bracket"
      );

      // For completeness, verify that the non-resolving domain "~b.com"
      // matches the input, so that we know that "~" was not given special
      // treatment. In filter list syntax, "~" before the domain negates the
      // meaning, but that should not be supported in DNR.
      await testMatchesRequest(
        { url: "http://~b.com/", type },
        [2, 6],
        "~b.com: Although a non-resolving domain, it matches the pattern"
      );

      // match_initiator_domains has more tests; here we just confirm that
      // requestDomains rules don't match initiator.
      await testMatchesRequest(
        { url: "http://url.does.not.match/", type, initiator: "http://a.com/" },
        [2],
        "requestDomains should not match initiator URL"
      );

      browser.test.notifyPass();
    },
  });
});

add_task(async function match_request_domains_punycode() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyAction, testMatchesRequest } = dnrTestUtils;

      // "modifyHeaders" is the only action that allows multiple rule matches.
      const action = makeDummyAction("modifyHeaders");

      // Note that the non-punycode domains are rejected by schema validation,
      // and checked by test validate_domains in test_ext_dnr_session_rules.js.

      await dnr.updateSessionRules({
        addRules: [
          {
            id: 1,
            condition: {
              // straß.de
              requestDomains: ["xn--stra-yna.de"],
            },
            action,
          },
          {
            id: 2,
            condition: {
              // IDNA2003 converted ß to ss. But IDNA2008 requires punycode.
              requestDomains: ["strass.de", "stras.de"],
            },
            action,
          },
        ],
      });

      const type = "sub_frame";

      await testMatchesRequest(
        { url: "https://straß.de/", type },
        [1],
        "straß.de matches"
      );
      await testMatchesRequest(
        { url: "https://xn--stra-yna.de/", type },
        [1],
        "xn--stra-yna.de matches"
      );
      await testMatchesRequest(
        { url: "https://strass.de/", type },
        [2],
        "strass.de does not match the punycode pattern of straß"
      );
      await testMatchesRequest(
        { url: "https://stras.de/", type },
        [2],
        "stras.de does not match the punycode pattern of straß"
      );

      browser.test.notifyPass();
    },
  });
});

// Tests: initiatorDomains, excludedInitiatorDomains
// More tests in: match_initiator_moz_extension.
add_task(async function match_initiator_domains() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyAction, testMatchesRequest } = dnrTestUtils;

      // "modifyHeaders" is the only action that allows multiple rule matches.
      const action = makeDummyAction("modifyHeaders");

      // The validation of initiatorDomains and requestDomains are shared.
      // The match_request_domains and match_request_domains_punycode tests
      // already verify semantics; this test just tests that the conditional
      // logic works as expected, plus coverage for initiator being void.

      await dnr.updateSessionRules({
        addRules: [
          {
            id: 1,
            condition: {
              initiatorDomains: ["a.com"],
            },
            action,
          },
          {
            id: 2,
            condition: {
              excludedInitiatorDomains: ["a.com"],
            },
            action,
          },
          {
            id: 3,
            condition: {
              initiatorDomains: ["c.com"],
              excludedInitiatorDomains: ["c.com"],
            },
            action,
          },
          {
            id: 4, // To verify that it does not match a void initiator.
            condition: {
              initiatorDomains: ["null"],
            },
            action,
          },
          {
            id: 5,
            condition: {
              excludedInitiatorDomains: ["null", "undefined"],
            },
            action,
          },
          {
            id: 6, // To verify that it does not match a void initiator.
            condition: {
              initiatorDomains: ["undefined"],
            },
            action,
          },
        ],
      });

      const url = "https://do.not.look.here/look_at_initator_instead";
      const type = "image";
      await testMatchesRequest(
        { url, type, initiator: "http://a.com/" },
        [1, 5],
        "initiatorDomains matches"
      );
      await testMatchesRequest(
        { url, type, initiator: "http://b.com/" },
        [2, 5],
        "excludedInitiatorDomains does not match, so request matched"
      );
      await testMatchesRequest(
        { url, type, initiator: "http://c.com/" },
        [2, 5], // 3 is not here, despite containing "c.com".
        "excludedInitiatorDomains takes precedence over initiatorDomains"
      );
      // When initiator is not specified, rules with initiatorDomains should not
      // match, and rules with excludedInitiatorDomains may match.
      await testMatchesRequest(
        { url, type },
        [2, 5],
        "request without initiator matches every excludedInitiatorDomains"
      );
      // http://null is unlikely to exist in practice. Regardless, verify that
      // it won't match a void initiators.
      await testMatchesRequest(
        { url, type, initiator: "http://null/" },
        [2, 4],
        "http://null is matched by the 'null' domain"
      );
      await testMatchesRequest(
        { url, type, initiator: "http://undefined/" },
        [2, 6],
        "http://null is matched by the 'undefined' domain"
      );
      await testMatchesRequest(
        { url: "http://a.com/", type },
        [2, 5],
        "initiatorDomains should not match the request URL (initiator=null)"
      );

      browser.test.notifyPass();
    },
  });
});

// Tests: initiatorDomains, excludedInitiatorDomains with moz-extension:-URLs.
add_task(async function match_initiator_moz_extension() {
  let extension = await runAsDNRExtension({
    manifest: { browser_specific_settings: { gecko: { id: "other@ext" } } },
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyAction, testMatchesRequest } = dnrTestUtils;

      // "modifyHeaders" is the only action that allows multiple rule matches.
      // But we cannot use "modifyHeaders" because that feature depends on
      // access to "triggering principal". Fortunately, the two test rules in
      // this test case are mutually exclusive, so the block action works.
      // TODO bug 1825824: change to makeDummyAction("modifyHeaders").
      const action = makeDummyAction("block");

      const thisExtensionUUID = location.hostname;
      await dnr.updateSessionRules({
        addRules: [
          {
            id: 1,
            condition: {
              initiatorDomains: [thisExtensionUUID],
            },
            action,
          },
          {
            id: 2,
            condition: {
              excludedInitiatorDomains: [thisExtensionUUID],
            },
            action,
          },
        ],
      });

      const url = "https://do.not.look.here/look_at_initator_instead";
      const type = "other";
      // Sanity check with non-moz-extension:-schemes as initiator.
      await testMatchesRequest(
        { url, type, initiator: `https://${thisExtensionUUID}/` },
        [1],
        "https:+UUID matches initiatorDomains"
      );
      await testMatchesRequest(
        { url, type, initiator: "https://random-uuid-here/" },
        [2],
        "https:+UUID matches excludedInitiatorDomains"
      );
      // Now test with moz-extension: as initiator.
      await testMatchesRequest(
        { url, type, initiator: location.origin },
        [1],
        "moz-extension: initiator matches when it should"
      );
      await testMatchesRequest(
        { url, type, initiator: `moz-extension://random-uuid-here/` },
        [],
        "moz-extension: from unrelated extension cannot match by default"
      );

      browser.test.onMessage.addListener(async msg => {
        browser.test.assertEq("test_with_pref", msg, "expected msg");
        await testMatchesRequest(
          { url, type, initiator: `moz-extension://random-uuid-here/` },
          [2],
          "With pref, moz-extension: from unrelated extension can match"
        );
        browser.test.sendMessage("test_with_pref:done");
      });

      // Notify to continue. We don't exit yet due to unloadTestAtEnd:false
      browser.test.notifyPass();
    },
    // Continue running the DNR extension because we want to test the current
    // DNR rules with other extensions.
    unloadTestAtEnd: false,
  });

  info("Testing foreign moz-extension request within same ext, with pref on");
  await runWithPrefs(
    [["extensions.dnr.match_requests_from_other_extensions", true]],
    async () => {
      extension.sendMessage("test_with_pref");
      await extension.awaitMessage("test_with_pref:done");
    }
  );

  const otherExtensionUUID = extension.uuid;

  await runAsDNRExtension({
    manifest: {
      // Pass the DNR extension UUID to this extension.
      description: otherExtensionUUID,
    },
    background: async () => {
      const otherExtensionUUID = browser.runtime.getManifest().description;
      const dnr = browser.declarativeNetRequest;

      const url = "https://do.not.look.here/look_at_initator_instead";
      const type = "other";

      browser.test.assertDeepEq(
        { matchedRules: [] },
        await dnr.testMatchOutcome({ url, type, initiator: location.origin }),
        "testMatchOutcome excludes other extensions by default"
      );
      browser.test.assertDeepEq(
        { matchedRules: [] },
        await dnr.testMatchOutcome(
          { url, type, initiator: location.origin },
          { includeOtherExtensions: true }
        ),
        "No matches when initiator is moz-extension:, different from DNR ext"
      );
      browser.test.assertDeepEq(
        {
          matchedRules: [
            { ruleId: 1, rulesetId: "_session", extensionId: "other@ext" },
          ],
        },
        await dnr.testMatchOutcome(
          { url, type, initiator: `moz-extension://${otherExtensionUUID}` },
          { includeOtherExtensions: true }
        ),
        "Simulated moz-extension: for original extension finds a match"
      );

      browser.test.notifyPass();
    },
  });

  info("Testing foreign moz-extension request in other ext, with pref on");
  await runWithPrefs(
    [["extensions.dnr.match_requests_from_other_extensions", true]],
    async () => {
      await runAsDNRExtension({
        manifest: {
          // Pass the DNR extension UUID to this extension.
          description: otherExtensionUUID,
        },
        background: async () => {
          const otherExtensionUUID = browser.runtime.getManifest().description;
          const dnr = browser.declarativeNetRequest;

          const url = "https://do.not.look.here/look_at_initator_instead";
          const type = "other";

          // Sanity check: testMatchOutcome for moz-extension:-URL different
          // from the DNR extension and the current test extension.
          browser.test.assertDeepEq(
            {
              matchedRules: [
                { ruleId: 2, rulesetId: "_session", extensionId: "other@ext" },
              ],
            },
            await dnr.testMatchOutcome(
              { url, type, initiator: "moz-extension://random-uuid-here/" },
              { includeOtherExtensions: true }
            ),
            "With pref, moz-extension: from unrelated extensions can match"
          );

          // Usually, DNR does not affect requests from other extensions. That
          // was checked in the previous test extension (without pref override).
          // Here, we check that with the pref override, testMatchOutcome can
          // return matches from other extensions for the given extension UUID.
          browser.test.assertDeepEq(
            {
              matchedRules: [
                { ruleId: 2, rulesetId: "_session", extensionId: "other@ext" },
              ],
            },
            await dnr.testMatchOutcome(
              { url, type, initiator: location.origin },
              { includeOtherExtensions: true }
            ),
            "With pref, moz-extension:-initiator different from DNR ext matches"
          );

          // Identical test as in the previous test extension (that ran without
          // the pref override). This verifies that the pref does not affect the
          // behavior of request matching for requests within that extension.
          browser.test.assertDeepEq(
            {
              matchedRules: [
                { ruleId: 1, rulesetId: "_session", extensionId: "other@ext" },
              ],
            },
            await dnr.testMatchOutcome(
              { url, type, initiator: `moz-extension://${otherExtensionUUID}` },
              { includeOtherExtensions: true }
            ),
            "With pref, moz-extension: for DNR ext still matches"
          );

          browser.test.notifyPass();
        },
      });
    }
  );

  await extension.unload();
});

// Tests: urlFilter. For more comprehensive tests, see
// toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js
add_task(async function match_urlFilter() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyAction, testMatchesRequest } = dnrTestUtils;

      // "modifyHeaders" is the only action that allows multiple rule matches.
      const action = makeDummyAction("modifyHeaders");

      await dnr.updateSessionRules({
        addRules: [
          // Some patterns that match literally everything:
          { id: 1, condition: { urlFilter: "." }, action },
          { id: 2, condition: { urlFilter: "^" }, action },
          { id: 3, condition: { urlFilter: "|" }, action },
          // Patterns that match the test URLs
          { id: 4, condition: { urlFilter: "https://example.com" }, action },
          {
            // urlFilter matches, requestDomains matches.
            id: 5,
            condition: { urlFilter: "*", requestDomains: ["example.com"] },
            action,
          },
          {
            // urlFilter matches, requestDomains does not match.
            id: 6,
            condition: { urlFilter: "*", requestDomains: ["notexample.com"] },
            action,
          },
          {
            // urlFilter does not match, requestDomains matches.
            id: 7,
            condition: { urlFilter: "notm", requestDomains: ["example.com"] },
            action,
          },
        ],
      });

      await testMatchesRequest(
        { url: "https://example.com/file.txt", type: "font" },
        [1, 2, 3, 4, 5],
        "urlFilter should match when needed, and correctly with requestDomains"
      );

      browser.test.notifyPass();
    },
  });
});

// Tests: regexFilter. For more comprehensive tests, see
// toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter.js
add_task(async function match_regexFilter() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyAction, testMatchesRequest } = dnrTestUtils;

      // "modifyHeaders" is the only action that allows multiple rule matches.
      const action = makeDummyAction("modifyHeaders");

      await dnr.updateSessionRules({
        addRules: [
          // Some patterns that match literally everything:
          { id: 1, condition: { regexFilter: ".*" }, action },
          { id: 2, condition: { regexFilter: "^" }, action },
          // Patterns that match the test URLs
          { id: 3, condition: { regexFilter: "https://.xample\\." }, action },
          { id: 4, condition: { regexFilter: "https://example.com" }, action },
          {
            // regexFilter matches, requestDomains matches.
            id: 5,
            condition: { regexFilter: "$", requestDomains: ["example.com"] },
            action,
          },
          {
            // regexFilter matches, requestDomains does not match.
            id: 6,
            condition: { regexFilter: "$", requestDomains: ["notexample.com"] },
            action,
          },
          {
            // regexFilter does not match, requestDomains matches.
            id: 7,
            condition: { regexFilter: "notm", requestDomains: ["example.com"] },
            action,
          },
        ],
      });

      await testMatchesRequest(
        { url: "https://example.com/file.txt", type: "font" },
        [1, 2, 3, 4, 5],
        "regexFilter should match when needed, and correctly with requestDomains"
      );

      browser.test.notifyPass();
    },
  });
});

// Tests: tabIds, excludedTabIds
add_task(async function match_tabIds() {
  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyAction, testMatchesRequest } = dnrTestUtils;

      // "modifyHeaders" is the only action that allows multiple rule matches.
      const action = makeDummyAction("modifyHeaders");

      await dnr.updateSessionRules({
        addRules: [
          {
            id: 1,
            condition: {
              excludedTabIds: [-1, Number.MAX_SAFE_INTEGER],
            },
            action,
          },
          {
            id: 2,
            condition: {
              tabIds: [1, Number.MAX_SAFE_INTEGER],
            },
            action,
          },
          {
            id: 3,
            condition: {
              tabIds: [-1],
            },
            action,
          },
        ],
      });

      const url = "https://example.com/some-dummy-url";
      const type = "font";
      await testMatchesRequest({ url, type }, [3], "tabId defaults to -1");
      await testMatchesRequest({ url, type, tabId: -1 }, [3], "tabId -1");
      await testMatchesRequest({ url, type, tabId: 1 }, [1, 2], "tabId 1");
      await testMatchesRequest(
        {
          url,
          type,
          tabId: Number.MAX_SAFE_INTEGER,
        },
        [2],
        `tabId high number (MAX_SAFE_INTEGER=${Number.MAX_SAFE_INTEGER})`
      );

      // tabId -2 is invalid and not encountered in practice, but technically
      // it matches the first rule.
      await testMatchesRequest({ url, type, tabId: -2 }, [1], "bad tabId -2");

      browser.test.notifyPass();
    },
  });
});

add_task(async function action_precedence_between_extensions() {
  // This test is structured as follows:
  // - otherExtension registers rules for several numeric conditions (tabId).
  // - otherExtensionNonBlockAndModifyHeaders adds allowAllRequests and
  //   modifyHeaders to all requests.
  // - otherExtensionModifyHeaders adds modifyHeaders rules to all requests.
  // - the main test extension also registers rules, and then simulates requests
  //   with testMatchOutcome for each tabId, and checks the result.

  let otherExtension = await runAsDNRExtension({
    manifest: { browser_specific_settings: { gecko: { id: "other@ext" } } },
    background: async dnrTestUtils => {
      const { makeDummyAction } = dnrTestUtils;

      // Dummy condition for testing requests in this test.
      const c = tabId => ({ resourceTypes: ["main_frame"], tabIds: [tabId] });

      await browser.declarativeNetRequest.updateSessionRules({
        addRules: [
          { id: 11, condition: c(1), action: makeDummyAction("allow") },
          { id: 12, condition: c(2), action: makeDummyAction("block") },
          { id: 13, condition: c(3), action: makeDummyAction("redirect") },
          { id: 14, condition: c(4), action: makeDummyAction("upgradeScheme") },
          {
            id: 15,
            condition: c(5),
            action: makeDummyAction("allowAllRequests"),
          },
          {
            id: 16,
            condition: c(6),
            action: makeDummyAction("allowAllRequests"),
          },
        ],
      });
      // Notify to continue. We don't exit yet due to unloadTestAtEnd:false
      browser.test.notifyPass();
    },
    unloadTestAtEnd: false,
  });

  let otherExtensionNonBlockAndModifyHeaders = await runAsDNRExtension({
    manifest: { browser_specific_settings: { gecko: { id: "other@ext2" } } },
    background: async dnrTestUtils => {
      const { makeDummyAction } = dnrTestUtils;

      // Matches all requests from this test.
      const condition = { resourceTypes: ["main_frame"] };

      await browser.declarativeNetRequest.updateSessionRules({
        addRules: [
          {
            id: 1000,
            condition,
            action: makeDummyAction("modifyHeaders"),
            // Same-or-lower priority "modifyHeaders" actions are ignored when
            // an "allowAllRequests" action exists within the same extension.
            // Since we have such a rule (ID 1001), this modifyHeaders rule must
            // have "priority: 2" to avoid being ignored.
            priority: 2,
          },
          { id: 1001, condition, action: makeDummyAction("allowAllRequests") },
          {
            id: 1002,
            condition,
            action: makeDummyAction("modifyHeaders"),
            priority: 2, // necessary as explained above at rule ID 1000.
          },
          // should never appear because the first allowAllRequests rule should
          // take precedence:
          { id: 1003, condition, action: makeDummyAction("allowAllRequests") },
        ],
      });

      // Notify to continue. We don't exit yet due to unloadTestAtEnd:false
      browser.test.notifyPass();
    },
    unloadTestAtEnd: false,
  });

  // |otherExtensionModifyHeaders| and |otherExtensionNonBlockAndModifyHeaders|
  // both have "modifyHeaders" rules. The documented order of rules is for
  // the most recently installed extension to take precedence when applying
  // modifyHeaders actions. The "priority" key is extension-specific, so even
  // though |otherExtensionNonBlockAndModifyHeaders| defines "priority: 2" for
  // modifyHeaders action (ID 1001), the modifyHeaders below (ID 1337) takes
  // precedence because the extension was installed later.
  let otherExtensionModifyHeaders = await runAsDNRExtension({
    manifest: { browser_specific_settings: { gecko: { id: "other@ext3" } } },
    background: async dnrTestUtils => {
      const { makeDummyAction } = dnrTestUtils;
      await browser.declarativeNetRequest.updateSessionRules({
        addRules: [
          {
            id: 1337,
            // Matches all requests from this test.
            condition: { resourceTypes: ["main_frame"] },
            action: makeDummyAction("modifyHeaders"),
            // Note: no "priority" key set, so defaults to 1.
          },
        ],
      });
      // Notify to continue. We don't exit yet due to unloadTestAtEnd:false
      browser.test.notifyPass();
    },
    unloadTestAtEnd: false,
  });

  await runAsDNRExtension({
    background: async dnrTestUtils => {
      const dnr = browser.declarativeNetRequest;
      const { makeDummyAction } = dnrTestUtils;

      // Dummy condition for testing requests in this test.
      const c = tabId => ({ resourceTypes: ["main_frame"], tabIds: [tabId] });

      await dnr.updateSessionRules({
        addRules: [
          { id: 91, condition: c(1), action: makeDummyAction("block") },
          { id: 92, condition: c(2), action: makeDummyAction("allow") },
          { id: 93, condition: c(3), action: makeDummyAction("block") },
          { id: 94, condition: c(4), action: makeDummyAction("block") },
          { id: 95, condition: c(5), action: makeDummyAction("allow") },
          {
            id: 96,
            condition: c(6),
            action: makeDummyAction("allowAllRequests"),
          },
        ],
      });

      const url = "https://example.com/dummy-url";
      const type = "main_frame";
      const options = { includeOtherExtensions: true };
      browser.test.assertDeepEq(
        [{ ruleId: 91, rulesetId: "_session" }],
        (await dnr.testMatchOutcome({ url, type, tabId: 1 }, options))
          .matchedRules,
        "block takes precedence over allow (from other extension)"
      );

      browser.test.assertDeepEq(
        [{ ruleId: 12, rulesetId: "_session", extensionId: "other@ext" }],
        (await dnr.testMatchOutcome({ url, type, tabId: 2 }, options))
          .matchedRules,
        "block (from other extension) takes precedence over allow"
      );
      browser.test.assertDeepEq(
        [{ ruleId: 93, rulesetId: "_session" }],
        (await dnr.testMatchOutcome({ url, type, tabId: 3 }, options))
          .matchedRules,
        "block takes precedence over redirect (from other extension)"
      );
      browser.test.assertDeepEq(
        [{ ruleId: 94, rulesetId: "_session" }],
        (await dnr.testMatchOutcome({ url, type, tabId: 4 }, options))
          .matchedRules,
        "block takes precedence over upgradeScheme (from other extension)"
      );
      browser.test.assertDeepEq(
        [
          // allow:
          { ruleId: 95, rulesetId: "_session" },
          // allowAllRequests (newest install first):
          { ruleId: 1001, rulesetId: "_session", extensionId: "other@ext2" },
          { ruleId: 15, rulesetId: "_session", extensionId: "other@ext" },
          // modifyHeaders (see comment at otherExtensionModifyHeaders):
          { ruleId: 1337, rulesetId: "_session", extensionId: "other@ext3" },
          { ruleId: 1000, rulesetId: "_session", extensionId: "other@ext2" },
          { ruleId: 1002, rulesetId: "_session", extensionId: "other@ext2" },
        ],
        (await dnr.testMatchOutcome({ url, type, tabId: 5 }, options))
          .matchedRules,
        "When allow matches, allowAllRequests from other extension matches too"
      );
      browser.test.assertDeepEq(
        [
          // allowAllRequests (newest install first):
          { ruleId: 96, rulesetId: "_session" },
          { ruleId: 1001, rulesetId: "_session", extensionId: "other@ext2" },
          { ruleId: 16, rulesetId: "_session", extensionId: "other@ext" },
          // modifyHeaders (see comment at otherExtensionModifyHeaders):
          { ruleId: 1337, rulesetId: "_session", extensionId: "other@ext3" },
          { ruleId: 1000, rulesetId: "_session", extensionId: "other@ext2" },
          { ruleId: 1002, rulesetId: "_session", extensionId: "other@ext2" },
        ],
        (await dnr.testMatchOutcome({ url, type, tabId: 6 }, options))
          .matchedRules,
        "allowAllRequests from all other extensions are matched"
      );

      browser.test.notifyPass();
    },
  });

  await otherExtension.unload();
  await otherExtensionNonBlockAndModifyHeaders.unload();
  await otherExtensionModifyHeaders.unload();
});
