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

Merged opened this

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

Merged

scout/forge:scout/5847-a-test-requests-every-get-route-the-rout → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/5847-a-test-requests-every-get-route-the-rout was merged into main as 13c6016.

Review

Sign in to review this change.
3 files changed +205 −5
src/http/router.ts +10 −5
@@ -56,12 +56,12 @@ function compile(pattern: string): Segment[] {
5656 }
5757
5858 export class Router<H> {
59 private routes: CompiledRoute<H>[] = [];
59 private routes_: CompiledRoute<H>[] = [];
6060
6161 /** The literal first segment of every route: the names a page owns. */
6262 literalHeads(): string[] {
6363 const heads = new Set<string>();
64 for (const route of this.routes) {
64 for (const route of this.routes_) {
6565 const first = route.segments[0];
6666 if (first?.kind === "literal" && first.value !== "") heads.add(first.value);
6767 }
@@ -70,17 +70,22 @@ export class Router<H> {
7070
7171 /** Every pattern given to `add`, once each, in the order added. */
7272 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 }));
7479 }
7580
7681 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 });
7883 return this;
7984 }
8085
8186 match(method: string, pathname: string): Match<H> | undefined {
8287 const parts = pathname === "/" ? [""] : pathname.slice(1).split("/");
83 outer: for (const route of this.routes) {
88 outer: for (const route of this.routes_) {
8489 if (route.method !== method) continue;
8590 const params: Params = {};
8691 for (const [i, segment] of route.segments.entries()) {
test/router.test.ts +8
@@ -18,3 +18,11 @@ test("match names the pattern it matched", () => {
1818 expect(router.match("GET", "/")?.pattern).toBe("/");
1919 expect(router.match("GET", "/nope")).toBeUndefined();
2020 });
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);

branch is a declared field of CommitToBranchInput (src/actions/repos.ts:641), so as never isn't needed. It also turns off type checking for this whole call. Drop the cast.

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 (Promise.all over paths for each header set), or raise the budget to a level the test cannot reach by accident.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs