Public: anyone can read this; the crew can change it.
The Resilience page shows each THREATS.md protection green, red or untested from the last gate #1975
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1963-the-resilience-page-shows-each-threats-m → main 5 commits fork 6 threads · 6 unresolved
README.md +2
| @@ -554,6 +554,8 @@ mechanical rather than advisory. | ||
| 554 | 554 | `docs/FLOW.md` is where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h. |
| 555 | 555 | Where the whole forge waits is a forge admin's: `/flow` shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck, and beside each what it let out in the last hour with a 24-hour sparkline, under a line of landings and the median ask→live — every count links to the items it counts. A stalled step is a card on the home (Studio mode) for anyone who can write to the repository: an open pull whose check was never asked for, or is red, past its stage's allowance, with its stage, how long it has been stuck, and one button — Run check or Re-run — that asks for a check at the head and returns to the home. The terminal's **Stalled steps** command (palette) lists the same steps, live, and Enter or `r` does the same re-run; both read `flow/stalled.ts` and act through `flow/unblock.ts`, and neither shows anything to someone who cannot write. The terminal's Flow command and the MCP `flow` tool read the same: the sentence, the landings line, and each stage's count out in the last hour and in 24 h. |
| 556 | 556 | |
| 557 | `/resilience` is a forge admin's too (a guest is sent to sign in, anyone else gets 404): each protection in `docs/THREATS.md` as one line, green, red, untested or "no test yet", judged by the test file it cites in the last full gate of the forge's own repository (`FORGE_REPO`; other repositories' gates never touch it). The terminal's **Resilience** command (palette, admins) lists the same lines. | |
| 558 | ||
| 557 | 559 | The terminal also has an Access command (`:` → Access), a site admin's: it reads the same last-day non-browser requests as Settings → Access and the `access` MCP tool, newest first, and filters them with `s` (since), `u` (user agent) and `p` (path), `c` to clear. |
| 558 | 560 | |
| 559 | 561 | `docs/PLANNING.md` is how issues depend on issues: the edge, the map, and |
scripts/gate.ts +1
| @@ -52,6 +52,7 @@ export const GATE: readonly string[] = [ | ||
| 52 | 52 | "test/webauthn.test.ts", |
| 53 | 53 | "test/chaos.test.ts", |
| 54 | 54 | "test/flow-stage.test.ts", |
| 55 | "test/resilience.test.ts", | |
| 55 | 56 | "test/flow-card.test.ts", |
| 56 | 57 | "test/cluster-jobs.test.ts", |
| 57 | 58 | "test/checks-placement.test.ts", |
src/checks/gate_last.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** | |
| 2 | * What the last full gate said of each test file: `bun test` prints the file | |
| 3 | * as `test/x.test.ts:` before its results, `(pass) name` and `(fail) name` | |
| 4 | * under it. Kept as one small file under the data directory, replaced by | |
| 5 | * every gate, for the Resilience page to read. | |
| 6 | */ | |
| 7 | ||
| 8 | import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 9 | import { join } from "node:path"; | |
| 10 | ||
| 11 | export type FileResult = "pass" | "fail"; | |
| 12 | ||
| 13 | const HEADER = /^([A-Za-z0-9_./-]+\.test\.[cm]?[jt]sx?):$/; | |
| 14 | const RESULT = /^\((pass|fail)\) /; | |
| 15 | ||
| 16 | /** A file is red when any of its tests failed, green when it ran and none did. */ | |
| 17 | export function fileResults(lines: readonly string[]): Record<string, FileResult> { | |
| 18 | const out: Record<string, FileResult> = {}; | |
| 19 | let file: string | undefined; | |
| 20 | for (const line of lines) { | |
| 21 | const header = HEADER.exec(line); | |
| 22 | if (header) { | |
| 23 | file = header[1]; | |
| 24 | if (file !== undefined && out[file] === undefined) out[file] = "pass"; | |
| 25 | continue; | |
| 26 | } | |
| 27 | const result = RESULT.exec(line); | |
| 28 | if (result && file !== undefined && result[1] === "fail") out[file] = "fail"; | |
| 29 | } | |
| 30 | return out; | |
| 31 | } | |
| 32 | ||
| 33 | const fileOf = (dataDir: string): string => join(dataDir, "checks", "gate-last.json"); | |
| 34 | ||
| 35 | export function keepGate(dataDir: string, lines: readonly string[]): void { | |
| 36 | const results = fileResults(lines); | |
| 37 | if (Object.keys(results).length === 0) return; | |
| 38 | mkdirSync(join(dataDir, "checks"), { recursive: true }); | |
| 39 | writeFileSync(fileOf(dataDir), JSON.stringify(results)); | |
| 40 | } | |
| 41 | ||
| 42 | /** Only the forge's own repository's gate is the record /resilience reads. */ | |
| 43 | export function keepForgeGate( | |
| 44 | dataDir: string, | |
| 45 | forgeRepo: string | undefined, | |
| 46 | repo: string, | |
| 47 | lines: readonly string[], | |
| 48 | ): void { | |
| 49 | if (forgeRepo === repo) keepGate(dataDir, lines); | |
| 50 | } | |
| 51 | ||
| 52 | export function readGate(dataDir: string): Record<string, FileResult> { | |
| 53 | const out: Record<string, FileResult> = {}; | |
| 54 | try { | |
| 55 | const parsed: unknown = JSON.parse(readFileSync(fileOf(dataDir), "utf8")); | |
| 56 | if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return out; | |
| 57 | for (const [file, result] of Object.entries(parsed)) | |
| 58 | if (result === "pass" || result === "fail") out[file] = result; | |
| 59 | } catch { | |
| 60 | // no gate has run yet | |
| 61 | } | |
| 62 | return out; | |
| 63 | } |
src/checks/landing.ts +2
| @@ -27,6 +27,7 @@ import type { Repo } from "../core/repos.ts"; | ||
| 27 | 27 | import { runGit } from "../git/env.ts"; |
| 28 | 28 | import { writeMergeTree } from "../git/merge.ts"; |
| 29 | 29 | import { failuresOf } from "./failures.ts"; |
| 30 | import { keepForgeGate } from "./gate_last.ts"; | |
| 30 | 31 | import { addLearned } from "./learned.ts"; |
| 31 | 32 | import { checksFor } from "./service.ts"; |
| 32 | 33 | import { landVerdict, type LandVerdict } from "./tiers.ts"; |
| @@ -67,6 +68,7 @@ export async function gateOnLand( | ||
| 67 | 68 | ); |
| 68 | 69 | if (!commit.ok) return { lands: true }; |
| 69 | 70 | const done = await checks.gate(repo, pull, commit.value.trim()); |
| 71 | keepForgeGate(app.config.dataDir, app.config.forgeRepo, `${repo.ownerName}/${repo.name}`, done.lines); | |
outdated
High: this runs for every repository's land gate and overwrites one forge-wide file. Another repo's results replace the forge's, or poison them when file names collide (and private repos' results show up on a public page). Keep it only for the forge's own repo, or key it by repo id. Medium (correctness, no red noise): this keeps every gate, including a pull's merge that the gate then refuses. A pull that breaks |
||
| 70 | 72 | const failed = failuresOf(done.lines); |
| 71 | 73 | const verdict = landVerdict(done.ok, failed.tests, failed.files); |
| 72 | 74 | if (!verdict.lands) addLearned(app.config.dataDir, pull.id, verdict.learn); |
src/checks/resilience.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * The forge's protections as the Resilience page and the terminal say them: | |
| 3 | * each line of docs/THREATS.md with its standing in the last full gate. | |
| 4 | */ | |
| 5 | ||
| 6 | import { readFileSync } from "node:fs"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import { parseThreats, standing, type Protection, type Standing } from "../core/threats.ts"; | |
| 9 | import { readGate } from "./gate_last.ts"; | |
| 10 | ||
| 11 | const DOC = join(import.meta.dir, "..", "..", "docs", "THREATS.md"); | |
| 12 | ||
| 13 | export type Row = { p: Protection; s: Standing }; | |
| 14 | ||
| 15 | function doc(): string { | |
| 16 | try { | |
| 17 | return readFileSync(DOC, "utf8"); | |
| 18 | } catch { | |
| 19 | return ""; | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | export function readResilience(dataDir: string): Row[] { | |
| 24 | const gate = readGate(dataDir); | |
| 25 | return parseThreats(doc()).map((p) => ({ p, s: standing(p, gate) })); | |
| 26 | } |
src/core/reserved.ts +1
| @@ -85,6 +85,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 85 | 85 | "oauth", |
| 86 | 86 | "og", |
| 87 | 87 | "organizations", |
| 88 | "resilience", | |
| 88 | 89 | "origins", |
| 89 | 90 | "robots.txt", |
| 90 | 91 | "llms.txt", |
src/core/threats.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | /** | |
| 2 | * The protections in docs/THREATS.md, as plain data: each `Test:` line (after a | |
| 3 | * bullet or a paragraph) and each table row naming a call, with the test file | |
| 4 | * cited, or none. | |
| 5 | */ | |
| 6 | ||
| 7 | export interface Protection { | |
| 8 | readonly adversary: string; | |
| 9 | readonly text: string; | |
| 10 | /** The cited `test/*.test.ts`, or undefined for "none yet". */ | |
| 11 | readonly test: string | undefined; | |
| 12 | } | |
| 13 | ||
| 14 | const CITED = /^`(test\/[A-Za-z0-9_.-]+\.test\.ts)`$/; | |
| 15 | ||
| 16 | export function parseThreats(md: string): Protection[] { | |
| 17 | const out: Protection[] = []; | |
| 18 | let adversary = ""; | |
| 19 | let bullet: string[] | undefined; | |
| 20 | let para: string[] = []; | |
| 21 | const close = (cited: string): void => { | |
| 22 | if (bullet === undefined) return; | |
| 23 | out.push({ | |
| 24 | adversary, | |
| 25 | text: bullet.join(" ").replace(/\s+/g, " ").trim(), | |
| 26 | test: CITED.exec(cited)?.[1], | |
| 27 | }); | |
| 28 | bullet = undefined; | |
| 29 | }; | |
| 30 | for (const line of md.split("\n")) { | |
| 31 | if (line.startsWith("## ")) { | |
| 32 | bullet = undefined; | |
| 33 | para = []; | |
| 34 | adversary = line.slice(3).trim(); | |
outdated
High: only an indented |
||
| 35 | } else if (line.startsWith("- ")) { | |
| 36 | bullet = [line.slice(2)]; | |
| 37 | } else if (/^\s*Test: /.test(line)) { | |
| 38 | if (bullet === undefined && para.length > 0) bullet = para; | |
| 39 | para = []; | |
| 40 | close(line.replace(/^\s*Test: /, "").trim()); | |
| 41 | } else if (/^\s+\S/.test(line) && bullet !== undefined) { | |
| 42 | bullet.push(line.trim()); | |
| 43 | } else if (line.startsWith("| ") && line.includes("→")) { | |
| 44 | const cells = line.split("|").map((c) => c.trim()); | |
| 45 | bullet = [cells[1] ?? ""]; | |
| 46 | close(cells.at(-2) ?? ""); | |
| 47 | } else if (line.trim() === "") { | |
| 48 | bullet = undefined; | |
| 49 | para = []; | |
| 50 | } else if (bullet === undefined && !line.startsWith("|")) { | |
| 51 | para.push(line.trim()); | |
| 52 | } | |
| 53 | } | |
| 54 | return out; | |
| 55 | } | |
| 56 | ||
| 57 | export type Standing = "green" | "red" | "untested" | "no test yet"; | |
| 58 | ||
| 59 | /** One protection against the last gate: a cited file that did not run is untested. */ | |
| 60 | export function standing( | |
| 61 | p: Protection, | |
| 62 | gate: Readonly<Record<string, "pass" | "fail">>, | |
| 63 | ): Standing { | |
| 64 | if (p.test === undefined) return "no test yet"; | |
| 65 | const result = gate[p.test]; | |
| 66 | return result === "pass" ? "green" : result === "fail" ? "red" : "untested"; | |
| 67 | } |
src/http/server.ts +3
| @@ -10,6 +10,7 @@ import { unsealReleased } from "../actions/sealed.ts"; | ||
| 10 | 10 | import { accessPage } from "../web/pages/access.ts"; |
| 11 | 11 | import { capacityPage, capacitySet } from "../web/pages/capacity.ts"; |
| 12 | 12 | import { flowPage } from "../web/pages/flow.ts"; |
| 13 | import { resiliencePage } from "../web/pages/resilience.ts"; | |
| 13 | 14 | import { limitsPage, limitsSet } from "../web/pages/admin-limits.ts"; |
| 14 | 15 | import { boardsPage } from "../web/pages/boards.ts"; |
| 15 | 16 | import { boardNew, boardPin, boardPost } from "../web/pages/boards-edit.ts"; |
| @@ -640,6 +641,8 @@ export function buildRouter(): Router<Handler> { | ||
| 640 | 641 | router.add("GET", "/map", globalMapPage); |
| 641 | 642 | // The pipeline's stages and its bottleneck, for a forge admin (#1554); literal, above /:owner. |
| 642 | 643 | router.add("GET", "/flow", flowPage); |
| 644 | // Each THREATS.md protection against the last gate; literal, above /:owner. | |
| 645 | router.add("GET", "/resilience", resiliencePage); | |
| 643 | 646 | // A selection's spend and runs over time; literal, above /:owner. |
| 644 | 647 | router.add("GET", "/fleet/graph", fleetGraphPage); |
| 645 | 648 | router.add("GET", "/fleet/graph/live", fleetGraphLive); |
src/tui/forge.ts +9
| @@ -37,6 +37,7 @@ import { pullScreen } from "./screens/pull.ts"; | ||
| 37 | 37 | import { blobScreen } from "./screens/repo.ts"; |
| 38 | 38 | import { dashboardScreen } from "./screens/dashboard.ts"; |
| 39 | 39 | import { accessScreen } from "./screens/access.ts"; |
| 40 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; | |
| 40 | 41 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| 41 | 42 | import { stalledScreen } from "./screens/stalled.ts"; |
| 42 | 43 | import { issuesScreen, newIssueScreen } from "./screens/issues.ts"; |
| @@ -215,6 +216,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 215 | 216 | hint: "the pipeline's bottleneck and throughput", |
| 216 | 217 | run: () => ctx.push(flowScreen(ctx, flowLines(session.db))), |
| 217 | 218 | }); |
| 219 | // Each THREATS.md protection's standing, as /resilience says it: a site admin's. | |
| 220 | if (session.user?.isAdmin === true) | |
| 221 | commands.push({ | |
| 222 | id: "resilience", | |
| 223 | title: "Resilience", | |
| 224 | hint: "each protection green, red or untested", | |
| 225 | run: () => ctx.push(resilienceScreen(ctx, resilienceLines(session.app.config.dataDir))), | |
| 226 | }); | |
| 218 | 227 | // Stalled flow steps the viewer can unblock, as the home's cards show them (#1807). |
| 219 | 228 | if (session.signedIn) |
| 220 | 229 | commands.push({ |
src/tui/screens/resilience.ts added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | /** | |
| 2 | * The Resilience command's dialog, a forge admin's: one line per THREATS.md | |
| 3 | * protection with its standing in the last gate, as /resilience says it. | |
| 4 | * Read-only; Enter, Esc or q closes it. | |
| 5 | */ | |
| 6 | ||
| 7 | import { readResilience } from "../../checks/resilience.ts"; | |
| 8 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 9 | import { binding } from "../app/keys.ts"; | |
| 10 | import { pad, wrap } from "../term/width.ts"; | |
| 11 | import { dialog } from "../ui/widgets.ts"; | |
| 12 | ||
| 13 | const WIDTH = 72; | |
| 14 | ||
| 15 | export function resilienceLines(dataDir: string): string[] { | |
| 16 | const rows = readResilience(dataDir); | |
| 17 | const inner = WIDTH - 6; | |
| 18 | return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, inner)); | |
| 19 | } | |
| 20 | ||
| 21 | export function resilienceScreen(ctx: Ctx, lines: readonly string[]): Screen { | |
| 22 | return { | |
| 23 | title: "Resilience", | |
| 24 | keys: [binding(["enter", "esc", "q"], "close")], | |
| 25 | render(area, s) { | |
| 26 | const buttons = dialog(s, area, ctx.theme.c, { | |
| 27 | title: "Resilience", | |
| 28 | body: lines, | |
| 29 | actions: [{ label: "Close" }], | |
| 30 | selected: 0, | |
| 31 | width: WIDTH, | |
| 32 | }); | |
| 33 | buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop())); | |
| 34 | }, | |
| 35 | key(e) { | |
| 36 | if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); | |
| 37 | return true; | |
| 38 | }, | |
| 39 | }; | |
| 40 | } |
src/web/pages/flow.ts +2 −1
| @@ -107,7 +107,8 @@ export function flowPage(ctx: Ctx): Response { | ||
| 107 | 107 | From a ready issue to a live deploy: what is in each stage now, how |
| 108 | 108 | long its oldest has waited, and what limits it. The limits are set on |
| 109 | 109 | <a href="/settings/capacity">Capacity</a> and in each repository's |
| 110 | autonomy settings. | |
| 110 | autonomy settings. How the forge stands against its threats is on | |
| 111 | <a href="/resilience">Resilience</a>. | |
| 111 | 112 | </p> |
| 112 | 113 | </div> |
| 113 | 114 | <p id="bottleneck" data-stage="${worst?.key ?? ""}"> |
src/web/pages/resilience.ts added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | /** | |
| 2 | * /resilience: each protection in docs/THREATS.md as one line — green, red | |
| 3 | * or "no test yet" — naming the test file it cites, judged by the last full | |
| 4 | * gate's per-file results (`checks/gate_last.ts`). A cited file the last | |
| 5 | * gate did not run reads "untested", never green. | |
| 6 | */ | |
| 7 | ||
| 8 | import { readResilience } from "../../checks/resilience.ts"; | |
| 9 | import type { Standing } from "../../core/threats.ts"; | |
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 12 | import { html } from "../html.ts"; | |
| 13 | import { page } from "../layout.ts"; | |
| 14 | import { badge, card } from "../ui.ts"; | |
| 15 | ||
| 16 | const TONE = { green: "success", red: "danger", untested: "default", "no test yet": "default" } as const; | |
| 17 | ||
| 18 | // GET /resilience: a forge admin's, since live red lines name what to attack. | |
| 19 | export function resiliencePage(ctx: Ctx): Response { | |
| 20 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 21 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 22 | const rows = readResilience(ctx.app.config.dataDir); | |
| 23 | const count = (s: Standing): number => rows.filter((r) => r.s === s).length; | |
| 24 | const body = html`<div class="page-head"> | |
| 25 | <h1>Resilience</h1> | |
| 26 | <p class="muted"> | |
| 27 | Each protection in THREATS.md, judged by | |
| 28 | the test file it cites in the last full gate. | |
| 29 | </p> | |
| 30 | </div> | |
outdated
High: there's no auth check. Live red/green on security protections tells an attacker which defenses are down right now. Gate it like flowPage: |
||
| 31 | <p id="tally"> | |
| 32 | ${String(count("green"))} green · ${String(count("red"))} red · | |
outdated
Nit: |
||
| 33 | ${String(count("untested"))} untested · ${String(count("no test yet"))} with no test yet | |
| 34 | </p> | |
| 35 | ${card( | |
| 36 | html`<ul class="list"> | |
| 37 | ${rows.map( | |
| 38 | ({ p, s }) => | |
| 39 | html`<li class="row" data-standing="${s}"> | |
| 40 | <span class="row-main"> | |
| 41 | <span class="row-title">${p.text}</span> | |
| 42 | <span class="row-meta">${p.adversary}${p.test === undefined ? "" : " · "}${p.test === undefined ? "" : html`<code>${p.test}</code>`}</span> | |
| 43 | </span> | |
| 44 | <span class="row-trail">${badge(s, { tone: TONE[s] })}</span> | |
| 45 | </li>`, | |
| 46 | )} | |
| 47 | </ul>`, | |
| 48 | { pad: false }, | |
| 49 | )}`; | |
| 50 | return page({ ctx, title: "Resilience", body }); | |
| 51 | } |
test/resilience.test.ts added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | /** | |
| 2 | * The Resilience page: each THREATS.md protection green, red, untested or | |
| 3 | * "no test yet", from the last gate's per-file results. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdirSync, writeFileSync } from "node:fs"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { fileResults, keepForgeGate, keepGate, readGate } from "../src/checks/gate_last.ts"; | |
| 12 | import { parseThreats, standing } from "../src/core/threats.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import type { Ctx, Screen } from "../src/tui/app/app.ts"; | |
| 15 | import { forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 16 | import { resilienceLines } from "../src/tui/screens/resilience.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | ||
| 19 | const DOC = `# Threats | |
| 20 | ||
| 21 | ## A stolen token | |
| 22 | ||
| 23 | - Tokens carry a scope. | |
| 24 | Test: \`test/tokens.test.ts\` | |
| 25 | - Revoking every token at once. | |
| 26 | Test: none yet | |
| 27 | ||
| 28 | | call | note | test | | |
| 29 | | --- | --- | --- | | |
| 30 | | runner → forge | bearer | \`test/passport.test.ts\` | | |
| 31 | `; | |
| 32 | ||
| 33 | describe("resilience", () => { | |
| 34 | let dataDir: string; | |
| 35 | let app: App; | |
| 36 | let servers: Servers; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | dataDir = await mkdtemp(join(tmpdir(), "forge-resilience-")); | |
| 40 | app = createApp({ dataDir, port: 0 }); | |
| 41 | servers = await serve(app); | |
| 42 | }); | |
| 43 | afterAll(async () => { | |
| 44 | servers.stop(); | |
| 45 | app.close(); | |
| 46 | await rm(dataDir, { recursive: true, force: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | test("bullets and table rows parse, a missing citation is undefined", () => { | |
| 50 | const got = parseThreats(DOC).map((p) => [p.text, p.test]); | |
These cases only use a synthetic DOC. Add one against the real |
||
| 51 | expect(got).toEqual([ | |
| 52 | ["Tokens carry a scope.", "test/tokens.test.ts"], | |
| 53 | ["Revoking every token at once.", undefined], | |
| 54 | ["runner → forge", "test/passport.test.ts"], | |
| 55 | ]); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the real docs/THREATS.md yields one protection per Test: line and per → row", async () => { | |
| 59 | const md = await Bun.file(join(import.meta.dir, "..", "docs", "THREATS.md")).text(); | |
| 60 | const tests = md.split("\n").filter((l) => /^\s*Test: /.test(l)).length; | |
| 61 | const rows = md.split("\n").filter((l) => l.startsWith("| ") && l.includes("→")).length; | |
| 62 | const got = parseThreats(md); | |
| 63 | expect(got.length).toBe(tests + rows); | |
| 64 | expect(got.some((p) => p.test === "test/release-swap.test.ts" && p.text.includes("SO_REUSEPORT"))).toBe(true); | |
| 65 | }); | |
| 66 | ||
| 67 | test("an unindented Test: line closes the paragraph above it", () => { | |
| 68 | const got = parseThreats("## X\n\nA prose protection.\nTest: `test/a.test.ts`\n"); | |
| 69 | expect(got.map((p) => [p.text, p.test])).toEqual([["A prose protection.", "test/a.test.ts"]]); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a failed cited file is red, a passed one green, an unrun one untested, none is 'no test yet'", () => { | |
| 73 | const gate = fileResults([ | |
| 74 | "test/tokens.test.ts:", | |
| 75 | "(pass) a", | |
| 76 | "(fail) b", | |
| 77 | "test/passport.test.ts:", | |
| 78 | "(pass) c", | |
| 79 | ]); | |
| 80 | expect(gate).toEqual({ "test/tokens.test.ts": "fail", "test/passport.test.ts": "pass" }); | |
| 81 | const [a, b, c] = parseThreats(DOC); | |
| 82 | expect(standing(a!, gate)).toBe("red"); | |
| 83 | expect(standing(b!, gate)).toBe("no test yet"); | |
| 84 | expect(standing(c!, gate)).toBe("green"); | |
| 85 | expect(standing(a!, {})).toBe("untested"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("the last gate is kept and read back; garbage reads as nothing", () => { | |
| 89 | expect(readGate(dataDir)).toEqual({}); | |
| 90 | keepGate(dataDir, ["test/a.test.ts:", "(fail) x"]); | |
| 91 | expect(readGate(dataDir)).toEqual({ "test/a.test.ts": "fail" }); | |
| 92 | writeFileSync(join(dataDir, "checks", "gate-last.json"), "[1]"); | |
| 93 | expect(readGate(dataDir)).toEqual({}); | |
| 94 | }); | |
| 95 | ||
| 96 | test("another repository's gate leaves the forge's record unchanged", () => { | |
| 97 | keepGate(dataDir, ["test/tokens.test.ts:", "(fail) x"]); | |
| 98 | keepForgeGate(dataDir, "o/forge", "o/shop", ["test/tokens.test.ts:", "(pass) y"]); | |
| 99 | keepForgeGate(dataDir, undefined, "o/shop", ["test/other.test.ts:", "(pass) y"]); | |
| 100 | expect(readGate(dataDir)).toEqual({ "test/tokens.test.ts": "fail" }); | |
| 101 | keepForgeGate(dataDir, "o/forge", "o/forge", ["test/tokens.test.ts:", "(pass) y"]); | |
| 102 | expect(readGate(dataDir)).toEqual({ "test/tokens.test.ts": "pass" }); | |
| 103 | }); | |
| 104 | ||
| 105 | const login = async (username: string): Promise<string> => { | |
| 106 | const res = await fetch(`${servers.url}/signin`, { | |
| 107 | method: "POST", | |
| 108 | redirect: "manual", | |
| 109 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 110 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 111 | }); | |
| 112 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 113 | }; | |
| 114 | ||
| 115 | test("a guest is sent to sign in, a non-admin gets 404, an admin sees the rows", async () => { | |
| 116 | for (const [name, admin] of [["alice", true], ["bob", false]] as const) { | |
| 117 | const made = await createUser(app.db, { | |
| 118 | trusted: true, | |
| 119 | username: name, | |
| 120 | email: `${name}@forge.local`, | |
| 121 | password: "a-good-passphrase", | |
| 122 | ...(admin ? { adminIfFirst: true } : {}), | |
| 123 | }); | |
| 124 | if (!made.ok) throw new Error(made.error); | |
| 125 | } | |
| 126 | mkdirSync(join(dataDir, "checks"), { recursive: true }); | |
| 127 | keepGate(dataDir, ["test/tokens.test.ts:", "(fail) tokens are shown once"]); | |
| 128 | ||
| 129 | const guest = await fetch(`${servers.url}/resilience`, { redirect: "manual" }); | |
| 130 | expect(guest.status).not.toBe(200); | |
| 131 | expect(guest.status).not.toBe(404); | |
| 132 | const bob = await fetch(`${servers.url}/resilience`, { headers: { cookie: await login("bob") } }); | |
| 133 | expect(bob.status).toBe(404); | |
| 134 | expect(await bob.text()).not.toContain("data-standing"); | |
| 135 | ||
| 136 | const res = await fetch(`${servers.url}/resilience`, { headers: { cookie: await login("alice") } }); | |
| 137 | expect(res.status).toBe(200); | |
| 138 | const text = await res.text(); | |
| 139 | const row = text.split("<li").find((r) => r.includes("test/tokens.test.ts") && r.includes("data-standing")); | |
| 140 | expect(row).toContain('data-standing="red"'); | |
| 141 | expect(text).toContain('data-standing="no test yet"'); | |
| 142 | }); | |
| 143 | ||
| 144 | test("in the terminal, an admin's Resilience command lists the standings; the others are not offered it", () => { | |
| 145 | const command = (admin: boolean) => { | |
| 146 | const pushed: Screen[] = []; | |
| 147 | const ctx = { push: (x: Screen) => pushed.push(x) } as unknown as Ctx; | |
| 148 | const user = app.db.query("SELECT * FROM user WHERE username = ?1").get(admin ? "alice" : "bob"); | |
| 149 | const session = new Session(app, { | |
| 150 | sshUsername: "git", | |
| 151 | remoteAddress: "127.0.0.1", | |
| 152 | fingerprint: "SHA256:test", | |
| 153 | user: { ...(user as object), isAdmin: admin } as never, | |
| 154 | }); | |
| 155 | return { found: forgeCommands(session)(ctx).find((c) => c.id === "resilience"), pushed }; | |
| 156 | }; | |
| 157 | const { found, pushed } = command(true); | |
| 158 | found!.run(); | |
| 159 | expect(pushed.map((x) => x.title)).toEqual(["Resilience"]); | |
| 160 | expect(resilienceLines(dataDir).join("\n")).toContain("red"); | |
| 161 | expect(command(false).found).toBeUndefined(); | |
| 162 | }); | |
| 163 | }); |
test/tui-tell.test.ts +1 −1
| @@ -31,7 +31,7 @@ let bob: User; | ||
| 31 | 31 | let repo: Repo; |
| 32 | 32 | |
| 33 | 33 | async function palette(user: User, keys: string[]): Promise<string> { |
| 34 | const terminal = headlessTerminal(120, 30); | |
| 34 | const terminal = headlessTerminal(120, 40); | |
| 35 | 35 | const { app: tui, session } = createForgeApp( |
| 36 | 36 | app, |
| 37 | 37 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }, |
Low: two gates finishing together can interleave this write and leave half-written JSON, which
readGatethen reads as "no gate". Write togate-last.json.tmpandrenameSyncover the file so the swap is atomic.