"use strict";

/**
 * The ChannelWrapper API is part of the implementation of WebRequest, and not
 * really meant to be used in isolation. In practice, there are several in-tree
 * uses of ChannelWrapper, so this test serves as a sanity check that
 * ChannelWrapper behaves reasonable in the absence of WebRequest.
 */

const server = createHttpServer({
  hosts: ["origin.example.net", "example.com"],
});
server.registerPathHandler("/home", () => {});
server.registerPathHandler("/dummy", (request, response) => {
  response.setStatusLine(request.httpVersion, 200, "OK");
  response.setHeader(
    "Access-Control-Allow-Origin",
    "http://origin.example.net"
  );
  response.write("Server's reply");
});

// Some properties do not have a straightforward comparison, so we just verify
// that the property is set (or not).
const EXPECT_TRUTHY = Symbol("EXPECT_TRUTHY");
const EXPECT_FALSEY = Symbol("EXPECT_FALSEY");

// Properties in the order specified in ChannelWrapper.webidl
const EXPECTATION_BASIC_FETCH = {
  id: EXPECT_TRUTHY,
  channel: EXPECT_TRUTHY,
  contentType: "",
  method: "GET",
  type: "xmlhttprequest",
  suspended: false,
  finalURI: EXPECT_TRUTHY,
  finalURL: "http://example.com/dummy",
  statusCode: 0,
  statusLine: "",
  errorString: null,
  onerror: null,
  onstart: null,
  onstop: null,
  proxyInfo: EXPECT_TRUTHY, // The xpcshell test server uses a proxy.
  remoteAddress: null, // Not set at start of request
  loadInfo: EXPECT_TRUTHY,
  isServiceWorkerScript: false,
  originURL: "http://origin.example.net/home",
  documentURL: "http://origin.example.net/home",
  originURI: EXPECT_TRUTHY,
  documentURI: EXPECT_TRUTHY,
  canModify: true,
  frameId: 0, // Top-level frame.
  parentFrameId: -1,
  browserElement: EXPECT_TRUTHY,
  frameAncestors: [], // Top-level frame does not have ancestors.
  urlClassification: {
    firstParty: [],
    thirdParty: [],
  },
  thirdParty: true, // origin.example.net vs example.com is third-party.
  requestSize: 0, // Request not sent yet at start of request.
  responseSize: 0, // Response not received yet at start of request.
};

const EXPECTATION_BASIC_FETCH_COMPLETED = {
  ...EXPECTATION_BASIC_FETCH,
  contentType: "text/plain",
  statusCode: 200,
  statusLine: "HTTP/1.1 200 OK",
  remoteAddress: "127.0.0.1",
  browserElement: EXPECT_FALSEY,
  requestSize: EXPECT_TRUTHY,
  responseSize: EXPECT_TRUTHY,
};

const EXPECTATION_BASIC_FETCH_ABORTED = {
  ...EXPECTATION_BASIC_FETCH,
  errorString: "NS_ERROR_ABORT",
  browserElement: EXPECT_FALSEY,
};

// We don't really care about the values; the main purpose of checking these
// properties is to make sure that something reasonable happens. In particular,
// that we are not hitting assertion failures or crashes.
const EXPECTATION_INVALID_CHANNEL = {
  id: EXPECT_TRUTHY,
  channel: EXPECT_FALSEY,
  contentType: "",
  method: "",
  type: "other",
  suspended: false,
  finalURI: EXPECT_FALSEY,
  finalURL: "",
  statusCode: 0,
  statusLine: "",
  errorString: "NS_ERROR_UNEXPECTED",
  onerror: null,
  onstart: null,
  onstop: null,
  proxyInfo: null,
  remoteAddress: null,
  loadInfo: null,
  isServiceWorkerScript: false,
  originURL: "",
  documentURL: "",
  originURI: null,
  documentURI: null,
  canModify: false,
  frameId: 0,
  parentFrameId: -1,
  browserElement: EXPECT_FALSEY,
  frameAncestors: null,
  urlClassification: {
    firstParty: [],
    thirdParty: [],
  },
  thirdParty: false,
  requestSize: 0,
  responseSize: 0,
};

function channelWrapperEquals(channelWrapper, expectedProps) {
  for (let [k, v] of Object.entries(expectedProps)) {
    if (v === EXPECT_TRUTHY) {
      Assert.ok(channelWrapper[k], `ChannelWrapper.${k} is truthy`);
    } else if (v === EXPECT_FALSEY) {
      Assert.ok(!channelWrapper[k], `ChannelWrapper.${k} is falsey`);
    } else {
      Assert.deepEqual(channelWrapper[k], v, `ChannelWrapper.${k}`);
    }
  }
}

let gContentPage;

async function forceChannelGC() {
  await Promise.resolve();
  Cu.forceGC();
  Cu.forceCC();
}

function checkChannelWrapperMethodsAfterGC(channelWrapper) {
  // All methods in the order of appearance in ChannelWrapper.webidl.
  // The exact behavior does not matter, as long as it is somewhat reasonable,
  // and in particular does not trigger assertions or crashes.

  const dummyURI = Services.io.newURI("http://example.com/neverloaded");
  const dummyPolicy = new WebExtensionPolicy({
    id: "@dummyPolicy",
    mozExtensionHostname: "c3c73091-8fab-4229-83cf-84c061dd9ead",
    baseURL: "resource://modules/whatever_does_not_need_to_exist",
    allowedOrigins: new MatchPatternSet(["*://*/*"]),
    localizeCallback: () => "",
  });

  Assert.throws(
    () => channelWrapper.cancel(0),
    /NS_ERROR_UNEXPECTED/,
    "channelWrapper.cancel() throws"
  );
  Assert.throws(
    () => channelWrapper.redirectTo(dummyURI),
    /NS_ERROR_UNEXPECTED/,
    "channelWrapper.redirectTo() throws"
  );
  Assert.throws(
    () => channelWrapper.upgradeToSecure(),
    /NS_ERROR_UNEXPECTED/,
    "channelWrapper.upgradeToSecure() throws"
  );
  Assert.throws(
    () => channelWrapper.suspend(""),
    /NS_ERROR_UNEXPECTED/,
    "channelWrapper.suspend() throws"
  );
  // resume() trivially returns because it is no-op when suspend() did not run.
  Assert.equal(
    channelWrapper.resume(),
    undefined,
    "channelWrapper.resume() returns"
  );
  Assert.equal(
    channelWrapper.matches({}, null, {}),
    false,
    "channelWrapper.matches() returns"
  );
  Assert.equal(
    channelWrapper.registerTraceableChannel(dummyPolicy, null),
    undefined,
    "registerTraceableChannel() returns"
  );
  Assert.equal(channelWrapper.errorCheck(), undefined, "errorCheck() returns");
  Assert.throws(
    () => channelWrapper.getRequestHeaders(),
    /NS_ERROR_UNEXPECTED/,
    "channelWrapper.getRequestHeaders() throws"
  );
  Assert.throws(
    () => channelWrapper.getRequestHeader("Content-Type"),
    /NS_ERROR_UNEXPECTED/,
    "channelWrapper.getRequestHeader() throws"
  );
  Assert.throws(
    () => channelWrapper.getResponseHeaders(),
    /NS_ERROR_UNEXPECTED/,
    "channelWrapper.getResponseHeaders() throws"
  );
  Assert.throws(
    () => channelWrapper.setRequestHeader("Content-Type", ""),
    /NS_ERROR_UNEXPECTED/,
    "channelWrapper.setRequestHeader() throws"
  );
  Assert.throws(
    () => channelWrapper.setResponseHeader("Content-Type", ""),
    /NS_ERROR_UNEXPECTED/,
    "channelWrapper.setResponseHeader() throws"
  );
}

function createChannel(url) {
  const dummyPrincipal = Services.scriptSecurityManager.createNullPrincipal({});
  return Services.io.newChannelFromURI(
    Services.io.newURI(url),
    /* loadingNode */ null,
    /* loadingPrincipal */ dummyPrincipal,
    /* triggeringPrincipal */ dummyPrincipal,
    /* securityFlags */ 0,
    /* contentPolicyType */ Ci.nsIContentPolicy.TYPE_FETCH
  );
}

function assertChannelWrapperUnsupportedForChannel(channel) {
  let channelWrapper = ChannelWrapper.get(channel);
  Assert.equal(
    channelWrapper.channel,
    null,
    `ChannelWrapper cannot wrap channel for ${channel.URI.spec}`
  );
  channelWrapperEquals(channelWrapper, EXPECTATION_INVALID_CHANNEL);
}

add_setup(async function create_content_page() {
  gContentPage = await ExtensionTestUtils.loadContentPage(
    "http://origin.example.net/home"
  );
  registerCleanupFunction(() => gContentPage.close());
});

add_task(async function during_basic_fetch() {
  let promise = TestUtils.topicObserved("http-on-modify-request", channel => {
    equal(channel.URI.spec, "http://example.com/dummy", "expected URL");
    let channelWrapper = ChannelWrapper.get(channel);

    channelWrapperEquals(channelWrapper, EXPECTATION_BASIC_FETCH);

    return true;
  });
  await gContentPage.spawn([], async () => {
    let res = await content.fetch("http://example.com/dummy");
    Assert.equal(await res.text(), "Server's reply", "Got response");
  });
  await promise;
});

add_task(async function after_basic_fetch() {
  let promise = TestUtils.topicObserved("http-on-modify-request");
  await gContentPage.spawn([], async () => {
    let res = await content.fetch("http://example.com/dummy");
    Assert.equal(await res.text(), "Server's reply", "Got response");
  });
  let [channel] = await promise;

  equal(channel.URI.spec, "http://example.com/dummy", "expected URL");
  let channelWrapper = ChannelWrapper.get(channel);

  channelWrapperEquals(channelWrapper, EXPECTATION_BASIC_FETCH_COMPLETED);
});

add_task(async function after_cancel_request() {
  let channelWrapper;
  let promise = TestUtils.topicObserved("http-on-modify-request", channel => {
    equal(channel.URI.spec, "http://example.com/dummy", "expected URL");
    channelWrapper = ChannelWrapper.get(channel);
    channel.cancel(Cr.NS_ERROR_ABORT);
    return true;
  });
  await gContentPage.spawn([], async () => {
    await Assert.rejects(
      content.fetch("http://example.com/dummy"),
      /NetworkError when attempting to fetch resource./,
      "Request should be aborted"
    );
  });
  await promise;

  channelWrapperEquals(channelWrapper, EXPECTATION_BASIC_FETCH_ABORTED);
});

add_task(async function after_basic_fetch_and_gc() {
  let promise = TestUtils.topicObserved("http-on-modify-request");
  await gContentPage.spawn([], async () => {
    let res = await content.fetch("http://example.com/dummy");
    Assert.equal(await res.text(), "Server's reply", "Got response");
  });
  let [channel] = await promise;

  equal(channel.URI.spec, "http://example.com/dummy", "expected URL");
  let channelWrapper = ChannelWrapper.get(channel);

  Assert.equal(channelWrapper.channel, channel, "channel not GC'd yet");

  channel = promise = null;
  await forceChannelGC();

  Assert.equal(channelWrapper.channel, null, "Channel has been GC'd");

  channelWrapperEquals(channelWrapper, EXPECTATION_INVALID_CHANNEL);
  checkChannelWrapperMethodsAfterGC(channelWrapper);
});

// getRegisteredChannel should be called before the response has started. In
// this test case we instantiate ChannelWrapper after the request completed,
// and confirm that there are no weird crashes or assertion failures.
add_task(async function getRegisteredChannel_after_response_start() {
  const dummyPolicy = new WebExtensionPolicy({
    id: "@dummyPolicy",
    mozExtensionHostname: "c3c73091-8fab-4229-83cf-84c061dd9ead",
    baseURL: "resource://modules/whatever_does_not_need_to_exist",
    allowedOrigins: new MatchPatternSet(["*://*/*"]),
    localizeCallback: () => "",
  });
  let promise = TestUtils.topicObserved("http-on-modify-request");
  await gContentPage.spawn([], async () => {
    let res = await content.fetch("http://example.com/dummy");
    Assert.equal(await res.text(), "Server's reply", "Got response");
  });
  let [channel] = await promise;

  equal(channel.URI.spec, "http://example.com/dummy", "expected URL");
  let channelWrapper = ChannelWrapper.get(channel);
  let channelId = channelWrapper.id;

  // NOTE: registerTraceableChannel() should return early when a channel is
  // past OnStartRequest, but if ChannelWrapper.get() is called after that,
  // then ChannelWrapper::mResponseStarted is not set, and the implementation
  // is unaware of the fact that the response has already started. While not
  // ideal, it may happen in practice, so confirm that there are no crashes or
  // assertion failures.
  channelWrapper.registerTraceableChannel(dummyPolicy, null);
  Assert.equal(
    ChannelWrapper.getRegisteredChannel(channelId, dummyPolicy, null),
    channelWrapper,
    "getRegisteredChannel() returns wrapper after registerTraceableChannel()"
  );
  // Reset internal cached fields via ChannelWrapper::SetChannel.
  channelWrapper.channel = channel;

  channel = promise = null;
  await forceChannelGC();

  Assert.equal(channelWrapper.channel, null, "Channel has been GC'd");
  Assert.equal(
    ChannelWrapper.getRegisteredChannel(channelId, dummyPolicy, null),
    null,
    "getRegisteredChannel() returns nothing after channel was GC'd"
  );
  channelWrapperEquals(channelWrapper, EXPECTATION_INVALID_CHANNEL);
  checkChannelWrapperMethodsAfterGC(channelWrapper);
});

add_task(async function ChannelWrapper_https_url() {
  // https: and http: are the only channels supported by WebRequest and
  // ChannelWrapper. http: was tested with real requests before, here we also
  // test https: just by simulating a channel for a https:-URL.
  const channel = createChannel("https://example.com/dummyhttps");
  let channelWrapper = ChannelWrapper.get(channel);
  Assert.equal(
    channelWrapper.channel,
    channel,
    "ChannelWrapper can wrap channel for https"
  );
  // The following two expectations are identical. The expectations are repeated
  // twice, to make it easier to see what the difference is between https vs
  // invalid, and https vs the http:-test elsewhere.
  channelWrapperEquals(channelWrapper, {
    ...EXPECTATION_INVALID_CHANNEL,
    channel,
    method: "GET",
    type: "xmlhttprequest",
    finalURI: EXPECT_TRUTHY,
    finalURL: "https://example.com/dummyhttps",
    errorString: null,
    loadInfo: EXPECT_TRUTHY,
    canModify: true,
    thirdParty: true, // null principal from createChannel vs example.com.
  });
  channelWrapperEquals(channelWrapper, {
    ...EXPECTATION_BASIC_FETCH,
    finalURL: "https://example.com/dummyhttps",
    proxyInfo: null,
    originURL: "", // triggeringPrincipal is null principal in createChannel.
    documentURL: "", // triggeringPrincipal is null principal in createChannel.
    originURI: null,
    documentURI: null,
    browserElement: null, // simulated load not associated with any <browser>.
    frameAncestors: null, // simulated load not associated with BrowsingContext.
  });
});

add_task(async function ChannelWrapper_moz_extension_url() {
  const xpi = AddonTestUtils.createTempWebExtensionFile({});
  const dummyPolicy = new WebExtensionPolicy({
    id: "@dummyPolicy",
    mozExtensionHostname: "e17d45dd-fe2a-4ece-8794-d487062cadf4",
    baseURL: `jar:${Services.io.newFileURI(xpi).spec}!/`,
    allowedOrigins: new MatchPatternSet(["*://*/*"]),
    localizeCallback: () => "",
  });
  dummyPolicy.active = true;
  const channel = createChannel(
    "moz-extension://e17d45dd-fe2a-4ece-8794-d487062cadf4/manifest.json"
  );
  Assert.ok(channel instanceof Ci.nsIJARChannel, "Is nsIJARChannel");
  assertChannelWrapperUnsupportedForChannel(channel);
  dummyPolicy.active = false;
});

add_task(async function ChannelWrapper_blob_url() {
  const blobUrl = await gContentPage.spawn([], () => {
    return content.URL.createObjectURL(new content.Blob(new content.Array()));
  });
  const channel = createChannel(blobUrl);
  assertChannelWrapperUnsupportedForChannel(channel);
});

add_task(async function ChannelWrapper_data_url() {
  const channel = createChannel("data:,");
  Assert.ok(channel instanceof Ci.nsIDataChannel, "Is nsIDataChannel");
  assertChannelWrapperUnsupportedForChannel(channel);
});

add_task(async function ChannelWrapper_file_url() {
  // Note: "file://C:/" is a valid file:-URL across all platforms.
  const channel = createChannel("file://C:/");
  Assert.ok(channel instanceof Ci.nsIFileChannel, "Is nsIFileChannel");
  assertChannelWrapperUnsupportedForChannel(channel);
});

add_task(async function ChannelWrapper_about_blank_url() {
  const channel = createChannel("about:blank");
  assertChannelWrapperUnsupportedForChannel(channel);
});

add_task(async function ChannelWrapper_javascript_url() {
  const channel = createChannel("javascript://");
  assertChannelWrapperUnsupportedForChannel(channel);
});

add_task(async function ChannelWrapper_resource_url() {
  const channel = createChannel("resource://content-accessible/viewsource.css");
  assertChannelWrapperUnsupportedForChannel(channel);
});

add_task(async function ChannelWrapper_chrome_url() {
  const channel = createChannel(
    "chrome://extensions/content/schemas/web_request.json"
  );
  assertChannelWrapperUnsupportedForChannel(channel);
});

add_task(async function sanity_check_expectations_complete() {
  const channelWrapper = ChannelWrapper.get(createChannel("http://whatever/"));
  channelWrapper.channel = null;
  const uncheckedKeys = new Set(Object.keys(ChannelWrapper.prototype));
  const channelWrapperWithSpy = new Proxy(channelWrapper, {
    get(target, prop) {
      uncheckedKeys.delete(prop);
      let value = Reflect.get(target, prop, target);
      // Methods throw if not invoked on the ChannelWrapper interface, so bind
      // to target (=channelWrapper) instead of channelWrapperWithSpy.
      return typeof value == "function" ? value.bind(target) : value;
    },
  });

  channelWrapperEquals(channelWrapperWithSpy, EXPECTATION_INVALID_CHANNEL);
  checkChannelWrapperMethodsAfterGC(channelWrapperWithSpy);

  Assert.deepEqual(
    Array.from(uncheckedKeys),
    [],
    "All ChannelWrapper properties and methods have been checked"
  );

  // The above channelWrapper(channelWrapperWithSpy, ...) call triggers a lookup
  // for each property listed in EXPECTATION_INVALID_CHANNEL as a way to verify
  // that all properties are accounted for. To make sure that the test case for
  // a valid channel also have complete property coverage, confirm that each
  // property is also present in EXPECTATION_BASIC_FETCH.
  Assert.deepEqual(
    Object.keys(EXPECTATION_BASIC_FETCH),
    Object.keys(EXPECTATION_INVALID_CHANNEL),
    "EXPECTATION_BASIC_FETCH has same properties as EXPECTATION_INVALID_CHANNEL"
  );
});

add_task(async function sanity_check_WebRequest_module_not_loaded() {
  // The purpose of this whole test file is to test the behavior of
  // ChannelWrapper, independently of the webRequest API implementation.
  // So as a sanity check, confirm that we have indeed not loaded that module.
  Assert.equal(
    Cu.isESModuleLoaded("resource://gre/modules/WebRequest.sys.mjs"),
    false,
    "WebRequest.sys.mjs should not have been loaded in this test"
  );
});
