Public: anyone can read this; the crew can change it.

Every error response carries the security floor: a test walks 400, 401, 403, 404, 405, 413 and 500 #10954

Merged opened this

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

Merged

scout/forge:scout/10949-every-error-response-carries-the-securit → main 8 commits fork 2 threads · 2 unresolved

  • Merged. scout/10949-every-error-response-carries-the-securit was merged into main as ba38efc.

Review

Sign in to review this change.
7 files changed +463 −45
src/http/respond.ts +29 −6
@@ -29,10 +29,15 @@ export function json(
2929 data = { ...data, retryAfter: wait };
3030 }
3131 data = withHint(data, status);
outdated

Only JSON responses get the floor here. Other new Response( sites across src/ do not. Route them through securityFloor or prove with a test that each passes the server door.

32 return new Response(JSON.stringify(data), {
33 status,
34 headers: { "content-type": "application/json", ...headers },
35 });
32 // The floor is laid here as well as at the server's one door (#10949): a
33 // JSON answer carried by a path that skips the door — the internal
34 // listener, a test calling a handler — still leaves inert.
35 return securityFloor(
36 new Response(JSON.stringify(data), {
37 status,
38 headers: { "content-type": "application/json", ...headers },
39 }),
40 );
3641 }
3742
3843 /**
@@ -339,13 +344,31 @@ function asObject(bytes: Uint8Array): Record<string, unknown> {
339344 * A JSON object body, at most \`BODY_MAX\` bytes for a signed-in caller and
340345 * \`STRANGER_MAX\` for anyone else (\`http/limits.ts\`) — a route reads its
341346 * body before it asks who is calling, so the cap cannot wait for that. Past
342 * the cap, or malformed, \`{}\`; an over-limit body closes its connection.
347 * the cap it throws \`BodyTooLarge\` (answered 413) and the connection closes;
348 * malformed, \`{}\`.
343349 */
344350 export async function body(request: Request): Promise<Record<string, unknown>> {
345351 const bytes = await readBounded(request, bodyCapFor(request));
346 return bytes === "too_large" ? {} : asObject(bytes);
352 if (bytes === "too_large") throw new BodyTooLarge(bodyCapFor(request));
353 return asObject(bytes);
347354 }
348355
356 /**
357 * A body past its cap, thrown by \`body\` so the handler stops before it acts
358 * on nothing: read as \`{}\`, an oversize issue answered 422 for a missing
359 * title, and a route with defaults would have acted on none (#10949). The
360 * server's door answers it 413 (\`http/server.ts\`, \`failure\`).
361 */
362 export class BodyTooLarge extends Error {
363 constructor(readonly max: number) {
364 super(`body over ${max} bytes`);
365 }
366 }
367
368 /** The 413 a \`BodyTooLarge\` is answered with. */
369 export const bodyTooLarge = (error: BodyTooLarge): Response =>
370 apiError("too_large", 413, `at most ${error.max} bytes`);
371
349372 /**
350373 * A JSON object body kept to at most \`max\` bytes, for a route a stranger
351374 * may call: the declared length is a claim and a chunked body declares
src/http/server.ts +25 −18
@@ -542,6 +542,8 @@ import {
542542 prefersHtml,
543543 prefersJson,
544544 securityFloor,
545 BodyTooLarge,
546 bodyTooLarge,
545547 privateWhenSignedIn,
546548 tooManyAttempts,
547549 tryAgain,
@@ -2178,6 +2180,7 @@ export interface Servers {
21782180 * when a database trigger refused, else a 500 that says nothing of the fault.
21792181 */
21802182 function failure(app: App, error: unknown): Response {
2183 if (error instanceof BodyTooLarge) return securityFloor(bodyTooLarge(error));
21812184 const refused = refusedByDatabase(error);
21822185 if (refused !== undefined) {
21832186 const shown = shownRefusal(refused);
@@ -2262,24 +2265,8 @@ function serveInternal(app: App): InternalListener {
22622265 const onPort = portInsteadOfSocket();
22632266 const internal = Bun.serve({
22642267 ...(onPort ? { hostname: "127.0.0.1", port: 0 } : { unix: app.socketPath }),
2265 fetch(request) {
2266 // The socket's file mode used to be the only door check; a loopback
2267 // port has none, so every internal request proves the boot secret.
2268 if (!hookCallerAllowed(app, request)) {
2269 const path = new URL(request.url).pathname;
2270 if (noteRefusedInternal(request.method, path, Date.now(), app.signals.refused))
2271 app.log.warn("internal call refused: no boot secret", {
2272 method: request.method,
2273 path: path.slice(0, 120),
2274 });
2275 return new Response("forbidden", { status: 403 });
2276 }
2277 const url = new URL(request.url);
2278 if (request.method === "POST" && url.pathname === "/internal/hook") {
2279 return handleHook(app, request);
2280 }
2281 return new Response("not found", { status: 404 });
2282 },
2268 // Only hooks call here, but the floor holds on every listener (#10949).
2269 fetch: async (request) => securityFloor(await answerInternal(app, request)),
22832270 });
22842271 if (onPort) app.hookAddress.current = tcpAddress(internal.port ?? 0);
22852272 return {
@@ -2291,6 +2278,26 @@ function serveInternal(app: App): InternalListener {
22912278 };
22922279 }
22932280
2281 /** The internal listener's answer: a hook, with the boot secret, or nothing. */
2282 async function answerInternal(app: App, request: Request): Promise<Response> {
2283 // The socket's file mode used to be the only door check; a loopback
2284 // port has none, so every internal request proves the boot secret.
2285 if (!hookCallerAllowed(app, request)) {
2286 const path = new URL(request.url).pathname;
2287 if (noteRefusedInternal(request.method, path, Date.now(), app.signals.refused))
2288 app.log.warn("internal call refused: no boot secret", {
2289 method: request.method,
2290 path: path.slice(0, 120),
2291 });
2292 return new Response("forbidden", { status: 403 });
2293 }
2294 const url = new URL(request.url);
2295 if (request.method === "POST" && url.pathname === "/internal/hook") {
2296 return handleHook(app, request);
2297 }
2298 return new Response("not found", { status: 404 });
2299 }
2300
22942301 /**
22952302 * One forge's HTTP routes without a listener: what the parent's Host
22962303 * dispatch uses to answer for a hosted child in-process.
src/spawn/beat-door.ts +18 −14
@@ -8,6 +8,7 @@
88 */
99
1010 import type { App } from "../app.ts";
11 import { securityFloor } from "../http/respond.ts";
1112 import { readCapped } from "./bounded_read.ts";
1213 import { receiveBeat, SIGNATURE_HEADER } from "./heartbeat.ts";
1314 import { creditAnswer, lendModel, MODEL_BODY_MAX, type ModelDeps } from "./model_door.ts";
@@ -31,20 +32,23 @@ export function openBeatDoor(app: App, deps: { readonly model?: ModelDeps } = {}
3132 hostname: "127.0.0.1",
3233 // Bun holds one limit per listener, and the model route needs the larger; each route counts the bytes it reads (`readCapped`).
3334 maxRequestBodySize: MODEL_BODY_MAX,
34 async fetch(req) {
35 const refused = new Response("no such door", { status: 404 });
36 const path = new URL(req.url).pathname;
37 if (req.method === "POST" && path === "/v1/messages") return lendModel(app, req, deps.model);
38 if (req.method === "GET" && path === "/api/v1/credit") return creditAnswer(app, req);
39 if (req.method !== "POST" || path !== "/api/v1/heartbeat") return refused;
40 // A beat is signed and small: no signature, no read; then at most MAX_BODY bytes, counted as they arrive.
41 const signature = req.headers.get(SIGNATURE_HEADER);
42 if (signature === null) return refused;
43 const bytes = await readCapped(req, MAX_BODY);
44 if (bytes === undefined) return refused;
45 const kept = await receiveBeat(app, new TextDecoder().decode(bytes), signature);
46 return kept ? Response.json({ ok: true }) : refused;
47 },
35 // The security floor on every answer, like the forge's own door (#10949).
36 fetch: async (req) => securityFloor(await answerBeat(app, deps, req)),
4837 });
4938 return { port: server.port!, close: () => server.stop(true) };
5039 }
40
41 async function answerBeat(app: App, deps: { readonly model?: ModelDeps }, req: Request): Promise<Response> {
42 const refused = new Response("no such door", { status: 404 });
43 const path = new URL(req.url).pathname;
44 if (req.method === "POST" && path === "/v1/messages") return lendModel(app, req, deps.model);
45 if (req.method === "GET" && path === "/api/v1/credit") return creditAnswer(app, req);
46 if (req.method !== "POST" || path !== "/api/v1/heartbeat") return refused;
47 // A beat is signed and small: no signature, no read; then at most MAX_BODY bytes, counted as they arrive.
48 const signature = req.headers.get(SIGNATURE_HEADER);
49 if (signature === null) return refused;
50 const bytes = await readCapped(req, MAX_BODY);
51 if (bytes === undefined) return refused;
52 const kept = await receiveBeat(app, new TextDecoder().decode(bytes), signature);
53 return kept ? Response.json({ ok: true }) : refused;
54 }
test/mcp-system-stalls.test.ts +7 −4
@@ -9,6 +9,7 @@ import { createUser } from "../src/core/users.ts";
99 import { serve, type Servers } from "../src/http/server.ts";
1010 import { during } from "../src/perf/jobs.ts";
1111 import { loopWatch } from "../src/perf/live.ts";
12 import { LIMIT_MS } from "../src/perf/loop.ts";
1213 import { quietLogs } from "./helpers/globals.ts";
1314
1415 quietLogs();
@@ -34,10 +35,12 @@ afterAll(async () => {
3435 });
3536
3637 test("a stall under a named job is read through system, at most 10, most ms first", async () => {
37 // A stall is blamed on a request first: let any request an earlier file in this process ran fall out of the stall's window.
38 await Bun.sleep(400);
38 // A stall is blamed on any request that finished inside it, before a job:
39 // wait out the requests an earlier file in this process ended, then stall
40 // just past the limit so the window holds the job alone.
41 await Bun.sleep(LIMIT_MS + 50);
3942 during("test-job-10907", () => 1);
40 loopWatch.stalled(300, performance.now());
43 loopWatch.stalled(LIMIT_MS + 10, performance.now());
4144 const res = await fetch(`${servers.url}/mcp`, {
4245 method: "POST",
4346 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
@@ -46,7 +49,7 @@ test("a stall under a named job is read through system, at most 10, most ms firs
4649 const body = (await res.json()) as any;
4750 const out = JSON.parse(body.result.content[0].text);
4851 expect(out.stalls.length).toBeLessThanOrEqual(10);
49 expect(out.stalls).toContainEqual({ cause: "job:test-job-10907", ms: 300, count: 1 });
52 expect(out.stalls).toContainEqual({ cause: "job:test-job-10907", ms: LIMIT_MS + 10, count: 1 });
5053 const ms = out.stalls.map((s: any) => s.ms);
5154 expect(ms).toEqual([...ms].sort((a, b) => b - a));
5255 });
test/security-floor-doors.test.ts added +168
@@ -0,0 +1,168 @@
1 /**
2 * Every response leaves through a listener, so the floor is proven at the
3 * listeners (#10949). A `new Response(` anywhere in src/ — html, text, a
4 * redirect, a stream — reaches a client only as the answer of some
5 * `Bun.serve` fetch; this test lists every one of those in src/ with why
6 * what it returns carries the security floor, and fails on a listener it
7 * has not been told about. Then it walks the public door's own returns,
8 * and asks the running forge for answers that are not JSON.
9 */
10 import { afterAll, beforeAll, expect, test } from "bun:test";
11 import { readdirSync, readFileSync } from "node:fs";
12 import { mkdtemp, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join, relative } from "node:path";
15 import { createApp, type App } from "../src/app.ts";
16 import { securityFloor } from "../src/http/respond.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { openBeatDoor } from "../src/spawn/beat-door.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 const ROOT = join(import.meta.dir, "..");
24 const FLOOR = ["content-security-policy", "x-content-type-options", "referrer-policy"] as const;
25
26 /** file → how many `Bun.serve(` it holds, and why each floors or is not a door. */
27 const LISTENERS: Record<string, { count: number; why: string }> = {
28 "src/http/server.ts": {
29 count: 2,
30 why: "the public door (`handle`, walked below) and the internal hook listener, whose fetch is `securityFloor(await answerInternal(...))`",
31 },
32 "src/spawn/beat-door.ts": { count: 1, why: "fetch is `securityFloor(await answerBeat(...))`, asked below" },
33 "src/kernel/port.ts": { count: 1, why: "a port probe, stopped before any request can reach it" },
34 "src/apps/process.ts": { count: 1, why: "a port probe, stopped before any request can reach it" },
35 "src/apps/scaffold.ts": { count: 1, why: "text of another app's server, written into its repository; it lays its own floor" },
36 "src/actions/spawn.ts": { count: 1, why: "text of a fresh app's index.ts, written into its repository" },
37 };
38
39 const sources = (dir: string): string[] =>
40 readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
41 e.isDirectory() ? sources(join(dir, e.name)) : e.name.endsWith(".ts") ? [join(dir, e.name)] : [],
42 );
43
44 test("every listener in src/ is named, with why its answers carry the floor", () => {
45 const found: Record<string, number> = {};
46 for (const file of sources(join(ROOT, "src"))) {
47 const n = readFileSync(file, "utf8").split("Bun.serve(").length - 1;
48 if (n > 0) found[relative(ROOT, file)] = n;
49 }
50 expect(found).toEqual(Object.fromEntries(Object.entries(LISTENERS).map(([f, l]) => [f, l.count])));
51 });
52
53 test("the internal listener and the beat door hand their fetch to securityFloor", () => {
54 const server = readFileSync(join(ROOT, "src/http/server.ts"), "utf8");
55 expect(server).toContain("fetch: async (request) => securityFloor(await answerInternal(app, request)),");
56 const beat = readFileSync(join(ROOT, "src/spawn/beat-door.ts"), "utf8");
57 expect(beat).toContain("fetch: async (req) => securityFloor(await answerBeat(app, deps, req)),");
58 });
59
60 /**
61 * What the public door may return: an answer it floored itself, one that
62 * came through `answered` (which floors after compressing), a cell's or an
63 * app's own (the app's headers are the app's business, `apps/proxy.ts`), or
64 * nothing after an upgrade.
65 */
66 const DOOR_RETURNS = [
67 /^securityFloor\(/,
68 /^response;$/,
69 /^withHeader\(response, "connection", "close"\);$/,
70 /^proxyApp\(/,
71 /^fromCell\(/,
72 /^undefined;$/,
73 /^socket === undefined \? undefined : securityFloor\(socket\);$/,
74 /^first;$/,
75 /^done;$/,
76 ];
77
78 test("each return of the public door and of `answered` is floored", () => {
79 const server = readFileSync(join(ROOT, "src/http/server.ts"), "utf8");
80 const from = server.indexOf(" const answered = async (");
81 const to = server.indexOf(" const web = Bun.serve(");
82 expect(from).toBeGreaterThan(0);
83 expect(to).toBeGreaterThan(from);
84 const region = server.slice(from, to);
85 const code = region
86 .split("\n")
87 .filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l))
88 .join("\n");
89 const returns = [...code.matchAll(/\breturn\s+([^\n]+)/g)].map((m) => m[1]!.trim());
90 expect(returns.length).toBeGreaterThan(10);
91 expect(returns.filter((r) => !DOOR_RETURNS.some((p) => p.test(r)))).toEqual([]);
92 // `response` is only ever what `answered` gave back.
93 for (const m of region.matchAll(/\bresponse\s*=\s*([^\n;]+)/g)) expect(m[1]!.trim()).toMatch(/^await answered\(/);
94 // `answered` floors what the work returns, and its failure path floors too.
95 expect(region).toContain("const done = await compress(request, response).then(securityFloor)");
96 expect(server).toMatch(/function failure\([^]*?\n}\n/);
97 const failure = server.match(/function failure\([^]*?\n}\n/)![0];
98 for (const r of failure.matchAll(/\breturn\s+([^\n]+)/g)) expect(r[1]!.trim()).toMatch(/^securityFloor\(/);
99 });
100
101 let dataDir = "";
102 let app: App, servers: Servers;
103
104 beforeAll(async () => {
105 dataDir = await mkdtemp(join(tmpdir(), "forge-floor-doors-"));
106 app = createApp({ dataDir, port: 0 });
107 servers = await serve(app);
108 }, 30_000);
109
110 afterAll(async () => {
111 servers?.stop();
112 app?.close();
113 await rm(dataDir, { recursive: true, force: true });
114 });
115
116 const lacks = (res: Response): string[] => FLOOR.filter((h) => !res.headers.get(h));
117
118 test("an html page, a redirect and plain text through the door carry the floor", async () => {
119 const ask = (path: string, init: RequestInit = {}) =>
120 fetch(`${servers.url}${path}`, { redirect: "manual", ...init, headers: { accept: "text/html", ...init.headers } });
121 const page = await ask("/");
122 expect(page.headers.get("content-type")).toStartWith("text/html");
123 expect(lacks(page)).toEqual([]);
124 // An html page writes its own policy; the floor keeps it.
125 expect(page.headers.get("content-security-policy")).toContain("style-src");
126
127 const redirect = await ask("/new");
128 expect(redirect.status).toBeGreaterThanOrEqual(300);
129 expect(redirect.status).toBeLessThan(400);
130 expect(redirect.headers.get("location")).toBeTruthy();
131 expect(lacks(redirect)).toEqual([]);
132
133 const text = await ask("/robots.txt", { headers: { accept: "text/plain" } });
134 expect(text.status).toBe(200);
135 expect(text.headers.get("content-type")).toStartWith("text/plain");
136 expect(lacks(text)).toEqual([]);
137 });
138
139 test("the internal listener floors even the refusal of a caller with no boot secret", async () => {
140 const at = app.hookAddress.current;
141 const res = at.startsWith("tcp:")
142 ? await fetch(`http://${at.slice("tcp:".length)}/internal/hook`, { method: "POST" })
143 : await fetch("http://localhost/internal/hook", { method: "POST", unix: at });
144 expect(res.status).toBe(403);
145 expect(lacks(res)).toEqual([]);
146 });
147
148 test("the beat door floors its refusals", async () => {
149 const door = openBeatDoor(app);
150 try {
151 const res = await fetch(`http://127.0.0.1:${door.port}/nowhere`);
152 expect(res.status).toBe(404);
153 expect(lacks(res)).toEqual([]);
154 } finally {
155 door.close();
156 }
157 });
158
159 test("securityFloor over an html response or a redirect lays the floor and keeps a page's own policy", () => {
160 const html = securityFloor(
161 new Response("<!doctype html>", { headers: { "content-type": "text/html", "content-security-policy": "default-src 'self'" } }),
162 );
163 expect(lacks(html)).toEqual([]);
164 expect(html.headers.get("content-security-policy")).toBe("default-src 'self'");
165 const moved = securityFloor(new Response(null, { status: 303, headers: { location: "/login" } }));
166 expect(lacks(moved)).toEqual([]);
167 expect(moved.headers.get("content-security-policy")).toContain("default-src 'none'");
168 });
test/security-floor-errors.test.ts added +208
@@ -0,0 +1,208 @@
1 /**
2 * The security floor on the error paths nobody looks at (#10949): each of
3 * 400, 401, 403, 404, 405, 413 and 500, provoked once on a page route and
4 * once on an API route, must leave with Content-Security-Policy,
5 * X-Content-Type-Options and Referrer-Policy. A 500 must also say nothing of
6 * the fault: no stack, no path, no secret.
7 *
8 * `security-floor-every-route.test.ts` walks the kinds of answer; this one
9 * walks the error statuses, page and API side by side, so a status that only
10 * one side covers cannot hide a responder that skips the floor on the other.
11 */
12 import { afterAll, beforeAll, expect, test } from "bun:test";
13 import { mkdtemp, rm } from "node:fs/promises";
14 import { tmpdir } from "node:os";
15 import { join } from "node:path";
16 import { createApp, type App } from "../src/app.ts";
17 import { createRepo } from "../src/core/repos.ts";
18 import { createSession } from "../src/core/sessions.ts";
19 import { BODY_MAX } from "../src/http/limits.ts";
20 import { apiError, forbidden, json, methodNotAllowed, notFound, respondAccess, tryAgain, unauthorized } from "../src/http/respond.ts";
21 import { serve, type Servers } from "../src/http/server.ts";
22 import { seedAdmin } from "./helpers/admin.ts";
23 import { quietLogs } from "./helpers/globals.ts";
24
25 quietLogs();
26
27 const FLOOR = ["content-security-policy", "x-content-type-options", "referrer-policy"] as const;
28 const SECRET = "hunter2-the-database-password";
29
30 type Side = "page" | "api";
31 type Provoke = { status: number; side: Side; path: string; init?: RequestInit; signedIn?: boolean; failing?: boolean };
32
33 let dataDir = "";
34 let app: App, servers: Servers;
35 let cookie = "";
36
37 beforeAll(async () => {
38 dataDir = await mkdtemp(join(tmpdir(), "forge-floor-errors-"));
39 app = createApp({ dataDir, port: 0 });
40 servers = await serve(app);
41 const admin = await seedAdmin(app, { username: "ada", email: "[email protected]", password: "correct-horse-battery" });
42 if (!admin.ok) throw new Error(admin.error);
43 const made = await createRepo(app.db, app.reposRoot, { ownerId: admin.value.id, name: "notes", visibility: "public" });
44 if (!made.ok) throw new Error(String(made.error));
45 cookie = `forge_session=${createSession(app.db, admin.value.id).token}`;
46 }, 30_000);
47
48 afterAll(async () => {
49 servers?.stop();
50 app?.close();
51 await rm(dataDir, { recursive: true, force: true });
52 });
53
54 const HTML = { accept: "text/html" };
55 const own = () => ({ origin: servers.url, ...HTML });
56 const tooBig = () => new Uint8Array(BODY_MAX + 1024);
57
58 const PROVOKED = (): Provoke[] => [
59 // 400
60 { status: 400, side: "page", path: "/talk/stream?after=not-a-turn", signedIn: true, init: { headers: HTML } },
61 { status: 400, side: "api", path: "/api/v1/explore?cursor=not-a-cursor" },
62 // 401
63 { status: 401, side: "page", path: "/talk/stream?after=x", init: { headers: HTML } },
64 { status: 401, side: "api", path: "/api/v1/user" },
65 // 403: the origin gate, for a form and for JSON
66 {
67 status: 403,
68 side: "page",
69 path: "/new",
70 signedIn: true,
71 init: { method: "POST", headers: { origin: "https://evil.example", ...HTML, "content-type": "application/x-www-form-urlencoded" }, body: "name=x" },
72 },
73 {
74 status: 403,
75 side: "api",
76 path: "/api/v1/repos/ada/notes/issues",
77 signedIn: true,
78 init: { method: "POST", headers: { origin: "https://evil.example", "content-type": "application/json" }, body: "{}" },
79 },
80 // 404
81 { status: 404, side: "page", path: "/no/such/path/at/all", init: { headers: HTML } },
82 { status: 404, side: "api", path: "/api/v1/repos/ada/nothing-here" },
83 // 405
84 // A page path under the wrong method answers 404 (the page router keeps no
85 // method table); the page that answers 405 is /mcp, to a browser.
86 { status: 405, side: "page", path: "/mcp", init: { headers: HTML } },
87 { status: 404, side: "page", path: "/ada/notes", init: { method: "PUT", headers: HTML } },
88 { status: 405, side: "api", path: "/api/v1/repos/ada/notes", init: { method: "PUT" } },
89 // 413: a body past the cap, a form and JSON
90 {
91 status: 413,
92 side: "page",
93 path: "/new",
94 signedIn: true,
95 init: { method: "POST", headers: { ...own(), "content-type": "application/x-www-form-urlencoded" }, body: tooBig() },
96 },
97 {
98 status: 413,
99 side: "api",

The idempotency-key header hides the wrong status and unbounded read on issue create without it (422 instead of 413). Add a case without the key that expects 413, and fix the read cap.

100 path: "/api/v1/repos/ada/notes/issues",
101 signedIn: true,
102 init: { method: "POST", headers: { origin: servers.url, "content-type": "application/json" }, body: tooBig() },
103 },
104 // The same with an Idempotency-Key, which reads the body on its own path.
105 {
106 status: 413,
107 side: "api",
108 path: "/api/v1/repos/ada/notes/issues",
109 signedIn: true,
110 init: { method: "POST", headers: { origin: servers.url, "content-type": "application/json", "idempotency-key": "k1" }, body: tooBig() },
111 },
112 // 500: the database throws under a handler
113 { status: 500, side: "page", path: "/", signedIn: true, failing: true, init: { headers: HTML } },
114 { status: 500, side: "api", path: "/api/v1/user", signedIn: true, failing: true },
115 ];
116
117 async function provoke(p: Provoke): Promise<{ res: Response; body: string }> {
118 const headers = new Headers(p.init?.headers);
119 headers.set("accept-encoding", "identity");
120 if (p.signedIn === true) headers.set("cookie", cookie);
121 const query = app.db.query.bind(app.db);
122 if (p.failing === true)
123 app.db.query = (() => {
124 throw new Error(`SQLITE_IOERR at ${dataDir}/forge.db with password=${SECRET}`);
125 }) as typeof app.db.query;
126 try {
127 // A fresh connection each: an oversize body closes its connection, and a
128 // client that reused it would read the runtime's own bare 400 instead.
129 const res = await fetch(`${servers.url}${p.path}`, {
130 ...p.init,
131 headers,
132 keepalive: false,
133 redirect: "manual",
134 signal: AbortSignal.timeout(10_000),
135 });
136 return { res, body: await res.text() };
137 } finally {
138 app.db.query = query;
139 }
140 }
141
142 test("each error status, on a page and on the API, carries the floor", async () => {
143 const wrong: string[] = [];
144 // One at a time: a 500 swaps the database's query for a throwing one.
145 for (const p of PROVOKED()) {
146 const { res } = await provoke(p);
147 const name = `${p.status} ${p.side} ${p.init?.method ?? "GET"} ${p.path}`;
148 if (res.status !== p.status) wrong.push(`${name} answered ${res.status}`);
149 for (const h of FLOOR) if (!res.headers.get(h)) wrong.push(`${name} lacks ${h}`);
150 }
151 expect(wrong).toEqual([]);
152 }, 30_000);
153
154 test("every status in the issue is walked on both sides", () => {
155 const walked = new Set(PROVOKED().map((p) => `${p.status}:${p.side}`));
156 for (const status of [400, 401, 403, 404, 405, 413, 500])
157 for (const side of ["page", "api"]) expect(walked.has(`${status}:${side}`)).toBe(true);
158 });
159
160 test("a 500 names no stack, path or secret", async () => {
161 for (const p of PROVOKED().filter((p) => p.status === 500)) {
162 const { res, body } = await provoke(p);
163 expect(res.status).toBe(500);
164 expect(body).not.toContain(SECRET);
165 expect(body).not.toContain(dataDir);
166 expect(body).not.toContain("SQLITE_IOERR");
167 expect(body).not.toMatch(/\bat .+:\d+:\d+/); // a stack frame
168 expect(body).not.toContain("/src/");
169 }
170 });
171
172 test("every responder in respond.ts lays the floor itself, before the server's door", () => {
173 const built = [
174 json({ ok: true }),
175 apiError("bad_query", 400, "no"),
176 unauthorized(),
177 forbidden(),
178 notFound(),
179 methodNotAllowed("PUT", ["GET", "HEAD"]),
180 apiError("too_large", 413, "no"),
181 json({ error: "internal" }, 500),
182 respondAccess("refused", 403),
183 tryAgain("slow_down", 429, "wait", 5),
184 ];
185 for (const res of built) {
186 for (const h of FLOOR) expect(res.headers.get(h)).toBeTruthy();
187 expect(res.headers.get("content-security-policy")).toContain("default-src 'none'");
188 }
189 });
190
191 test("a responder's own policy survives the floor", () => {
192 const res = json({}, 200, { "content-security-policy": "sandbox" });
193 expect(res.headers.get("content-security-policy")).toBe("sandbox");
194 });
195
196 test("an oversize JSON body is refused 413 before the route acts on it", async () => {
197 const { res, body } = await provoke({
198 status: 413,
199 side: "api",
200 path: "/api/v1/repos/ada/notes/issues",
201 signedIn: true,
202 init: { method: "POST", headers: { origin: servers.url, "content-type": "application/json" }, body: tooBig() },
203 });
204 expect(res.status).toBe(413);
205 expect(JSON.parse(body).error).toBe("too_large");
206 const listed = await fetch(`${servers.url}/api/v1/repos/ada/notes/issues`, { headers: { cookie } });
207 expect(((await listed.json()) as { items: unknown[] }).items).toEqual([]);
208 });
test/tui-transfer-repo.test.ts +8 −3
@@ -69,12 +69,16 @@ function open(user: User, at: Repo, nested = false) {
6969 /** Open the delete prompt from the palette once its row has drawn. */
7070 async function prompt(h: ReturnType<typeof open>): Promise<void> {
7171 await h.feed(":", ..."Move this");
72 for (let i = 0; i < 100 && !h.text().includes("old links answer"); i++)
73 await new Promise<void>((r) => setTimeout(r, 10)), await h.feed();
74 if (!h.text().includes("old links answer")) throw new Error(`never came: still waiting while !h.text().includes("old links answer")`);
72 await until(h, () => h.text().includes("old links answer"), "the palette row");
7573 await h.feed("enter");
7674 }
7775
76 /** Wait, feeding nothing, until `done` holds: a loaded shard draws late, so give it 5 s. */
77 async function until(h: ReturnType<typeof open>, done: () => boolean, what: string): Promise<void> {
78 for (let i = 0; i < 500 && !done(); i++) await new Promise<void>((r) => setTimeout(r, 10)), await h.feed();
79 if (!done()) throw new Error(`never came: ${what}`);
80 }
81
7882 async function mkRepo(owner: string | undefined, name: string): Promise<Repo> {
7983 const made = await createRepo(app, alice, { ...(owner ? { owner } : {}), name, visibility: "public" });
8084 if (!made.ok) throw new Error(String(made.error));
@@ -118,6 +122,7 @@ describe("move a repository in the terminal", () => {
118122 await prompt(h);
119123 expect(h.text()).toContain("alice/wander");
120124 await h.feed(..."lab", "enter");
125 await until(h, () => h.text().includes("moved alice/wander"), "the move's receipt");
121126 expect(getRepoById(app.db, repo.id)?.ownerName).toBe("lab");
122127 expect(h.text()).toContain("moved alice/wander to lab/wander");
123128 h.stop();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs