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

A test pins resending the email-verification link: throttled, capped, and honest when already verified #7040

Merged opened this

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

Merged

scout/forge:scout/7025-a-test-pins-resending-the-email-verifica → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/7025-a-test-pins-resending-the-email-verifica was merged into main as b3b67b7.

Review

Sign in to review this change.
2 files changed +133 −0
src/web/pages/account.ts +1
@@ -246,6 +246,7 @@ function unverifiedNotice(ctx: Ctx): Html {
246246
247247 // POST /settings/email/verify
248248 export async function resendVerifyLink(ctx: Ctx): Promise<Response> {
249 if (!ctx.auth.user) return signInFirst(ctx);

Good: same guard as the siblings. Follow-up: signInFirst carries next=/settings/email/verify, which after sign-in is a GET on a POST-only route; redirect to /settings instead.

249250 const form = await readForm(ctx);
250251 if (form instanceof Response) return form;
251252 const sent = await resendVerification(ctx.app, ctx.auth.user);
test/settings-email-verify.test.ts added +132
@@ -0,0 +1,132 @@
1 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { mkdtemp, rm } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5 import { createApp, type App } from "../src/app.ts";
6 import { createUser } from "../src/core/users.ts";
7 import { createSession } from "../src/core/sessions.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { markUnproven, markVerified } from "../src/core/verify.ts";
10 import type { Mail } from "../src/kernel/mail.ts";
11
12 // POST /settings/email/verify: the "Send another link" button.
13 describe("resending the email-verification link", () => {
14 let dirs: string[] = [];
15 let app: App;
16 let bare: App;
17 let servers: Servers;
18 let bareServers: Servers;
19 const sent: Mail[] = [];
20
21 const boot = async (mailer?: (mail: Mail) => Promise<boolean>) => {
22 const dir = await mkdtemp(join(tmpdir(), "forge-resend-"));
23 dirs.push(dir);
24 const made = createApp({
25 dataDir: dir,
26 port: 0,
27 url: "https://forge.example",
28 ...(mailer === undefined ? {} : { mailer }),
29 });
30 return { made, up: await serve(made) };
31 };
32
33 beforeAll(async () => {
34 const a = await boot(async (mail) => {
35 sent.push(mail);
36 return true;
37 });
38 app = a.made;
39 servers = a.up;
40 const b = await boot();
41 bare = b.made;
42 bareServers = b.up;
43 });
44 afterAll(async () => {
45 servers.stop();
46 bareServers.stop();
47 app.close();
48 bare.close();
49 for (const dir of dirs) await rm(dir, { recursive: true, force: true });
50 });
51
52 const person = async (a: App, name: string, proven = false) => {
53 const made = await createUser(a.db, {
54 origin: "edge",
55 username: name,
56 email: `${name}@outsider.test`,
57 });
58 if (!made.ok) throw new Error(made.error);
59 const user = made.value;
60 if (proven) markVerified(a.db, user.id, new Date().toISOString());
61 else markUnproven(a.db, user.id);
62 return `forge_session=${createSession(a.db, user.id).token}`;
63 };
64
65 // The form the page posts: urlencoded, no fields, session cookie, same origin.
66 const press = (
67 s: Servers,
68 cookie: string | undefined,
69 origin: string | undefined = "https://forge.example",
70 ) =>
71 fetch(`${s.url}/settings/email/verify`, {
72 method: "POST",
73 redirect: "manual",
74 headers: {
75 "content-type": "application/x-www-form-urlencoded",
76 ...(cookie === undefined ? {} : { cookie }),
77 ...(origin === undefined ? {} : { origin }),
78 },
79 body: "",
80 });
81 const flash = (res: Response): string => {
82 expect(res.status).toBe(303);
83 expect(res.headers.get("location")).toBe("/settings");
84 const raw = res.headers.get("set-cookie") ?? "";
85 const value = /forge_flash=([^;]*)/.exec(raw)?.[1] ?? "";
86 return (JSON.parse(decodeURIComponent(value)) as { message: string })
87 .message;
88 };
89
90 test("an unverified person is sent a link; a second press at once waits", async () => {
91 const cookie = await person(app, "ada");
92 const before = sent.length;
93 expect(flash(await press(servers, cookie))).toBe(
94 "A new link is on its way.",
95 );
96 expect(sent.length).toBe(before + 1);
97 expect(sent.at(-1)!.to).toBe("[email protected]");
98 expect(flash(await press(servers, cookie))).toBe(
99 "A link was sent a moment ago. Check your inbox, or try again in a few minutes.",
100 );
101 expect(sent.length).toBe(before + 1);
102 });
103
104 test("a verified person is told so, and no mail is sent", async () => {
105 const cookie = await person(app, "grace", true);
106 const before = sent.length;
107 expect(flash(await press(servers, cookie))).toBe(
108 "Your email is already verified.",
109 );
110 expect(sent.length).toBe(before);
111 });
112
113 test("with no mail set up, it says to ask the owner", async () => {
114 const cookie = await person(bare, "hopper");
115 expect(flash(await press(bareServers, cookie))).toBe(
116 "This forge sends no mail yet. Ask its owner to verify you.",
117 );
118 });
119
120 test("signed out and cross-origin posts are refused", async () => {
121 const cookie = await person(app, "turing");
122 const before = sent.length;
123 const out = await press(servers, undefined);
124 expect(out.status).toBe(303);
125 expect(out.headers.get("location")).toStartWith("/signin");
126 expect(out.headers.get("set-cookie") ?? "").not.toContain("link");
127 expect(sent.length).toBe(before);
128 const cross = await press(servers, cookie, "https://evil.example");
129 expect(cross.status).toBe(403);
130 expect(sent.length).toBe(before);
131 });
132 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs