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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9752-the-apps-list-answers-a-warm-request-und → main 1 commit fork
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 | }); |