/** * 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 Response | Promise>): 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("", { 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,", "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); }); });