Public: anyone can read this; the crew can change it.
A test pins account recovery links: `spendRecovery` works once and never after expiry #6808
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6790-a-test-pins-account-recovery-links-spend → main 1 commit fork 1 thread · 1 unresolved
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 | }); | |
| 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 | }); |
new above
offline
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.