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

A test pins /boards as an admin's: a guest and a non-admin get not found on every board page and edit, and nothing changes #6883

Merged opened this

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

Merged

scout/forge:scout/6855-a-test-pins-boards-as-an-admin-s-a-guest → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/6855-a-test-pins-boards-as-an-admin-s-a-guest was merged into main as b8f85f7.

Review

Sign in to review this change.
1 file changed +124 −0
test/boards-doors.test.ts added +124
@@ -0,0 +1,124 @@
1 /**
2 * /boards is an admin's: a guest and a signed-in non-admin get the not-found
3 * page on every board page and every edit, and the board reads as before; an
4 * admin's rename and delete answer with a redirect and a flash.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { listAllBoards, showBoard, writeBoard } from "../src/actions/boards.ts";
11 import { createApp } from "../src/app.ts";
12 import { createToken } from "../src/core/tokens.ts";
13 import { createUser, type User } from "../src/core/users.ts";
14 import { serve } from "../src/http/server.ts";
15
16 let dir: string;
17 let app: ReturnType<typeof createApp>;
18 let servers: Awaited<ReturnType<typeof serve>>;
19 let alice: User;
20 const tok: Record<string, string> = {};
21
22 const panel = (title: string) => ({ title, chart: "line", query: { source: "runs", aggregate: "count", groupBy: "kind" }, w: 1, h: 1 });
23 const auth = (who: string | null): Record<string, string> => (who ? { authorization: `Bearer ${tok[who]}` } : {});
24 const get = (path: string, who: string | null) => fetch(servers.url + path, { redirect: "manual", headers: auth(who) });
25 const post = (path: string, fields: Record<string, string>, who: string | null) =>
26 fetch(servers.url + path, { method: "POST", redirect: "manual", body: new URLSearchParams(fields), headers: auth(who) });
27
28 const panelFields = { title: "Added", chart: "line", source: "runs", aggregate: "count", w: "1", h: "1" };
29 const edits: [string, Record<string, string>][] = [
30 ["/boards/new", { title: "Intruder" }],
31 ["/boards/pin", { slug: "mine", ...panelFields }],
32 ["/boards/mine/rename", { title: "Renamed" }],
33 ["/boards/mine/settings", { range: "7d", refresh: "10s" }],
34 ["/boards/mine/add", panelFields],
35 ["/boards/mine/edit", { index: "0", ...panelFields }],
36 ["/boards/mine/remove", { index: "0" }],
37 ["/boards/mine/move", { index: "0", by: "1" }],
38 ["/boards/mine/duplicate", {}],
39 ["/boards/mine/delete", {}],
40 ];
41
42 beforeAll(async () => {
43 dir = await mkdtemp(join(tmpdir(), "forge-boards-doors-"));
44 app = createApp({ dataDir: dir, port: 0 });
45 servers = await serve(app);
46 const mk = async (username: string, adminIfFirst?: boolean) => {
47 const r = await createUser(app.db, {
48 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
49 password: "a-good-passphrase", ...(adminIfFirst ? { adminIfFirst } : {}),
50 });
51 if (!r.ok) throw new Error(r.error);
52 return r.value;
53 };
54 alice = await mk("alice", true);
55 const bob = await mk("bob");
56 tok.alice = createToken(app.db, alice.id, "t", "admin").token;
57 tok.bob = createToken(app.db, bob.id, "t", "admin").token;
58 const w = await writeBoard(app, alice, "mine", { title: "Mine", range: "all", refresh: "off", panels: [panel("One"), panel("Two")] });
59 if (!w.ok) throw new Error(JSON.stringify(w.error));
60 });
61
62 afterAll(async () => {
63 servers.stop();
64 app.close();
65 await rm(dir, { recursive: true, force: true });
66 });
67
68 /** The boards as the store holds them, for the admin: nothing the page's nonces could vary. */
69 const read = async () => ({
70 mine: await showBoard(app, alice, "mine"),
71 all: await listAllBoards(app, alice),
72 });
73
74 describe("/boards doors", () => {
75 test("the admin sees the board, so the refusals below mean something", async () => {
76 const page = await (await get("/boards/mine", "alice")).text();
77 expect(page).toContain("<h1>Mine</h1>");
78 expect(page).toContain("One");
79 expect(await (await get("/boards", "alice")).text()).toContain("/boards/mine");
80 expect((await get("/boards/mine/live", "alice")).status).toBe(200);
81 });
82
83 for (const who of [null, "bob"]) {
84 const name = who ?? "a guest";
85 test(`${name}: every board page is not found`, async () => {
86 for (const path of ["/boards", "/boards/mine", "/boards/mine/live"]) {
87 const r = await get(path, who);
88 // The list sends a guest to sign in first; it is never the list.
89 if (who === null && path === "/boards") {
90 expect(r.status).toBe(303);
91 expect(r.headers.get("location")).toStartWith("/signin");
92 } else expect(r.status, path).toBe(404);
93 const body = await r.text();
94 expect(body).not.toContain("One");
95 expect(body).not.toContain("/boards/mine");
96 }
97 });
98
99 test(`${name}: every edit is refused and the board reads as before`, async () => {

Replace the two not.toBe checks with expect(r.status, path).toBe(404) (or the exact guest sign-in redirect). As written, any refusal passes, including one unrelated to admin.

100 const before = await read();
101 for (const [path, fields] of edits) {
102 const r = await post(path, fields, who);
103 // A guest has no scope, so the form gate answers 403 before any board is read; a non-admin gets not found.
104 expect(r.status, path).toBe(who === null ? 403 : 404);
105 }
106 expect(await read()).toEqual(before);
107 expect((await get("/boards/intruder", "alice")).status).toBe(404);
108 });
109 }
110
111 test("an admin's rename and delete redirect with a flash", async () => {
112 const renamed = await post("/boards/mine/rename", { title: "Renamed" }, "alice");
113 expect(renamed.status).toBe(303);
114 expect(renamed.headers.get("location")).toBe("/boards/mine");
115 expect(renamed.headers.get("set-cookie") ?? "").toContain("flash");
116 expect(await (await get("/boards/mine", "alice")).text()).toContain("<h1>Renamed</h1>");
117
118 const gone = await post("/boards/mine/delete", {}, "alice");
119 expect(gone.status).toBe(303);
120 expect(gone.headers.get("location")).toBe("/boards");
121 expect(gone.headers.get("set-cookie") ?? "").toContain("flash");
122 expect((await get("/boards/mine", "alice")).status).toBe(404);
123 });
124 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs