"use strict";

// This file tests whether the "allowAllRequests" action is correctly applied
// to subresource requests. The relative precedence to other actions/extensions
// is tested in test_ext_dnr_testMatchOutcome.js, specifically by test tasks
// rule_priority_and_action_type_precedence and
// action_precedence_between_extensions.

ChromeUtils.defineESModuleGetters(this, {
  TestUtils: "resource://testing-common/TestUtils.sys.mjs",
});

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

const server = createHttpServer({
  hosts: ["example.com", "example.net", "example.org"],
});
server.registerPathHandler("/never_reached", () => {
  Assert.ok(false, "Server should never have been reached");
});
server.registerPathHandler("/allowed", (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "*");
  res.setHeader("Access-Control-Max-Age", "0");
  // Any test that is able to check the response body will be able to assert
  // the response body's value. Let's use "fetchAllowed" so that the compared
  // values are obvious when assertEq/assertDeepEq are used.
  res.write("fetchAllowed");
});
server.registerPathHandler("/", (req, res) => {
  res.write("Dummy page");
});
server.registerPathHandler("/echo_html", (req, res) => {
  let code = decodeURIComponent(req.queryString);
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  if (req.hasHeader("prependhtml")) {
    code = req.getHeader("prependhtml") + code;
  }
  res.write(`<!DOCTYPE html>${code}`);
});
server.registerPathHandler("/bfcache_test", (req, res) => {
  res.setHeader("Content-Type", "text/html; charset=utf-8");
  res.write(`<body><script>
    // false at initial load, true when loaded from bfcache.
    onpageshow = e => document.body.textContent = e.persisted;
  </script>`);
});

async function waitForRequestAtServer(path) {
  return new Promise(resolve => {
    let callCount = 0;
    server.registerPathHandler(path, (req, res) => {
      Assert.equal(++callCount, 1, `Got one request for: ${path}`);
      res.processAsync();
      resolve({ req, res });
    });
  });
}

// Several tests expect fetch() to fail due to the request being blocked.
// They can use testLoadInFrame({ ..., expectedError: FETCH_BLOCKED }).
const FETCH_BLOCKED =
  "TypeError: NetworkError when attempting to fetch resource.";

function urlEchoHtml(domain, html) {
  return `http://${domain}/echo_html?${encodeURIComponent(html)}`;
}

function htmlEscape(html) {
  return html
    .replaceAll("&", "&amp;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#39;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;");
}

// Values for domains in testLoadInFrame.
const ABOUT_SRCDOC_SAME_ORIGIN = "about:srcdoc (same-origin)";
const ABOUT_SRCDOC_CROSS_ORIGIN = "about:srcdoc (cross-origin)";

async function testLoadInFrame({
  description,
  // domains[0] = main frame, every extra item is a child frame.
  domains = ["example.com"],
  htmlPrependedToEachFrame = "",
  // jsForFrame will be serialized and run in the deepest frame.
  jsForFrame,
  // The expected (potentially async) return value of jsForFrame.
  expectedResult,
  // The expected (potentially async) error thrown from jsForFrame.
  expectedError,
}) {
  const frameJs = async jsForFrame => {
    let result = {};
    try {
      result.returnValue = await jsForFrame();
    } catch (e) {
      result.error = String(e);
    }
    // jsForFrame may return "delay_postMessage" to postpone the resolution of
    // the promise. When the test is ready to resume, `top.postMessage()` can
    // be called with the result, from any frame. This would also happen if the
    // URL generated by this testLoadInFrame helper are re-used, e.g. by a new
    // navigation to the URL that triggers a return value from jsForFrame that
    // differs from "delay_postMessage".
    if (result.returnValue !== "delay_postMessage") {
      top.postMessage(result, "*");
    }
  };
  const frameHtml = `<body><script>(${frameJs})(${jsForFrame})</script>`;

  // Construct the frame tree so that domains[0] is the main frame, and
  // domains[domains.length - 1] is the deepest level frame (if any).

  const [mainFrameDomain, ...subFramesDomains] = domains;

  // The loop below generates the HTML for the deepest frame first, so we have
  // to reverse the list of domains.
  subFramesDomains.reverse();

  let html = frameHtml;
  for (let domain of subFramesDomains) {
    html = htmlPrependedToEachFrame + html;
    if (domain === ABOUT_SRCDOC_SAME_ORIGIN) {
      html = `<iframe srcdoc="${htmlEscape(html)}"></iframe>`;
    } else if (domain === ABOUT_SRCDOC_CROSS_ORIGIN) {
      html = `<iframe srcdoc="${htmlEscape(
        html
      )}" sandbox="allow-scripts"></iframe>`;
    } else {
      html = `<iframe src="${urlEchoHtml(domain, html)}"></iframe>`;
    }
  }

  const mainFrameJs = () => {
    window.resultPromise = new Promise(resolve => {
      window.onmessage = e => resolve(e.data);
    });
  };
  const mainFrameHtml = `<script>(${mainFrameJs})()</script>${html}`;
  const mainFrameUrl = urlEchoHtml(mainFrameDomain, mainFrameHtml);

  let contentPage = await ExtensionTestUtils.loadContentPage(mainFrameUrl);
  let result = await contentPage.spawn([], () => {
    return content.wrappedJSObject.resultPromise;
  });
  await contentPage.close();
  if (expectedError) {
    Assert.deepEqual(result, { error: expectedError }, description);
  } else {
    Assert.deepEqual(result, { returnValue: expectedResult }, description);
  }
}

async function loadExtensionWithDNRRules(
  rules,
  {
    // host_permissions is only required for modifyHeaders/redirect, or when
    // "declarativeNetRequestWithHostAccess" is used.
    host_permissions = [],
    permissions = ["declarativeNetRequest"],
  } = {}
) {
  async function background(rules) {
    try {
      await browser.declarativeNetRequest.updateSessionRules({
        addRules: rules,
      });
    } catch (e) {
      browser.test.fail(`Failed to register DNR rules: ${e} :: ${e.stack}`);
    }
    browser.test.sendMessage("dnr_registered");
  }
  let extension = ExtensionTestUtils.loadExtension({
    background: `(${background})(${JSON.stringify(rules)})`,
    temporarilyInstalled: true, // Needed for granted_host_permissions
    manifest: {
      manifest_version: 3,
      granted_host_permissions: true,
      host_permissions,
      permissions,
    },
  });
  await extension.startup();
  await extension.awaitMessage("dnr_registered");
  return extension;
}

add_task(async function allowAllRequests_allows_request() {
  let extension = await loadExtensionWithDNRRules([
    // allowAllRequests should take precedence over block.
    {
      id: 1,
      condition: { resourceTypes: ["main_frame", "xmlhttprequest"] },
      action: { type: "block" },
    },
    {
      id: 2,
      condition: { resourceTypes: ["main_frame"] },
      action: { type: "allowAllRequests" },
    },
    {
      id: 3,
      priority: 2,
      // Note: when not specified, main_frame is excluded by default. So
      // when a main_frame request is triggered, only rules 1 and 2 match.
      condition: { requestDomains: ["example.com"] },
      action: { type: "block" },
    },
  ]);

  let contentPage = await ExtensionTestUtils.loadContentPage(
    "http://example.com/"
  );
  Assert.equal(
    await contentPage.spawn([], () => content.document.URL),
    "http://example.com/",
    "main_frame request should have been allowed by allowAllRequests"
  );

  async function checkCanFetch(url) {
    return contentPage.spawn([url], async url => {
      try {
        return await (await content.fetch(url)).text();
      } catch (e) {
        return e.toString();
      }
    });
  }

  Assert.equal(
    await checkCanFetch("http://example.com/never_reached"),
    FETCH_BLOCKED,
    "should be blocked by DNR rule 3"
  );
  Assert.equal(
    await checkCanFetch("http://example.net/allowed"),
    "fetchAllowed",
    "should not be blocked by block rule due to allowAllRequests rule"
  );

  await contentPage.close();
  await extension.unload();
});

add_task(async function allowAllRequests_in_sub_frame() {
  const extension = await loadExtensionWithDNRRules([
    {
      id: 1,
      condition: { resourceTypes: ["xmlhttprequest"] },
      action: { type: "block" },
    },
    {
      id: 2,
      condition: {
        requestDomains: ["example.com"],
        resourceTypes: ["main_frame", "sub_frame"],
      },
      action: { type: "allowAllRequests" },
    },
  ]);

  const testFetch = async () => {
    // Should be able to read, unless blocked by DNR rule 1 above.
    return (await fetch("http://example.com/allowed")).text();
  };

  // Sanity check: verify that the test result is different (i.e. FETCH_BLOCKED)
  // when the "allowAllRequests" rule (rule ID 2) is not matched.
  await testLoadInFrame({
    description: "allowAllRequests was not matched anywhere, req in subframe",
    domains: ["example.net", "example.org"],
    jsForFrame: testFetch,
    expectedError: FETCH_BLOCKED,
  });

  // allowAllRequests applied to domains[0], i.e. "main_frame".
  await testLoadInFrame({
    description: "allowAllRequests for main frame, req in main frame",
    domains: ["example.com"],
    jsForFrame: testFetch,
    expectedResult: "fetchAllowed",
  });
  await testLoadInFrame({
    description: "allowAllRequests for main frame, req in same-origin frame",
    domains: ["example.com", "example.com"],
    jsForFrame: testFetch,
    expectedResult: "fetchAllowed",
  });
  await testLoadInFrame({
    description: "allowAllRequests for main frame, req in cross-origin frame",
    domains: ["example.com", "example.net"],
    jsForFrame: testFetch,
    expectedResult: "fetchAllowed",
  });

  // allowAllRequests applied to domains[1], i.e. "sub_frame".
  await testLoadInFrame({
    description: "allowAllRequests for subframe, req in same subframe",
    domains: ["example.net", "example.com"],
    jsForFrame: testFetch,
    expectedResult: "fetchAllowed",
  });
  await testLoadInFrame({
    description: "allowAllRequests for subframe, req in same-origin subframe",
    domains: ["example.net", "example.com", "example.com"],
    jsForFrame: testFetch,
    expectedResult: "fetchAllowed",
  });
  await testLoadInFrame({
    description: "allowAllRequests for subframe, req in cross-origin subframe",
    domains: ["example.net", "example.com", "example.org"],
    jsForFrame: testFetch,
    expectedResult: "fetchAllowed",
  });

  await extension.unload();
});

add_task(async function allowAllRequests_does_not_affect_other_extension() {
  const extension = await loadExtensionWithDNRRules([
    {
      id: 1,
      condition: { resourceTypes: ["xmlhttprequest"] },
      action: { type: "block" },
    },
  ]);
  const otherExtension = await loadExtensionWithDNRRules([
    {
      id: 2,
      condition: { resourceTypes: ["main_frame", "sub_frame"] },
      action: { type: "allowAllRequests" },
    },
  ]);

  const testFetch = async () => {
    return (await fetch("http://example.com/allowed")).text();
  };

  // Sanity check: verify that the test result is different (i.e. FETCH_BLOCKED)
  // when the "allowAllRequests" rule (rule ID 2) is not matched.
  await testLoadInFrame({
    description: "block rule from extension not superseded by otherExtension",
    domains: ["example.net", "example.org"],
    jsForFrame: testFetch,
    expectedError: FETCH_BLOCKED,
  });

  await extension.unload();
  await otherExtension.unload();
});

// When there are multiple frames and matching allowAllRequests, we need to
// use the highest-priority allowAllRequests rule. The selected rule can be
// observed through interleaved modifyHeaders rules.
add_task(async function allowAllRequests_multiple_frames_and_modifyHeaders() {
  const domains = ["example.com", "example.com", "example.net", "example.org"];
  const rules = [
    {
      id: 1,
      priority: 3,
      condition: { requestDomains: [domains[1]], resourceTypes: ["sub_frame"] },
      action: { type: "allowAllRequests" },
    },
    {
      id: 2,
      priority: 7,
      condition: { requestDomains: [domains[2]], resourceTypes: ["sub_frame"] },
      action: { type: "allowAllRequests" },
    },
    {
      id: 3,
      priority: 5,
      condition: { requestDomains: [domains[3]], resourceTypes: ["sub_frame"] },
      action: { type: "allowAllRequests" },
    },
    // The loop below will add modifyHeaders rules with priorities 1 - 9.
  ];
  for (let i = 1; i <= 9; ++i) {
    rules.push({
      id: 10 + i, // not overlapping with any rule in |rules|.
      priority: i,
      condition: { resourceTypes: ["xmlhttprequest"] },
      action: {
        type: "modifyHeaders",
        responseHeaders: [
          {
            // Expose the header via CORS to allow fetch() to read the header.
            operation: "set",
            header: "Access-Control-Expose-Headers",
            value: "addedByDnr",
          },
          { operation: "append", header: "addedByDnr", value: `${i}` },
        ],
      },
    });
  }

  const extension = await loadExtensionWithDNRRules(rules, {
    // host_permissions required for "modifyHeaders" action.
    host_permissions: ["<all_urls>"],
  });

  await testLoadInFrame({
    description: "Should select highest-prio allowAllRequests among ancestors",
    domains,
    jsForFrame: async () => {
      let res = await fetch("http://example.com/allowed");
      return res.headers.get("addedByDnr");
    },
    // The fetch request matches all xmlhttprequest rules, which would append
    // the numbers 1...9 to the results via "modifyHeaders".
    //
    // But every frame also has one matching "allowAllRequests" rule. Among
    // these, we should not select an arbitrary rule, but the one with the
    // highest priority, i.e. priority 7 (matches domains[2]).
    //
    // Given the "allowAllRequests" of priority 7, all rules of lower-or-equal
    // priority are ignored, so only "modifyHeaders" remain with priority 8 & 9.
    //
    // modifyHeaders are applied in the order of priority: "9, 8", not "8, 9".
    expectedResult: "9, 8",
  });

  await extension.unload();
});

add_task(async function allowAllRequests_initiatorDomains() {
  const rules = [
    {
      id: 1,
      condition: {
        initiatorDomains: ["example.com"], // Note: in host_permissions below.
        resourceTypes: ["main_frame", "sub_frame"],
      },
      action: { type: "allowAllRequests" },
    },
    {
      id: 2,
      condition: {
        initiatorDomains: ["example.net"], // Note: NOT in host_permissions.
        resourceTypes: ["sub_frame"],
      },
      action: { type: "allowAllRequests" },
    },
    {
      id: 3,
      condition: { resourceTypes: ["xmlhttprequest"] },
      action: { type: "block" },
    },
  ];

  const extension = await loadExtensionWithDNRRules(rules, {
    // host_permissions matches initiatorDomains from rule 1 (allowAllRequests)
    // and the origin of the frame that calls testCanFetch.
    host_permissions: ["*://example.com/*", "*://example.org/*"],
  });

  const testCanFetch = async () => {
    return (await fetch("http://example.com/allowed")).text();
  };

  await testLoadInFrame({
    description: "main_frame request does not have an initiator",
    domains: ["example.com"],
    jsForFrame: testCanFetch,
    // Rule 1 (initiatorDomains: ["example.com"]) should not match.
    expectedError: FETCH_BLOCKED,
  });
  await testLoadInFrame({
    description: "sub_frame loaded by initiator in host_permissions",
    domains: ["example.com", "example.org"],
    jsForFrame: testCanFetch,
    // Matched by rule 1 (initiatorDomains: ["example.com"])
    expectedResult: "fetchAllowed",
  });
  await testLoadInFrame({
    description: "sub_frame loaded by initiator not in host_permissions",
    domains: ["example.net", "example.org"],
    jsForFrame: testCanFetch,
    // Matched by rule 2 (initiatorDomains: ["example.net"]). While example.net
    // is not in host_permissions, the "allowAllRequests" rule can apply because
    // the extension does have the "declarativeNetRequest" permission (opposed
    // to just "declarativeNetRequestWithHostAccess", which is covered by the
    // allowAllRequests_initiatorDomains_dnrWithHostAccess test task below).
    expectedResult: "fetchAllowed",
  });

  // about:srcdoc inherits parent origin.
  await testLoadInFrame({
    description: "about:srcdoc with matching initiator",
    domains: ["example.com", ABOUT_SRCDOC_SAME_ORIGIN],
    jsForFrame: testCanFetch,
    // While the "about:srcdoc" frame's initiator is matched by rule 1
    // (initiatorDomains: ["example.com"]), the frame's URL itself is
    // "about:srcdoc" and consequently ignored in the matcher.
    expectedError: FETCH_BLOCKED,
  });
  await testLoadInFrame({
    description: "subframe in about:srcdoc with matching initiator",
    domains: ["example.com", ABOUT_SRCDOC_SAME_ORIGIN, "example.org"],
    jsForFrame: testCanFetch,
    // The parent URL is "about:srcdoc", but its principal is inherit from its
    // parent, i.e. "example.com". Therefore it matches rule 1.
    expectedResult: "fetchAllowed",
  });
  await testLoadInFrame({
    description: "subframe in opaque about:srcdoc despite matching initiator",
    domains: ["example.com", ABOUT_SRCDOC_CROSS_ORIGIN, "example.org"],
    jsForFrame: testCanFetch,
    // The parent URL is "about:srcdoc". Because it is sandboxed, it has an
    // opaque origin and therefore none of the allowAllRequests rules match,
    // even not rule 1 even though the "about:srcdoc" frame was created by
    // "example.com".
    expectedError: FETCH_BLOCKED,
  });

  await extension.unload();
});

add_task(async function allowAllRequests_initiatorDomains_dnrWithHostAccess() {
  const rules = [
    {
      id: 1,
      condition: {
        // This test shows that it does not matter whether initiatorDomains is
        // in host_permissions; it only matters if the frame's URL is matched
        // by host_permissions.
        initiatorDomains: ["example.net"], // Not in host_permissions.
        resourceTypes: ["sub_frame"],
      },
      action: { type: "allowAllRequests" },
    },
    {
      id: 2,
      condition: { resourceTypes: ["xmlhttprequest"] },
      action: { type: "block" },
    },
  ];

  const extension = await loadExtensionWithDNRRules(rules, {
    host_permissions: ["*://example.org/*"],
    permissions: ["declarativeNetRequestWithHostAccess"],
  });

  const testCanFetch = async () => {
    // example.org is in host_permissions above so "xmlhttprequest" rule is
    // always expected to match this, unless "allowAllRequests" applied.
    // If "allowAllRequests" applies, then expectedResult: "fetchAllowed".
    // If "allowAllRequests" did not apply, then expectedError: FETCH_BLOCKED.
    return (await fetch("http://example.org/allowed")).text();
  };

  await testLoadInFrame({
    description:
      "frame URL in host_permissions despite initiator not in host_permissions",
    domains: ["example.com", "example.net", "example.org"],
    jsForFrame: testCanFetch,
    // The "xmlhttprequest" block rule applies because the request URL
    // (example.org) and initiator (example.org) are part of host_permissions.
    //
    // The "allowAllRequests" rule applies and overrides the block because the
    // "example.org" frame has "example.net" as initiator (as specified in the
    // initiatorDomains DNR rule). Despite the lack of host_permissions for
    // "example.net", the DNR rule is matched because navigation requests do
    // not require host permissions.
    expectedResult: "fetchAllowed",
  });

  await testLoadInFrame({
    description: "frame URL and initiator not in host_permissions",
    domains: ["example.net", "example.com", "example.org"],
    jsForFrame: testCanFetch,
    // The "xmlhttprequest" block rule applies because the request URL
    // (example.org) and initiator (example.org) are part of host_permissions.
    //
    // The "allowAllRequests" rule does not apply because it would only apply
    // to the "example.com" frame (that frame has "example.net" as initiator),
    // but the DNR extension does not have host permissions for example.com.
    expectedError: FETCH_BLOCKED,
  });

  await extension.unload();
});

add_task(async function allowAllRequests_initiator_is_parent() {
  // The actual initiator of a request is the principal (origin) that triggered
  // the request. Navigations of subframes are usually triggered by the parent,
  // except in case of cross-frame/window navigations.
  //
  // There are some limits on cross-frame navigations, specified by:
  // https://html.spec.whatwg.org/multipage/browsing-the-web.html#allowed-to-navigate
  // An ancestor can always navigate a descendant, so we do that here.
  //
  // - example.com (main frame)
  //   - example.net (sub frame 1)
  //     - example.org (sub frame 2)
  //       - example.com (sub frame 3) - will be navigated by sub frame 1.
  //
  // "initiatorDomains" is usually matched against the actual initiator of a
  // request. Since the actual initiator (triggering principal) is not always
  // known nor obvious, the parent principal (origin) is used instead, when the
  // conditions for "allowAllRequests" are retroactively checked for a document.
  const domains = ["example.com", "example.net", "example.org", "example.com"];
  const rules = [
    {
      id: 1,
      condition: {
        // Note: restrict to example.org, so that we can verify that the
        // "allowAllRequests" rule applies to subresource requests within any
        // child frame of "example.org" (i.e. that rule 3 is ignored).
        //
        // Side note: the ultimate navigation request for the child frame
        // itself has actual initiator "example.net" and does not match this
        // rule, which we verify by confirming that rule 2 matches.
        initiatorDomains: ["example.org"],
        requestDomains: ["example.com"],
        resourceTypes: ["sub_frame"],
      },
      action: { type: "allowAllRequests" },
    },
    {
      id: 2,
      condition: { resourceTypes: ["xmlhttprequest"] },
      action: { type: "block" },
    },
    // The modifyHeaders rules below are not affected by the "allowAllRequests"
    // rule above, but are part of the test to serve as a sanity check that the
    // "initiatorDomains" field of sub_frame navigations are compared against
    // the actual initiator.
    {
      id: 3,
      priority: 2, // To not be ignored by allowAllRequests (rule 1).
      condition: {
        // The initial sub_frame navigation request is initiated by its parent,
        // i.e. example.org.
        initiatorDomains: ["example.org"],
        requestDomains: ["example.com"],
        resourceTypes: ["sub_frame"],
      },
      action: {
        type: "modifyHeaders",
        requestHeaders: [
          {
            operation: "append",
            header: "prependhtml",
            value: "<title>DNR rule 3 for initiator example.org</title>",
          },
        ],
      },
    },
    {
      id: 4,
      condition: {
        // The final sub_frame navigation request is initiated by a frame other
        // than the parent (i.e. example.net).
        initiatorDomains: ["example.net"],
        requestDomains: ["example.com"],
        resourceTypes: ["sub_frame"],
      },
      action: {
        type: "modifyHeaders",
        requestHeaders: [
          {
            operation: "append",
            header: "prependhtml",
            value: "<title>DNR rule 4 for initiator example.net</title>",
          },
        ],
      },
    },
  ];

  const extension = await loadExtensionWithDNRRules(rules, {
    // host_permissions needed for allowAllRequests of ancestors
    // (initiatorDomains & requestDomains) and modifyHeaders.
    host_permissions: ["<all_urls>"],
  });

  const jsNavigateOnMessage = () => {
    window.onmessage = e => {
      dump(`\nReceived message at ${origin} from ${e.origin}: ${e.data}\n`);
      e.source.location = e.data;
    };
  };
  const htmlNavigateOnMessage = `<script>(${jsNavigateOnMessage})()</script>`;

  // First: sanity check that the actual initiators are as expected, which we
  // verify through the modifyHeaders+initiatorDomains rules, observed through
  // document.title (/echo_html prepends the "prependhtml" header's value).
  await testLoadInFrame({
    description: "Sanity check: navigation matches actual initiator (parent)",
    domains,
    jsForFrame: () => document.title,
    expectedResult: "DNR rule 3 for initiator example.org",
  });

  await testLoadInFrame({
    description: "Sanity check: navigation matches actual initiator (ancestor)",
    domains,
    htmlPrependedToEachFrame: htmlNavigateOnMessage,
    jsForFrame: () => {
      if (location.hash !== "#End") {
        dump("Sanity: Trying to navigate with initiator set to example.net\n");
        parent.parent.postMessage(document.URL + ".#End", "http://example.net");
        return "delay_postMessage";
      }
      return document.title;
    },
    expectedResult: "DNR rule 4 for initiator example.net",
  });

  // Now the actual test: when fetch() is called, "allowAllRequests" should use
  // the parent origin for each frame in the frame tree.

  await testLoadInFrame({
    description: "allowAllRequests matches parent (which is the initiator)",
    domains,
    jsForFrame: async () => {
      return (await fetch("http://example.com/allowed")).text();
    },
    expectedResult: "fetchAllowed", // fetch() not blocked, rule 1 > rule 2.
  });

  // This is where the result differs from what one may expect from
  // "initiatorDomains". This is consistent with Chrome's behavior,
  // https://source.chromium.org/chromium/chromium/src/+/main:extensions/browser/api/declarative_net_request/request_params.cc;l=123-130;drc=8a27797c643fb0f2d9ae835f8d8b509e027c97e9
  await testLoadInFrame({
    description: "allowAllRequests matches parent (not actual initiator)",
    domains,
    htmlPrependedToEachFrame: htmlNavigateOnMessage,
    jsForFrame: async () => {
      if (location.hash !== "#End") {
        dump("Final: Trying to navigate with initiator set to example.net\n");
        parent.parent.postMessage(document.URL + ".#End", "http://example.net");
        return "delay_postMessage";
      }
      return (await fetch("http://example.com/allowed")).text();
    },
    expectedResult: "fetchAllowed", // fetch() not blocked, rule 1 > rule 2.
  });

  await extension.unload();
});

// Tests how initiatorDomains applies to document and non-document (fetch)
// requests triggered from content scripts.
add_task(async function allowAllRequests_initiatorDomains_content_script() {
  const rules = [
    {
      id: 1,
      condition: {
        initiatorDomains: ["example.com"],
        resourceTypes: ["sub_frame"],
      },
      action: { type: "allowAllRequests" },
    },
    {
      id: 2,
      condition: { resourceTypes: ["xmlhttprequest"] },
      action: { type: "block" },
    },
    {
      id: 3,
      condition: {
        resourceTypes: ["sub_frame"],
        requestDomains: ["example.com"],
      },
      action: {
        type: "redirect",
        redirect: { transform: { host: "example.net" } },
      },
    },
  ];

  const extension = await loadExtensionWithDNRRules(rules, {
    host_permissions: ["*://example.com/*", "*://example.net/*"],
  });

  let contentScriptExtension = ExtensionTestUtils.loadExtension({
    manifest: {
      // Intentionally MV2 because its fetch() is tied to the content script
      // sandbox, and thus potentially more likely to trigger bugs than the MV3
      // fetch (fetch in MV3 is the same as the web page due to bug 1578405).
      manifest_version: 2,
      content_scripts: [
        {
          run_at: "document_end",
          js: ["contentscript_load_frame.js"],
          matches: ["http://*/?test_contentscript_load_frame"],
        },
        {
          all_frames: true,
          run_at: "document_end",
          js: ["contentscript_in_iframe.js"],
          matches: ["http://example.net/?test_contentscript_triggered_frame"],
        },
      ],
    },
    files: {
      "contentscript_load_frame.js": () => {
        browser.test.log("Waiting for frame, then contentscript_in_iframe.js");
        // Created by content script; initiatorDomains should match the page's
        // domain (and not somehow be confused by the content script principal).
        // let document = window.document.wrappedJSObject;
        let f = document.createElement("iframe");
        f.src = "http://example.com/?test_contentscript_triggered_frame";
        document.body.append(f);
      },
      "contentscript_in_iframe.js": async () => {
        // When the iframe request was generated by the content script, its
        // initiator is void because the content script has an ExpandedPrincipal
        // that is treated as void when the request initiator is computed:
        // https://searchfox.org/mozilla-central/rev/d85572c1963f72e8bef2787d900e0a8ffd8e6728/toolkit/components/extensions/webrequest/ChannelWrapper.cpp#551
        // Therefore the initiatorDomains condition of rule 1 (allowAllRequests)
        // does not match, so rule 3 (redirect to example.net) applies.
        browser.test.assertEq(
          "example.net", // instead of the pre-redirect URL (example.com).
          location.host,
          "redirect rule matched because initiator is void for content-script-triggered navigation"
        );
        async function isFetchOk(fetchPromise) {
          try {
            await fetchPromise;
            return true; // allowAllRequests matched.
          } catch (e) {
            await browser.test.assertRejects(fetchPromise, /NetworkError/);
            return false; // block rule matched because allowAllRequests didn't.
          }
        }
        browser.test.assertTrue(
          await isFetchOk(content.fetch("http://example.net/allowed")),
          "frame's parent origin matches initiatorDomains (content script fetch)"
        );
        // fetch() in MV2 content script is associated with the content script
        // sandbox, not the frame, so there are no allowAllRequests rules to
        // apply. For equivalent request details, see bug 1444729.
        browser.test.assertFalse(
          await isFetchOk(fetch("http://example.net/allowed")),
          "MV2 content script fetch() is not associated with the document"
        );
        browser.test.sendMessage("contentscript_initiator");
      },
    },
  });
  await contentScriptExtension.startup();
  let contentPage = await ExtensionTestUtils.loadContentPage(
    "http://example.com/?test_contentscript_load_frame"
  );
  info("Waiting for page load, will continue at contentscript_load_frame.js");
  await contentScriptExtension.awaitMessage("contentscript_initiator");
  await contentScriptExtension.unload();
  await contentPage.close();
  await extension.unload();
});

// Verifies that allowAllRequests is evaluated against the currently committed
// document, even if another document load has been initiated.
add_task(async function allowAllRequests_during_and_after_navigation() {
  let extension = await loadExtensionWithDNRRules([
    {
      id: 1,
      condition: { resourceTypes: ["xmlhttprequest"] },
      action: { type: "block" },
    },
    {
      id: 2,
      condition: { urlFilter: "WITH_AAR", resourceTypes: ["sub_frame"] },
      action: { type: "allowAllRequests" },
    },
  ]);

  const contentPage = await ExtensionTestUtils.loadContentPage(
    "http://example.com/?dummy_see_iframe_for_interesting_stuff"
  );
  await contentPage.spawn([], async () => {
    let f = content.document.createElement("iframe");
    f.id = "frame_to_navigate";
    f.src = "/?init_WITH_AAR"; // allowAllRequests initially applies.
    await new Promise(resolve => {
      f.onload = resolve;
      content.document.body.append(f);
    });
  });
  async function navigateIframe(url) {
    await contentPage.spawn([url], url => {
      let f = content.document.getElementById("frame_to_navigate");
      content.frameLoadedPromise = new Promise(resolve => {
        f.addEventListener("load", resolve, { once: true });
      });
      f.contentWindow.location.href = url;
    });
  }
  async function waitForNavigationCompleted(expectLoad = true) {
    await contentPage.spawn([expectLoad], async expectLoad => {
      if (expectLoad) {
        info("Waiting for frame load - if stuck the load never happened\n");
        return content.frameLoadedPromise.then(() => {});
      }
      // When HTTP 204 No Content is used, onload is not fired.
      // Here we load another frame, and assume that once this completes, that
      // any previous load of navigateIframe() would have completed by now.
      let f = content.document.createElement("iframe");
      f.src = "/?dummy_no_dnr_matched_" + Math.random();
      await new Promise(resolve => {
        f.onload = resolve;
        content.document.body.append(f);
      });
      f.remove();
    });
  }
  async function assertIframePath(expectedPath, description) {
    let actualPath = await contentPage.spawn([], () => {
      return content.frames[0].location.pathname;
    });
    Assert.equal(actualPath, expectedPath, description);
  }
  async function assertHasAAR(expected, description) {
    let actual = await contentPage.spawn([], async () => {
      try {
        await (await content.frames[0].fetch("/allowed")).text();
        return true; // allowAllRequests overrides block rule.
      } catch (e) {
        // Sanity check: NetworkError from fetch(), not a random other error.
        Assert.equal(
          e.toString(),
          "TypeError: NetworkError when attempting to fetch resource.",
          "Got error for failed fetch"
        );
        return false; // blocked by xmlhttprequest block rule.
      }
    });
    Assert.equal(actual, expected, description);
  }
  await assertHasAAR(true, "Initial allowAllRequests overrides block rule");

  const PATH_1_NO_AAR = "/delayed/PATH_1_NO_AAR";
  const PATH_2_WITH_AAR = "/delayed/PATH_2_WITH_AAR";
  const PATH_3_NO_AAR = "/delayed/PATH_3_NO_AAR";
  info("First: transition from /?init_WITH_AAR to PATH_NOT_MATCHED_BY_DNR.");
  {
    let promisedServerReq = waitForRequestAtServer(PATH_1_NO_AAR);
    await navigateIframe(PATH_1_NO_AAR);
    let serverReq = await promisedServerReq;
    await assertHasAAR(
      true,
      "Initial allowAllRequests still applies despite pending navigation"
    );
    await assertIframePath("/", "Frame has not navigated yet");
    serverReq.res.finish();
    await waitForNavigationCompleted();
    await assertIframePath(PATH_1_NO_AAR, "Navigated to PATH_1_NO_AAR");

    await assertHasAAR(
      false,
      "Old allowAllRequests should no longer apply after navigation to PATH_1_NO_AAR"
    );
  }

  info("Second: transition from PATH_1_NO_AAR to PATH_2_WITH_AAR.");
  {
    let promisedServerReq = waitForRequestAtServer(PATH_2_WITH_AAR);
    await navigateIframe(PATH_2_WITH_AAR);
    let serverReq = await promisedServerReq;
    await assertHasAAR(
      false,
      "No allowAllRequests yet despite pending navigation to PATH_2_WITH_AAR"
    );
    await assertIframePath(PATH_1_NO_AAR, "Frame has not navigated yet");
    serverReq.res.finish();
    await waitForNavigationCompleted();
    await assertIframePath(PATH_2_WITH_AAR, "Navigated to PATH_2_WITH_AAR");

    await assertHasAAR(
      true,
      "allowAllRequests should apply after navigation to PATH_2_WITH_AAR"
    );
  }

  info("Third: AAR still applies after canceling navigation to PATH_3_NO_AAR.");
  {
    let promisedServerReq = waitForRequestAtServer(PATH_3_NO_AAR);
    await navigateIframe(PATH_3_NO_AAR);
    let serverReq = await promisedServerReq;
    serverReq.res.setStatusLine(serverReq.req.httpVersion, 204, "No Content");
    serverReq.res.finish();
    await waitForNavigationCompleted(/* expectLoad */ false);
    await assertIframePath(PATH_2_WITH_AAR, "HTTP 204 does not navigate away");

    await assertHasAAR(
      true,
      "allowAllRequests still applied after aborted navigation to PATH_3_NO_AAR"
    );
  }

  await contentPage.close();
  await extension.unload();
});

add_task(
  {
    // Ensure that there is room for at least 2 non-evicted bfcache entries.
    // Note: this pref is ignored (i.e forced 0) when configured (non-default)
    // with bfcacheInParent=false while SHIP is enabled:
    // https://searchfox.org/mozilla-central/rev/00ea1649b59d5f427979e2d6ba42be96f62d6e82/docshell/shistory/nsSHistory.cpp#360-363
    // ... we mainly care about the bfcache here because it triggers interesting
    // behavior. DNR evaluation is correct regardless of bfcache.
    pref_set: [["browser.sessionhistory.max_total_viewers", 3]],
  },
  async function allowAllRequests_and_bfcache_navigation() {
    let extension = await loadExtensionWithDNRRules([
      {
        id: 1,
        condition: { resourceTypes: ["xmlhttprequest"] },
        action: { type: "block" },
      },
      {
        id: 2,
        condition: { urlFilter: "aar_yes", resourceTypes: ["main_frame"] },
        action: { type: "allowAllRequests" },
      },
    ]);

    info("Navigating to initial URL: 1_aar_no");
    const contentPage = await ExtensionTestUtils.loadContentPage(
      "http://example.com/bfcache_test?1_aar_no"
    );
    async function navigateBackInHistory(expectedUrl) {
      await contentPage.spawn([], () => {
        content.history.back();
      });
      await TestUtils.waitForCondition(
        () => contentPage.browsingContext.currentURI.spec === expectedUrl,
        `Waiting for history.back() to trigger navigation to ${expectedUrl}`
      );
      await contentPage.spawn([expectedUrl], async expectedUrl => {
        Assert.equal(content.location.href, expectedUrl, "URL after back");
        Assert.equal(content.document.body.textContent, "true", "from bfcache");
      });
    }
    async function checkCanFetch(url) {
      return contentPage.spawn([url], async url => {
        try {
          return await (await content.fetch(url)).text();
        } catch (e) {
          return e.toString();
        }
      });
    }

    info("Navigating from initial URL to: 2_aar_yes");
    await contentPage.loadURL("http://example.com/bfcache_test?2_aar_yes");
    info("Navigating from 2_aar_yes to: 3_aar_no");
    await contentPage.loadURL("http://example.com/bfcache_test?3_aar_no");

    info("Going back in history (from 3_aar_no to 2_aar_yes)");
    await navigateBackInHistory("http://example.com/bfcache_test?2_aar_yes");
    Assert.equal(
      await checkCanFetch("http://example.com/allowed"),
      "fetchAllowed",
      "after history.back(), allowAllRequests should apply from 2_aar_yes"
    );

    info("Going back in history (from 2_aar_yes to 1_aar_no)");
    await navigateBackInHistory("http://example.com/bfcache_test?1_aar_no");
    Assert.equal(
      await checkCanFetch("http://example.net/never_reached"),
      FETCH_BLOCKED,
      "after history.back(), no allowAllRequests action applied at 1_aar_no"
    );

    await contentPage.close();
    await extension.unload();
  }
);

add_task(
  {
    // Usually, back/forward navigation to a POST form requires the user to
    // confirm the form resubmission. Set pref to approve without prompting.
    pref_set: [["dom.confirm_repost.testing.always_accept", true]],
  },
  async function allowAllRequests_navigate_with_http_method_POST() {
    const rules = [
      {
        id: 1,
        condition: {
          requestMethods: ["post"],
          resourceTypes: ["main_frame", "sub_frame"],
        },
        action: { type: "allowAllRequests" },
      },
      {
        id: 2,
        condition: { resourceTypes: ["xmlhttprequest"] },
        action: { type: "block" },
      },
    ];

    if (!Services.appinfo.sessionHistoryInParent) {
      // POST detection relies on SHIP being enabled. This is true by default,
      // but there are some test configurations with SHIP disabled. When SHIP
      // is disabled, all methods are interpreted as GET instead of POST.
      // Rewrite the rule to specifically match the POST requests that are
      // misinterpreted as GET, to verify that the request evaluation by DNR is
      // functional (opposed to throwing errors).
      rules[0].condition.requestMethods = ["get"];
      rules[0].condition.urlFilter = "do_post|";
      info(`WARNING: SHIP is disabled. POST will be misinterpreted as GET`);
    }

    const extension = await loadExtensionWithDNRRules(rules);

    const contentPage = await ExtensionTestUtils.loadContentPage(
      "http://example.com/?do_get"
    );
    async function checkCanFetch(url) {
      return contentPage.spawn([url], async url => {
        try {
          return await (await content.fetch(url)).text();
        } catch (e) {
          return e.toString();
        }
      });
    }

    // Check fetch() with regular GET navigation in main_frame.
    Assert.equal(
      await checkCanFetch("http://example.net/never_reached"),
      FETCH_BLOCKED,
      "main_frame: non-POST not matched by requestMethods:['post']"
    );

    // Check fetch() after POST navigation in main_frame.
    await contentPage.spawn([], () => {
      let form = content.document.createElement("form");
      form.action = "/?do_post";
      form.method = "POST";
      content.document.body.append(form);
      form.submit();
    });
    await TestUtils.waitForCondition(
      () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_post",
      "Waiting for navigation with POST to complete"
    );
    Assert.equal(
      await checkCanFetch("http://example.net/allowed"),
      "fetchAllowed",
      "main_frame: requestMethods:['post'] applies to POST"
    );

    // Navigate back to the beginning and verify that allowAllRequests does not
    // match any more.
    await contentPage.spawn([], () => {
      content.history.back();
    });
    await TestUtils.waitForCondition(
      () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_get",
      "Waiting for (back) navigation to initial GET page to complete"
    );
    Assert.equal(
      await checkCanFetch("http://example.net/never_reached"),
      FETCH_BLOCKED,
      "main_frame: back to non-POST not matched by requestMethods:['post']"
    );

    // Now navigate forwards to verify that the POST method is still seen.
    await contentPage.spawn([], () => {
      content.history.forward();
    });
    await TestUtils.waitForCondition(
      () => contentPage.browsingContext.currentURI.pathQueryRef === "/?do_post",
      "Waiting for (forward) navigation to POST page to complete"
    );

    Assert.equal(
      await checkCanFetch("http://example.net/allowed"),
      "fetchAllowed",
      "main_frame: requestMethods:['post'] detects POST after history.forward()"
    );

    // Now check that adding a new history entry drops the POST method.
    await contentPage.spawn([], () => {
      content.history.pushState(null, null, "/?hist_p");
    });
    await TestUtils.waitForCondition(
      () => contentPage.browsingContext.currentURI.pathQueryRef === "/?hist_p",
      "Waiting for history.pushState to have changed the URL"
    );
    Assert.equal(
      await checkCanFetch("http://example.net/never_reached"),
      FETCH_BLOCKED,
      "history.pushState drops POST, not matched by requestMethods:['post']"
    );

    await contentPage.close();

    // Finally, check that POST detection also works for child frames.
    await testLoadInFrame({
      description: "sub_frame: non-POST not matched by requestMethods:['post']",
      domains: ["example.com", "example.com"],
      jsForFrame: async () => {
        return (await fetch("http://example.com/allowed")).text();
      },
      expectedError: FETCH_BLOCKED,
    });

    await testLoadInFrame({
      description: "sub_frame: requestMethods:['post'] applies to POST",
      domains: ["example.com", "example.com"],
      jsForFrame: async () => {
        if (!location.href.endsWith("?do_post")) {
          dump("Triggering navigation with POST\n");
          let form = document.createElement("form");
          form.action = location.href + "?do_post";
          form.method = "POST";
          document.body.append(form);
          form.submit();
          return "delay_postMessage";
        }
        dump("Navigation with POST completed; testing fetch()...\n");
        return (await fetch("http://example.com/allowed")).text();
      },
      expectedResult: "fetchAllowed",
    });
    await extension.unload();
  }
);
