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

"use strict";

const { HawkClient } = ChromeUtils.importESModule(
  "resource://services-common/hawkclient.sys.mjs"
);

const SECOND_MS = 1000;
const MINUTE_MS = SECOND_MS * 60;
const HOUR_MS = MINUTE_MS * 60;

const TEST_CREDS = {
  id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
  key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
  algorithm: "sha256",
};

initTestLogging("Trace");

add_task(function test_now() {
  let client = new HawkClient("https://example.com");

  Assert.ok(client.now() - Date.now() < SECOND_MS);
});

add_task(function test_updateClockOffset() {
  let client = new HawkClient("https://example.com");

  let now = new Date();
  let serverDate = now.toUTCString();

  // Client's clock is off
  client.now = () => {
    return now.valueOf() + HOUR_MS;
  };

  client._updateClockOffset(serverDate);

  // Check that they're close; there will likely be a one-second rounding
  // error, so checking strict equality will likely fail.
  //
  // localtimeOffsetMsec is how many milliseconds to add to the local clock so
  // that it agrees with the server.  We are one hour ahead of the server, so
  // our offset should be -1 hour.
  Assert.ok(Math.abs(client.localtimeOffsetMsec + HOUR_MS) <= SECOND_MS);
});

add_task(async function test_authenticated_get_request() {
  let message = '{"msg": "Great Success!"}';
  let method = "GET";

  let server = httpd_setup({
    "/foo": (request, response) => {
      Assert.ok(request.hasHeader("Authorization"));

      response.setStatusLine(request.httpVersion, 200, "OK");
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);

  let response = await client.request("/foo", method, TEST_CREDS);
  let result = JSON.parse(response.body);

  Assert.equal("Great Success!", result.msg);

  await promiseStopServer(server);
});

async function check_authenticated_request(method) {
  let server = httpd_setup({
    "/foo": (request, response) => {
      Assert.ok(request.hasHeader("Authorization"));

      response.setStatusLine(request.httpVersion, 200, "OK");
      response.setHeader("Content-Type", "application/json");
      response.bodyOutputStream.writeFrom(
        request.bodyInputStream,
        request.bodyInputStream.available()
      );
    },
  });

  let client = new HawkClient(server.baseURI);

  let response = await client.request("/foo", method, TEST_CREDS, {
    foo: "bar",
  });
  let result = JSON.parse(response.body);

  Assert.equal("bar", result.foo);

  await promiseStopServer(server);
}

add_task(async function test_authenticated_post_request() {
  await check_authenticated_request("POST");
});

add_task(async function test_authenticated_put_request() {
  await check_authenticated_request("PUT");
});

add_task(async function test_authenticated_patch_request() {
  await check_authenticated_request("PATCH");
});

add_task(async function test_extra_headers() {
  let server = httpd_setup({
    "/foo": (request, response) => {
      Assert.ok(request.hasHeader("Authorization"));
      Assert.ok(request.hasHeader("myHeader"));
      Assert.equal(request.getHeader("myHeader"), "fake");

      response.setStatusLine(request.httpVersion, 200, "OK");
      response.setHeader("Content-Type", "application/json");
      response.bodyOutputStream.writeFrom(
        request.bodyInputStream,
        request.bodyInputStream.available()
      );
    },
  });

  let client = new HawkClient(server.baseURI);

  let response = await client.request(
    "/foo",
    "POST",
    TEST_CREDS,
    { foo: "bar" },
    { myHeader: "fake" }
  );
  let result = JSON.parse(response.body);

  Assert.equal("bar", result.foo);

  await promiseStopServer(server);
});

add_task(async function test_credentials_optional() {
  let method = "GET";
  let server = httpd_setup({
    "/foo": (request, response) => {
      Assert.ok(!request.hasHeader("Authorization"));

      let message = JSON.stringify({ msg: "you're in the friend zone" });
      response.setStatusLine(request.httpVersion, 200, "OK");
      response.setHeader("Content-Type", "application/json");
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);
  let result = await client.request("/foo", method); // credentials undefined
  Assert.equal(JSON.parse(result.body).msg, "you're in the friend zone");

  await promiseStopServer(server);
});

add_task(async function test_server_error() {
  let message = "Ohai!";
  let method = "GET";

  let server = httpd_setup({
    "/foo": (request, response) => {
      response.setStatusLine(request.httpVersion, 418, "I am a Teapot");
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);

  try {
    await client.request("/foo", method, TEST_CREDS);
    do_throw("Expected an error");
  } catch (err) {
    Assert.equal(418, err.code);
    Assert.equal("I am a Teapot", err.message);
  }

  await promiseStopServer(server);
});

add_task(async function test_server_error_json() {
  let message = JSON.stringify({ error: "Cannot get ye flask." });
  let method = "GET";

  let server = httpd_setup({
    "/foo": (request, response) => {
      response.setStatusLine(
        request.httpVersion,
        400,
        "What wouldst thou deau?"
      );
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);

  try {
    await client.request("/foo", method, TEST_CREDS);
    do_throw("Expected an error");
  } catch (err) {
    Assert.equal("Cannot get ye flask.", err.error);
  }

  await promiseStopServer(server);
});

add_task(async function test_offset_after_request() {
  let message = "Ohai!";
  let method = "GET";

  let server = httpd_setup({
    "/foo": (request, response) => {
      response.setStatusLine(request.httpVersion, 200, "OK");
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);
  let now = Date.now();
  client.now = () => {
    return now + HOUR_MS;
  };

  Assert.equal(client.localtimeOffsetMsec, 0);

  await client.request("/foo", method, TEST_CREDS);
  // Should be about an hour off
  Assert.ok(Math.abs(client.localtimeOffsetMsec + HOUR_MS) < SECOND_MS);

  await promiseStopServer(server);
});

add_task(async function test_offset_in_hawk_header() {
  let message = "Ohai!";
  let method = "GET";

  let server = httpd_setup({
    "/first": function (request, response) {
      response.setStatusLine(request.httpVersion, 200, "OK");
      response.bodyOutputStream.write(message, message.length);
    },

    "/second": function (request, response) {
      // We see a better date now in the ts component of the header
      let delta = getTimestampDelta(request.getHeader("Authorization"));

      // We're now within HAWK's one-minute window.
      // I hope this isn't a recipe for intermittent oranges ...
      if (delta < MINUTE_MS) {
        response.setStatusLine(request.httpVersion, 200, "OK");
      } else {
        response.setStatusLine(request.httpVersion, 400, "Delta: " + delta);
      }
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);

  client.now = () => {
    return Date.now() + 12 * HOUR_MS;
  };

  // We begin with no offset
  Assert.equal(client.localtimeOffsetMsec, 0);
  await client.request("/first", method, TEST_CREDS);

  // After the first server response, our offset is updated to -12 hours.
  // We should be safely in the window, now.
  Assert.ok(Math.abs(client.localtimeOffsetMsec + 12 * HOUR_MS) < MINUTE_MS);
  await client.request("/second", method, TEST_CREDS);

  await promiseStopServer(server);
});

add_task(async function test_2xx_success() {
  // Just to ensure that we're not biased toward 200 OK for success
  let credentials = {
    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
    algorithm: "sha256",
  };
  let method = "GET";

  let server = httpd_setup({
    "/foo": (request, response) => {
      response.setStatusLine(request.httpVersion, 202, "Accepted");
    },
  });

  let client = new HawkClient(server.baseURI);

  let response = await client.request("/foo", method, credentials);

  // Shouldn't be any content in a 202
  Assert.equal(response.body, "");

  await promiseStopServer(server);
});

add_task(async function test_retry_request_on_fail() {
  let attempts = 0;
  let credentials = {
    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
    algorithm: "sha256",
  };
  let method = "GET";

  let server = httpd_setup({
    "/maybe": function (request, response) {
      // This path should be hit exactly twice; once with a bad timestamp, and
      // again when the client retries the request with a corrected timestamp.
      attempts += 1;
      Assert.ok(attempts <= 2);

      let delta = getTimestampDelta(request.getHeader("Authorization"));

      // First time through, we should have a bad timestamp
      if (attempts === 1) {
        Assert.ok(delta > MINUTE_MS);
        let message = "never!!!";
        response.setStatusLine(request.httpVersion, 401, "Unauthorized");
        response.bodyOutputStream.write(message, message.length);
        return;
      }

      // Second time through, timestamp should be corrected by client
      Assert.ok(delta < MINUTE_MS);
      let message = "i love you!!!";
      response.setStatusLine(request.httpVersion, 200, "OK");
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);

  client.now = () => {
    return Date.now() + 12 * HOUR_MS;
  };

  // We begin with no offset
  Assert.equal(client.localtimeOffsetMsec, 0);

  // Request will have bad timestamp; client will retry once
  let response = await client.request("/maybe", method, credentials);
  Assert.equal(response.body, "i love you!!!");

  await promiseStopServer(server);
});

add_task(async function test_multiple_401_retry_once() {
  // Like test_retry_request_on_fail, but always return a 401
  // and ensure that the client only retries once.
  let attempts = 0;
  let credentials = {
    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
    algorithm: "sha256",
  };
  let method = "GET";

  let server = httpd_setup({
    "/maybe": function (request, response) {
      // This path should be hit exactly twice; once with a bad timestamp, and
      // again when the client retries the request with a corrected timestamp.
      attempts += 1;

      Assert.ok(attempts <= 2);

      let message = "never!!!";
      response.setStatusLine(request.httpVersion, 401, "Unauthorized");
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);

  client.now = () => {
    return Date.now() - 12 * HOUR_MS;
  };

  // We begin with no offset
  Assert.equal(client.localtimeOffsetMsec, 0);

  // Request will have bad timestamp; client will retry once
  try {
    await client.request("/maybe", method, credentials);
    do_throw("Expected an error");
  } catch (err) {
    Assert.equal(err.code, 401);
  }
  Assert.equal(attempts, 2);

  await promiseStopServer(server);
});

add_task(async function test_500_no_retry() {
  // If we get a 500 error, the client should not retry (as it would with a
  // 401)
  let credentials = {
    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
    algorithm: "sha256",
  };
  let method = "GET";

  let server = httpd_setup({
    "/no-shutup": function (request, response) {
      let message = "Cannot get ye flask.";
      response.setStatusLine(request.httpVersion, 500, "Internal server error");
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);

  // Throw off the clock so the HawkClient would want to retry the request if
  // it could
  client.now = () => {
    return Date.now() - 12 * HOUR_MS;
  };

  // Request will 500; no retries
  try {
    await client.request("/no-shutup", method, credentials);
    do_throw("Expected an error");
  } catch (err) {
    Assert.equal(err.code, 500);
  }

  await promiseStopServer(server);
});

add_task(async function test_401_then_500() {
  // Like test_multiple_401_retry_once, but return a 500 to the
  // second request, ensuring that the promise is properly rejected
  // in client.request.
  let attempts = 0;
  let credentials = {
    id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
    key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
    algorithm: "sha256",
  };
  let method = "GET";

  let server = httpd_setup({
    "/maybe": function (request, response) {
      // This path should be hit exactly twice; once with a bad timestamp, and
      // again when the client retries the request with a corrected timestamp.
      attempts += 1;
      Assert.ok(attempts <= 2);

      let delta = getTimestampDelta(request.getHeader("Authorization"));

      // First time through, we should have a bad timestamp
      // Client will retry
      if (attempts === 1) {
        Assert.ok(delta > MINUTE_MS);
        let message = "never!!!";
        response.setStatusLine(request.httpVersion, 401, "Unauthorized");
        response.bodyOutputStream.write(message, message.length);
        return;
      }

      // Second time through, timestamp should be corrected by client
      // And fail on the client
      Assert.ok(delta < MINUTE_MS);
      let message = "Cannot get ye flask.";
      response.setStatusLine(request.httpVersion, 500, "Internal server error");
      response.bodyOutputStream.write(message, message.length);
    },
  });

  let client = new HawkClient(server.baseURI);

  client.now = () => {
    return Date.now() - 12 * HOUR_MS;
  };

  // We begin with no offset
  Assert.equal(client.localtimeOffsetMsec, 0);

  // Request will have bad timestamp; client will retry once
  try {
    await client.request("/maybe", method, credentials);
  } catch (err) {
    Assert.equal(err.code, 500);
  }
  Assert.equal(attempts, 2);

  await promiseStopServer(server);
});

// End of tests.
// Utility functions follow

function getTimestampDelta(authHeader, now = Date.now()) {
  let tsMS = new Date(
    parseInt(/ts="(\d+)"/.exec(authHeader)[1], 10) * SECOND_MS
  );
  return Math.abs(tsMS - now);
}

function run_test() {
  initTestLogging("Trace");
  run_next_test();
}
