Public: anyone can read this; the crew can change it.
A test requests every GET route the router registers and finds Content-Security-Policy, X-Content-Type-Options and Referrer-Policy on each answer, 404s and errors included #5859
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5847-a-test-requests-every-get-route-the-rout → main 1 commit fork 2 threads · 2 unresolved
3 files
changed +205
−5
src/http/router.ts +10 −5
| @@ -56,12 +56,12 @@ function compile(pattern: string): Segment[] { | ||
| 56 | 56 | } |
| 57 | 57 | |
| 58 | 58 | export class Router<H> { |
| 59 | private routes: CompiledRoute<H>[] = []; | |
| 59 | private routes_: CompiledRoute<H>[] = []; | |
| 60 | 60 | |
| 61 | 61 | /** The literal first segment of every route: the names a page owns. */ |
| 62 | 62 | literalHeads(): string[] { |
| 63 | 63 | const heads = new Set<string>(); |
| 64 | for (const route of this.routes) { | |
| 64 | for (const route of this.routes_) { | |
| 65 | 65 | const first = route.segments[0]; |
| 66 | 66 | if (first?.kind === "literal" && first.value !== "") heads.add(first.value); |
| 67 | 67 | } |
| @@ -70,17 +70,22 @@ export class Router<H> { | ||
| 70 | 70 | |
| 71 | 71 | /** Every pattern given to `add`, once each, in the order added. */ |
| 72 | 72 | patterns(): string[] { |
| 73 | return [...new Set(this.routes.map((r) => r.pattern))]; | |
| 73 | return [...new Set(this.routes_.map((r) => r.pattern))]; | |
| 74 | } | |
| 75 | ||
| 76 | /** Every registered route, method and pattern, in the order added — for tests that walk the table. */ | |
| 77 | routes(): { method: string; pattern: string }[] { | |
| 78 | return this.routes_.map(({ method, pattern }) => ({ method, pattern })); | |
| 74 | 79 | } |
| 75 | 80 | |
| 76 | 81 | add(method: string, pattern: string, handler: H): this { |
| 77 | this.routes.push({ method, pattern, segments: compile(pattern), handler }); | |
| 82 | this.routes_.push({ method, pattern, segments: compile(pattern), handler }); | |
| 78 | 83 | return this; |
| 79 | 84 | } |
| 80 | 85 | |
| 81 | 86 | match(method: string, pathname: string): Match<H> | undefined { |
| 82 | 87 | const parts = pathname === "/" ? [""] : pathname.slice(1).split("/"); |
| 83 | outer: for (const route of this.routes) { | |
| 88 | outer: for (const route of this.routes_) { | |
| 84 | 89 | if (route.method !== method) continue; |
| 85 | 90 | const params: Params = {}; |
| 86 | 91 | for (const [i, segment] of route.segments.entries()) { |
test/router.test.ts +8
| @@ -18,3 +18,11 @@ test("match names the pattern it matched", () => { | ||
| 18 | 18 | expect(router.match("GET", "/")?.pattern).toBe("/"); |
| 19 | 19 | expect(router.match("GET", "/nope")).toBeUndefined(); |
| 20 | 20 | }); |
| 21 | ||
| 22 | test("routes() lists method and pattern in the order added", () => { | |
| 23 | const router = new Router<string>().add("GET", "/a/:b", "x").add("POST", "/a/:b", "y"); | |
| 24 | expect(router.routes()).toEqual([ | |
| 25 | { method: "GET", pattern: "/a/:b" }, | |
| 26 | { method: "POST", pattern: "/a/:b" }, | |
| 27 | ]); | |
| 28 | }); |
test/security-floor-routes.test.ts added +187
| @@ -0,0 +1,187 @@ | ||
| 1 | /** | |
| 2 | * The security floor (`securityFloor` in src/http/respond.ts) on every GET | |
| 3 | * route the two routers register, signed out and signed in, 404s included. | |
| 4 | * The route table is read from the routers themselves; each pattern's | |
| 5 | * parameters are filled from one seeded repository, issue, pull, run and app. | |
| 6 | * A pattern that needs a parameter the seed cannot supply is named in | |
| 7 | * `UNSEEDED` with why — it is still requested, with a placeholder, so its | |
| 8 | * miss answers carry the floor too — and the list is checked both ways. | |
| 9 | */ | |
| 10 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 16 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 17 | import { createIssue } from "../src/core/issues.ts"; | |
| 18 | import { createPull } from "../src/core/pulls.ts"; | |
| 19 | import { createRepo } from "../src/core/repos.ts"; | |
| 20 | import { createRun } from "../src/core/runs.ts"; | |
| 21 | import { createSession } from "../src/core/sessions.ts"; | |
| 22 | import { repoDir } from "../src/git/repos.ts"; | |
| 23 | import { buildApiRouter, buildRouter, serve, type Servers } from "../src/http/server.ts"; | |
| 24 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | ||
| 27 | quietLogs(); | |
| 28 | ||
| 29 | /** Parameters the seed supplies; any other parameter makes a pattern unseeded. */ | |
| 30 | const SEEDED = new Set(["owner", "name", "username", "number", "ref", "oid", "path", "env", "slug", "spec"]); | |
| 31 | ||
| 32 | const ORIGIN = "needs a connected origin account and a mirror job; the seed connects none"; | |
| 33 | const MIGRATE = "needs a migration job, which only an import creates"; | |
| 34 | const ORG = "needs an organization and a team, which the seed does not create"; | |
| 35 | const BOARD = "needs a board with a panel; the seed makes no board"; | |
| 36 | const LOOK = "needs a captured look; looks come from a running app's screenshot"; | |
| 37 | const WAVE = "needs a wave of a milestone; the seed has no milestone"; | |
| 38 | const VARIANT = "needs a variant group of agent tries; the seed has none"; | |
| 39 | const LIMB = "needs a milestone with a limb; the seed has no milestone"; | |
| 40 | const FLEET = "needs a fleet operation, started by an operator action"; | |
| 41 | const SECRET = "needs a secret scope an operator has set"; | |
| 42 | const GO = "needs a short code minted by a share; the seed shares nothing"; | |
| 43 | const ARCH = "needs an archived-home operation id; the seed archives nothing"; | |
| 44 | ||
| 45 | /** GET patterns the seed cannot fill → why. Requested with a placeholder all the same. */ | |
| 46 | const UNSEEDED: Record<string, string> = { | |
| 47 | "/:owner/-/teams/:team": ORG, | |
| 48 | "/:owner/:name/looks/:env/:commit/:kind.png": LOOK, | |
| 49 | "/:owner/:name/milestones/:number/limb/:op": LIMB, | |
| 50 | "/:owner/:name/variants/:group": VARIANT, | |
| 51 | "/:owner/:name/waves/:version": WAVE, | |
| 52 | "/api/v1/boards/:slug/panels/:id": BOARD, | |
| 53 | "/api/v1/fleet/ops/:id": FLEET, | |
| 54 | "/api/v1/migrate/:job": MIGRATE, | |
| 55 | "/api/v1/migrate/:job/stream": MIGRATE, | |
| 56 | "/api/v1/orgs/:org": ORG, | |
| 57 | "/api/v1/orgs/:org/teams": ORG, | |
| 58 | "/api/v1/orgs/:org/teams/:team/members": ORG, | |
| 59 | "/api/v1/repos/:owner/:name/waves/:version": WAVE, | |
| 60 | "/api/v1/secrets/:scope": SECRET, | |
| 61 | "/api/v1/user/origins/:id/browse/:owner/:name": ORIGIN, | |
| 62 | "/api/v1/user/origins/:id/browse/:owner/:name/blob/:ref/*path": ORIGIN, | |
| 63 | "/api/v1/user/origins/:id/browse/:owner/:name/branches": ORIGIN, | |
| 64 | "/api/v1/user/origins/:id/browse/:owner/:name/commits": ORIGIN, | |
| 65 | "/api/v1/user/origins/:id/browse/:owner/:name/commits/:oid": ORIGIN, | |
| 66 | "/api/v1/user/origins/:id/browse/:owner/:name/issues": ORIGIN, | |
| 67 | "/api/v1/user/origins/:id/browse/:owner/:name/issues/:number": ORIGIN, | |
| 68 | "/api/v1/user/origins/:id/browse/:owner/:name/labels": ORIGIN, | |
| 69 | "/api/v1/user/origins/:id/browse/:owner/:name/paths/:ref": ORIGIN, | |
| 70 | "/api/v1/user/origins/:id/browse/:owner/:name/pulls": ORIGIN, | |
| 71 | "/api/v1/user/origins/:id/browse/:owner/:name/pulls/:number": ORIGIN, | |
| 72 | "/api/v1/user/origins/:id/browse/:owner/:name/tags": ORIGIN, | |
| 73 | "/api/v1/user/origins/:id/browse/:owner/:name/tree/:ref": ORIGIN, | |
| 74 | "/api/v1/user/origins/:id/browse/:owner/:name/tree/:ref/*path": ORIGIN, | |
| 75 | "/api/v1/user/origins/:id/mirror/:job": ORIGIN, | |
| 76 | "/api/v1/user/origins/:id/mirror/:job/stream": ORIGIN, | |
| 77 | "/api/v1/user/origins/:id/repos": ORIGIN, | |
| 78 | "/api/v1/user/origins/oauth/:id": ORIGIN, | |
| 79 | "/go/:code": GO, | |
| 80 | "/home/archived/:op": ARCH, | |
| 81 | "/migrate/jobs/:job": MIGRATE, | |
| 82 | "/origins/:id/r/:owner/:name": ORIGIN, | |
| 83 | "/origins/:id/r/:owner/:name/blob/:ref/*path": ORIGIN, | |
| 84 | "/origins/:id/r/:owner/:name/branches": ORIGIN, | |
| 85 | "/origins/:id/r/:owner/:name/commit/:oid": ORIGIN, | |
| 86 | "/origins/:id/r/:owner/:name/commits": ORIGIN, | |
| 87 | "/origins/:id/r/:owner/:name/edit/:ref/*path": ORIGIN, | |
| 88 | "/origins/:id/r/:owner/:name/issues": ORIGIN, | |
| 89 | "/origins/:id/r/:owner/:name/issues/:number": ORIGIN, | |
| 90 | "/origins/:id/r/:owner/:name/new/:ref": ORIGIN, | |
| 91 | "/origins/:id/r/:owner/:name/new/:ref/*path": ORIGIN, | |
| 92 | "/origins/:id/r/:owner/:name/paths/:ref": ORIGIN, | |
| 93 | "/origins/:id/r/:owner/:name/pulls": ORIGIN, | |
| 94 | "/origins/:id/r/:owner/:name/pulls/:number": ORIGIN, | |
| 95 | "/origins/:id/r/:owner/:name/raw/:ref/*path": ORIGIN, | |
| 96 | "/origins/:id/r/:owner/:name/tree/:ref": ORIGIN, | |
| 97 | "/origins/:id/r/:owner/:name/tree/:ref/*path": ORIGIN, | |
| 98 | "/settings/origins/:id/mirror/:job": ORIGIN, | |
| 99 | "/settings/origins/:id/repos": ORIGIN, | |
| 100 | "/settings/origins/flows/:id": ORIGIN, | |
| 101 | }; | |
| 102 | ||
| 103 | let dataDir = ""; | |
| 104 | let app: App, servers: Servers; | |
| 105 | let cookie = ""; | |
| 106 | const fill: Record<string, string> = {}; | |
| 107 | ||
| 108 | beforeAll(async () => { | |
| 109 | dataDir = await mkdtemp(join(tmpdir(), "forge-floor-")); | |
| 110 | app = createApp({ dataDir, port: 0 }); | |
| 111 | servers = await serve(app); | |
| 112 | const admin = await seedAdmin(app, { username: "ada", email: "[email protected]", password: "correct-horse-battery" }); | |
| 113 | if (!admin.ok) throw new Error(admin.error); | |
| 114 | const ada = admin.value; | |
| 115 | const made = await createRepo(app.db, app.reposRoot, { ownerId: ada.id, name: "notes", visibility: "public" }); | |
| 116 | if (!made.ok) throw new Error(String(made.error)); | |
| 117 | const repo = made.value; | |
| 118 | const seed = await commitToBranch(app, ada, repo, { message: "seed", files: [{ path: "a.txt", content: "a\n" }] }); | |
| 119 | if (!seed.ok) throw new Error(String(seed.error)); | |
| 120 | const branch = await commitToBranch(app, ada, repo, { | |
| 121 | message: "one", branch: "one", files: [{ path: "one.txt", content: "one\n" }], | |
| 122 | } as never); | |
| 123 | if (!branch.ok) throw new Error(String(branch.error)); | |
| 124 | const issue = createIssue(app.db, { repoId: repo.id, title: "an issue", body: "b", authorId: ada.id }); | |
| 125 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 126 | const pull = await createPull(app.db, repoDir(app.reposRoot, ada.username, repo.name), { | |
| 127 | repoId: repo.id, authorId: ada.id, title: "a pull", baseBranch: "main", headBranch: "one", | |
| 128 | }); | |
| 129 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 130 | const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: pull.value.id, commitOid: pull.value.headOid }); | |
| 131 | const inserted = insertApp(app.db, { repoId: repo.id, slug: "notes", access: "public" }); | |
| 132 | if (!inserted.ok) throw new Error(inserted.error); | |
| 133 | Object.assign(fill, { | |
| 134 | owner: "ada", name: "notes", username: "ada", ref: "main", oid: pull.value.headOid, | |
| 135 | path: "a.txt", env: "prod", slug: "notes", spec: "192", | |
| 136 | issue: String(issue.value.number), pull: String(pull.value.number), run: run.id, | |
| 137 | }); | |
| 138 | cookie = `forge_session=${createSession(app.db, ada.id).token}`; | |
| 139 | }, 30_000); | |
| 140 | ||
| 141 | afterAll(async () => { | |
| 142 | servers?.stop(); | |
| 143 | app?.close(); | |
| 144 | await rm(dataDir, { recursive: true, force: true }); | |
| 145 | }); | |
| 146 | ||
| 147 | const paramsOf = (pattern: string): string[] => | |
| 148 | [...pattern.matchAll(/[:*]([a-z_]+)/g)].map((m) => m[1]!); | |
| 149 | ||
| 150 | const getPatterns = (): string[] => | |
| 151 | [...new Set([buildRouter(), buildApiRouter()].flatMap((r) => r.routes()).filter((r) => r.method === "GET").map((r) => r.pattern))]; | |
| 152 | ||
| 153 | function pathFor(pattern: string): string { | |
| 154 | const number = pattern.includes("/pulls/") ? fill.pull : fill.issue; | |
| 155 | const runId = pattern.includes("/runs/:id") ? fill.run : "x"; | |
| 156 | return pattern | |
| 157 | .replace(/[:*]([a-z_]+)/g, (_, key: string) => { | |
| 158 | if (key === "number") return number!; | |
| 159 | if (key === "id") return runId!; | |
| 160 | return SEEDED.has(key) ? fill[key]! : "x"; | |
| 161 | }); | |
| 162 | } | |
| 163 | ||
| 164 | const unseeded = (pattern: string): boolean => | |
| 165 | paramsOf(pattern).some((key) => !SEEDED.has(key) && !(key === "id" && pattern.includes("/runs/:id"))); | |
| 166 | ||
| 167 | async function floor(path: string, headers: Record<string, string>): Promise<string[]> { | |
| 168 | const res = await fetch(`${servers.url}${path}`, { headers, redirect: "manual", signal: AbortSignal.timeout(5_000) }); | |
| 169 | await res.body?.cancel(); | |
| 170 | return ["content-security-policy", "x-content-type-options", "referrer-policy"] | |
| 171 | .filter((h) => !res.headers.get(h)) | |
| 172 | .map((h) => `${res.status} ${path} lacks ${h}`); | |
| 173 | } | |
| 174 | ||
| 175 | test("every GET route, and an unknown path, carries the floor signed out and signed in", async () => { | |
| 176 | const paths = [...getPatterns().map(pathFor), "/no/such/path/at/all", "/api/v1/no-such-route"]; | |
| 177 | const missing: string[] = []; | |
| 178 | for (const headers of [{}, { cookie }]) | |
| 179 | for (const path of paths) missing.push(...(await floor(path, headers))); | |
| 180 | expect(missing).toEqual([]); | |
| 181 | }, 9_000); | |
This test makes about 660 requests one after another, and the whole run gets 9 s. It takes about 3.3 s here, which leaves under 3x headroom on a loaded check runner. The direction asks for tests that never flake. Two options: run each pass's requests concurrently ( |
||
| 182 | ||
| 183 | test("the patterns the seed cannot fill are exactly the ones named, each with a reason", () => { | |
| 184 | const computed = getPatterns().filter(unseeded).sort(); | |
| 185 | expect(computed).toEqual(Object.keys(UNSEEDED).sort()); | |
| 186 | for (const why of Object.values(UNSEEDED)) expect(why.length).toBeGreaterThan(10); | |
| 187 | }); |
new above
offline
branchis a declared field ofCommitToBranchInput(src/actions/repos.ts:641), soas neverisn't needed. It also turns off type checking for this whole call. Drop the cast.