/* Tests various aspects of nsIResumableChannel in combination with HTTP */
"use strict";

const { HttpServer } = ChromeUtils.importESModule(
  "resource://testing-common/httpd.sys.mjs"
);

ChromeUtils.defineLazyGetter(this, "URL", function () {
  return "http://localhost:" + httpserver.identity.primaryPort;
});

var httpserver = null;

const NS_ERROR_ENTITY_CHANGED = 0x804b0020;
const NS_ERROR_NOT_RESUMABLE = 0x804b0019;

const rangeBody = "Body of the range request handler.\r\n";

function make_channel(url) {
  return NetUtil.newChannel({ uri: url, loadUsingSystemPrincipal: true });
}

function AuthPrompt2() {}

AuthPrompt2.prototype = {
  user: "guest",
  pass: "guest",

  QueryInterface: ChromeUtils.generateQI(["nsIAuthPrompt2"]),

  promptAuth: function ap2_promptAuth(channel, level, authInfo) {
    authInfo.username = this.user;
    authInfo.password = this.pass;
    return true;
  },

  asyncPromptAuth: function ap2_async() {
    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
  },
};

function Requestor() {}

Requestor.prototype = {
  QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor"]),

  getInterface: function requestor_gi(iid) {
    if (iid.equals(Ci.nsIAuthPrompt2)) {
      // Allow the prompt to store state by caching it here
      if (!this.prompt2) {
        this.prompt2 = new AuthPrompt2();
      }
      return this.prompt2;
    }

    throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
  },

  prompt2: null,
};

function run_test() {
  dump("*** run_test\n");
  httpserver = new HttpServer();
  httpserver.registerPathHandler("/auth", authHandler);
  httpserver.registerPathHandler("/range", rangeHandler);
  httpserver.registerPathHandler("/acceptranges", acceptRangesHandler);
  httpserver.registerPathHandler("/redir", redirHandler);

  var entityID;

  function get_entity_id(request) {
    dump("*** get_entity_id()\n");
    Assert.ok(
      request instanceof Ci.nsIResumableChannel,
      "must be a resumable channel"
    );
    entityID = request.entityID;
    dump("*** entity id = " + entityID + "\n");

    // Try a non-resumable URL (responds with 200)
    var chan = make_channel(URL);
    chan.nsIResumableChannel.resumeAt(1, entityID);
    chan.asyncOpen(new ChannelListener(try_resume, null, CL_EXPECT_FAILURE));
  }

  function try_resume(request) {
    dump("*** try_resume()\n");
    Assert.equal(request.status, NS_ERROR_NOT_RESUMABLE);

    // Try a successful resume
    var chan = make_channel(URL + "/range");
    chan.nsIResumableChannel.resumeAt(1, entityID);
    chan.asyncOpen(new ChannelListener(try_resume_zero, null));
  }

  function try_resume_zero(request, data) {
    dump("*** try_resume_zero()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(data, rangeBody.substring(1));

    // Try a server which doesn't support range requests
    var chan = make_channel(URL + "/acceptranges");
    chan.nsIResumableChannel.resumeAt(0, entityID);
    chan.nsIHttpChannel.setRequestHeader("X-Range-Type", "none", false);
    chan.asyncOpen(new ChannelListener(try_no_range, null, CL_EXPECT_FAILURE));
  }

  function try_no_range(request) {
    dump("*** try_no_range()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(request.status, NS_ERROR_NOT_RESUMABLE);

    // Try a server which supports "bytes" range requests
    var chan = make_channel(URL + "/acceptranges");
    chan.nsIResumableChannel.resumeAt(0, entityID);
    chan.nsIHttpChannel.setRequestHeader("X-Range-Type", "bytes", false);
    chan.asyncOpen(new ChannelListener(try_bytes_range, null));
  }

  function try_bytes_range(request, data) {
    dump("*** try_bytes_range()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(data, rangeBody);

    // Try a server which supports "foo" and "bar" range requests
    var chan = make_channel(URL + "/acceptranges");
    chan.nsIResumableChannel.resumeAt(0, entityID);
    chan.nsIHttpChannel.setRequestHeader("X-Range-Type", "foo, bar", false);
    chan.asyncOpen(
      new ChannelListener(try_foo_bar_range, null, CL_EXPECT_FAILURE)
    );
  }

  function try_foo_bar_range(request) {
    dump("*** try_foo_bar_range()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(request.status, NS_ERROR_NOT_RESUMABLE);

    // Try a server which supports "foobar" range requests
    var chan = make_channel(URL + "/acceptranges");
    chan.nsIResumableChannel.resumeAt(0, entityID);
    chan.nsIHttpChannel.setRequestHeader("X-Range-Type", "foobar", false);
    chan.asyncOpen(
      new ChannelListener(try_foobar_range, null, CL_EXPECT_FAILURE)
    );
  }

  function try_foobar_range(request) {
    dump("*** try_foobar_range()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(request.status, NS_ERROR_NOT_RESUMABLE);

    // Try a server which supports "bytes" and "foobar" range requests
    var chan = make_channel(URL + "/acceptranges");
    chan.nsIResumableChannel.resumeAt(0, entityID);
    chan.nsIHttpChannel.setRequestHeader(
      "X-Range-Type",
      "bytes, foobar",
      false
    );
    chan.asyncOpen(new ChannelListener(try_bytes_foobar_range, null));
  }

  function try_bytes_foobar_range(request, data) {
    dump("*** try_bytes_foobar_range()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(data, rangeBody);

    // Try a server which supports "bytesfoo" and "bar" range requests
    var chan = make_channel(URL + "/acceptranges");
    chan.nsIResumableChannel.resumeAt(0, entityID);
    chan.nsIHttpChannel.setRequestHeader(
      "X-Range-Type",
      "bytesfoo, bar",
      false
    );
    chan.asyncOpen(
      new ChannelListener(try_bytesfoo_bar_range, null, CL_EXPECT_FAILURE)
    );
  }

  function try_bytesfoo_bar_range(request) {
    dump("*** try_bytesfoo_bar_range()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(request.status, NS_ERROR_NOT_RESUMABLE);

    // Try a server which doesn't send Accept-Ranges header at all
    var chan = make_channel(URL + "/acceptranges");
    chan.nsIResumableChannel.resumeAt(0, entityID);
    chan.asyncOpen(new ChannelListener(try_no_accept_ranges, null));
  }

  function try_no_accept_ranges(request, data) {
    dump("*** try_no_accept_ranges()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(data, rangeBody);

    // Try a successful suspend/resume from 0
    var chan = make_channel(URL + "/range");
    chan.nsIResumableChannel.resumeAt(0, entityID);
    chan.asyncOpen(
      new ChannelListener(
        try_suspend_resume,
        null,
        CL_SUSPEND | CL_EXPECT_3S_DELAY
      )
    );
  }

  function try_suspend_resume(request, data) {
    dump("*** try_suspend_resume()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(data, rangeBody);

    // Try a successful resume from 0
    var chan = make_channel(URL + "/range");
    chan.nsIResumableChannel.resumeAt(0, entityID);
    chan.asyncOpen(new ChannelListener(success, null));
  }

  function success(request, data) {
    dump("*** success()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(data, rangeBody);

    // Authentication (no password; working resume)
    // (should not give us any data)
    var chan = make_channel(URL + "/range");
    chan.nsIResumableChannel.resumeAt(1, entityID);
    chan.nsIHttpChannel.setRequestHeader("X-Need-Auth", "true", false);
    chan.asyncOpen(
      new ChannelListener(test_auth_nopw, null, CL_EXPECT_FAILURE)
    );
  }

  function test_auth_nopw(request) {
    dump("*** test_auth_nopw()\n");
    Assert.ok(!request.nsIHttpChannel.requestSucceeded);
    Assert.equal(request.status, NS_ERROR_ENTITY_CHANGED);

    // Authentication + not working resume
    var chan = make_channel(
      "http://guest:guest@localhost:" +
        httpserver.identity.primaryPort +
        "/auth"
    );
    chan.nsIResumableChannel.resumeAt(1, entityID);
    chan.notificationCallbacks = new Requestor();
    chan.asyncOpen(new ChannelListener(test_auth, null, CL_EXPECT_FAILURE));
  }
  function test_auth(request) {
    dump("*** test_auth()\n");
    Assert.equal(request.status, NS_ERROR_NOT_RESUMABLE);
    Assert.less(request.nsIHttpChannel.responseStatus, 300);

    // Authentication + working resume
    var chan = make_channel(
      "http://guest:guest@localhost:" +
        httpserver.identity.primaryPort +
        "/range"
    );
    chan.nsIResumableChannel.resumeAt(1, entityID);
    chan.notificationCallbacks = new Requestor();
    chan.nsIHttpChannel.setRequestHeader("X-Need-Auth", "true", false);
    chan.asyncOpen(new ChannelListener(test_auth_resume, null));
  }

  function test_auth_resume(request, data) {
    dump("*** test_auth_resume()\n");
    Assert.equal(data, rangeBody.substring(1));
    Assert.ok(request.nsIHttpChannel.requestSucceeded);

    // 404 page (same content length as real content)
    var chan = make_channel(URL + "/range");
    chan.nsIResumableChannel.resumeAt(1, entityID);
    chan.nsIHttpChannel.setRequestHeader("X-Want-404", "true", false);
    chan.asyncOpen(new ChannelListener(test_404, null, CL_EXPECT_FAILURE));
  }

  function test_404(request) {
    dump("*** test_404()\n");
    Assert.equal(request.status, NS_ERROR_ENTITY_CHANGED);
    Assert.equal(request.nsIHttpChannel.responseStatus, 404);

    // 416 Requested Range Not Satisfiable
    var chan = make_channel(URL + "/range");
    chan.nsIResumableChannel.resumeAt(1000, entityID);
    chan.asyncOpen(new ChannelListener(test_416, null, CL_EXPECT_FAILURE));
  }

  function test_416(request) {
    dump("*** test_416()\n");
    Assert.equal(request.status, NS_ERROR_ENTITY_CHANGED);
    Assert.equal(request.nsIHttpChannel.responseStatus, 416);

    // Redirect + successful resume
    var chan = make_channel(URL + "/redir");
    chan.nsIHttpChannel.setRequestHeader("X-Redir-To", URL + "/range", false);
    chan.nsIResumableChannel.resumeAt(1, entityID);
    chan.asyncOpen(new ChannelListener(test_redir_resume, null));
  }

  function test_redir_resume(request, data) {
    dump("*** test_redir_resume()\n");
    Assert.ok(request.nsIHttpChannel.requestSucceeded);
    Assert.equal(data, rangeBody.substring(1));
    Assert.equal(request.nsIHttpChannel.responseStatus, 206);

    // Redirect + failed resume
    var chan = make_channel(URL + "/redir");
    chan.nsIHttpChannel.setRequestHeader("X-Redir-To", URL + "/", false);
    chan.nsIResumableChannel.resumeAt(1, entityID);
    chan.asyncOpen(
      new ChannelListener(test_redir_noresume, null, CL_EXPECT_FAILURE)
    );
  }

  function test_redir_noresume(request) {
    dump("*** test_redir_noresume()\n");
    Assert.equal(request.status, NS_ERROR_NOT_RESUMABLE);

    httpserver.stop(do_test_finished);
  }

  httpserver.start(-1);
  var chan = make_channel(URL + "/range");
  chan.asyncOpen(new ChannelListener(get_entity_id, null));
  do_test_pending();
}

// HANDLERS

function handleAuth(metadata, response) {
  // btoa("guest:guest"), but that function is not available here
  var expectedHeader = "Basic Z3Vlc3Q6Z3Vlc3Q=";

  if (
    metadata.hasHeader("Authorization") &&
    metadata.getHeader("Authorization") == expectedHeader
  ) {
    response.setStatusLine(metadata.httpVersion, 200, "OK, authorized");
    response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);

    return true;
  }
  // didn't know guest:guest, failure
  response.setStatusLine(metadata.httpVersion, 401, "Unauthorized");
  response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
  return false;
}

// /auth
function authHandler(metadata, response) {
  response.setHeader("Content-Type", "text/html", false);
  var body = handleAuth(metadata, response) ? "success" : "failure";
  response.bodyOutputStream.write(body, body.length);
}

// /range
function rangeHandler(metadata, response) {
  response.setHeader("Content-Type", "text/html", false);

  if (metadata.hasHeader("X-Need-Auth")) {
    if (!handleAuth(metadata, response)) {
      body = "auth failed";
      response.bodyOutputStream.write(body, body.length);
      return;
    }
  }

  if (metadata.hasHeader("X-Want-404")) {
    response.setStatusLine(metadata.httpVersion, 404, "Not Found");
    body = rangeBody;
    response.bodyOutputStream.write(body, body.length);
    return;
  }

  var body = rangeBody;

  if (metadata.hasHeader("Range")) {
    // Syntax: bytes=[from]-[to] (we don't support multiple ranges)
    var matches = metadata
      .getHeader("Range")
      .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/);
    var from = matches[1] === undefined ? 0 : matches[1];
    var to = matches[2] === undefined ? rangeBody.length - 1 : matches[2];
    if (from >= rangeBody.length) {
      response.setStatusLine(metadata.httpVersion, 416, "Start pos too high");
      response.setHeader("Content-Range", "*/" + rangeBody.length, false);
      return;
    }
    body = body.substring(from, to + 1);
    // always respond to successful range requests with 206
    response.setStatusLine(metadata.httpVersion, 206, "Partial Content");
    response.setHeader(
      "Content-Range",
      from + "-" + to + "/" + rangeBody.length,
      false
    );
  }

  response.bodyOutputStream.write(body, body.length);
}

// /acceptranges
function acceptRangesHandler(metadata, response) {
  response.setHeader("Content-Type", "text/html", false);
  if (metadata.hasHeader("X-Range-Type")) {
    response.setHeader(
      "Accept-Ranges",
      metadata.getHeader("X-Range-Type"),
      false
    );
  }
  response.bodyOutputStream.write(rangeBody, rangeBody.length);
}

// /redir
function redirHandler(metadata, response) {
  response.setStatusLine(metadata.httpVersion, 302, "Found");
  response.setHeader("Content-Type", "text/html", false);
  response.setHeader("Location", metadata.getHeader("X-Redir-To"), false);
  var body = "redirect\r\n";
  response.bodyOutputStream.write(body, body.length);
}
