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

Settings › Account answers a warm request under 50 ms and works with JavaScript off, and a test holds both #9755

Merged opened this

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

Merged

torque/forge:torque/9751-settings-account-answers-a-warm-request → main 2 commits fork

  • Merged. torque/9751-settings-account-answers-a-warm-request was merged into main as 1498912.

Review

Sign in to review this change.
1 file changed +158 −0
test/account-nojs.test.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * Settings › Account is fast and works with no script (#9751). Speed is held
3 * as work (SQL statements), never a wall clock (#4429).
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdtempSync, rmSync } from "node:fs";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { proveAll } from "./helpers/verified.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { expectWork, requestStatements } from "./helpers/work.ts";
16
17 quietLogs();
18
19 const EDGE_TOKEN = "edge-secret-for-the-test";
20 const PARENT = "https://parent.test";
21 /** Measured: a warm /settings costs STATEMENTS_MEASURED statements; the ceiling sits just above it, so an N+1 fails. */
22 const TOKEN_STATUS = 303, KEY_STATUS = 303, PARENT_STATUS = 502, EDGE_STATUS = 303;
23 const STATEMENTS_MEASURED = 19;
24 const STATEMENT_CEILING = STATEMENTS_MEASURED + 2;
25 let root = "";
26 let app: App;
27 let servers: Servers;
28 let cookie = "";
29 let bobCookie = "";
30
31 /** The `app` phase of Server-Timing, or NaN. Reported, never judged (#4429). */
32 function appMs(res: Response): number {
33 const part = (res.headers.get("server-timing") ?? "").split(",").map((p) => p.trim()).find((p) => p.startsWith("app;"));
34 return Number(/dur=([\d.]+)/.exec(part ?? "")?.[1] ?? "NaN");
35 }
36 /** The platform edge vouching for a login, as it does in front of the forge. */
37 const edge = { "x-plat-edge-token": EDGE_TOKEN, "x-plat-user": "octo" };
38 const get = (path: string) => fetch(servers.url + path, { headers: { cookie, ...edge } });
39 const post = (path: string, body: Record<string, string>, origin: string) =>
40 fetch(servers.url + path, {
41 method: "POST",
42 headers: { cookie, origin, ...edge },
43 body: new URLSearchParams(body),
44 redirect: "manual",
45 });
46
47 beforeAll(async () => {
48 root = mkdtempSync(join(tmpdir(), "forge-account-nojs-"));
49 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge", edgeToken: EDGE_TOKEN, parentUrl: PARENT });
50 const u = await createUser(app.db, {
51 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
52 password: "a-good-passphrase", adminIfFirst: true,
53 });
54 if (!u.ok) throw new Error(u.error);
55 proveAll(app);
56 servers = await serve(app);
57 // An unverified account with a real email is the one shown "resend the link".
58 const bob = await createUser(app.db, { origin: "seed", username: "bob", email: "[email protected]", password: "a-good-passphrase" });
59 if (!bob.ok) throw new Error(bob.error);
60 bobCookie = `forge_session=${createSession(app.db, bob.value.id).token}`;
61 const signin = await fetch(`${servers.url}/signin`, {
62 method: "POST",
63 headers: { origin: servers.url },
64 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
65 redirect: "manual",
66 });
67 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
68 });
69 afterAll(() => {
70 servers.stop();
71 app.close();
72 rmSync(root, { recursive: true, force: true });
73 });
74
75 test("a warm /settings sends its app phase and costs a bounded number of statements", async () => {
76 await (await get("/settings")).text();
77 const second = await get("/settings");
78 expect(second.status).toBe(200);
79 await second.text();
80 expect(appMs(second)).not.toBeNaN();
81 const n = await requestStatements(app.db, servers.url + "/settings", { headers: { cookie, ...edge } });
82 expectWork(n, { max: STATEMENT_CEILING });
83 });
84
85 test("the page content (<main>, the shell excluded) depends on no script or inline handler, and its forms are in the server-rendered HTML", async () => {
86 for (const path of ["/settings", "/settings/tokens", "/settings/keys"]) {
87 const res = await get(path);
88 expect(res.status).toBe(200);
89 // The page's own content is <main>; the shell around it may carry progressive enhancement.
90 const main = /<main\b[\s\S]*<\/main>/i.exec(await res.text())?.[0] ?? "";
91 expect(main).toContain("<form");
92 expect(main).not.toMatch(/<script\b/i);
93 expect(main).not.toMatch(/\son[a-z]+\s*=/i);
94 expect(main).not.toMatch(/href="javascript:/i);
95 }
96 });
97
98 /** Every mutating form the three pages carry for this user, by action. */
99 const EXPECTED = [
100 "/settings", // profile
101 "/settings/notify",
102 "/settings/everyday",
103 "/settings/everyday/keep",
104 "/settings/email/verify", // shown to an unverified user only: bob
105 "/settings/password",
106 "/settings/tokens",
107 "/settings/keys",
108 "/settings/edge/link",
109 "/settings/parent/link",
110 ];
111
112 test("every form is a plain post form: the expected set is rendered and each refuses a cross-origin post", async () => {
113 const found = new Set<string>();
114 for (const path of ["/settings", "/settings/tokens", "/settings/keys", "/settings/password", "bob"]) {
115 const html = await (path === "bob"
116 ? fetch(servers.url + "/settings", { headers: { cookie: bobCookie } })
117 : get(path)
118 ).then((r) => r.text());
119 for (const m of html.matchAll(/<form\b[^>]*>/gi)) {
120 const f = m[0];
121 const action = /action="(\/settings[^"]*)"/.exec(f)?.[1];
122 if (action === undefined) continue;
123 expect(f).toMatch(/method="post"/i);
124 found.add(action);
125 }
126 }
127 expect([...found].sort()).toEqual([...EXPECTED].sort());
128 for (const action of found) {
129 const res = await post(action, {}, "https://evil.example");
130 expect(res.status).toBe(403);
131 }
132 });
133
134 test("a same-origin post lands: token and key rows appear, the status is pinned", async () => {
135 const refused = await post("/settings/tokens", { name: "ci-box", scope: "ro", aiAdmin: "on" }, "https://evil.example");
136 expect(refused.status).toBe(403);
137 expect(await (await get("/settings/tokens")).text()).not.toContain("ci-box");
138 const landed = await post("/settings/tokens", { name: "ci-box", scope: "ro", aiAdmin: "on" }, servers.url);
139 expect(landed.status).toBe(TOKEN_STATUS);
140 expect(await (await get("/settings/tokens")).text()).toContain("ci-box");
141
142 const key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl laptop";
143 expect((await post("/settings/keys", { name: "laptop", key }, "https://evil.example")).status).toBe(403);
144 expect(await (await get("/settings/keys")).text()).not.toContain("laptop");
145 const added = await post("/settings/keys", { name: "laptop", key }, servers.url);
146 expect(added.status).toBe(KEY_STATUS);
147 expect(await (await get("/settings/keys")).text()).toContain("laptop");
148 });
149
150 test("the sign-in link forms: cross-origin refused, same-origin gets past the origin gate", async () => {
151 // The parent round trip cannot reach parent.test from here: past the gate it answers 502, not 403.
152 const parent = await post("/settings/parent/link", {}, servers.url);
153 expect(parent.status).toBe(PARENT_STATUS);
154 const linked = await post("/settings/edge/link", {}, servers.url);
155 expect(linked.status).toBe(EDGE_STATUS);
156 expect(linked.headers.get("location")).toBe("/settings");
157 expect(await (await get("/settings")).text()).toContain("linked as <strong>octo</strong>");
158 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs