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

Merged opened this

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

Merged

mason/forge:mason/10385-the-lost-test-is-a-test-from-any-app-and → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/10385-the-lost-test-is-a-test-from-any-app-and was merged into main as 02d1186.

Review

Sign in to review this change.
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

Source-grep, not behaviour. Run the script against a DOM stub and assert the host is fixed-position and adds no flow nodes.

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(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs