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

A test walks a stranger from `/` to signed in on their own forge in three screens #11282

Merged opened this

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

Merged

scout/forge:scout/11242-a-test-walks-a-stranger-from-to-signed-i → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/11242-a-test-walks-a-stranger-from-to-signed-i was merged into main as e37ceca.

Review

Sign in to review this change.
4 files changed +206 −0
docs/features/start-my-forge.md +8
@@ -13,3 +13,11 @@ In the terminal, a guest's "Get your own forge" command prints the plans and the
1313 ## When the door refuses
1414
1515 The form carries `from=home`. If the door refuses a stranger's start (rate limit, name taken, bad or reserved name, no room), they get a 303 back to `/?start=<kind>&name=<name>` and the home page shows one fixed sentence (`src/web/start_refusal.ts`) above the field, with the name kept. Only a kind from a fixed list is read; the door's own message, codes and times are never shown. Tests: `test/home-start-forge.test.ts`.
16
17 ## Three screens, no link to copy
18
19 When the door accepts a start from the home page (`from=home`, signed out), the browser is not shown the claim link. If the new forge already answers, it gets a 303 straight to its `/claim#<code>` form; if it is still booting, a "Making your forge" page polls `GET /forges/<name>/ready` and goes on by itself (`walkOn` in `src/web/pages/new-forge.ts`). On the claim form the person picks a username and password and lands signed in as the forge's admin. Screens: `/`, the claim form, the forge. Test: `test/stranger-three-screens.test.ts` (in the gate, which runs every test file).
20
21 ### Why the walk is page-only
22
23 A person at the terminal is already past the stranger's problem: they have an SSH login, and "New forge" spawns the forge and hands over its claim link in the same session, so there is no link to copy between screens. The three-screen walk exists for someone with only a browser, so only that door gets it.
src/web/pages/new-forge.ts +56
@@ -17,6 +17,8 @@ import type { Ctx } from "../../http/context.ts";
1717 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1818 import { giftOf, REDEEM_WORDS } from "../../actions/codes.ts";
1919 import { quotaFull } from "../../spawn/quota.ts";
20 import { isChildName } from "../../core/proposal_token.ts";
21 import { childReady } from "../../spawn/ready.ts";
2022 import { childHost, childUrlOf, noRoom, spawnDoor, type Door } from "../../spawn/door.ts";
2123 import {
2224 LEFT_BEHIND,
@@ -457,6 +459,57 @@ function madeView(body: Record<string, unknown>): Html {
457459 </div>`;
458460 }
459461
462 /** The script that waits for the new forge to answer, then goes on to its claim form. */
463 export const WAIT_JS = /* js */ `
464 (function () {
465 var el = document.getElementById("making");
466 if (!el) return;
467 var name = el.getAttribute("data-name"), go = document.getElementById("making-go"), tries = 0;
468 function late() {
469 el.textContent = "This is taking longer than usual. Press Continue to try the new forge now; if it is not up yet, come back to this page in a minute.";
470 }
471 function again(ms) { if (tries < 120) setTimeout(poll, ms); else late(); }
472 function poll() {
outdated

Principle 8 (plain words) and usability: after 120 tries the poll silently stops. Show a sentence and a way on (button to the claim form, which is the working link) when it gives up, and on a network error.

473 tries++;
474 fetch("/forges/" + encodeURIComponent(name) + "/ready", { credentials: "same-origin" })
475 .then(function (r) { return r.json(); })
476 .then(function (j) { if (j && j.ready) location.replace(go.href); else again(1000); })
477 .catch(function () { again(2000); });
478 }
479 poll();
480 })();
481 `;
482
483 /**
484 * Screen two of "Start my forge": the new forge's claim form, where the
485 * person picks a username and password and lands signed in as its admin
486 * (`/claim`). Answering already, the browser is sent straight there; still
487 * booting, a waiting page polls `/forges/<name>/ready` and goes on by itself.
488 * The code travels in the fragment of that one address and is never copied.
489 */
490 async function walkOn(ctx: Ctx, body: Record<string, unknown>): Promise<Response | undefined> {
491 const link = body["claimUrl"];
492 const name = String(body["name"] ?? "");
493 if (typeof link !== "string" || !/^https?:\/\//.test(link) || !isChildName(name)) return undefined;
494 if ((await childReady(ctx.app, name)) === true) {
495 const res = redirect(link);
496 res.headers.set("cache-control", "no-store");
497 return res;
498 }
499 const response = page({
500 ctx,
501 title: "Making your forge",
outdated

The claim link (with its code) is put in data-go and also in the noscript button, so it sits in the HTML twice. Fine with no-store, but the Continue button is only for noscript: with JavaScript on and a slow forge the person has no door. Render the button always, as an <a>, and let the script go on ahead of it.

502 scriptJs: WAIT_JS,
503 body: html`<div class="narrow stack">
504 ${pageHead("Making your forge")}
505 <p id="making" role="status" data-name="${name}">It will be ready in a moment; you will go on by yourself, to choose a username and a password.</p>
506 <p>${btn("Continue", { href: link, variant: "primary", id: "making-go" })}</p>
507 </div>`,
508 });
509 response.headers.set("cache-control", "no-store");
510 return response;
511 }
512
460513 const asBool = (form: Form, name: string): boolean =>
461514 field(form, name) === "on";
462515
@@ -561,6 +614,9 @@ export async function newForgeSubmit(ctx: Ctx): Promise<Response> {
561614 body: formView(ctx, user, values, {}, planView(ctx, user, values, door.body), readsFor(ctx, user)),
562615 });
563616 noteForgeMade(ctx);
617 // A stranger from the home page is not handed a link: the browser goes on to the claim form itself (#11242).
618 const going = user === undefined && field(form, "from") === "home" ? await walkOn(ctx, door.body) : undefined;
619 if (going !== undefined) return going;
564620 const response = page(
565621 { ctx, title: "New forge", body: madeView(door.body) },
566622 door.status,
test/home-start-forge.test.ts +1
@@ -106,6 +106,7 @@ test("no capacity lands on / with the sentence, and no raw message", async () =>
106106
107107 test("the rate limit lands on / with the sentence, and no code or time", async () => {
108108 refusal = undefined;
109 useProbe(app, () => async () => false); // every forge started here is still booting: a 200 waiting page, then the 303 refusal
109110 resetAnonSpawns(app.db);
110111 let last = { to: "", body: "" };
111112 for (let i = 0; i < 12; i++) {
test/stranger-three-screens.test.ts added +141
@@ -0,0 +1,141 @@
1 /**
2 * The stranger's walk (#11242, acceptance of #11153): with no cookies, from `/`
3 * to signed in on their own forge in at most three screens — the home page,
4 * the new forge's claim form, the forge itself — and no link to copy. The
5 * parent and the child it spawns both run for real; only the placement's
6 * address is a stand-in, so each hop is followed by hand the way a browser does.
7 */
8 import { afterAll, beforeAll, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { childDirOf, childNamed } from "../src/actions/spawn.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { WAIT_JS } from "../src/web/pages/new-forge.ts";
16 import { useProbe } from "../src/spawn/door.ts";
17 import { firstBoot } from "../src/spawn/first-boot.ts";
18 import { getUserByName } from "../src/core/users.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 let dir = "";
24 let parent: App;
25 let parentServers: Servers;
26 let child: App | undefined;
27 let childServers: Servers | undefined;
28 let up = false;
29
30 beforeAll(async () => {
31 dir = await mkdtemp(join(tmpdir(), "forge-three-"));
32 parent = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
33 useProbe(parent, () => async () => up);
34 parentServers = await serve(parent);
35 });
36 afterAll(async () => {
37 childServers?.stop();
38 child?.close();
39 parentServers.stop();
40 parent.close();
41 await rm(dir, { recursive: true, force: true });
42 });
43
44 const start = (name: string) =>
45 fetch(`${parentServers.url}/forges/new`, {
46 method: "POST",
47 headers: { "content-type": "application/x-www-form-urlencoded" },
48 body: new URLSearchParams({ name, from: "home" }),
49 redirect: "manual",
50 });
51
52 test("screen 1 to 2: a forge still booting shows a waiting page that goes on by itself, with no link to copy", async () => {
53 up = false;
54 const home = await (await fetch(`${parentServers.url}/`)).text();
55 expect(home).toContain('id="start-forge"');
56 const res = await start("walker");
57 expect(res.status).toBe(200);
58 expect(res.headers.get("cache-control")).toBe("no-store");
59 const body = await res.text();
60 expect(body).toContain('id="making"');
61 expect(body).toContain('data-name="walker"');
62 expect(body).not.toContain("The link to send");
63 expect(body).not.toContain("data-copy");
64 // The only way on is one Continue link, always rendered (not only without JavaScript), to the claim form.
65 const claims = body.match(/href="https:\/\/walker--forge\.test\/claim#[A-Za-z0-9_-]{43}"/g) ?? [];
66 expect(claims).toHaveLength(1);
67 expect(body).toMatch(/<a[^>]*id="making-go"[^>]*>\s*Continue/);
68 expect(body).not.toContain("<noscript>");
69 });
70
71 /** Run the waiting script against stubs; `answers` is what each poll gets. */
72 function runWait(answers: Array<"pending" | "down">) {
73 const status = { textContent: "", getAttribute: () => "walker" };
74 const go = { href: "https://walker--forge.test/claim#x" };
75 const replaced: string[] = [];
76 let timers = 0;
77 let n = 0;
78 const fetchStub = () => {
79 const a = answers[Math.min(n++, answers.length - 1)];
80 return a === "down" ? Promise.reject(new Error("net")) : Promise.resolve({ json: async () => ({ ready: false }) });
81 };
82 const doc = { getElementById: (id: string) => (id === "making" ? status : go) };
83 const timeout = (f: () => void) => { timers++; queueMicrotask(f); return 0; };
84 new Function("document", "fetch", "setTimeout", "location", WAIT_JS)(doc, fetchStub, timeout, { replace: (u: string) => replaced.push(u) });
85 return { status, replaced, polls: () => n, timers: () => timers };
86 }
87 const settle = () => new Promise((r) => setTimeout(r, 50));
88
89 test("the waiting script gives up after 120 tries and says so in plain words, never going on by itself", async () => {
90 const w = runWait(["pending"]);
91 await settle();
92 expect(w.polls()).toBe(120);
93 expect(w.status.textContent).toContain("taking longer than usual");
94 expect(w.status.textContent).toContain("Continue");
95 expect(w.replaced).toEqual([]);
96 });
97
98 test("a network error keeps trying and says the same when it gives up", async () => {
99 const w = runWait(["down"]);
100 await settle();
101 expect(w.polls()).toBe(120);
102 expect(w.status.textContent).toContain("taking longer than usual");
103 });
104
105 test("a name the door refuses still comes back to `/`, not to the waiting page", async () => {
106 const res = await start("Bad_Name");
107 expect(res.status).toBe(303);
108 expect(res.headers.get("location") ?? "").toStartWith("/?start=");
109 });
110
111 test("screen 2 to 3: a forge that answers sends the browser to its claim form; a username and password land signed in as its admin", async () => {
112 up = true;
113 const res = await start("runner");
114 expect(res.status).toBe(303);
115 const to = res.headers.get("location") ?? "";
116 expect(to).toMatch(/^https:\/\/runner--forge\.test\/claim#[A-Za-z0-9_-]{43}$/);
117 expect(res.headers.get("cache-control")).toBe("no-store");
118 const token = to.split("#")[1] ?? "";
119
120 const made = await childNamed(parent, "runner");
121 if (made === undefined) throw new Error("no child");
122 child = createApp({ dataDir: childDirOf(parent, made.bodyId), port: 0 });
123 await firstBoot(child);
124 childServers = await serve(child);
125
126 const form = await fetch(`${childServers.url}/claim`);
127 expect(form.status).toBe(200);
128 const claimed = await fetch(`${childServers.url}/claim`, {
129 method: "POST",
130 headers: { origin: childServers.url },
131 body: new URLSearchParams({ token, username: "walker-owner", password: "a-long-walker-passphrase" }),
132 redirect: "manual",
133 });
134 expect(claimed.status).toBe(303);
135 const cookie = (claimed.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
136 expect(cookie).not.toBe("");
137 expect(getUserByName(child.db, "walker-owner")?.isAdmin).toBe(true);
138 const landed = await fetch(`${childServers.url}${claimed.headers.get("location") ?? "/"}`, { headers: { cookie } });
139 expect(landed.status).toBe(200);
140 expect(await landed.text()).toContain("walker-owner");
141 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs