/**
 * Signing in, and the things signing in must not reveal.
 *
 * The positive case is one test; the rest of this file is about the negatives,
 * because those are the ones that fail quietly in production. A wrong username
 * and a wrong password have to be the same event byte for byte. A box with no
 * local account has to look like a box with no such route. And a private office
 * has to go back to not existing the moment the cookie is cleared — a logout
 * that only hides the UI is not a logout.
 *
 * Follows `offices.test.ts`: a temp directory of office packs, `buildApp` with a
 * config built from a fake environment, and `inject()` rather than a socket.
 */

import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { hashPassword, parseScryptHash, verifyPassword } from "../auth/password.ts";
import type { SessionBody } from "../routes/session.ts";
import type { HealthBody } from "../../../src/server/wire.ts";

const SECRET = "not-a-real-secret-and-never-was";
const USER = "karti";
const PASSWORD = "correct horse battery staple";

/**
 * Produced by the exact command documented in `auth/password.ts`, pasted
 * verbatim, for the password above. It is a fixture rather than a call to
 * `hashPassword` so that the shell an operator is told to run and the parser
 * this server ships cannot drift apart without this test going red.
 */
const HASH_FROM_THE_DOCUMENTED_COMMAND =
  "scrypt$16384$8$1$Ns_--HDodVjObBpWzIp5TQ$_2BLRqJI14VRzr2iA-3OqR-Z1X-bVrezZYqmaU477xc";

/** A minimal floor. `Plan` is what makes sense of it; the API only carries it. */
const floor = { id: "hq", name: "HQ", levels: [], viewpoints: [] };

let dir = "";

before(async () => {
  dir = await mkdtemp(join(tmpdir(), "tera-session-"));
  await writeFile(
    join(dir, "open.json"),
    JSON.stringify({ id: "open", name: "Open office", visibility: "public", floor }),
  );
  await writeFile(
    join(dir, "closed.json"),
    JSON.stringify({ id: "closed", name: "Closed office", visibility: "private", floor }),
  );
});

const passwordEnv = {
  TERA_AUTH_MODE: "password",
  TERA_AUTH_PASSWORD_USER: USER,
  TERA_AUTH_PASSWORD_HASH: HASH_FROM_THE_DOCUMENTED_COMMAND,
  TERA_AUTH_JWT_SECRET: SECRET,
};

function appWith(env: Record<string, string>) {
  const config = loadConfig({ TERA_OFFICES_DIR: dir, ...env });
  config.logLevel = "silent";
  return buildApp(config);
}

function login(app: ReturnType<typeof buildApp>, username: string, password: string) {
  return app.inject({
    method: "POST",
    url: "/api/v1/session",
    payload: { username, password },
  });
}

/** The `Set-Cookie` value, reduced to what a browser would send back. */
function cookiePair(setCookie: unknown): string {
  const header = Array.isArray(setCookie) ? String(setCookie[0]) : String(setCookie);
  return header.split(";")[0] ?? "";
}

describe("the login endpoint", () => {
  it("accepts the right credentials and sets a locked-down cookie", async () => {
    const app = appWith(passwordEnv);
    after(() => app.close());

    const res = await login(app, USER, PASSWORD);
    assert.equal(res.statusCode, 200);
    assert.deepEqual(res.json<SessionBody>(), {
      authenticated: true,
      subject: USER,
      admin: false,
      passwordLogin: true,
    });

    const header = String(res.headers["set-cookie"]);
    assert.match(header, /^tera_session=[^;]+;/);
    assert.match(header, /HttpOnly/);
    assert.match(header, /Secure/);
    assert.match(header, /SameSite=Lax/);
    assert.match(header, /Path=\//);
    // Whatever else it is, it must never be a body a shared cache would keep.
    assert.equal(res.headers["cache-control"], "private, no-store");
  });

  it("rejects a wrong password and an unknown user identically", async () => {
    const app = appWith(passwordEnv);
    after(() => app.close());

    const wrongPassword = await login(app, USER, "not the password");
    const unknownUser = await login(app, "someone-else", PASSWORD);

    assert.equal(wrongPassword.statusCode, 401);
    assert.equal(unknownUser.statusCode, 401);
    assert.deepEqual(wrongPassword.json(), unknownUser.json());
    // Nothing that could be used to tell the two apart, including a cookie that
    // was set and immediately cleared.
    assert.equal(wrongPassword.headers["set-cookie"], undefined);
    assert.equal(unknownUser.headers["set-cookie"], undefined);
  });

  it("treats a malformed body as a failed attempt rather than a hint", async () => {
    const app = appWith(passwordEnv);
    after(() => app.close());

    for (const payload of [{}, { username: USER }, { username: 1, password: 2 }]) {
      const res = await app.inject({ method: "POST", url: "/api/v1/session", payload });
      assert.equal(res.statusCode, 401);
    }
  });

  it("stops answering after too many failures from one address", async () => {
    const app = appWith({ ...passwordEnv, TERA_AUTH_LOGIN_ATTEMPTS: "3" });
    after(() => app.close());

    for (let i = 0; i < 3; i += 1) {
      assert.equal((await login(app, USER, "wrong")).statusCode, 401);
    }

    const limited = await login(app, USER, "wrong");
    assert.equal(limited.statusCode, 429);
    assert.ok(Number(limited.headers["retry-after"]) > 0);

    // And the limit is not a way past the password: the right credentials are
    // refused too while the window is open.
    assert.equal((await login(app, USER, PASSWORD)).statusCode, 429);
  });
});

describe("a session cookie and a private office", () => {
  it("opens the private office, then closes again once the session is cleared", async () => {
    const app = appWith(passwordEnv);
    after(() => app.close());

    const before = await app.inject({ method: "GET", url: "/api/v1/offices/closed" });
    assert.equal(before.statusCode, 404);

    const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
    const opened = await app.inject({
      method: "GET",
      url: "/api/v1/offices/closed",
      headers: { cookie },
    });
    assert.equal(opened.statusCode, 200);
    assert.equal(opened.headers["cache-control"], "private, no-store");

    const out = await app.inject({ method: "DELETE", url: "/api/v1/session", headers: { cookie } });
    assert.equal(out.statusCode, 200);
    assert.equal(out.json<SessionBody>().authenticated, false);
    // The browser is told to drop it, and the same attributes are repeated so
    // that the replacement actually matches the cookie it is replacing.
    const cleared = String(out.headers["set-cookie"]);
    assert.match(cleared, /^tera_session=;/);
    assert.match(cleared, /Max-Age=0/);
    assert.match(cleared, /HttpOnly/);
    assert.match(cleared, /Secure/);

    // A browser that dropped the cookie is a browser with no session, and the
    // office goes back to not existing.
    const after_ = await app.inject({ method: "GET", url: "/api/v1/offices/closed" });
    assert.equal(after_.statusCode, 404);
  });

  it("reports the session state for the client to gate its UI", async () => {
    const app = appWith(passwordEnv);
    after(() => app.close());

    const anonymous = await app.inject({ method: "GET", url: "/api/v1/session" });
    assert.deepEqual(anonymous.json<SessionBody>(), {
      authenticated: false,
      subject: null,
      admin: false,
      passwordLogin: true,
    });

    const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
    const signedIn = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
    assert.deepEqual(signedIn.json<SessionBody>(), {
      authenticated: true,
      subject: USER,
      admin: false,
      passwordLogin: true,
    });
  });

  it("still keeps the token out of reach of a script", async () => {
    const app = appWith(passwordEnv);
    after(() => app.close());

    const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
    const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
    // The session body is what a page can read. It must not contain the bearer
    // token the cookie is carrying.
    assert.equal(state.body.includes(cookie.split("=")[1] ?? "never"), false);
  });
});

/**
 * The god tier is a fact the server states and the client repeats. Everything
 * below is one shape of the same question — *can the environment, and only the
 * environment, decide this?* — so the negatives outnumber the positive again:
 * an unlisted account, an anonymous caller under the blanket switch, a name
 * that differs only in case. The `*` case is tested for its `degraded` line as
 * much as for the grant, because a silent grant-everyone switch is the failure
 * this whole setting is shaped around.
 */
describe("the admin tier", () => {
  /** Sign in as USER on a box configured this way, and report what it says. */
  async function signedIn(env: Record<string, string>): Promise<SessionBody> {
    const app = appWith({ ...passwordEnv, ...env });
    after(() => app.close());
    const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
    const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
    return state.json<SessionBody>();
  }

  it("grants nobody when TERA_ADMIN_SUBJECTS is unset", async () => {
    assert.equal((await signedIn({})).admin, false);
    // And the safe default is silent: an unset variable is not a demotion.
    assert.deepEqual(loadConfig(passwordEnv).degraded, []);
  });

  it("grants a listed subject, on the login itself and on the session after it", async () => {
    const app = appWith({ ...passwordEnv, TERA_ADMIN_SUBJECTS: USER });
    after(() => app.close());

    const res = await login(app, USER, PASSWORD);
    // The POST answers with the real grant for the account that just signed in,
    // not a placeholder the following GET would contradict.
    assert.deepEqual(res.json<SessionBody>(), {
      authenticated: true,
      subject: USER,
      admin: true,
      passwordLogin: true,
    });

    const cookie = cookiePair(res.headers["set-cookie"]);
    const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
    assert.deepEqual(state.json<SessionBody>(), res.json<SessionBody>());
  });

  it("does not grant an authenticated subject nobody listed", async () => {
    const state = await signedIn({ TERA_ADMIN_SUBJECTS: "someone-else,third-party" });
    assert.equal(state.authenticated, true);
    assert.equal(state.admin, false);
  });

  it("matches exactly after trimming, and is case-sensitive", async () => {
    // Surrounding whitespace is an artefact of writing a list in an env file and
    // is dropped; the id itself must be the id.
    assert.equal((await signedIn({ TERA_ADMIN_SUBJECTS: "  karti  , other " })).admin, true);
    // Case is not. A subject id is an opaque string an issuer minted, and two
    // ids that differ only in case can be two accounts.
    assert.equal((await signedIn({ TERA_ADMIN_SUBJECTS: "KARTI" })).admin, false);
  });

  it("grants everyone under the wildcard, and announces it on health", async () => {
    const config = loadConfig({ TERA_OFFICES_DIR: dir, ...passwordEnv, TERA_ADMIN_SUBJECTS: "*" });
    config.logLevel = "silent";
    const app = buildApp(config);
    after(() => app.close());

    const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
    const state = await app.inject({ method: "GET", url: "/api/v1/session", headers: { cookie } });
    assert.equal(state.json<SessionBody>().admin, true);

    // The whole point of the wildcard being allowed at all: it cannot be on
    // without `/api/v1/health` saying so.
    const health = (await app.inject({ method: "GET", url: "/api/v1/health" })).json<HealthBody>();
    assert.ok(health.degraded.some((line) => line.includes("TERA_ADMIN_SUBJECTS=*")));
  });

  it("still refuses the anonymous caller under the wildcard", async () => {
    const app = appWith({ ...passwordEnv, TERA_ADMIN_SUBJECTS: "*" });
    after(() => app.close());

    // "Everyone" means everyone *authenticated*. A browser with no cookie is
    // not a member, let alone a god.
    const state = await app.inject({ method: "GET", url: "/api/v1/session" });
    assert.deepEqual(state.json<SessionBody>(), {
      authenticated: false,
      subject: null,
      admin: false,
      passwordLogin: true,
    });
  });

  it("takes the tier away with the session", async () => {
    const app = appWith({ ...passwordEnv, TERA_ADMIN_SUBJECTS: USER });
    after(() => app.close());

    const cookie = cookiePair((await login(app, USER, PASSWORD)).headers["set-cookie"]);
    const out = await app.inject({ method: "DELETE", url: "/api/v1/session", headers: { cookie } });
    assert.equal(out.statusCode, 200);
    assert.deepEqual(out.json<SessionBody>(), {
      authenticated: false,
      subject: null,
      // Said out loud rather than implied by `authenticated: false`, so a page
      // holding the old value has something to overwrite it with.
      admin: false,
      passwordLogin: true,
    });
  });

  it("keeps the list and its size off the health body", async () => {
    const app = appWith({
      ...passwordEnv,
      TERA_ADMIN_SUBJECTS: `${USER},someone-else,third-party`,
    });
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/health" });
    const health = res.json<HealthBody>();
    // Who the admins are is not a public question, and neither is how many
    // there are — a count is an invitation to go looking for the one account.
    assert.equal(res.body.includes(USER), false);
    assert.equal(res.body.includes("someone-else"), false);
    assert.equal(res.body.includes("admin"), false);
    assert.deepEqual(health.degraded, []);
  });
});

describe("deployments that cannot sign anyone in", () => {
  it("leaves mode=none open and offers no login", async () => {
    const app = appWith({});
    after(() => app.close());

    assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/open" })).statusCode, 200);

    const state = await app.inject({ method: "GET", url: "/api/v1/session" });
    assert.deepEqual(state.json<SessionBody>(), {
      authenticated: false,
      subject: null,
      admin: false,
      passwordLogin: false,
    });

    // Not "login is disabled" — the same 404 an unrouted path gets.
    const attempt = await login(app, USER, PASSWORD);
    const nowhere = await app.inject({ method: "POST", url: "/api/v1/no-such-thing" });
    assert.equal(attempt.statusCode, 404);
    assert.deepEqual(attempt.json(), nowhere.json());
  });

  it("demotes to mode=none when the hash is unreadable, and says so", async () => {
    const config = loadConfig({
      TERA_OFFICES_DIR: dir,
      ...passwordEnv,
      TERA_AUTH_PASSWORD_HASH: "scrypt$notanumber$8$1$aaaa$bbbb",
    });
    config.logLevel = "silent";
    const app = buildApp(config);
    after(() => app.close());

    assert.equal(config.auth.mode, "none");
    assert.equal(config.auth.passwordLogin, null);
    assert.ok(config.degraded.some((line) => line.includes("TERA_AUTH_PASSWORD_HASH")));
    // The demotion runs towards closed: nobody gets the private office.
    assert.equal((await app.inject({ method: "GET", url: "/api/v1/offices/closed" })).statusCode, 404);
    assert.equal((await login(app, USER, PASSWORD)).statusCode, 404);
  });

  it("demotes when there is no secret to sign a session with", async () => {
    const config = loadConfig({
      TERA_AUTH_MODE: "password",
      TERA_AUTH_PASSWORD_USER: USER,
      TERA_AUTH_PASSWORD_HASH: HASH_FROM_THE_DOCUMENTED_COMMAND,
    });
    assert.equal(config.auth.mode, "none");
    assert.ok(config.degraded.some((line) => line.includes("TERA_AUTH_JWT_SECRET")));
  });

  it("resolves password mode to jwt so there is one authorisation path", async () => {
    const config = loadConfig(passwordEnv);
    assert.equal(config.auth.mode, "jwt");
    assert.equal(config.auth.jwtVerify, "hs256");
    assert.equal(config.auth.passwordLogin?.username, USER);
  });
});

describe("the hash format", () => {
  it("reads what the documented one-liner writes", async () => {
    const parsed = parseScryptHash(HASH_FROM_THE_DOCUMENTED_COMMAND);
    assert.notEqual(parsed, null);
    assert.equal(parsed?.n, 16384);
    assert.equal(parsed?.r, 8);
    assert.equal(parsed?.p, 1);
    assert.equal(await verifyPassword(parsed!, PASSWORD), true);
    assert.equal(await verifyPassword(parsed!, "close but no"), false);
  });

  it("round-trips a freshly generated hash, with a different salt each time", async () => {
    const first = await hashPassword(PASSWORD);
    const second = await hashPassword(PASSWORD);
    assert.notEqual(first, second);

    const parsed = parseScryptHash(first);
    assert.notEqual(parsed, null);
    assert.equal(await verifyPassword(parsed!, PASSWORD), true);
  });

  it("refuses anything it cannot verify against", () => {
    for (const bad of [
      "",
      "hunter2",
      "bcrypt$16384$8$1$aaaa$bbbb",
      "scrypt$16384$8$1$aaaa",
      "scrypt$16383$8$1$c21d5m2wtoFIu4-rXO6mXA$aaaaaaaaaaaaaaaaaaaaaaaa", // N not a power of two
      "scrypt$1073741824$8$1$c21d5m2wtoFIu4-rXO6mXA$aaaaaaaaaaaaaaaaaaaaaaaa", // absurd memory
      "scrypt$16384$8$1$not base64!$aaaaaaaaaaaaaaaaaaaaaaaa",
      "scrypt$16384$8$1$c21d5m2wtoFIu4-rXO6mXA$aa", // key too short to be a key
    ]) {
      assert.equal(parseScryptHash(bad), null, `${bad} must not parse`);
    }
  });
});
