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

The /apps list answers a warm request under 50 ms and works with JavaScript off, and a test holds both #9757

Merged opened this

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

Merged

scout/forge:scout/9752-the-apps-list-answers-a-warm-request-und → main 1 commit fork

  • Merged. scout/9752-the-apps-list-answers-a-warm-request-und was merged into main as df2340b.

Review

Sign in to review this change.
1 file changed +88 −0
test/apps-nojs.test.ts added +88
@@ -0,0 +1,88 @@
1 /**
2 * The /apps list is fast and works with no script (#9752). Speed is held as
3 * work (SQL statements that do not grow with the number of apps), never a
4 * wall clock (#4429).
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp as makeAppRecord } from "../src/core/apps.ts";
13 import { createUser, getUserByName } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { expectWork, requestStatements } from "./helpers/work.ts";
16
17 let dir = "";
18 let app: App;
19 let servers: Servers;
20 let cookie = "";
21 let made = 0;
22
23 /** The `app` phase of Server-Timing (the handler's own time), or NaN. Reported, never judged (#4429). */
24 function appMs(res: Response): number {
25 const part = (res.headers.get("server-timing") ?? "").split(",").map((p) => p.trim()).find((p) => p.startsWith("app;"));
26 return Number(/dur=([\d.]+)/.exec(part ?? "")?.[1] ?? "NaN");
27 }
28 const get = () => fetch(`${servers.url}/apps`, { headers: { cookie } });
29
30 async function addApps(n: number): Promise<void> {
31 const alice = getUserByName(app.db, "alice")!;
32 for (let i = 0; i < n; i++, made++) {
33 const r = await createRepo(app, alice, { name: `nojs-${made}`, visibility: "public", description: "an app" });
34 if (!r.ok) throw new Error(String(r.error));
35 const rec = makeAppRecord(app.db, { repoId: r.value.id, slug: `nojs${made}`, access: "public" });
36 if (!rec.ok) throw new Error(String(rec.error));
37 }
38 }
39
40 beforeAll(async () => {
41 dir = await mkdtemp(join(tmpdir(), "forge-apps-nojs-"));
42 app = createApp({ dataDir: dir, port: 0 });
43 servers = await serve(app);
44 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
45 if (!u.ok) throw new Error(u.error);
46 const res = await fetch(`${servers.url}/signin`, { method: "POST", headers: { origin: servers.url }, body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual" });
47 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
48 await addApps(3);
49 }, 60_000);
50 afterAll(async () => {
51 servers.stop();
52 app.close();
53 await rm(dir, { recursive: true, force: true });
54 });
55
56 test("a warm /apps sends its app phase and its statement count does not grow with the apps", async () => {
57 await (await get()).text();
58 const second = await get();
59 const body = await second.text();
60 expect(second.status).toBe(200);
61 expect(body).toContain("nojs-2");
62 expect(appMs(second)).not.toBeNaN();
63 const few = await requestStatements(app.db, `${servers.url}/apps`, { headers: { cookie } });
64 expectWork(few, { max: 60 });
65 await addApps(6);
66 const many = await requestStatements(app.db, `${servers.url}/apps`, { headers: { cookie } });
67 expectWork(many, { max: few });
68 });
69
70 test("the list works with every script stripped: plain links and a plain same-origin post form", async () => {
71 const html = await (await get()).text();
72 const main = html.slice(html.indexOf("<h1>Apps</h1>"));
73 const stripped = main.replace(/<script\b[\s\S]*?<\/script>/gi, "");
74 for (let i = 0; i < made; i++) expect(stripped).toMatch(new RegExp(`<a\\b[^>]*href="/[^"]*nojs-${i}"`));
75 expect(stripped).not.toMatch(/<[a-z][^>]*\son[a-z]+\s*=/i);
76 expect(stripped).not.toMatch(/href="\s*(javascript:|#)/i);
77 // The new-app step is one plain link away, and there a plain form.
78 expect(stripped).toMatch(/<a\b[^>]*href="\/apps\/new"/);
79 const step = await (await fetch(`${servers.url}/apps/new`, { headers: { cookie } })).text();
80 const form = /<form\b[^>]*action="\/apps\/new"[^>]*>/.exec(step)?.[0] ?? "";
81 expect(form).toMatch(/method="post"/i);
82 // The same-origin check: a cross-origin post is refused, a same-origin one is not.
83 const body = new URLSearchParams({ name: "x" });
84 const cross = await fetch(`${servers.url}/apps/new`, { method: "POST", headers: { cookie, origin: "https://evil.example" }, body, redirect: "manual" });
85 expect(cross.status).toBe(403);
86 const same = await fetch(`${servers.url}/apps/new`, { method: "POST", headers: { cookie, origin: servers.url }, body, redirect: "manual" });
87 expect(same.status).not.toBe(403);
88 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs