/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, you can obtain one at http://mozilla.org/MPL/2.0/. */

/**
 * Utils for testing interactions with OAuth2 authentication servers.
 */

import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs";
import { CommonUtils } from "resource://services-common/utils.sys.mjs";
import { HttpsProxy } from "resource://testing-common/mailnews/HttpsProxy.sys.mjs";
import { HttpServer, HTTP_405 } from "resource://testing-common/httpd.sys.mjs";
import { TestUtils } from "resource://testing-common/TestUtils.sys.mjs";

import { OAuth2Module } from "resource:///modules/OAuth2Module.sys.mjs";

const validCodes = new Set();

export const OAuth2TestUtils = {
  /**
   * Start an OAuth2 server and add it to the proxy at oauth.test.test:443.
   */
  async startServer(options) {
    this._oAuth2Server = new OAuth2Server(options);
    this._proxy = await HttpsProxy.create(
      this._oAuth2Server.httpServer.identity.primaryPort,
      "oauth",
      "oauth.test.test"
    );
    TestUtils.promiseTestFinished?.then(() => this.stopServer());
    return this._oAuth2Server;
  },

  stopServer() {
    this._proxy?.destroy();
    this._proxy = null;
    this._oAuth2Server?.close();
    this._oAuth2Server = null;
  },

  /**
   * Forget any `OAuth2` objects remembered by OAuth2Module.sys.mjs
   */
  forgetObjects() {
    OAuth2Module._forgetObjects();
  },

  /**
   * Waits for a login prompt window to appear and load.
   *
   * @returns {Window}
   */
  async promiseOAuthWindow() {
    const oAuthWindow = await BrowserTestUtils.domWindowOpenedAndLoaded(
      undefined,
      win =>
        win.document.documentURI ==
        "chrome://messenger/content/browserRequest.xhtml"
    );
    const oAuthBrowser = oAuthWindow.getBrowser();
    if (
      oAuthBrowser.webProgress?.isLoadingDocument ||
      oAuthBrowser.currentURI.spec == "about:blank"
    ) {
      await BrowserTestUtils.browserLoaded(oAuthBrowser);
    }
    return oAuthWindow;
  },

  /**
   * Callback function to run in a login prompt window. Note: This function is
   * serialized by SpecialPowers, so it can't use function shorthand.
   *
   * @param {object} options
   * @param {string} [options.expectedHint] - If given, the login_hint URL parameter
   *   will be checked.
   * @param {string} options.username - The username to use to log in.
   * @param {string} options.password - The password to use to log in.
   */
  submitOAuthLogin: ({ expectedHint, username, password }) => {
    /* globals content, EventUtils */
    const searchParams = new URL(content.location).searchParams;
    Assert.equal(
      searchParams.get("response_type"),
      "code",
      "request response_type"
    );
    Assert.equal(
      searchParams.get("client_id"),
      "test_client_id",
      "request client_id"
    );
    Assert.equal(
      searchParams.get("redirect_uri"),
      "https://localhost",
      "request redirect_uri"
    );
    Assert.equal(searchParams.get("scope"), "test_scope", "request scope");
    if (expectedHint) {
      Assert.equal(
        searchParams.get("login_hint"),
        expectedHint,
        "request login_hint"
      );
    }

    EventUtils.synthesizeMouseAtCenter(
      content.document.querySelector(`input[name="username"]`),
      {},
      content
    );
    EventUtils.sendString(username, content);
    EventUtils.synthesizeMouseAtCenter(
      content.document.querySelector(`input[name="password"]`),
      {},
      content
    );
    EventUtils.sendString(password, content);
    EventUtils.synthesizeMouseAtCenter(
      content.document.querySelector(`input[type="submit"]`),
      {},
      content
    );
  },
};

class OAuth2Server {
  constructor({
    username = "user",
    password = "password",
    accessToken = "access_token",
    refreshToken = "refresh_token",
    expiry = null,
  } = {}) {
    this.username = username;
    this.password = password;
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    this.expiry = expiry;

    this.httpServer = new HttpServer();
    this.httpServer.registerPathHandler("/form", this.formHandler.bind(this));
    this.httpServer.registerPathHandler(
      "/authorize",
      this.authorizeHandler.bind(this)
    );
    this.httpServer.registerPathHandler("/token", this.tokenHandler.bind(this));
    this.httpServer.start(-1);

    const port = this.httpServer.identity.primaryPort;
    dump(`OAuth2 server at localhost:${port} opened\n`);
  }

  close() {
    const port = this.httpServer.identity.primaryPort;
    this.httpServer.stop();
    dump(`OAuth2 server at localhost:${port} closed\n`);
  }

  formHandler(request, response) {
    if (request.method != "GET") {
      throw HTTP_405;
    }
    const params = new URLSearchParams(request.queryString);
    this._formHandler(response, params.get("redirect_uri"));
  }

  _formHandler(response, redirectUri) {
    response.setHeader("Content-Type", "text/html", false);
    response.write(`<!DOCTYPE html>
      <html>
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Log in to test.test</title>
      </head>
      <body>
        <form action="/authorize" method="post">
          <input type="text" name="redirect_uri" readonly="readonly" value="${redirectUri}" />
          <input type="text" name="username" />
          <input type="password" name="password" />
          <input type="submit" />
        </form>
      </body>
      </html>
    `);
  }

  authorizeHandler(request, response) {
    if (request.method != "POST") {
      throw HTTP_405;
    }

    const input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
    const params = new URLSearchParams(input);

    if (
      params.get("username") != this.username ||
      params.get("password") != this.password
    ) {
      this._formHandler(response, params.get("redirect_uri"));
      return;
    }

    // Create a unique code. It will become invalid after the first use.
    const bytes = new Uint8Array(12);
    for (let i = 0; i < bytes.length; i++) {
      bytes[i] = Math.floor(Math.random() * 255);
    }
    const code = ChromeUtils.base64URLEncode(bytes, { pad: false });
    validCodes.add(code);

    const url = new URL(params.get("redirect_uri"));
    url.searchParams.set("code", code);

    response.setStatusLine(request.httpVersion, 303, "Redirected");
    response.setHeader("Location", url.href);
  }

  tokenHandler(request, response) {
    if (request.method != "POST") {
      throw HTTP_405;
    }

    const stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
      Ci.nsIBinaryInputStream
    );
    stream.setInputStream(request.bodyInputStream);
    const input = stream.readBytes(request.bodyInputStream.available());
    const params = new URLSearchParams(input);

    const goodRequest =
      params.get("client_id") == "test_client_id" &&
      params.get("client_secret") == "test_secret";
    const grantType = params.get("grant_type");
    const code = params.get("code");
    const data = {};

    if (
      goodRequest &&
      grantType == "authorization_code" &&
      code &&
      validCodes.has(code)
    ) {
      // Authorisation just happened.
      validCodes.delete(code);
      data.access_token = this.accessToken;
      data.refresh_token = this.refreshToken;
    } else if (
      goodRequest &&
      grantType == "refresh_token" &&
      params.get("refresh_token") == this.refreshToken
    ) {
      // Client provided a valid refresh token.
      data.access_token = this.accessToken;
    } else {
      response.setStatusLine("1.1", 400, "Bad Request");
      data.error = "invalid_grant";
    }

    if (data.access_token && this.expiry !== null) {
      data.expires_in = this.expiry;
    }

    response.setHeader("Content-Type", "application/json", false);
    response.write(JSON.stringify(data));
  }
}
