Public: anyone can read this; the crew can change it.
The Lost test is a test: from any app and any preview the toolbar tells where you are, gets you home, and reaches comment and tell without a second page #10393
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10385-the-lost-test-is-a-test-from-any-app-and → main 2 commits fork 3 threads · 3 unresolved
1 file
changed +293
−0
test/lost-journey.test.ts added +293
| @@ -0,0 +1,293 @@ | ||
| 1 | /** | |
| 2 | * The Lost journey (#10385): a person dropped into a preview knows the app, | |
| 3 | * the environment and the pull, and has three exits (home, comment, tell). | |
| 4 | * Served through the app proxy, public and private, member and stranger. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { newApp } from "../src/actions/apps.ts"; | |
| 13 | import { signGrant, type AppTarget } from "../src/apps/proxy.ts"; | |
| 14 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 15 | import { TOOLBAR_ASSET_PATH, toolbarJs } from "../src/apps/toolbar.ts"; | |
| 16 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; | |
| 17 | import { createIssue } from "../src/core/issues.ts"; | |
| 18 | import { createUser } from "../src/core/users.ts"; | |
| 19 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const DOMAIN = "apps.test"; | |
| 25 | const HTML = "<html><body><h1>hi</h1></body></html>"; | |
| 26 | const SECRET_TITLE = "Rework the secret billing flow"; | |
| 27 | ||
| 28 | describe("lost journey: where you are and three exits", () => { | |
| 29 | let dataDir: string; | |
| 30 | let app: App; | |
| 31 | let servers: Servers; | |
| 32 | let runtime: AppsRuntime; | |
| 33 | let backend: ReturnType<typeof Bun.serve>; | |
| 34 | let status: ReturnType<typeof spyOn>; | |
| 35 | let alice: string; | |
| 36 | const apps: Record<string, { record: AppRecord; pull: number }> = {}; | |
| 37 | ||
| 38 | beforeAll(async () => { | |
| 39 | backend = Bun.serve({ | |
| 40 | port: 0, | |
| 41 | fetch: () => new Response(HTML, { headers: { "content-type": "text/html; charset=utf-8" } }), | |
| 42 | }); | |
| 43 | dataDir = await mkdtemp(join(tmpdir(), "forge-lost-journey-")); | |
| 44 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 45 | servers = await serve(app); | |
| 46 | runtime = new AppsRuntime(app, { | |
| 47 | domain: DOMAIN, | |
| 48 | scheme: "http", | |
| 49 | publicPort: servers.port, | |
| 50 | forgeUrl: servers.url, | |
| 51 | concurrency: 1, | |
| 52 | }); | |
| 53 | await runtime.start(); | |
| 54 | status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation( | |
| 55 | (_app: unknown, env: string) => | |
| 56 | ({ env, state: "running", port: backend.port, commitOid: "a".repeat(40) }) as EnvStatus, | |
| 57 | ); | |
| 58 | const u = await createUser(app.db, { | |
| 59 | origin: "seed", | |
| 60 | trusted: true, | |
| 61 | username: "alice", | |
| 62 | email: "[email protected]", | |
| 63 | password: "a-good-passphrase", | |
| 64 | adminIfFirst: true, | |
| 65 | }); | |
| 66 | if (!u.ok) throw new Error(u.error); | |
| 67 | alice = u.value.id; | |
| 68 | for (const visibility of ["public", "private"] as const) { | |
| 69 | const made = await newApp(app, u.value, { name: visibility, description: "x", visibility }); | |
| 70 | if (!made.ok) throw new Error(String(made.error)); | |
| 71 | const record = getAppByRepo(app.db, made.value.repo.id)!; | |
| 72 | const p = createIssue(app.db, { repoId: record.repoId, authorId: alice, title: SECRET_TITLE, kind: "pull" }); | |
| 73 | if (!p.ok) throw new Error(p.error); | |
| 74 | apps[visibility] = { record, pull: p.value.number }; | |
| 75 | } | |
| 76 | }, 120_000); | |
| 77 | ||
| 78 | afterAll(async () => { | |
| 79 | status.mockRestore(); | |
| 80 | await runtime.stop(); | |
| 81 | servers.stop(); | |
| 82 | backend.stop(true); | |
| 83 | app.close(); | |
| 84 | await rm(dataDir, { recursive: true, force: true }); | |
| 85 | }); | |
| 86 | ||
| 87 | const cookieFor = (name: string, env: string) => { | |
| 88 | const record = apps[name]!.record; | |
| 89 | return `forge_grant_${record.id.slice(-8)}=${signGrant(dataDir, { app: record, env } as AppTarget, 60_000, Date.now(), { | |
| 90 | id: alice, | |
| 91 | name: "alice", | |
| 92 | role: "admin", | |
| 93 | })}`; | |
| 94 | }; | |
| 95 | const ask = (host: string, path: string, cookie?: string) => | |
| 96 | fetch(`${servers.url}${path}`, { | |
| 97 | headers: { host, accept: "text/html", ...(cookie === undefined ? {} : { cookie }) }, | |
| 98 | redirect: "manual", | |
| 99 | }); | |
| 100 | const tagOf = (html: string) => /<script src="([^"]+)"([^>]*) defer><\/script>/.exec(html); | |
| 101 | ||
| 102 | for (const name of ["public", "private"]) { | |
| 103 | for (const preview of [false, true]) { | |
| 104 | test(`${name} app, ${preview ? "preview" : "production"}: member sees app, environment, pull and the exits`, async () => { | |
| 105 | const { pull } = apps[name]!; | |
| 106 | const env = preview ? `pull-${pull}` : "production"; | |
| 107 | const host = preview ? `${name}--${pull}.${DOMAIN}` : `${name}.${DOMAIN}`; | |
| 108 | const res = await ask(host, "/", cookieFor(name, env)); | |
| 109 | expect(res.status).toBe(200); | |
| 110 | const html = await res.text(); | |
| 111 | const tag = tagOf(html); | |
| 112 | expect(tag).not.toBeNull(); | |
| 113 | // The single immutable asset, and nothing else injected into the page's flow. | |
| 114 | expect(tag![1]).toBe(TOOLBAR_ASSET_PATH); | |
| 115 | expect(html.replace(tag![0], "")).toBe(HTML); | |
| 116 | expect(tag![2]).toContain(` data-app="${name}"`); | |
| 117 | expect(tag![2]).toContain(` data-env="${env}"`); | |
| 118 | expect(tag![2]).toContain(` data-repo="alice/${name}"`); | |
| 119 | if (preview) { | |
| 120 | expect(tag![2]).toContain(` data-pull="${pull}"`); | |
| 121 | expect(tag![2]).toContain(SECRET_TITLE); | |
| 122 | } else expect(tag![2]).not.toContain("data-pull"); | |
| 123 | ||
| 124 | // Same exits for the member on every app: crew door offers tell on the forge origin. | |
| 125 | const crew = await (await ask(host, "/__forge/crew", cookieFor(name, env))).json(); | |
| 126 | expect((crew as { tell?: string }).tell).toBe(`${servers.url}/alice/${name}/tell`); | |
| 127 | }); | |
| 128 | ||
| 129 | test(`${name} app, ${preview ? "preview" : "production"}: a stranger gets none of the pull's text`, async () => { | |
| 130 | const { pull } = apps[name]!; | |
| 131 | const host = preview ? `${name}--${pull}.${DOMAIN}` : `${name}.${DOMAIN}`; | |
| 132 | const res = await ask(host, "/"); | |
| 133 | const body = await res.text(); | |
| 134 | expect(body).not.toContain(`alice/${name}/tell`); | |
| 135 | // A public pull's title is public; a private app's is the members' alone. | |
| 136 | if (name === "private") { | |
| 137 | expect(body).not.toContain(SECRET_TITLE); | |
| 138 | expect(body).not.toContain('data-repo="alice/private"'); | |
| 139 | expect(body).not.toContain('data-state='); | |
| 140 | } | |
| 141 | expect(await (await ask(host, "/__forge/crew")).text()).not.toContain("/tell"); | |
| 142 | }); | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | test("the asset is one immutable script served from the app's own host", async () => { | |
| 147 | const res = await fetch(`${servers.url}${TOOLBAR_ASSET_PATH}`, { headers: { host: `public.${DOMAIN}` } }); | |
| 148 | expect(res.status).toBe(200); | |
| 149 | expect(res.headers.get("cache-control")).toContain("immutable"); | |
| 150 | expect(await res.text()).toBe(toolbarJs); | |
| 151 | }); | |
| 152 | ||
| 153 | // ---- Run the served script against a minimal DOM stub and inspect what it builds. ---- | |
| 154 | interface El { | |
| 155 | tag: string; | |
| 156 | attrs: Record<string, string>; | |
| 157 | children: El[]; | |
| 158 | style: Record<string, string>; | |
| 159 | listeners: Record<string, unknown[]>; | |
| 160 | textContent: string; | |
outdated
These string greps assert the source text, not that Home, Comment and Tell are focusable and point at the right place. Execute and inspect the built elements. |
||
| 161 | [k: string]: unknown; | |
| 162 | } | |
| 163 | const el = (tag: string): El => ({ | |
| 164 | tag, | |
| 165 | attrs: {}, | |
| 166 | children: [], | |
| 167 | style: {}, | |
| 168 | listeners: {}, | |
| 169 | textContent: "", | |
outdated
Asserting that "/pulls/" appears in the source does not show the pull links to its forge page. Assert the built href. |
||
| 170 | setAttribute(k: string, v: string) { this.attrs[k] = v; }, | |
| 171 | hasAttribute(k: string) { return k in this.attrs || (k === "href" && Boolean(this.href)); }, | |
| 172 | appendChild(c: El) { this.children.push(c); return c; }, | |
| 173 | addEventListener(t: string, f: unknown) { (this.listeners[t] ??= []).push(f); }, | |
| 174 | attachShadow() { return (this.shadow = el("#shadow")); }, | |
| 175 | }); | |
| 176 | const walk = (e: El): El[] => [e, ...e.children.flatMap(walk)]; | |
| 177 | const unescape = (v: string) => | |
| 178 | v.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); | |
| 179 | /** Boot the toolbar exactly as the browser would for the served tag, and let the crew answer land. */ | |
| 180 | const boot = async (html: string, crew: object, href: string) => { | |
| 181 | const t = tagOf(html)!; | |
| 182 | const dataset: Record<string, string> = {}; | |
| 183 | for (const m of t[0].matchAll(/ data-([a-z]+)="([^"]*)"/g)) dataset[m[1]!] = unescape(m[2]!); | |
| 184 | const bodyChildren: El[] = []; | |
| 185 | const doc = { | |
| 186 | currentScript: { dataset, removeAttribute() {} }, | |
| 187 | createElement: (tag: string) => el(tag), | |
| 188 | addEventListener() {}, | |
| 189 | body: { appendChild: (c: El) => bodyChildren.push(c) }, | |
| 190 | }; | |
| 191 | const win: Record<string, unknown> = { innerWidth: 1200, innerHeight: 800 }; | |
| 192 | const loc = new URL(href); | |
| 193 | const fetcher = async () => ({ ok: true, json: async () => crew, headers: { get: () => null } }); | |
| 194 | new Function("window", "document", "URL", "location", "fetch", toolbarJs)(win, doc, URL, loc, fetcher); | |
| 195 | for (let i = 0; i < 10; i++) await Promise.resolve(); | |
| 196 | const host = bodyChildren[0]!; | |
| 197 | const built = walk((host as unknown as { shadow: El }).shadow); | |
| 198 | const named = (text: string) => built.find((e) => e.textContent === text); | |
| 199 | return { host, bodyChildren, built, named, dataset }; | |
| 200 | }; | |
| 201 | const focusable = (e: El | undefined) => | |
| 202 | e !== undefined && (e.tag === "button" || (e.tag === "a" && typeof e.href === "string" && e.href !== "")); | |
| 203 | const crewOf = async (host: string, cookie?: string) => (await ask(host, "/__forge/crew", cookie)).json() as Promise<object>; | |
| 204 | ||
| 205 | test("run: the host is one fixed element, the only node added to the body", async () => { | |
| 206 | const { pull } = apps.public!; | |
| 207 | const host = `public--${pull}.${DOMAIN}`; | |
| 208 | const html = await (await ask(host, "/", cookieFor("public", `pull-${pull}`))).text(); | |
| 209 | const run = await boot(html, {}, `http://${host}/x`); | |
| 210 | expect(run.bodyChildren).toHaveLength(1); | |
| 211 | expect(run.host.attrs).toEqual({}); | |
| 212 | expect(run.host.id).toBe("forge-toolbar"); | |
| 213 | expect(run.host.style.cssText).toContain("position:fixed"); | |
| 214 | expect(run.host.style.bottom).toBe("12px"); | |
| 215 | expect(run.host.style.right).toBe("12px"); | |
| 216 | // Nothing is styled with a stylesheet: the script never makes a style element. | |
| 217 | expect(run.built.some((e) => e.tag === "style")).toBe(false); | |
| 218 | }); | |
| 219 | ||
| 220 | test("run: a member's preview builds Home, the pull link, comment and tell, all focusable and on the forge", async () => { | |
| 221 | const { pull } = apps.public!; | |
| 222 | const host = `public--${pull}.${DOMAIN}`; | |
| 223 | const cookie = cookieFor("public", `pull-${pull}`); | |
| 224 | const html = await (await ask(host, "/", cookie)).text(); | |
| 225 | const run = await boot(html, await crewOf(host, cookie), `http://${host}/some/page`); | |
| 226 | const origin = new URL(servers.url).origin; | |
| 227 | ||
| 228 | const home = run.named("Home")!; | |
| 229 | expect(focusable(home)).toBe(true); | |
| 230 | expect(home.href).toBe(`${origin}/`); | |
| 231 | ||
| 232 | const pullLink = run.named(`Preview of pull #${pull}`)!; | |
| 233 | expect(focusable(pullLink)).toBe(true); | |
| 234 | expect(pullLink.href).toBe(`${origin}/alice/public/pulls/${pull}`); | |
| 235 | ||
| 236 | const comment = run.named("Comment, tell, make it yours")!; | |
| 237 | expect(focusable(comment)).toBe(true); | |
| 238 | const target = new URL(comment.href as string); | |
| 239 | expect(target.origin).toBe(origin); | |
| 240 | expect(target.pathname).toBe("/alice/public/toolbar"); | |
| 241 | expect(target.searchParams.get("env")).toBe(`pull-${pull}`); | |
| 242 | expect(target.searchParams.get("page")).toBe(`http://${host}/some/page`); | |
| 243 | ||
| 244 | const tell = run.named("Tell the crew")!; | |
| 245 | expect(focusable(tell)).toBe(true); | |
| 246 | const tellTarget = new URL(tell.href as string); | |
| 247 | expect(tellTarget.origin).toBe(origin); | |
| 248 | expect(tellTarget.pathname).toBe("/alice/public/tell"); | |
| 249 | expect(tellTarget.searchParams.get("pull")).toBe(String(pull)); | |
| 250 | ||
| 251 | // The note box is a real form with a submit button, not a click-only div. | |
| 252 | expect(run.built.some((e) => e.tag === "form" && (e.listeners.submit ?? []).length === 1)).toBe(true); | |
| 253 | expect(focusable(run.built.find((e) => e.tag === "button" && e.type === "submit"))).toBe(true); | |
| 254 | }); | |
| 255 | ||
| 256 | test("run: production shows the environment and no pull link", async () => { | |
| 257 | const host = `public.${DOMAIN}`; | |
| 258 | const cookie = cookieFor("public", "production"); | |
| 259 | const html = await (await ask(host, "/", cookie)).text(); | |
| 260 | const run = await boot(html, await crewOf(host, cookie), `http://${host}/`); | |
| 261 | expect(run.named("Production")).toBeDefined(); | |
| 262 | expect(run.built.some((e) => String(e.textContent).startsWith("Preview of pull"))).toBe(false); | |
| 263 | expect(run.named("Home")!.href).toBe(`${new URL(servers.url).origin}/`); | |
| 264 | }); | |
| 265 | ||
| 266 | test("a stranger on a public preview still learns app, environment and pull, with no tell", async () => { | |
| 267 | const { pull } = apps.public!; | |
| 268 | const host = `public--${pull}.${DOMAIN}`; | |
| 269 | const html = await (await ask(host, "/")).text(); | |
| 270 | const tag = tagOf(html)!; | |
| 271 | expect(tag[2]).toContain(' data-app="public"'); | |
| 272 | expect(tag[2]).toContain(` data-env="pull-${pull}"`); | |
| 273 | expect(tag[2]).toContain(` data-pull="${pull}"`); | |
| 274 | const run = await boot(html, await crewOf(host), `http://${host}/`); | |
| 275 | expect(run.named(`Preview of pull #${pull}`)!.href).toBe(`${new URL(servers.url).origin}/alice/public/pulls/${pull}`); | |
| 276 | expect(run.named("Home")).toBeDefined(); | |
| 277 | expect(run.named("Tell the crew")).toBeUndefined(); | |
| 278 | // Comment goes to the forge's own page, which asks the stranger to sign in there. | |
| 279 | expect(new URL(run.named("Comment, tell, make it yours")!.href as string).origin).toBe(new URL(servers.url).origin); | |
| 280 | }); | |
| 281 | ||
| 282 | test("a stranger on a private preview is given no toolbar, so no comment, tell or pull link", async () => { | |
| 283 | const { pull } = apps.private!; | |
| 284 | const html = await (await ask(`private--${pull}.${DOMAIN}`, "/")).text(); | |
| 285 | expect(tagOf(html)).toBeNull(); | |
| 286 | expect(html).not.toContain("/toolbar"); | |
| 287 | expect(html).not.toContain("/pulls/"); | |
| 288 | }); | |
| 289 | ||
| 290 | test("security: the script has no eval and no dynamic code", () => { | |
| 291 | expect(toolbarJs).not.toMatch(/\beval\s*\(|new Function/); | |
| 292 | }); | |
| 293 | }); |
Source-grep, not behaviour. Run the script against a DOM stub and assert the host is fixed-position and adds no flow nodes.