Public: anyone can read this; the crew can change it.
A test pins the held card: a private repository's row of why issues wait is drawn for its owner only #6888
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6857-a-test-pins-the-held-card-a-private-repo → main 1 commit fork
1 file
changed +86
−0
test/boards-held.test.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** The held card (#6857): a private repository's row of why issues wait is drawn for its owner only, with the counts `flow/held.ts` holds. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { readHeld } from "../src/flow/held.ts"; | |
| 9 | import { flowPanel } from "../src/web/pages/flow-panel.ts"; | |
| 10 | import { heldCard } from "../src/web/pages/boards-held.ts"; | |
| 11 | ||
| 12 | const NOW = Date.parse("2026-10-27T12:00:00Z"); | |
| 13 | const MARKUP = "<img src=x onerror=alert(1)>"; | |
| 14 | const SECRET = "secret-plans"; | |
| 15 | let app: App; | |
| 16 | let alice = ""; | |
| 17 | ||
| 18 | function issue(id: string, repo: string, n: number): void { | |
| 19 | app.db.run("INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state) VALUES (?1, ?2, ?3, 'issue', 't', '', ?4, 'open')", [id, repo, n, alice]); | |
| 20 | } | |
| 21 | function running(repo: string, issueId: string): void { | |
| 22 | app.db.run("INSERT INTO run (id, repo_id, kind, issue_id, state) VALUES (?1, ?2, 'agent', ?3, 'running')", [`run-${issueId}`, repo, issueId]); | |
| 23 | } | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "boards-held-")), port: 0 }); | |
| 27 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 28 | if (!u.ok) throw new Error(u.error); | |
| 29 | alice = u.value.id; | |
| 30 | const repo = (id: string, name: string, visibility: string): void => { | |
| 31 | app.db.run("INSERT INTO repo (id, owner_id, name, visibility, autonomy, autonomy_by) VALUES (?1, ?4, ?2, ?3, 'autonomous', ?4)", [id, name, visibility, alice]); | |
| 32 | }; | |
| 33 | repo("pub", MARKUP, "public"); | |
| 34 | repo("priv", SECRET, "private"); | |
| 35 | // pub: two startable, one with a run on it. priv: one startable, two with a run. | |
| 36 | issue("p1", "pub", 1); | |
| 37 | issue("p2", "pub", 2); | |
| 38 | issue("p3", "pub", 3); | |
| 39 | running("pub", "p3"); | |
| 40 | issue("q1", "priv", 1); | |
| 41 | issue("q2", "priv", 2); | |
| 42 | issue("q3", "priv", 3); | |
| 43 | running("priv", "q2"); | |
| 44 | running("priv", "q3"); | |
| 45 | }); | |
| 46 | afterAll(() => app.close?.()); | |
| 47 | ||
| 48 | const row = (page: string, repo: string): string => page.split("<tr data-held-repo=").find((r) => r.startsWith(`"${repo}"`)) ?? ""; | |
| 49 | ||
| 50 | describe("held card", () => { | |
| 51 | test("the counts are the ones flow/held.ts holds for the seeded holds", () => { | |
| 52 | const rows = readHeld(app, true, NOW); | |
| 53 | const pub = rows.find((r) => r.repo === `alice/${MARKUP}`)!; | |
| 54 | const priv = rows.find((r) => r.repo === `alice/${SECRET}`)!; | |
| 55 | expect([pub.startable, pub.counts.running]).toEqual([2, 1]); | |
| 56 | expect([priv.startable, priv.counts.running]).toEqual([1, 2]); | |
| 57 | const page = String(heldCard(rows)); | |
| 58 | const privRow = row(page, `alice/${SECRET}`); | |
| 59 | expect(privRow).toContain('data-held="startable"><span class="mono">startable=1<'); | |
| 60 | expect(privRow).toContain('data-held="running"><span class="mono">running=2<'); | |
| 61 | expect(privRow).toContain("1 of 3 ready can start"); | |
| 62 | expect(privRow).not.toContain('data-held="stalled"'); | |
| 63 | }); | |
| 64 | ||
| 65 | test("the owner's panel has both rows", () => { | |
| 66 | const page = String(flowPanel(app, { kind: "owner" }, new URL("http://x/boards"), "/boards")); | |
| 67 | expect(page).toContain(`data-held-repo="alice/${SECRET}"`); | |
| 68 | expect(page).toContain('data-held-repo="alice/<img'); | |
| 69 | }); | |
| 70 | ||
| 71 | test("a reader who cannot read the private repository gets the public row only, and never its name", () => { | |
| 72 | const cut = readHeld(app, false, NOW); | |
| 73 | expect(cut.map((r) => r.public)).toEqual([true]); | |
| 74 | for (const page of [String(heldCard(cut)), String(flowPanel(app, { kind: "agent" }, new URL("http://x/boards"), "/boards"))]) { | |
| 75 | expect(page).toContain('data-held-repo="alice/<img'); | |
| 76 | expect(page).not.toContain(SECRET); | |
| 77 | } | |
| 78 | }); | |
| 79 | ||
| 80 | test("a repository name with markup is escaped", () => { | |
| 81 | const page = String(heldCard(readHeld(app, true, NOW))); | |
| 82 | expect(page).not.toContain(MARKUP); | |
| 83 | expect(page).not.toContain("<img"); | |
| 84 | expect(page).toContain("<img src=x onerror=alert(1)>"); | |
| 85 | }); | |
| 86 | }); |
new above
offline