Public: anyone can read this; the crew can change it.
Ops security page: one line per control and when it was last checked #10595
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10452-ops-security-page-one-line-per-control-a → main 5 commits fork 4 threads · 4 unresolved
11 files
changed +314
−2
docs/design/audit.md +1
| @@ -144,6 +144,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 144 | 144 | - `/settings/teams` — `pages/teams.ts` · the named crew teams, each with a charter, a dial and members · `/settings/crew` · none seen. |
| 145 | 145 | - `/settings/origins` — `pages/origins.ts` (1,497 lines) · other forges this one reads and mirrors from · `/migrate` · none seen. |
| 146 | 146 | - `/settings/security` — `pages/security.ts` · the Shield and the evidence an admin needs to act with no AI · `/settings/traffic`, `/settings/backups` · none seen. |
| 147 | - `/settings/security/controls` — `pages/security_controls.ts` · one line per RED-TEAM control and when it was last checked · `/settings/security` · none seen. | |
| 147 | 148 | - `/settings/traffic` — `pages/traffic.ts` · who used the answering time in the last 15 minutes · `/settings/usage`, `/settings/telemetry` · none seen. |
| 148 | 149 | - `/settings/traffic/sources` — `pages/sources.ts` · where people come from and where they go: sources, campaigns, landing pages, exits, a path to a goal, GitHub traffic; each number links to its probe · `/settings/traffic`, `/settings/traffic/scrapes` · none seen. |
| 149 | 150 | - `/settings/traffic/scrapes` — `pages/scrapes.ts` · what was read by repository and path, which networks and addresses did it, a bounded capture, the abuse export · `/settings/traffic`, `/settings/security` · none seen. |
docs/features/security-controls.md added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | # Security controls | |
| 2 | ||
| 3 | Each safeguard of `docs/ops/RED-TEAM.md`, and when it was last tested. A site | |
| 4 | admin sees it at `/settings/security/controls` (linked from Security) and in | |
| 5 | the terminal as the palette command "Security controls". Anyone else gets a 404 | |
| 6 | on the page and is offered nothing in the terminal. Read-only. | |
| 7 | ||
| 8 | ## Where the times come from | |
| 9 | ||
| 10 | `<FORGE_DATA>/security-report.json`, the file the Security page reads, written | |
| 11 | whole by the timer outside the forge. Per control, first match wins: | |
| 12 | ||
| 13 | 1. An entry in the optional `controls` list: `{ "id": "app-env", "at": "<ISO date>", "ok": true }`. | |
| 14 | Ids: `app-env`, `identity-headers`, `edge-secret`, `crew-sandbox`, | |
| 15 | `injection-marking`, `pod-security`. Unknown ids, duplicates, a bad date or a | |
| 16 | non-boolean `ok` are ignored. | |
| 17 | 2. For the five controls a test file keeps: the report's own `at`, passing when | |
| 18 | `selfTest.failed` is 0. The timer's self-test runs those tests, so existing | |
| 19 | reports already give a time. | |
| 20 | 3. Otherwise "No check recorded". Pod security is kept in the cluster's source, | |
| 21 | so only a `controls` entry written by whatever checks the cluster gives it a time. | |
| 22 | ||
| 23 | A line says "Failed" when `ok` is false and "Out of date" when the check is | |
| 24 | more than 8 days old; both use one badge. Hover a name for what it holds and | |
| 25 | what keeps it. | |
| 26 | ||
| 27 | ## Code | |
| 28 | ||
| 29 | `src/checks/controls.ts` (the list and the reading), `src/web/pages/security_controls.ts`, | |
| 30 | `src/tui/screens/controls.ts`. Tests: `test/security-controls.test.ts`. |
docs/ops/RED-TEAM.md +1 −1
| @@ -25,4 +25,4 @@ so the live run is still open as its own issue. | ||
| 25 | 25 | happens there. |
| 26 | 26 | - **The live run inside the pod and the sandbox escape attempts**: #10450. |
| 27 | 27 | - **Wider prompt-injection tests**: #10451. |
| 28 | - **A security page under ops** with one line per control: #10452. | |
| 28 | - Done: the security controls page, `/settings/security/controls` (#10452); how it reads `security-report.json` is in `docs/features/security-controls.md`. |
src/checks/controls.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * Each safeguard of docs/ops/RED-TEAM.md and when it was last tested: one | |
| 3 | * `Standing` per control, read from `<dataDir>/security-report.json` (the file | |
| 4 | * the Security page reads). A control the report names in its optional | |
| 5 | * `controls` list (`[{ id, at, ok }]`) uses that; a control a test file keeps | |
| 6 | * otherwise takes the report's own `at` and `selfTest.failed === 0`, since the | |
| 7 | * timer's self-test runs those tests. Pod security is kept in the cluster's | |
| 8 | * source, no test here can see it: it has a time only when `controls` says so. | |
| 9 | */ | |
| 10 | ||
| 11 | import { existsSync, readFileSync, statSync } from "node:fs"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { readSecurityReport } from "../web/pages/security.ts"; | |
| 14 | ||
| 15 | const DAY_MS = 86_400_000; | |
| 16 | export const STALE_DAYS = 8; | |
| 17 | const REPORT_MAX_BYTES = 64 * 1024; | |
| 18 | ||
| 19 | export interface Control { | |
| 20 | readonly id: string; | |
| 21 | readonly name: string; | |
| 22 | /** What the door does, in a sentence. */ | |
| 23 | readonly holds: string; | |
| 24 | /** The test that fails if it reopens, or where it is kept when no test here can. */ | |
| 25 | readonly keptBy: string; | |
| 26 | /** True when the timer's self-test covers it. */ | |
| 27 | readonly tested: boolean; | |
| 28 | } | |
| 29 | ||
| 30 | /** The controls of docs/ops/RED-TEAM.md, in its order. */ | |
| 31 | export const CONTROLS: readonly Control[] = [ | |
| 32 | { id: "app-env", name: "App environment allowlist", holds: "An app starts from a short allowlist, never the forge's environment.", keptBy: "test/redteam-doors.test.ts", tested: true }, | |
| 33 | { id: "identity-headers", name: "Identity header strip", holds: "The proxy deletes visitor-sent identity headers and writes signed ones.", keptBy: "test/apps-identity.test.ts", tested: true }, | |
| 34 | { id: "edge-secret", name: "Edge secret", holds: "Only a request carrying the edge secret can claim a person; apps never see it.", keptBy: "test/edge-auth.test.ts", tested: true }, | |
| 35 | { id: "crew-sandbox", name: "Crew sandbox", holds: "A crew run stays in its worktree and its network allowlist.", keptBy: "test/crew-sandbox.test.ts", tested: true }, | |
| 36 | { id: "injection-marking", name: "Injection marking", holds: "Text from people who cannot write here is marked untrusted in briefs.", keptBy: "test/redteam-doors.test.ts", tested: true }, | |
| 37 | { id: "pod-security", name: "Pod security", holds: "Pod security labels on the cluster's namespaces; kept in the cluster's own source.", keptBy: "the cluster's GitOps source", tested: false }, | |
| 38 | ]; | |
| 39 | ||
| 40 | export interface ControlCheck { | |
| 41 | readonly at: string; | |
| 42 | readonly ok: boolean; | |
| 43 | } | |
| 44 | ||
| 45 | export interface Standing { | |
| 46 | readonly control: Control; | |
| 47 | readonly check: ControlCheck | null; | |
| 48 | /** Failed, or older than STALE_DAYS: the one thing worth a badge. */ | |
| 49 | readonly attention: boolean; | |
| 50 | } | |
| 51 | ||
| 52 | /** `controls` of `<dataDir>/security-report.json`, by id; anything malformed is left out. */ | |
| 53 | export function readControlChecks(dataDir: string): ReadonlyMap<string, ControlCheck> { | |
| 54 | const out = new Map<string, ControlCheck>(); | |
| 55 | const path = join(dataDir, "security-report.json"); | |
| 56 | try { | |
| 57 | if (!existsSync(path) || statSync(path).size > REPORT_MAX_BYTES) return out; | |
| 58 | const r = JSON.parse(readFileSync(path, "utf8")) as { controls?: unknown } | null; | |
| 59 | if (!Array.isArray(r?.controls)) return out; | |
| 60 | const known = new Set(CONTROLS.map((c) => c.id)); | |
| 61 | for (const c of r.controls as unknown[]) { | |
| 62 | const e = c as { id?: unknown; at?: unknown; ok?: unknown } | null; | |
| 63 | if (typeof e?.id !== "string" || !known.has(e.id) || out.has(e.id)) continue; | |
| 64 | if (typeof e.at !== "string" || Number.isNaN(Date.parse(e.at)) || typeof e.ok !== "boolean") continue; | |
| 65 | out.set(e.id, { at: e.at, ok: e.ok }); | |
| 66 | } | |
| 67 | } catch { | |
| 68 | // Unreadable evidence is no evidence. | |
| 69 | } | |
| 70 | return out; | |
| 71 | } | |
| 72 | ||
| 73 | export function controlStandings(dataDir: string, nowMs: number): Standing[] { | |
| 74 | const named = readControlChecks(dataDir); | |
| 75 | const report = readSecurityReport(dataDir); | |
| 76 | return CONTROLS.map((control) => { | |
| 77 | const check: ControlCheck | null = | |
| 78 | named.get(control.id) ?? (control.tested && report ? { at: report.at, ok: report.selfTest.failed === 0 } : null); | |
| 79 | const attention = check !== null && (!check.ok || nowMs - Date.parse(check.at) > STALE_DAYS * DAY_MS); | |
| 80 | return { control, check, attention }; | |
| 81 | }); | |
| 82 | } |
src/http/settings_routes.ts +2
| @@ -19,6 +19,7 @@ import { economicsPage, economicsSet } from "../web/pages/economics.ts"; | ||
| 19 | 19 | import { trustedAdd, trustedPage, trustedRemove, trustedRevert, trustedStop } from "../web/pages/trusted.ts"; |
| 20 | 20 | import { usagePage } from "../web/pages/usage.ts"; |
| 21 | 21 | import { spendPage } from "../web/pages/spend-tab.ts"; |
| 22 | import { securityControlsPage } from "../web/pages/security_controls.ts"; | |
| 22 | 23 | import { securityPage, securityShieldSet } from "../web/pages/security.ts"; |
| 23 | 24 | import { trafficPage } from "../web/pages/traffic.ts"; |
| 24 | 25 | import { sourcesPage } from "../web/pages/sources.ts"; |
| @@ -203,6 +204,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 203 | 204 | router.add("POST", "/settings/traffic/scrapes/stop", captureStop); |
| 204 | 205 | router.add("POST", "/settings/traffic/scrapes/networks", networksImport); |
| 205 | 206 | router.add("GET", "/settings/security", securityPage); |
| 207 | router.add("GET", "/settings/security/controls", securityControlsPage); | |
| 206 | 208 | router.add("POST", "/settings/security/shield", securityShieldSet); |
| 207 | 209 | router.add("GET", "/settings/backups", backupsPage); |
| 208 | 210 | router.add("GET", "/settings/backups/live", backupsLive); |
src/tui/screens/controls.ts added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | /** | |
| 2 | * Security controls in the terminal, a forge admin's: the same lines as | |
| 3 | * /settings/security/controls (`checks/controls.ts`), each safeguard and when | |
| 4 | * it was last tested. Read-only. | |
| 5 | */ | |
| 6 | ||
| 7 | import { controlStandings } from "../../checks/controls.ts"; | |
| 8 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 9 | import { binding } from "../app/keys.ts"; | |
| 10 | import type { Session } from "../session.ts"; | |
| 11 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 12 | import type { PaletteEntry } from "./entries.ts"; | |
| 13 | ||
| 14 | const isAdmin = (f: Session): boolean => f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 15 | ||
| 16 | export function controlRows(dataDir: string, nowMs: number): ListRow[] { | |
| 17 | return controlStandings(dataDir, nowMs).map(({ control, check, attention }) => ({ | |
| 18 | title: control.name, | |
| 19 | secondary: "", | |
| 20 | meta: | |
| 21 | check === null | |
| 22 | ? "No check recorded" | |
| 23 | : `${attention ? (check.ok ? "Out of date, " : "Failed, ") : ""}checked ${check.at.slice(0, 10)}`, | |
| 24 | })); | |
| 25 | } | |
| 26 | ||
| 27 | export function controlsScreen(f: Session, ctx: Ctx): Screen { | |
| 28 | const state = new ListState(); | |
| 29 | const rows = controlRows(f.app.config.dataDir, Date.now()); | |
| 30 | return { | |
| 31 | title: "Security controls", | |
| 32 | keys: [binding(["j", "k"], "move cursor")], | |
| 33 | render(area, s) { | |
| 34 | drawList(s, area, ctx.theme.c, state, rows); | |
| 35 | }, | |
| 36 | key(e) { | |
| 37 | return state.key(e, rows.length); | |
| 38 | }, | |
| 39 | }; | |
| 40 | } | |
| 41 | ||
| 42 | /** Each safeguard, and when it was last tested. */ | |
| 43 | export const entry: PaletteEntry = { | |
| 44 | id: "security-controls", | |
| 45 | title: "Security controls", | |
| 46 | hint: "each safeguard, and when it was last tested", | |
| 47 | offered: isAdmin, | |
| 48 | run: (session, ctx) => ctx.push(controlsScreen(session, ctx)), | |
| 49 | }; |
src/tui/screens/entries.ts +2
| @@ -56,6 +56,7 @@ import { entry as resilience } from "./resilience.ts"; | ||
| 56 | 56 | import { entry as scorecard } from "./scorecard.ts"; |
| 57 | 57 | import { entry as scrapes } from "./scrapes.ts"; |
| 58 | 58 | import { entry as trafficScreenEntry } from "./traffic.ts"; |
| 59 | import { entry as securityControls } from "./controls.ts"; | |
| 59 | 60 | import { entry as secrets } from "./secrets.ts"; |
| 60 | 61 | import { entry as sentinel } from "./sentinel.ts"; |
| 61 | 62 | import { entry as subscriptions } from "./subscriptions.ts"; |
| @@ -97,6 +98,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 97 | 98 | edgeOnly, |
| 98 | 99 | traffic, |
| 99 | 100 | audit, |
| 101 | securityControls, | |
| 100 | 102 | secrets, |
| 101 | 103 | crewSplits, |
| 102 | 104 | crewHandover, |
src/web/pages/security.ts +1 −1
| @@ -258,7 +258,7 @@ export function securityPage(ctx: Ctx): Response { | ||
| 258 | 258 | const none = (n: number, what: string) => (n === 0 ? html`<span class="muted">None</span>` : html`${n} ${what}`); |
| 259 | 259 | const deleted = deletedRows(db, since); |
| 260 | 260 | const body = html`<p class="small muted mb"> |
| 261 | Checked ${time(nowIso)}. Something looks wrong? <a href="${RUNBOOK}" title="docs/SECURITY-RUNBOOK.md">The runbook</a> says what to do. | |
| 261 | Checked ${time(nowIso)}. <a href="/settings/security/controls">Each safeguard, and when it was last tested.</a> Something looks wrong? <a href="${RUNBOOK}" title="docs/SECURITY-RUNBOOK.md">The runbook</a> says what to do. | |
| 262 | 262 | </p> |
| 263 | 263 | ${shieldCard(ctx)} |
| 264 | 264 | <dl class="status-rows"> |
src/web/pages/security_controls.ts added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | /** GET /settings/security/controls: each safeguard, and when it was last tested (`checks/controls.ts`). */ | |
| 2 | ||
| 3 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 4 | import { controlStandings, type Standing } from "../../checks/controls.ts"; | |
| 5 | import type { Ctx } from "../../http/context.ts"; | |
| 6 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; | |
| 7 | import { html, type Html } from "../html.ts"; | |
| 8 | import { page } from "../layout.ts"; | |
| 9 | import { badge, time } from "../ui.ts"; | |
| 10 | import { accountSections } from "./origins.ts"; | |
| 11 | ||
| 12 | function line({ control: c, check, attention }: Standing): Html { | |
| 13 | const state = | |
| 14 | check === null | |
| 15 | ? html`<span class="muted">No check recorded</span>` | |
| 16 | : html`${attention ? html`${badge(check.ok ? "Out of date" : "Failed", { tone: "attn" })} ` : null}<span class="muted">Checked ${time(check.at)}</span>`; | |
| 17 | return html`<div class="status-row" id="${c.id}"> | |
| 18 | <dt title="${c.holds} Kept by ${c.keptBy}.">${c.name}</dt> | |
| 19 | <dd>${state}</dd> | |
| 20 | </div>`; | |
| 21 | } | |
| 22 | ||
| 23 | export function securityControlsPage(ctx: Ctx): Response { | |
| 24 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 25 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 26 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 27 | const rendering = performance.now(); | |
| 28 | const standings = controlStandings(ctx.app.config.dataDir, Date.now()); | |
| 29 | const body = html`<p class="small muted mb"> | |
| 30 | Each safeguard, and when it was last tested. <a href="/settings/security">Back to Security</a>. | |
| 31 | </p> | |
| 32 | <dl class="status-rows"> | |
| 33 | ${standings.map(line)} | |
| 34 | </dl>`; | |
| 35 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 36 | return page({ | |
| 37 | ctx, | |
| 38 | title: "Security controls", | |
| 39 | body: html`<div class="page-head"><h1>Security controls</h1></div> | |
| 40 | ${settingsLayout(accountSections("security", { admin: true }), body, "Account settings")}`, | |
| 41 | }); | |
| 42 | } |
test/palette-roster.test.ts +1
| @@ -139,6 +139,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 139 | 139 | ["edge-only-accounts", "Edge-only accounts", "who would be locked out if the platform edge sign-in went off"], |
| 140 | 140 | ["traffic-sources", "Traffic sources", "where people come from and where they go, for the forge"], |
| 141 | 141 | ["audit", "Who can do what", "each app's doors from its contract.json: yes, own or no"], |
| 142 | ["security-controls", "Security controls", "each safeguard, and when it was last tested"], | |
| 142 | 143 | ["archived", "Archived repositories", "put away, not gone: open one or wake it"], |
| 143 | 144 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
| 144 | 145 | ["pinned", "Pinned forges", "deploy, update and roll back a forge on your cloud"], |
test/security-controls.test.ts added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { createSession } from "../src/core/sessions.ts"; | |
| 7 | import { createUser } from "../src/core/users.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | import { controlRows } from "../src/tui/screens/controls.ts"; | |
| 10 | import { CONTROLS, readControlChecks } from "../src/checks/controls.ts"; | |
| 11 | ||
| 12 | let dir = ""; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let admin = ""; | |
| 16 | let other = ""; | |
| 17 | const get = (cookie?: string) => | |
| 18 | fetch(`${servers.url}/settings/security/controls`, { redirect: "manual", headers: cookie ? { cookie } : {} }); | |
| 19 | ||
| 20 | beforeAll(async () => { | |
| 21 | dir = await mkdtemp(join(tmpdir(), "forge-controls-")); | |
| 22 | app = createApp({ dataDir: dir, port: 0 }); | |
| 23 | servers = await serve(app); | |
| 24 | const seed = { origin: "seed", trusted: true, password: "a-good-passphrase" } as const; | |
| 25 | const a = await createUser(app.db, { ...seed, username: "alice", email: "[email protected]", isAdmin: true }); | |
| 26 | const b = await createUser(app.db, { ...seed, username: "bob", email: "[email protected]" }); | |
| 27 | if (!a.ok || !b.ok) throw new Error("no users"); | |
| 28 | admin = `forge_session=${createSession(app.db, a.value.id).token}`; | |
| 29 | other = `forge_session=${createSession(app.db, b.value.id).token}`; | |
| 30 | }); | |
| 31 | afterAll(async () => { | |
| 32 | servers.stop(); | |
| 33 | await rm(dir, { recursive: true, force: true }); | |
| 34 | }); | |
| 35 | ||
| 36 | test("an admin sees every control; with no report each says no check recorded", async () => { | |
| 37 | const res = await get(admin); | |
| 38 | expect(res.status).toBe(200); | |
| 39 | const text = await res.text(); | |
| 40 | for (const c of CONTROLS) expect(text).toContain(c.name); | |
| 41 | expect(text.match(/No check recorded/g)?.length).toBe(CONTROLS.length); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the page is an admin's", async () => { | |
| 45 | expect((await get()).status).toBe(303); | |
| 46 | expect((await get(other)).status).toBe(404); | |
| 47 | }); | |
| 48 | ||
| 49 | test("a recorded check shows its time; a failed one says failed; junk is ignored", async () => { | |
| 50 | const at = new Date().toISOString(); | |
| 51 | await writeFile( | |
| 52 | join(dir, "security-report.json"), | |
| 53 | JSON.stringify({ | |
| 54 | controls: [ | |
| 55 | { id: "app-env", at, ok: true }, | |
| 56 | { id: "edge-secret", at, ok: false }, | |
| 57 | { id: "nope", at, ok: true }, | |
| 58 | { id: "crew-sandbox", at: "garbage", ok: true }, | |
| 59 | ], | |
| 60 | }), | |
| 61 | ); | |
| 62 | const m = readControlChecks(dir); | |
| 63 | expect([...m.keys()].sort()).toEqual(["app-env", "edge-secret"]); | |
| 64 | const text = await (await get(admin)).text(); | |
| 65 | expect(text).toContain("Failed"); | |
| 66 | expect(text.match(/No check recorded/g)?.length).toBe(CONTROLS.length - 2); | |
outdated
Add a test with a check older than 8 days that expects Out of date, and one that expects the link on /settings/security. |
||
| 67 | }); | |
| 68 | ||
| 69 | test("a check older than 8 days says out of date; one inside 8 days does not", async () => { | |
| 70 | const old = new Date(Date.now() - 9 * 86_400_000).toISOString(); | |
| 71 | const fresh = new Date(Date.now() - 7 * 86_400_000).toISOString(); | |
| 72 | await writeFile(join(dir, "security-report.json"), JSON.stringify({ controls: [{ id: "app-env", at: old, ok: true }] })); | |
| 73 | expect(await (await get(admin)).text()).toContain("Out of date"); | |
| 74 | await writeFile(join(dir, "security-report.json"), JSON.stringify({ controls: [{ id: "app-env", at: fresh, ok: true }] })); | |
| 75 | expect(await (await get(admin)).text()).not.toContain("Out of date"); | |
| 76 | }); | |
| 77 | ||
| 78 | test("without a controls list the report's own time covers the tested controls, not pod security", async () => { | |
| 79 | const at = new Date().toISOString(); | |
| 80 | await writeFile( | |
| 81 | join(dir, "security-report.json"), | |
| 82 | JSON.stringify({ at, secretScan: { findings: 0 }, selfTest: { passed: 3, failed: 0 } }), | |
| 83 | ); | |
| 84 | const text = await (await get(admin)).text(); | |
| 85 | expect(text.match(/No check recorded/g)?.length).toBe(1); | |
| 86 | expect(text).not.toContain("Failed"); | |
| 87 | await writeFile( | |
| 88 | join(dir, "security-report.json"), | |
| 89 | JSON.stringify({ at, secretScan: { findings: 0 }, selfTest: { passed: 2, failed: 1 } }), | |
| 90 | ); | |
| 91 | expect(await (await get(admin)).text()).toContain("Failed"); | |
| 92 | }); | |
| 93 | ||
| 94 | test("the security page links to the controls", async () => { | |
| 95 | const res = await fetch(`${servers.url}/settings/security`, { headers: { cookie: admin } }); | |
| 96 | expect(await res.text()).toContain('href="/settings/security/controls"'); | |
| 97 | }); | |
| 98 | ||
| 99 | test("the terminal rows match the page", () => { | |
| 100 | const rows = controlRows(dir, Date.now()); | |
| 101 | expect(rows.map((r) => r.title)).toEqual(CONTROLS.map((c) => c.name)); | |
| 102 | expect(rows.find((r) => r.title === "Pod security")?.meta).toBe("No check recorded"); | |
| 103 | }); |
Principle 1: this link repeats the page's own introduction above it. Fold it into the existing sentence or a status row instead of a second line.