Public: anyone can read this; the crew can change it.

A test pins the admin gate: a person without `administer` gets a 403 before any admin handler runs #6864

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6852-a-test-pins-the-admin-gate-a-person-with → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/6852-a-test-pins-the-admin-gate-a-person-with was merged into main as a29e438.

Review

Sign in to review this change.
1 file changed +161 −0
test/admin-gate.test.ts added +161
@@ -0,0 +1,161 @@
1 /**
2 * The admin gate (`src/http/admin_gate.ts`): which paths it guards, and that a
3 * person without `administer` is refused with one plain sentence before any
4 * admin handler runs, while a guest of `/releases.json` and an admin pass.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { NOT_AN_ADMIN, setMember } from "../src/core/roles.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createToken } from "../src/core/tokens.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { adminGate, isAdminPath } from "../src/http/admin_gate.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 describe("isAdminPath", () => {
23 test("is true for the guarded paths and their children", () => {
24 for (const p of [
25 "/admin",
26 "/admin/limits",
27 "/api/v1/admin",
28 "/api/v1/admin/capacity",
29 "/releases",
30 `/releases/${"a".repeat(40)}/rollback`,
31 ])
32 expect(isAdminPath(p), p).toBe(true);
33 });
34
35 test("is false for look-alikes", () => {
36 for (const p of ["/administer", "/api/v1/adminx", "/releases.json", "/", "/api/v1/releases", "/adminx/y"])
37 expect(isAdminPath(p), p).toBe(false);
38 });
39 });
40
41 describe("the admin gate", () => {
42 let dataDir = "";
43 let app: App;
44 let servers: Servers;
45 let alice: User; // admin
46 let carol: User; // member: no `administer`
47 let dave: User; // guest: no `administer`
48
49 const mk = async (username: string, admin = false) => {
50 const r = await createUser(app.db, {
51 origin: "seed",
52 trusted: true,
53 username,
54 email: `${username}@forge.local`,
55 password: "a-good-passphrase",
56 ...(admin ? { adminIfFirst: true } : {}),
57 });
58 if (!r.ok) throw new Error(r.error);
59 return r.value;
60 };
61 const bearer = (u: User) => `Bearer ${createToken(app.db, u.id, "admin", "admin").token}`;
62 const api = (method: string, path: string, u: User) =>
63 fetch(`${servers.url}${path}`, {
64 method,
65 headers: { "content-type": "application/json", authorization: bearer(u) },
66 ...(method === "GET" ? {} : { body: "{}" }),
67 });
68 const browse = (method: string, path: string, cookie: string) =>
69 fetch(`${servers.url}${path}`, {
70 method,
71 headers: { cookie, origin: servers.url, accept: "text/html" },
72 ...(method === "POST" ? { body: new URLSearchParams({}) } : {}),
73 redirect: "manual",
74 });
75 const cookieOf = (u: User) => `forge_session=${createSession(app.db, u.id).token}`;
76
77 /** Every table's row count, so a refused write is seen as no change. */
78 const counts = (): Record<string, number> => {
79 const names = app.db
80 .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")
81 .all() as { name: string }[];
82 const out: Record<string, number> = {};
83 for (const { name } of names)
84 out[name] = (app.db.query(`SELECT count(*) AS n FROM "${name}"`).get() as { n: number }).n;
85 return out;
86 };
87
88 beforeAll(async () => {

The adminGate unit test asserts undefined for a signed-out caller; add an HTTP assertion that /api/v1/admin/settings without credentials is refused by the handler, so a regression to a fall-through is caught.

89 dataDir = await mkdtemp(join(tmpdir(), "forge-admin-gate-"));
90 app = createApp({ dataDir, port: 0 });
91 servers = await serve(app);
92 alice = await mk("alice", true);
93 carol = await mk("carol");
94 dave = await mk("dave");
95 setMember(app.db, carol.id, true, alice.id, new Date(0));
96 }, 60_000);
97
98 afterAll(async () => {
99 servers.stop();
100 app.close();
101 await rm(dataDir, { recursive: true, force: true });
102 });
103
104 test("adminGate refuses a person without administer and lets everyone else carry on", async () => {
105 const auth = (user: User | undefined) => ({ user, scope: undefined, via: "none" as const });
106 const ask = (path: string, user: User | undefined) =>
107 adminGate(app, new Request(`http://x${path}`), new URL(`http://x${path}`), auth(user));
108 expect(ask("/api/v1/admin/capacity", carol)?.status).toBe(403);
109 expect(ask("/releases", dave)?.status).toBe(403);
110 expect(ask("/api/v1/admin/capacity", alice)).toBeUndefined();
111 expect(ask("/api/v1/admin/capacity", undefined)).toBeUndefined();
112 expect(ask("/administer", carol)).toBeUndefined();
113 expect(ask("/releases.json", dave)).toBeUndefined();
114 });
115
116 test("the API answers 403 JSON with one sentence", async () => {
117 for (const u of [carol, dave]) {
118 const res = await api("GET", "/api/v1/admin/settings", u);
119 expect(res.status).toBe(403);
120 expect(res.headers.get("content-type")).toContain("application/json");
121 expect(await res.json()).toEqual({ error: "not_admin", message: NOT_AN_ADMIN });
122 }
123 });
124
125 test("a browser gets a 403 page with the same sentence", async () => {
126 for (const u of [carol, dave]) {
127 for (const path of ["/admin/limits", "/releases"]) {
128 const res = await browse("GET", path, cookieOf(u));
129 expect(res.status, path).toBe(403);
130 expect(res.headers.get("content-type")).toContain("text/html");
131 const body = await res.text();
132 expect(body).toContain("<h1>403</h1>");
133 expect(body).toContain(NOT_AN_ADMIN.replace("'", "&#39;"));
134 }
135 }
136 });
137
138 test("a refused POST to a rollback or an admin form writes nothing", async () => {
139 const cookie = cookieOf(carol);
140 const before = counts();
141 for (const path of [`/releases/${"a".repeat(40)}/rollback`, "/admin/limits", "/admin/signup"]) {
142 const res = await browse("POST", path, cookie);
143 expect(res.status, path).toBe(403);
144 }
145 const res = await api("POST", "/api/v1/admin/invites", carol);
146 expect(res.status).toBe(403);
147 expect(counts()).toEqual({ ...before, token: before["token"]! + 1 });
148 });
149
150 test("/releases.json answers a guest and a signed-out caller", async () => {
151 expect((await fetch(`${servers.url}/releases.json`)).status).toBe(200);
152 const res = await fetch(`${servers.url}/releases.json`, { headers: { authorization: bearer(dave) } });
153 expect(res.status).toBe(200);
154 });
155
156 test("an admin reaches the handlers unchanged", async () => {
157 expect((await api("GET", "/api/v1/admin/settings", alice)).status).toBe(200);
158 expect((await browse("GET", "/admin/limits", cookieOf(alice))).status).toBe(200);
159 expect((await browse("GET", "/releases", cookieOf(alice))).status).toBe(200);
160 });
161 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs