902481a03d
Written against a `Capabilities` that had `liveData` on it, and landing on a
master where it does not: 1db868a split that flag into `liveEnvironment` and
`liveMarkers` and made both true for everybody, so my assertion that an
anonymous visitor gets `liveData: false` was pinning a rule the codebase had
deliberately just removed. A test that fails an intentional change is noise; a
test that would have *blocked* one is worse.
So the assertion is inverted and given the reasoning that replaced it. The sky is
public because it is a government observation and an unencrypted broadcast —
neither is a thing an account can grant access to, because neither is withheld
from anyone — and the gate cost the only moment that makes this project land, on
a first visit, from a stranger. That argument is now pinned by a test named after
it, so the next person to reason from first principles that live feeds are what
an account buys you finds out in CI rather than in the diff.
Markers keep their own assertion: asked for by everyone, granted by the server,
enforced at the 401. The client flag reports what the server already decided and
is not the place that decision lives.
32 client tests, 137 server, both packages typecheck.
314 lines
13 KiB
TypeScript
314 lines
13 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|