Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

password-reset.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

password-reset.test.tsBlame733 lines · 2 contributors
c63b860Claude1/**
2 * Block P1 — Password reset flow.
3 *
4 * Covers `src/lib/password-reset.ts` (token primitives, request creation,
5 * token consumption) and the `src/routes/password-reset.tsx` HTTP surface
6 * (forgot-password GET/POST, reset-password GET/POST).
7 *
8 * Strategy:
9 * - The library tests stub the `db` module (K1 spread-from-real pattern)
10 * so they don't require Neon. The stub stores rows in in-memory arrays
11 * keyed by `tableName(t)` and re-uses the same fluent chain shape that
12 * mcp-write.test.ts established.
13 * - Email sending is replaced via `__setEmailForTests` so we can assert
14 * "an email was queued for this user" without hitting a real provider.
15 * - The HTTP-surface tests use `app.request(...)` so the real Hono router
16 * answers — including the `csrfProtect` middleware (which exempts
17 * unauth POSTs via the session-cookie shortcut).
18 *
19 * All `mock.module(...)` calls capture the REAL module first and restore
20 * in `afterAll` so we don't poison every test file that runs after us.
21 */
22
23import {
24 describe,
25 it,
26 expect,
27 mock,
28 beforeEach,
29 afterEach,
30 afterAll,
31} from "bun:test";
32
33// Capture real modules BEFORE any mock.module() so afterAll can restore them.
34const _real_db = await import("../db");
35
36// ---------------------------------------------------------------------------
37// In-memory DB stub
38// ---------------------------------------------------------------------------
39
40interface FakeRow { [k: string]: any; }
41interface FakeStore {
42 users: FakeRow[];
43 password_reset_tokens: FakeRow[];
44 sessions: FakeRow[];
45}
46
47const store: FakeStore = { users: [], password_reset_tokens: [], sessions: [] };
48
49function resetStore() {
50 store.users = [];
51 store.password_reset_tokens = [];
52 store.sessions = [];
53}
54
55function tableName(t: any): keyof FakeStore | "?" {
56 if (!t || typeof t !== "object") return "?";
57 if ("tokenHash" in t && "usedAt" in t) return "password_reset_tokens";
58 if ("token" in t && "expiresAt" in t && !("tokenHash" in t)) return "sessions";
59 if ("passwordHash" in t && "username" in t) return "users";
60 return "?";
61}
62
63let _whereFilter: any = null;
64
65function makeEq(lhs: any, rhs: any) {
66 return { __op: "eq", lhs, rhs };
67}
68
69function colKey(lhs: any): string | null {
70 if (!lhs || typeof lhs !== "object") return null;
71 const name: string = lhs.name || "";
72 const camel = name.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase());
73 return camel || null;
74}
75
bdf47f0ccantynz-alt76/**
77 * Read a column/value pair out of a REAL drizzle `eq(col, value)` predicate.
78 *
79 * This file used to `mock.module("drizzle-orm", ...)` to replace `eq` with a
80 * marker object. That swaps a module EVERY file imports, and `mock.module`
81 * cannot rebind namespaces an importer already holds — so restoring it in
82 * afterAll did nothing for anything loaded during the window. It leaked into
83 * src/lib/playground.ts (which imports and/eq/isNotNull/lt), whose queries
84 * then matched the wrong rows, so claimPlaygroundAccount and
85 * purgeExpiredPlaygroundAccounts failed in a full run while passing alone.
86 *
87 * Scoping the mock does not help either: static `import` is hoisted above
88 * every top-level statement, so the module under test loads before any
89 * mock.module call can run.
90 *
91 * Real shape of eq(users.email, "x"), verified by inspection:
92 * [0] StringChunk { value: "(" }
93 * [1] PgText { name: "email", ... } <- the column
94 * [2] StringChunk { value: " = " }
95 * [3] Param { brand, value, encoder } <- the bound value
96 * [4] StringChunk { value: ")" }
97 *
98 * StringChunk also carries `.value`, so "first object with a value" picks up
99 * "(". The Param is the chunk carrying an `encoder`.
100 */
101function readEq(where: any): { key: string; value: unknown } | null {
102 if (!where || typeof where !== "object") return null;
103
104 // Marker form, still produced by the local makeEq() helper for unit tests
105 // that build predicates directly.
106 if (where.__op === "eq") {
107 const k = colKey(where.lhs);
108 return k ? { key: k, value: where.rhs } : null;
109 }
110
111 const chunks: any[] = where.queryChunks ?? [];
112 let key: string | null = null;
113 let value: unknown;
114 let sawValue = false;
115 for (const chunk of chunks) {
116 if (!chunk || typeof chunk !== "object") continue;
117 // Composite predicates (and/or) nest SQL objects.
118 if (Array.isArray(chunk.queryChunks)) {
119 const nested = readEq(chunk);
120 if (nested) return nested;
121 continue;
122 }
123 if (key === null && typeof chunk.name === "string") key = colKey(chunk);
124 if (!sawValue && "encoder" in chunk && "value" in chunk) {
125 value = (chunk as { value: unknown }).value;
126 sawValue = true;
127 }
128 }
129 return key && sawValue ? { key, value } : null;
130}
131
c63b860Claude132function applyFilter(rows: FakeRow[], where: any): FakeRow[] {
133 if (!where) return rows;
bdf47f0ccantynz-alt134 const parsed = readEq(where);
135 // An unrecognised predicate must NOT silently match everything — that turns
136 // a broken filter into a passing test.
137 if (!parsed) return rows;
138 return rows.filter((r) => r[parsed.key] === parsed.value);
c63b860Claude139}
140
141const _inserted: { table: string; values: any }[] = [];
142
143let _lastSelectTable: keyof FakeStore | "?" = "?";
144let _lastUpdateTable: keyof FakeStore | "?" = "?";
145let _lastDeleteTable: keyof FakeStore | "?" = "?";
146
147const selectChain: any = {
148 from(t: any) { _lastSelectTable = tableName(t); return selectChain; },
149 where(w: any) { _whereFilter = w; return selectChain; },
150 orderBy() { return selectChain; },
151 async limit(_n: number) {
152 const tbl = _lastSelectTable;
153 const where = _whereFilter;
154 _whereFilter = null;
155 if (tbl === "?") return [];
156 const rows = applyFilter(store[tbl] as FakeRow[], where);
157 return rows.slice(0, _n);
158 },
159};
160
161const fakeDb: any = {
162 select(_s?: any) { return selectChain; },
163 insert(t: any) {
164 const tbl = tableName(t);
165 return {
166 async values(v: any) {
167 const row = { ...v, id: v.id || crypto.randomUUID() };
168 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
169 _inserted.push({ table: tbl, values: row });
170 return { rows: [row] };
171 },
172 returning() {
173 return {
174 async values(v: any) {
175 const row = { ...v, id: v.id || crypto.randomUUID() };
176 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
177 _inserted.push({ table: tbl, values: row });
178 return [row];
179 },
180 };
181 },
182 };
183 },
184 update(t: any) {
185 _lastUpdateTable = tableName(t);
186 return {
187 set(s: any) {
188 const tbl = _lastUpdateTable;
189 return {
190 async where(w: any) {
191 if (tbl === "?") return;
192 const rows = applyFilter(store[tbl] as FakeRow[], w);
193 for (const r of rows) Object.assign(r, s);
194 },
195 };
196 },
197 };
198 },
199 delete(t: any) {
200 _lastDeleteTable = tableName(t);
201 return {
202 async where(w: any) {
203 const tbl = _lastDeleteTable;
204 if (tbl === "?") return;
205 const remaining = (store[tbl] as FakeRow[]).filter(
206 (r) => !applyFilter([r], w).length
207 );
208 (store[tbl] as FakeRow[]).length = 0;
209 (store[tbl] as FakeRow[]).push(...remaining);
210 },
211 };
212 },
213};
214
215mock.module("../db", () => ({ ..._real_db, db: fakeDb }));
216
bdf47f0ccantynz-alt217// drizzle-orm is deliberately NOT mocked — see readEq() above.
c63b860Claude218
219// ---------------------------------------------------------------------------
220// Email seam
221// ---------------------------------------------------------------------------
222
223const _emails: { to: string; subject: string; text: string; html?: string }[] = [];
224
225import {
226 generateResetToken,
227 createPasswordResetRequest,
228 consumeResetToken,
229 inspectResetToken,
230 buildResetUrl,
231 __setEmailForTests,
232} from "../lib/password-reset";
233
234__setEmailForTests((msg: any) => {
235 _emails.push({ to: msg.to, subject: msg.subject, text: msg.text, html: msg.html });
236 return { ok: true, provider: "log" as const };
237});
238
239afterAll(() => {
240 __setEmailForTests(null);
241 mock.module("../db", () => _real_db);
242});
243
244beforeEach(() => {
245 resetStore();
246 _inserted.length = 0;
247 _emails.length = 0;
248});
249
250// ---------------------------------------------------------------------------
251// Helpers
252// ---------------------------------------------------------------------------
253
254async function sha256Hex(input: string): Promise<string> {
255 const buf = new TextEncoder().encode(input);
256 const digest = await crypto.subtle.digest("SHA-256", buf);
257 return Array.from(new Uint8Array(digest))
258 .map((b) => b.toString(16).padStart(2, "0"))
259 .join("");
260}
261
262function seedUser(overrides: Partial<FakeRow> = {}): FakeRow {
263 const u: FakeRow = {
264 id: crypto.randomUUID(),
265 username: "ada",
266 email: "ada@example.com",
267 passwordHash: "$2b$10$oldhashplaceholder0123456789012345678901234567890123",
268 updatedAt: new Date(),
269 ...overrides,
270 };
271 store.users.push(u);
272 return u;
273}
274
275// ---------------------------------------------------------------------------
276// generateResetToken
277// ---------------------------------------------------------------------------
278
279describe("generateResetToken", () => {
280 it("emits 64 hex chars of plaintext + a matching sha256 hash", async () => {
281 const { plaintext, hash } = generateResetToken();
282 expect(plaintext).toMatch(/^[0-9a-f]{64}$/);
283 expect(hash).toMatch(/^[0-9a-f]{64}$/);
284 expect(hash).toBe(await sha256Hex(plaintext));
285 });
286
287 it("emits unique plaintexts on repeated calls", () => {
288 const a = generateResetToken();
289 const b = generateResetToken();
290 expect(a.plaintext).not.toBe(b.plaintext);
291 expect(a.hash).not.toBe(b.hash);
292 });
293});
294
295// ---------------------------------------------------------------------------
296// createPasswordResetRequest
297// ---------------------------------------------------------------------------
298
299describe("createPasswordResetRequest", () => {
300 it("creates a token row and queues an email for a known user", async () => {
301 const u = seedUser({ email: "ada@example.com", username: "ada" });
302 const res = await createPasswordResetRequest("ada@example.com", { requestIp: "127.0.0.1" });
303 expect(res.ok).toBe(true);
304
305 await new Promise((r) => setTimeout(r, 5));
306
307 expect(store.password_reset_tokens.length).toBe(1);
308 const row = store.password_reset_tokens[0]!;
309 expect(row.userId).toBe(u.id);
310 expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/);
311 expect(row.expiresAt instanceof Date).toBe(true);
312 expect(row.requestIp).toBe("127.0.0.1");
313
314 expect(_emails.length).toBe(1);
315 expect(_emails[0]!.to).toBe("ada@example.com");
316 expect(_emails[0]!.subject).toBe("Reset your Gluecron password");
317 expect(_emails[0]!.text).toContain("Hi ada,");
318 expect(_emails[0]!.text).toContain("/reset-password?token=");
319 expect(_emails[0]!.text).toContain("utm_source=password_reset");
320 expect(_emails[0]!.html).toContain("Reset password");
321 });
322
323 it("returns ok for unknown emails (no enumeration) and does NOT create a token", async () => {
324 const res = await createPasswordResetRequest("nobody@example.com");
325 expect(res.ok).toBe(true);
326 await new Promise((r) => setTimeout(r, 5));
327 expect(store.password_reset_tokens.length).toBe(0);
328 expect(_emails.length).toBe(0);
329 });
330
331 it("returns ok for garbage inputs without throwing", async () => {
332 expect((await createPasswordResetRequest("")).ok).toBe(true);
333 expect((await createPasswordResetRequest("not-an-email")).ok).toBe(true);
334 expect(store.password_reset_tokens.length).toBe(0);
335 expect(_emails.length).toBe(0);
336 });
337
338 it("normalizes email case before lookup", async () => {
339 seedUser({ email: "ada@example.com", username: "ada" });
340 const res = await createPasswordResetRequest("ADA@Example.COM");
341 expect(res.ok).toBe(true);
342 await new Promise((r) => setTimeout(r, 5));
343 expect(store.password_reset_tokens.length).toBe(1);
344 });
345});
346
347// ---------------------------------------------------------------------------
348// consumeResetToken
349// ---------------------------------------------------------------------------
350
351describe("consumeResetToken", () => {
352 it("rejects garbage tokens with reason=invalid", async () => {
353 const res = await consumeResetToken("not-a-real-token", "newpassword1");
354 expect(res.ok).toBe(false);
355 expect(res.reason).toBe("invalid");
356 });
357
358 it("rejects an empty token", async () => {
359 const res = await consumeResetToken("", "newpassword1");
360 expect(res.ok).toBe(false);
361 });
362
363 it("rejects passwords shorter than 8 chars with reason=weak", async () => {
364 const u = seedUser();
365 const { plaintext, hash } = generateResetToken();
366 store.password_reset_tokens.push({
367 id: crypto.randomUUID(),
368 userId: u.id,
369 tokenHash: hash,
370 expiresAt: new Date(Date.now() + 60_000),
371 usedAt: null,
372 });
373 const res = await consumeResetToken(plaintext, "short");
374 expect(res.ok).toBe(false);
375 expect(res.reason).toBe("weak");
376 });
377
378 it("rejects expired tokens", async () => {
379 const u = seedUser();
380 const { plaintext, hash } = generateResetToken();
381 store.password_reset_tokens.push({
382 id: crypto.randomUUID(),
383 userId: u.id,
384 tokenHash: hash,
385 expiresAt: new Date(Date.now() - 60_000),
386 usedAt: null,
387 });
388 const res = await consumeResetToken(plaintext, "longenoughpassword");
389 expect(res.ok).toBe(false);
390 expect(res.reason).toBe("expired");
391 });
392
393 it("rejects already-used tokens", async () => {
394 const u = seedUser();
395 const { plaintext, hash } = generateResetToken();
396 store.password_reset_tokens.push({
397 id: crypto.randomUUID(),
398 userId: u.id,
399 tokenHash: hash,
400 expiresAt: new Date(Date.now() + 60_000),
401 usedAt: new Date(Date.now() - 1000),
402 });
403 const res = await consumeResetToken(plaintext, "longenoughpassword");
404 expect(res.ok).toBe(false);
405 expect(res.reason).toBe("used");
406 });
407
408 it("happy path: rotates password hash, marks token used, drops sessions", async () => {
409 const u = seedUser({ passwordHash: "OLD-HASH" });
410 store.sessions.push({ id: crypto.randomUUID(), userId: u.id, token: "sess-1", expiresAt: new Date(Date.now() + 86_400_000) });
411 store.sessions.push({ id: crypto.randomUUID(), userId: u.id, token: "sess-2", expiresAt: new Date(Date.now() + 86_400_000) });
412 const other = seedUser({ username: "bob", email: "bob@example.com" });
413 store.sessions.push({ id: crypto.randomUUID(), userId: other.id, token: "sess-other", expiresAt: new Date(Date.now() + 86_400_000) });
414
415 const { plaintext, hash } = generateResetToken();
416 store.password_reset_tokens.push({
417 id: crypto.randomUUID(),
418 userId: u.id,
419 tokenHash: hash,
420 expiresAt: new Date(Date.now() + 60_000),
421 usedAt: null,
422 });
423
424 const res = await consumeResetToken(plaintext, "brandnewpass");
425 expect(res.ok).toBe(true);
426
427 const updatedUser = store.users.find((r) => r.id === u.id)!;
428 expect(updatedUser.passwordHash).not.toBe("OLD-HASH");
429 expect(updatedUser.passwordHash).not.toBe("brandnewpass");
430 expect(updatedUser.passwordHash.length).toBeGreaterThan(20);
431
432 const tokenRow = store.password_reset_tokens[0]!;
433 expect(tokenRow.usedAt instanceof Date).toBe(true);
434
435 expect(store.sessions.filter((s) => s.userId === u.id).length).toBe(0);
436 expect(store.sessions.filter((s) => s.userId === other.id).length).toBe(1);
437 });
438
439 it("a second consume of the same token is rejected as already used", async () => {
440 const u = seedUser();
441 const { plaintext, hash } = generateResetToken();
442 store.password_reset_tokens.push({
443 id: crypto.randomUUID(),
444 userId: u.id,
445 tokenHash: hash,
446 expiresAt: new Date(Date.now() + 60_000),
447 usedAt: null,
448 });
449 const first = await consumeResetToken(plaintext, "newpass-one");
450 expect(first.ok).toBe(true);
451 const second = await consumeResetToken(plaintext, "newpass-two");
452 expect(second.ok).toBe(false);
453 expect(second.reason).toBe("used");
454 });
455});
456
457// ---------------------------------------------------------------------------
458// inspectResetToken
459// ---------------------------------------------------------------------------
460
461describe("inspectResetToken", () => {
462 it("reports valid for an unused, unexpired token", async () => {
463 const u = seedUser();
464 const { plaintext, hash } = generateResetToken();
465 store.password_reset_tokens.push({
466 id: crypto.randomUUID(),
467 userId: u.id,
468 tokenHash: hash,
469 expiresAt: new Date(Date.now() + 60_000),
470 usedAt: null,
471 });
472 const r = await inspectResetToken(plaintext);
473 expect(r.valid).toBe(true);
474 });
475
476 it("reports invalid for an unknown token", async () => {
477 const r = await inspectResetToken("not-a-real-token");
478 expect(r.valid).toBe(false);
479 expect(r.reason).toBe("invalid");
480 });
481
482 it("reports expired for an expired token", async () => {
483 const u = seedUser();
484 const { plaintext, hash } = generateResetToken();
485 store.password_reset_tokens.push({
486 id: crypto.randomUUID(),
487 userId: u.id,
488 tokenHash: hash,
489 expiresAt: new Date(Date.now() - 1000),
490 usedAt: null,
491 });
492 const r = await inspectResetToken(plaintext);
493 expect(r.valid).toBe(false);
494 expect(r.reason).toBe("expired");
495 });
496});
497
498// ---------------------------------------------------------------------------
499// buildResetUrl
500// ---------------------------------------------------------------------------
501
502describe("buildResetUrl", () => {
503 it("includes the token and utm tag", () => {
504 const u = buildResetUrl("abc123");
505 expect(u).toContain("/reset-password?token=abc123");
506 expect(u).toContain("utm_source=password_reset");
507 });
508});
509
510// ---------------------------------------------------------------------------
bdf47f0ccantynz-alt511// HTTP surface — mount ONLY the router under test, after the module mocks.
c63b860Claude512// ---------------------------------------------------------------------------
bdf47f0ccantynz-alt513//
514// This previously did `await import("../app")`, which drags the ENTIRE route
515// graph — ~200 modules — into memory while `../db` is mocked, so every one of
516// them captured this file's fake db at import time. The afterAll restore
517// cannot undo that: `mock.module` swaps the registry entry, it does not
518// rebind namespaces importers already hold.
519//
520// These cases exercise /forgot-password and /reset-password. Mounting just
521// that router keeps the fake db inside the graph actually under test, which
522// is also the correct unit boundary.
523const { Hono: TestHono } = await import("hono");
524const { csrfToken, csrfProtect } = await import("../middleware/csrf");
525const { rateLimit } = await import("../middleware/rate-limit");
526const passwordResetRouter = (await import("../routes/password-reset")).default;
527
528const app = new TestHono();
529// Mirror the middleware app.tsx installs for these paths, or the assertions
530// stop meaning anything: the pages render a CSRF field, the POSTs are
531// CSRF-protected, and /forgot-password is throttled. Keep the limit identical
532// to app.tsx's `rateLimit(5, 60_000, "forgot-password")`.
533app.use("*", csrfToken);
534app.use("*", csrfProtect);
535app.use("/forgot-password", rateLimit(5, 60_000, "forgot-password"));
536app.route("/", passwordResetRouter);
c63b860Claude537
538describe("GET /forgot-password", () => {
539 it("renders the email-entry form with a CSRF input", async () => {
540 const res = await app.request("/forgot-password");
541 expect(res.status).toBe(200);
542 const html = await res.text();
543 expect(html).toContain("Reset your password");
544 expect(html).toContain('name="email"');
545 expect(html).toContain('name="_csrf"');
546 expect(html).toContain("Send reset link");
547 });
548
549 it("?sent=1 renders the generic success page", async () => {
550 const res = await app.request("/forgot-password?sent=1");
551 expect(res.status).toBe(200);
552 const html = await res.text();
553 expect(html).toContain("Check your inbox");
554 expect(html).toContain("If we have an account");
555 });
556});
557
558describe("POST /forgot-password", () => {
559 it("always redirects to ?sent=1, regardless of whether the email exists", async () => {
560 seedUser({ email: "ada@example.com" });
561 const res1 = await app.request("/forgot-password", {
562 method: "POST",
563 headers: { "content-type": "application/x-www-form-urlencoded" },
564 body: new URLSearchParams({ email: "ada@example.com" }),
565 });
566 expect(res1.status).toBe(302);
567 expect(res1.headers.get("location")).toContain("/forgot-password?sent=1");
568
569 const res2 = await app.request("/forgot-password", {
570 method: "POST",
571 headers: { "content-type": "application/x-www-form-urlencoded" },
572 body: new URLSearchParams({ email: "ghost@example.com" }),
573 });
574 expect(res2.status).toBe(302);
575 expect(res2.headers.get("location")).toContain("/forgot-password?sent=1");
576 });
577});
578
579describe("GET /reset-password", () => {
580 it("renders the form when the token is valid", async () => {
581 const u = seedUser();
582 const { plaintext, hash } = generateResetToken();
583 store.password_reset_tokens.push({
584 id: crypto.randomUUID(),
585 userId: u.id,
586 tokenHash: hash,
587 expiresAt: new Date(Date.now() + 60_000),
588 usedAt: null,
589 });
590 const res = await app.request(`/reset-password?token=${encodeURIComponent(plaintext)}`);
591 expect(res.status).toBe(200);
592 const html = await res.text();
593 expect(html).toContain("Set a new password");
594 expect(html).toContain('name="password"');
595 expect(html).toContain('name="confirm"');
596 expect(html).toContain('name="token"');
597 });
598
599 it("shows the dead-link page for unknown tokens", async () => {
600 const res = await app.request("/reset-password?token=garbage");
601 expect(res.status).toBe(200);
602 const html = await res.text();
603 expect(html).toContain("This link is no longer valid");
604 expect(html).toContain("/forgot-password");
605 });
606
607 it("shows the dead-link page for expired tokens", async () => {
608 const u = seedUser();
609 const { plaintext, hash } = generateResetToken();
610 store.password_reset_tokens.push({
611 id: crypto.randomUUID(),
612 userId: u.id,
613 tokenHash: hash,
614 expiresAt: new Date(Date.now() - 60_000),
615 usedAt: null,
616 });
617 const res = await app.request(`/reset-password?token=${encodeURIComponent(plaintext)}`);
618 expect(res.status).toBe(200);
619 const html = await res.text();
620 expect(html).toContain("This link is no longer valid");
621 });
622
623 it("shows the dead-link page when no token query is present", async () => {
624 const res = await app.request("/reset-password");
625 expect(res.status).toBe(200);
626 const html = await res.text();
627 expect(html).toContain("This link is no longer valid");
628 });
629});
630
631describe("POST /reset-password", () => {
632 it("happy path: rotates password and redirects to /login?success=…", async () => {
633 const u = seedUser({ passwordHash: "OLD" });
634 const { plaintext, hash } = generateResetToken();
635 store.password_reset_tokens.push({
636 id: crypto.randomUUID(),
637 userId: u.id,
638 tokenHash: hash,
639 expiresAt: new Date(Date.now() + 60_000),
640 usedAt: null,
641 });
642
643 const res = await app.request("/reset-password", {
644 method: "POST",
645 headers: { "content-type": "application/x-www-form-urlencoded" },
646 body: new URLSearchParams({
647 token: plaintext,
648 password: "newpassword12",
649 confirm: "newpassword12",
650 }),
651 });
652 expect(res.status).toBe(302);
653 const loc = res.headers.get("location") || "";
654 expect(loc).toContain("/login");
655 expect(loc).toContain("success=");
656
657 const updated = store.users.find((r) => r.id === u.id)!;
658 expect(updated.passwordHash).not.toBe("OLD");
659 });
660
661 it("password / confirm mismatch redirects back with an error", async () => {
662 const u = seedUser();
663 const { plaintext, hash } = generateResetToken();
664 store.password_reset_tokens.push({
665 id: crypto.randomUUID(),
666 userId: u.id,
667 tokenHash: hash,
668 expiresAt: new Date(Date.now() + 60_000),
669 usedAt: null,
670 });
671 const res = await app.request("/reset-password", {
672 method: "POST",
673 headers: { "content-type": "application/x-www-form-urlencoded" },
674 body: new URLSearchParams({
675 token: plaintext,
676 password: "newpassword12",
677 confirm: "different12345",
678 }),
679 });
680 expect(res.status).toBe(302);
681 const loc = res.headers.get("location") || "";
682 expect(loc).toContain("/reset-password");
683 expect(loc).toContain("error=");
684 });
685
686 it("an invalid/used token renders the dead-link page", async () => {
687 const res = await app.request("/reset-password", {
688 method: "POST",
689 headers: { "content-type": "application/x-www-form-urlencoded" },
690 body: new URLSearchParams({
691 token: "not-a-real-token",
692 password: "newpassword12",
693 confirm: "newpassword12",
694 }),
695 });
696 expect(res.status).toBe(200);
697 const html = await res.text();
698 expect(html).toContain("This link is no longer valid");
699 });
700});
701
702// ---------------------------------------------------------------------------
703// Rate limit — temporarily flip out of "test" env so the in-memory limiter
704// is actually enforced.
705// ---------------------------------------------------------------------------
706
707describe("rate limit on /forgot-password", () => {
708 const _origNodeEnv = process.env.NODE_ENV;
709 const _origBunEnv = process.env.BUN_ENV;
710
711 afterEach(() => {
712 process.env.NODE_ENV = _origNodeEnv;
713 process.env.BUN_ENV = _origBunEnv;
714 });
715
716 it("returns 429 after 5 requests inside the 60s window", async () => {
717 process.env.NODE_ENV = "development";
718 process.env.BUN_ENV = "development";
719
720 const headers = {
721 "content-type": "application/x-www-form-urlencoded",
722 "x-forwarded-for": "203.0.113.55",
723 } as Record<string, string>;
724 const body = () => new URLSearchParams({ email: "ghost@example.com" });
725
726 for (let i = 0; i < 5; i++) {
727 const res = await app.request("/forgot-password", { method: "POST", headers, body: body() });
728 expect(res.status).toBe(302);
729 }
730 const sixth = await app.request("/forgot-password", { method: "POST", headers, body: body() });
731 expect(sixth.status).toBe(429);
732 });
733});