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

A test pins account recovery links: `spendRecovery` works once and never after expiry #6808

Merged opened this

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

Merged

scout/forge:scout/6790-a-test-pins-account-recovery-links-spend → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/6790-a-test-pins-account-recovery-links-spend was merged into main as a5d0217.

Review

Sign in to review this change.
1 file changed +125 −0
test/verify-recovery.test.ts added +125
@@ -0,0 +1,125 @@
1 /**
2 * Account recovery links (`core/verify.ts`): a link names its account before
3 * it is spent, is spent once, and dies at expiry; `markUnproven` writes a new
4 * account's unproven row and touches no other.
5 */
6 import { afterAll, beforeAll, describe, 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 { createUser } from "../src/core/users.ts";
12 import {
13 emailVerified,
14 issueLink,
15 issueRecovery,
16 markUnproven,
17 markVerified,
18 recoveryOwner,
19 resetVerification,
20 RECOVERY_TTL_MS,
21 spendRecovery,
22 } from "../src/core/verify.ts";
23
24 describe("recovery links", () => {
25 let dataDir: string;
26 let app: App;
27 let alice: string;
28 let bob: string;
29 const NOW = Date.parse("2026-01-01T00:00:00Z");
30
31 const make = async (username: string): Promise<string> => {
32 const r = await createUser(app.db, {
33 origin: "seed",
34 trusted: true,
35 username,
36 email: `${username}@forge.local`,
37 password: "a-good-passphrase",
38 adminIfFirst: true,
39 });
40 if (!r.ok) throw new Error(r.error);
41 return r.value.id;
42 };
43 const row = (id: string) =>
44 app.db
45 .query<{ verified_at: string | null }, [string]>(
46 "SELECT verified_at FROM email_check WHERE user_id = ?1",
47 )
48 .get(id);
49
50 beforeAll(async () => {
51 dataDir = await mkdtemp(join(tmpdir(), "forge-recovery-"));
52 app = createApp({ dataDir, port: 0 });
53 alice = await make("alice");
54 bob = await make("bob");
55 markVerified(app.db, alice, new Date(NOW).toISOString());
56 markVerified(app.db, bob, new Date(NOW).toISOString());
57 });
58 afterAll(async () => {
59 app.db.close();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 test("the token names its account before it is spent", () => {
64 const token = issueRecovery(app.db, alice, NOW)!;
65 expect(token).toBeString();
66 expect(recoveryOwner(app.db, token, NOW)).toBe(alice);
67 expect(recoveryOwner(app.db, token, NOW)).toBe(alice); // reading spends nothing
68 expect(spendRecovery(app.db, token, NOW)).toBe(alice);
69 expect(recoveryOwner(app.db, token, NOW)).toBeUndefined();
70 });
71
72 test("a spend returns the account once, then undefined", () => {
73 const token = issueRecovery(app.db, alice, NOW)!;
74 expect(spendRecovery(app.db, token, NOW)).toBe(alice);
75 expect(spendRecovery(app.db, token, NOW)).toBeUndefined();
76 });
77
78 test("a spend at or after expiry returns undefined; just before works", () => {
79 const at = issueRecovery(app.db, alice, NOW)!;
80 expect(spendRecovery(app.db, at, NOW + RECOVERY_TTL_MS)).toBeUndefined();
81 expect(recoveryOwner(app.db, at, NOW + RECOVERY_TTL_MS)).toBeUndefined();
82 const after = issueRecovery(app.db, alice, NOW)!;
83 expect(spendRecovery(app.db, after, NOW + RECOVERY_TTL_MS + 1)).toBeUndefined();
84 const before = issueRecovery(app.db, alice, NOW)!;
85 expect(spendRecovery(app.db, before, NOW + RECOVERY_TTL_MS - 1)).toBe(alice);
86 });
87
88 test("an unknown or empty token returns undefined", () => {
89 expect(spendRecovery(app.db, "nope", NOW)).toBeUndefined();
90 expect(recoveryOwner(app.db, "nope", NOW)).toBeUndefined();
91 expect(spendRecovery(app.db, "", NOW)).toBeUndefined();
92 expect(recoveryOwner(app.db, "", NOW)).toBeUndefined();
93 });

The comment says the account is created trusted, so proven. That is the only reason resetVerification is needed here, and the line is correct. It would be clearer to name why issueLink needs an unproven account, but this is not blocking.

94
95 test("spending one account's link leaves another's alone", () => {
96 const a = issueRecovery(app.db, alice, NOW)!;
97 const b = issueRecovery(app.db, bob, NOW)!;
98 expect(spendRecovery(app.db, a, NOW)).toBe(alice);
99 expect(recoveryOwner(app.db, b, NOW)).toBe(bob);
100 });
101
102 test("a pending verification link opens no recovery", async () => {
103 const carol = await make("carol");
104 resetVerification(app.db, carol); // created trusted, so proven; make it unproven
105 const token = issueLink(app.db, carol, "[email protected]", NOW)!;
106 expect(token).toBeString();
107 expect(recoveryOwner(app.db, token, NOW)).toBeUndefined();
108 expect(spendRecovery(app.db, token, NOW)).toBeUndefined();
109 expect(issueRecovery(app.db, carol, NOW)).toBeUndefined();
110 });
111
112 test("markUnproven writes an unproven row, keeps a proven one, touches no other", async () => {
113 const dan = await make("dan");
114 app.db.query("DELETE FROM email_check WHERE user_id = ?1").run(dan);
115 expect(row(dan)).toBeNull();
116 const before = row(bob);
117 markUnproven(app.db, dan);
118 expect(row(dan)).toEqual({ verified_at: null });
119 expect(emailVerified(app.db, dan)).toBe(false);
120 expect(row(bob)).toEqual(before);
121 expect(emailVerified(app.db, bob)).toBe(true);
122 markUnproven(app.db, bob); // never un-proves an existing row
123 expect(row(bob)).toEqual(before);
124 });
125 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs