/**
 * What the deployment says you are, and — mostly — what happens when it will not
 * say.
 *
 * `access.ts` is the only file in the browser bundle whose output is a set of
 * decisions about what a stranger may see, and until this file it had no test at
 * all. Its own header carries the reason that is not acceptable: there was a
 * line, shipped, that read
 *
 *     canEnterOffice = s.authenticated || !s.passwordLogin;
 *
 * which is true for `auth: none` and dangerously false for `sso`, where `POST
 * /session` answers 404 precisely *because* credentials are issued elsewhere —
 * so on an SSO deployment every anonymous visitor was handed the private view
 * while the config still said the box was private. That bug is fixed, and until
 * now nothing would have noticed it coming back.
 *
 * So the assertions here are weighted toward the closed direction. There is one
 * test that a member gets what a member is owed and half a dozen that nobody
 * gets more than nothing, because the two failures are not symmetric: showing a
 * member the public office is a bad afternoon and showing a stranger the private
 * one is the thing the tiers exist to prevent.
 *
 * No DOM and no network. `resolveAccess` takes its `fetch` as an argument for
 * exactly this reason, and every case here is a fake that answers the two probes
 * in a particular way — which is also the cheapest way to reach the branches a
 * real deployment only reaches while it is broken.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { capabilitiesFor, resolveAccess } from "../access.ts";

/**
 * One page's worth of browser.
 *
 * `entryHref` resolves a relative `entryUrl` against `window.location.origin`,
 * which is correct for a module that only ever runs in a tab and is the one
 * thing here that cannot be passed in. Stubbing it is not a workaround for a
 * design problem: the origin is genuinely an input to the answer, and naming it
 * explicitly means the "is this a path on my own origin" cases below are
 * asserting against a known one rather than against whatever the runner had.
 */
Object.defineProperty(globalThis, "window", {
  configurable: true,
  value: { location: { origin: "https://office.example.test" } },
});

/** A JSON answer, as `getJson` insists on seeing one: 2xx and a JSON content type. */
function json(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "content-type": "application/json" },
  });
}

/**
 * A fake deployment: one answer per path, and anything unlisted is a transport
 * failure, which is what a static host with no API actually does to `fetch`.
 */
function deployment(routes: Record<string, () => Response | Promise<Response>>): typeof fetch {
  return (async (input: RequestInfo | URL) => {
    const url = String(input);
    for (const [path, answer] of Object.entries(routes)) {
      if (url.endsWith(path)) return answer();
    }
    throw new TypeError("Failed to fetch");
  }) as typeof fetch;
}

const NO_API = deployment({});

describe("the table", () => {
  it("opens the office door at every tier, and varies what is behind it", () => {
    // The door being open to everyone is a decision with a paragraph behind it:
    // the anonymous view of this site used to be a map with a greyed-out button,
    // which is the most interesting thing the project does shown only as
    // something you cannot have.
    for (const tier of ["anon", "member", "god"] as const) {
      assert.equal(capabilitiesFor(tier).enterOffice, true, tier);
    }
    assert.equal(capabilitiesFor("anon").officeDepth, "public");
    assert.equal(capabilitiesFor("member").officeDepth, "full");
    assert.equal(capabilitiesFor("god").officeDepth, "full");
  });

  it("keeps the instruments god-only", () => {
    assert.equal(capabilitiesFor("member").timeControl, false);
    assert.equal(capabilitiesFor("member").debug, false);
    assert.equal(capabilitiesFor("god").timeControl, true);
  });

  /**
   * The sky is public, and this test exists because it briefly was not.
   *
   * `liveEnvironment` was once `tier !== "anon"`, on the reasoning that live
   * feeds are what an account buys you — and the field's own note records why
   * that did not survive contact with the data: the cloud cover over San
   * Francisco is a government observation and the aircraft are broadcasting
   * unencrypted to anyone with a forty-dollar receiver. Neither is a thing an
   * account can grant access to, because neither is withheld from anybody. What
   * the gate cost was the first impression — fog rolling off the Pacific onto a
   * city you recognise, at the real time of day — traded for a rule with nothing
   * behind it. Pinned here so it does not get re-argued from first principles.
   */
  it("offers the real sky to a visitor who has not signed in", () => {
    assert.equal(capabilitiesFor("anon").liveEnvironment, true);
    // Markers are asked for by everyone and granted by the server. Setting this
    // true against a deployment set to `members` earns a 401, which is the only
    // place that decision can actually be enforced.
    assert.equal(capabilitiesFor("anon").liveMarkers, true);
  });
});

describe("no API behind the page", () => {
  it("is a member with no door, because clone-and-run is the flagship case", async () => {
    const access = await resolveAccess(NO_API);
    assert.equal(access.tier, "member");
    assert.equal(access.signInUrl, null);
    assert.equal(access.can.officeDepth, "full");
  });

  it("is never god, however unreachable the server is", async () => {
    // A client that awards itself godmode when it cannot reach the server has
    // turned a network failure into a privilege escalation.
    const access = await resolveAccess(NO_API);
    assert.notEqual(access.tier, "god");
    assert.equal(access.can.debug, false);
  });

  it("treats a static host answering index.html as no API at all", async () => {
    // The failure this closes: a static host answers every unknown path with the
    // SPA shell and a 200, so without the content-type check `/health` would
    // "succeed" and the JSON parse would throw somewhere less convenient.
    const spa = deployment({
      "/health": () =>
        new Response("<!doctype html><html></html>", {
          status: 200,
          headers: { "content-type": "text/html" },
        }),
    });
    assert.equal((await resolveAccess(spa)).tier, "member");
  });
});

describe("a deployment that says it has auth", () => {
  const sso = () => json({ auth: { mode: "sso", entryUrl: "https://id.example.com/start" } });

  it("is anonymous until the session says otherwise", async () => {
    const access = await resolveAccess(
      deployment({ "/health": sso, "/session": () => json({ authenticated: false }) }),
    );
    assert.equal(access.tier, "anon");
    // The office is the thing a session changes. The sky is not — see above.
    assert.equal(access.can.officeDepth, "public");
  });

  /**
   * The regression test for the bug in the module header.
   *
   * An SSO box has no local password form, so `passwordLogin` is false — and the
   * old rule read that as "this box cannot sign anyone in, so it must be open".
   * If this ever passes with `member`, that line is back.
   */
  it("does not read the absence of a password form as an open door", async () => {
    const access = await resolveAccess(
      deployment({
        "/health": sso,
        "/session": () => json({ authenticated: false, passwordLogin: false }),
      }),
    );
    assert.equal(access.tier, "anon");
  });

  it("fails closed when the session probe fails, rather than back to member", async () => {
    // `/health` has already said this box has auth. Anything short of an
    // affirmative answer after that is `anon` — the asymmetry with the no-API
    // case above is the entire point of the three-way split in `getJson`.
    for (const session of [
      () => new Response("", { status: 500 }),
      () => new Response("", { status: 404 }),
      () => {
        throw new TypeError("Failed to fetch");
      },
    ]) {
      const access = await resolveAccess(deployment({ "/health": sso, "/session": session }));
      assert.equal(access.tier, "anon");
    }
  });

  it("promotes a signed-in caller, and only to god when the server says admin", async () => {
    const member = await resolveAccess(
      deployment({
        "/health": sso,
        "/session": () => json({ authenticated: true, subject: "someone@example.com" }),
      }),
    );
    assert.equal(member.tier, "member");
    assert.equal(member.subject, "someone@example.com");

    const god = await resolveAccess(
      deployment({
        "/health": sso,
        "/session": () => json({ authenticated: true, subject: "root", admin: true }),
      }),
    );
    assert.equal(god.tier, "god");
    assert.equal(god.can.timeControl, true);
  });

  it("never infers godmode from a server too old to mention admin", async () => {
    // `admin` is newer than some servers this client will meet. A missing field
    // has exactly one safe direction to fall.
    const access = await resolveAccess(
      deployment({
        "/health": sso,
        "/session": () => json({ authenticated: true, subject: "someone" }),
      }),
    );
    assert.equal(access.tier, "member");
    assert.equal(access.can.debug, false);
  });
});

describe("a sick API is not an absent one", () => {
  it("is anonymous while /health is 5xx, not a member", async () => {
    // `tera-api` restarts, Caddy answers 502 for eight seconds, and every
    // anonymous visitor in that window would otherwise be told they are a member
    // — badge, full-depth office, and a markers request about to be refused.
    const access = await resolveAccess(
      deployment({ "/health": () => new Response("", { status: 502 }) }),
    );
    assert.equal(access.tier, "anon");
    assert.equal(access.signInUrl, null, "no door, because we do not know which door yet");
  });

  it("is a member when auth is switched off, which is a choice and not a failure", async () => {
    const access = await resolveAccess(
      deployment({ "/health": () => json({ auth: { mode: "none" } }) }),
    );
    assert.equal(access.tier, "member");
    assert.equal(access.signInUrl, null);
  });
});

describe("the sign-in link is a URL this page may navigate to", () => {
  const withEntry = (entryUrl: unknown) =>
    deployment({
      "/health": () => json({ auth: { mode: "sso", entryUrl } }),
      "/session": () => json({ authenticated: false }),
    });

  it("takes an absolute http(s) URL from the identity provider", async () => {
    const access = await resolveAccess(withEntry("https://id.example.com/start"));
    assert.equal(access.signInUrl, "https://id.example.com/start");
  });

  /**
   * The one that is a security test rather than a parsing test.
   *
   * `entryUrl` arrives from whatever this browser is pointed at and lands in an
   * `a.href`. A CSP of `script-src 'self' 'unsafe-inline'` does not stop a
   * `javascript:` URL navigating, so an operator who pastes an untrusted
   * `TERA_AUTH_ENTRY_URL` — or an API that has been taken over — would get
   * script execution in the origin holding the session token.
   */
  it("refuses a scheme that is not a sign-in page", async () => {
    for (const hostile of [
      "javascript:alert(document.cookie)",
      "data:text/html,<script>alert(1)</script>",
      "blob:https://example.com/whatever",
      "",
      42,
      null,
    ]) {
      const access = await resolveAccess(withEntry(hostile));
      assert.notEqual(
        access.signInUrl?.startsWith("javascript:") ?? false,
        true,
        `accepted ${String(hostile)}`,
      );
      if (typeof hostile === "string" && hostile.startsWith("javascript:")) {
        assert.equal(access.signInUrl, null);
      }
    }
  });

  it("resolves a path against this origin rather than rejecting it", async () => {
    // `/login.html` ships in this bundle and is the local form's home.
    const access = await resolveAccess(withEntry("/login.html"));
    assert.ok(access.signInUrl?.endsWith("/login.html"), access.signInUrl ?? "null");
  });

  it("offers the local form only where the server said it can process one", async () => {
    const withForm = await resolveAccess(
      deployment({
        "/health": () => json({ auth: { mode: "jwt" } }),
        "/session": () => json({ authenticated: false, passwordLogin: true }),
      }),
    );
    assert.equal(withForm.signInUrl, "/login.html");

    // A form that renders, takes a password and posts it to a 404 is a worse
    // failure than no form at all.
    const without = await resolveAccess(
      deployment({
        "/health": () => json({ auth: { mode: "jwt" } }),
        "/session": () => json({ authenticated: false, passwordLogin: false }),
      }),
    );
    assert.equal(without.signInUrl, null);
  });
});
