Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame658 lines · 1 contributor
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
76function applyFilter(rows: FakeRow[], where: any): FakeRow[] {
77 if (!where) return rows;
78 const k = colKey(where.lhs);
79 if (!k) return rows;
80 return rows.filter((r) => r[k] === where.rhs);
81}
82
83const _inserted: { table: string; values: any }[] = [];
84
85let _lastSelectTable: keyof FakeStore | "?" = "?";
86let _lastUpdateTable: keyof FakeStore | "?" = "?";
87let _lastDeleteTable: keyof FakeStore | "?" = "?";
88
89const selectChain: any = {
90 from(t: any) { _lastSelectTable = tableName(t); return selectChain; },
91 where(w: any) { _whereFilter = w; return selectChain; },
92 orderBy() { return selectChain; },
93 async limit(_n: number) {
94 const tbl = _lastSelectTable;
95 const where = _whereFilter;
96 _whereFilter = null;
97 if (tbl === "?") return [];
98 const rows = applyFilter(store[tbl] as FakeRow[], where);
99 return rows.slice(0, _n);
100 },
101};
102
103const fakeDb: any = {
104 select(_s?: any) { return selectChain; },
105 insert(t: any) {
106 const tbl = tableName(t);
107 return {
108 async values(v: any) {
109 const row = { ...v, id: v.id || crypto.randomUUID() };
110 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
111 _inserted.push({ table: tbl, values: row });
112 return { rows: [row] };
113 },
114 returning() {
115 return {
116 async values(v: any) {
117 const row = { ...v, id: v.id || crypto.randomUUID() };
118 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
119 _inserted.push({ table: tbl, values: row });
120 return [row];
121 },
122 };
123 },
124 };
125 },
126 update(t: any) {
127 _lastUpdateTable = tableName(t);
128 return {
129 set(s: any) {
130 const tbl = _lastUpdateTable;
131 return {
132 async where(w: any) {
133 if (tbl === "?") return;
134 const rows = applyFilter(store[tbl] as FakeRow[], w);
135 for (const r of rows) Object.assign(r, s);
136 },
137 };
138 },
139 };
140 },
141 delete(t: any) {
142 _lastDeleteTable = tableName(t);
143 return {
144 async where(w: any) {
145 const tbl = _lastDeleteTable;
146 if (tbl === "?") return;
147 const remaining = (store[tbl] as FakeRow[]).filter(
148 (r) => !applyFilter([r], w).length
149 );
150 (store[tbl] as FakeRow[]).length = 0;
151 (store[tbl] as FakeRow[]).push(...remaining);
152 },
153 };
154 },
155};
156
157mock.module("../db", () => ({ ..._real_db, db: fakeDb }));
158
159const _real_drizzle = await import("drizzle-orm");
160mock.module("drizzle-orm", () => ({
161 ..._real_drizzle,
162 eq: (lhs: any, rhs: any) => makeEq(lhs, rhs),
163}));
164
165// ---------------------------------------------------------------------------
166// Email seam
167// ---------------------------------------------------------------------------
168
169const _emails: { to: string; subject: string; text: string; html?: string }[] = [];
170
171import {
172 generateResetToken,
173 createPasswordResetRequest,
174 consumeResetToken,
175 inspectResetToken,
176 buildResetUrl,
177 __setEmailForTests,
178} from "../lib/password-reset";
179
180__setEmailForTests((msg: any) => {
181 _emails.push({ to: msg.to, subject: msg.subject, text: msg.text, html: msg.html });
182 return { ok: true, provider: "log" as const };
183});
184
185afterAll(() => {
186 __setEmailForTests(null);
187 mock.module("../db", () => _real_db);
188 mock.module("drizzle-orm", () => _real_drizzle);
189});
190
191beforeEach(() => {
192 resetStore();
193 _inserted.length = 0;
194 _emails.length = 0;
195});
196
197// ---------------------------------------------------------------------------
198// Helpers
199// ---------------------------------------------------------------------------
200
201async function sha256Hex(input: string): Promise<string> {
202 const buf = new TextEncoder().encode(input);
203 const digest = await crypto.subtle.digest("SHA-256", buf);
204 return Array.from(new Uint8Array(digest))
205 .map((b) => b.toString(16).padStart(2, "0"))
206 .join("");
207}
208
209function seedUser(overrides: Partial<FakeRow> = {}): FakeRow {
210 const u: FakeRow = {
211 id: crypto.randomUUID(),
212 username: "ada",
213 email: "ada@example.com",
214 passwordHash: "$2b$10$oldhashplaceholder0123456789012345678901234567890123",
215 updatedAt: new Date(),
216 ...overrides,
217 };
218 store.users.push(u);
219 return u;
220}
221
222// ---------------------------------------------------------------------------
223// generateResetToken
224// ---------------------------------------------------------------------------
225
226describe("generateResetToken", () => {
227 it("emits 64 hex chars of plaintext + a matching sha256 hash", async () => {
228 const { plaintext, hash } = generateResetToken();
229 expect(plaintext).toMatch(/^[0-9a-f]{64}$/);
230 expect(hash).toMatch(/^[0-9a-f]{64}$/);
231 expect(hash).toBe(await sha256Hex(plaintext));
232 });
233
234 it("emits unique plaintexts on repeated calls", () => {
235 const a = generateResetToken();
236 const b = generateResetToken();
237 expect(a.plaintext).not.toBe(b.plaintext);
238 expect(a.hash).not.toBe(b.hash);
239 });
240});
241
242// ---------------------------------------------------------------------------
243// createPasswordResetRequest
244// ---------------------------------------------------------------------------
245
246describe("createPasswordResetRequest", () => {
247 it("creates a token row and queues an email for a known user", async () => {
248 const u = seedUser({ email: "ada@example.com", username: "ada" });
249 const res = await createPasswordResetRequest("ada@example.com", { requestIp: "127.0.0.1" });
250 expect(res.ok).toBe(true);
251
252 await new Promise((r) => setTimeout(r, 5));
253
254 expect(store.password_reset_tokens.length).toBe(1);
255 const row = store.password_reset_tokens[0]!;
256 expect(row.userId).toBe(u.id);
257 expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/);
258 expect(row.expiresAt instanceof Date).toBe(true);
259 expect(row.requestIp).toBe("127.0.0.1");
260
261 expect(_emails.length).toBe(1);
262 expect(_emails[0]!.to).toBe("ada@example.com");
263 expect(_emails[0]!.subject).toBe("Reset your Gluecron password");
264 expect(_emails[0]!.text).toContain("Hi ada,");
265 expect(_emails[0]!.text).toContain("/reset-password?token=");
266 expect(_emails[0]!.text).toContain("utm_source=password_reset");
267 expect(_emails[0]!.html).toContain("Reset password");
268 });
269
270 it("returns ok for unknown emails (no enumeration) and does NOT create a token", async () => {
271 const res = await createPasswordResetRequest("nobody@example.com");
272 expect(res.ok).toBe(true);
273 await new Promise((r) => setTimeout(r, 5));
274 expect(store.password_reset_tokens.length).toBe(0);
275 expect(_emails.length).toBe(0);
276 });
277
278 it("returns ok for garbage inputs without throwing", async () => {
279 expect((await createPasswordResetRequest("")).ok).toBe(true);
280 expect((await createPasswordResetRequest("not-an-email")).ok).toBe(true);
281 expect(store.password_reset_tokens.length).toBe(0);
282 expect(_emails.length).toBe(0);
283 });
284
285 it("normalizes email case before lookup", async () => {
286 seedUser({ email: "ada@example.com", username: "ada" });
287 const res = await createPasswordResetRequest("ADA@Example.COM");
288 expect(res.ok).toBe(true);
289 await new Promise((r) => setTimeout(r, 5));
290 expect(store.password_reset_tokens.length).toBe(1);
291 });
292});
293
294// ---------------------------------------------------------------------------
295// consumeResetToken
296// ---------------------------------------------------------------------------
297
298describe("consumeResetToken", () => {
299 it("rejects garbage tokens with reason=invalid", async () => {
300 const res = await consumeResetToken("not-a-real-token", "newpassword1");
301 expect(res.ok).toBe(false);
302 expect(res.reason).toBe("invalid");
303 });
304
305 it("rejects an empty token", async () => {
306 const res = await consumeResetToken("", "newpassword1");
307 expect(res.ok).toBe(false);
308 });
309
310 it("rejects passwords shorter than 8 chars with reason=weak", async () => {
311 const u = seedUser();
312 const { plaintext, hash } = generateResetToken();
313 store.password_reset_tokens.push({
314 id: crypto.randomUUID(),
315 userId: u.id,
316 tokenHash: hash,
317 expiresAt: new Date(Date.now() + 60_000),
318 usedAt: null,
319 });
320 const res = await consumeResetToken(plaintext, "short");
321 expect(res.ok).toBe(false);
322 expect(res.reason).toBe("weak");
323 });
324
325 it("rejects expired tokens", async () => {
326 const u = seedUser();
327 const { plaintext, hash } = generateResetToken();
328 store.password_reset_tokens.push({
329 id: crypto.randomUUID(),
330 userId: u.id,
331 tokenHash: hash,
332 expiresAt: new Date(Date.now() - 60_000),
333 usedAt: null,
334 });
335 const res = await consumeResetToken(plaintext, "longenoughpassword");
336 expect(res.ok).toBe(false);
337 expect(res.reason).toBe("expired");
338 });
339
340 it("rejects already-used tokens", async () => {
341 const u = seedUser();
342 const { plaintext, hash } = generateResetToken();
343 store.password_reset_tokens.push({
344 id: crypto.randomUUID(),
345 userId: u.id,
346 tokenHash: hash,
347 expiresAt: new Date(Date.now() + 60_000),
348 usedAt: new Date(Date.now() - 1000),
349 });
350 const res = await consumeResetToken(plaintext, "longenoughpassword");
351 expect(res.ok).toBe(false);
352 expect(res.reason).toBe("used");
353 });
354
355 it("happy path: rotates password hash, marks token used, drops sessions", async () => {
356 const u = seedUser({ passwordHash: "OLD-HASH" });
357 store.sessions.push({ id: crypto.randomUUID(), userId: u.id, token: "sess-1", expiresAt: new Date(Date.now() + 86_400_000) });
358 store.sessions.push({ id: crypto.randomUUID(), userId: u.id, token: "sess-2", expiresAt: new Date(Date.now() + 86_400_000) });
359 const other = seedUser({ username: "bob", email: "bob@example.com" });
360 store.sessions.push({ id: crypto.randomUUID(), userId: other.id, token: "sess-other", expiresAt: new Date(Date.now() + 86_400_000) });
361
362 const { plaintext, hash } = generateResetToken();
363 store.password_reset_tokens.push({
364 id: crypto.randomUUID(),
365 userId: u.id,
366 tokenHash: hash,
367 expiresAt: new Date(Date.now() + 60_000),
368 usedAt: null,
369 });
370
371 const res = await consumeResetToken(plaintext, "brandnewpass");
372 expect(res.ok).toBe(true);
373
374 const updatedUser = store.users.find((r) => r.id === u.id)!;
375 expect(updatedUser.passwordHash).not.toBe("OLD-HASH");
376 expect(updatedUser.passwordHash).not.toBe("brandnewpass");
377 expect(updatedUser.passwordHash.length).toBeGreaterThan(20);
378
379 const tokenRow = store.password_reset_tokens[0]!;
380 expect(tokenRow.usedAt instanceof Date).toBe(true);
381
382 expect(store.sessions.filter((s) => s.userId === u.id).length).toBe(0);
383 expect(store.sessions.filter((s) => s.userId === other.id).length).toBe(1);
384 });
385
386 it("a second consume of the same token is rejected as already used", async () => {
387 const u = seedUser();
388 const { plaintext, hash } = generateResetToken();
389 store.password_reset_tokens.push({
390 id: crypto.randomUUID(),
391 userId: u.id,
392 tokenHash: hash,
393 expiresAt: new Date(Date.now() + 60_000),
394 usedAt: null,
395 });
396 const first = await consumeResetToken(plaintext, "newpass-one");
397 expect(first.ok).toBe(true);
398 const second = await consumeResetToken(plaintext, "newpass-two");
399 expect(second.ok).toBe(false);
400 expect(second.reason).toBe("used");
401 });
402});
403
404// ---------------------------------------------------------------------------
405// inspectResetToken
406// ---------------------------------------------------------------------------
407
408describe("inspectResetToken", () => {
409 it("reports valid for an unused, unexpired token", async () => {
410 const u = seedUser();
411 const { plaintext, hash } = generateResetToken();
412 store.password_reset_tokens.push({
413 id: crypto.randomUUID(),
414 userId: u.id,
415 tokenHash: hash,
416 expiresAt: new Date(Date.now() + 60_000),
417 usedAt: null,
418 });
419 const r = await inspectResetToken(plaintext);
420 expect(r.valid).toBe(true);
421 });
422
423 it("reports invalid for an unknown token", async () => {
424 const r = await inspectResetToken("not-a-real-token");
425 expect(r.valid).toBe(false);
426 expect(r.reason).toBe("invalid");
427 });
428
429 it("reports expired for an expired token", async () => {
430 const u = seedUser();
431 const { plaintext, hash } = generateResetToken();
432 store.password_reset_tokens.push({
433 id: crypto.randomUUID(),
434 userId: u.id,
435 tokenHash: hash,
436 expiresAt: new Date(Date.now() - 1000),
437 usedAt: null,
438 });
439 const r = await inspectResetToken(plaintext);
440 expect(r.valid).toBe(false);
441 expect(r.reason).toBe("expired");
442 });
443});
444
445// ---------------------------------------------------------------------------
446// buildResetUrl
447// ---------------------------------------------------------------------------
448
449describe("buildResetUrl", () => {
450 it("includes the token and utm tag", () => {
451 const u = buildResetUrl("abc123");
452 expect(u).toContain("/reset-password?token=abc123");
453 expect(u).toContain("utm_source=password_reset");
454 });
455});
456
457// ---------------------------------------------------------------------------
458// HTTP surface — pull in the app AFTER the module mocks are installed.
459// ---------------------------------------------------------------------------
460
461const app = (await import("../app")).default;
462
463describe("GET /forgot-password", () => {
464 it("renders the email-entry form with a CSRF input", async () => {
465 const res = await app.request("/forgot-password");
466 expect(res.status).toBe(200);
467 const html = await res.text();
468 expect(html).toContain("Reset your password");
469 expect(html).toContain('name="email"');
470 expect(html).toContain('name="_csrf"');
471 expect(html).toContain("Send reset link");
472 });
473
474 it("?sent=1 renders the generic success page", async () => {
475 const res = await app.request("/forgot-password?sent=1");
476 expect(res.status).toBe(200);
477 const html = await res.text();
478 expect(html).toContain("Check your inbox");
479 expect(html).toContain("If we have an account");
480 });
481});
482
483describe("POST /forgot-password", () => {
484 it("always redirects to ?sent=1, regardless of whether the email exists", async () => {
485 seedUser({ email: "ada@example.com" });
486 const res1 = await app.request("/forgot-password", {
487 method: "POST",
488 headers: { "content-type": "application/x-www-form-urlencoded" },
489 body: new URLSearchParams({ email: "ada@example.com" }),
490 });
491 expect(res1.status).toBe(302);
492 expect(res1.headers.get("location")).toContain("/forgot-password?sent=1");
493
494 const res2 = await app.request("/forgot-password", {
495 method: "POST",
496 headers: { "content-type": "application/x-www-form-urlencoded" },
497 body: new URLSearchParams({ email: "ghost@example.com" }),
498 });
499 expect(res2.status).toBe(302);
500 expect(res2.headers.get("location")).toContain("/forgot-password?sent=1");
501 });
502});
503
504describe("GET /reset-password", () => {
505 it("renders the form when the token is valid", async () => {
506 const u = seedUser();
507 const { plaintext, hash } = generateResetToken();
508 store.password_reset_tokens.push({
509 id: crypto.randomUUID(),
510 userId: u.id,
511 tokenHash: hash,
512 expiresAt: new Date(Date.now() + 60_000),
513 usedAt: null,
514 });
515 const res = await app.request(`/reset-password?token=${encodeURIComponent(plaintext)}`);
516 expect(res.status).toBe(200);
517 const html = await res.text();
518 expect(html).toContain("Set a new password");
519 expect(html).toContain('name="password"');
520 expect(html).toContain('name="confirm"');
521 expect(html).toContain('name="token"');
522 });
523
524 it("shows the dead-link page for unknown tokens", async () => {
525 const res = await app.request("/reset-password?token=garbage");
526 expect(res.status).toBe(200);
527 const html = await res.text();
528 expect(html).toContain("This link is no longer valid");
529 expect(html).toContain("/forgot-password");
530 });
531
532 it("shows the dead-link page for expired tokens", async () => {
533 const u = seedUser();
534 const { plaintext, hash } = generateResetToken();
535 store.password_reset_tokens.push({
536 id: crypto.randomUUID(),
537 userId: u.id,
538 tokenHash: hash,
539 expiresAt: new Date(Date.now() - 60_000),
540 usedAt: null,
541 });
542 const res = await app.request(`/reset-password?token=${encodeURIComponent(plaintext)}`);
543 expect(res.status).toBe(200);
544 const html = await res.text();
545 expect(html).toContain("This link is no longer valid");
546 });
547
548 it("shows the dead-link page when no token query is present", async () => {
549 const res = await app.request("/reset-password");
550 expect(res.status).toBe(200);
551 const html = await res.text();
552 expect(html).toContain("This link is no longer valid");
553 });
554});
555
556describe("POST /reset-password", () => {
557 it("happy path: rotates password and redirects to /login?success=…", async () => {
558 const u = seedUser({ passwordHash: "OLD" });
559 const { plaintext, hash } = generateResetToken();
560 store.password_reset_tokens.push({
561 id: crypto.randomUUID(),
562 userId: u.id,
563 tokenHash: hash,
564 expiresAt: new Date(Date.now() + 60_000),
565 usedAt: null,
566 });
567
568 const res = await app.request("/reset-password", {
569 method: "POST",
570 headers: { "content-type": "application/x-www-form-urlencoded" },
571 body: new URLSearchParams({
572 token: plaintext,
573 password: "newpassword12",
574 confirm: "newpassword12",
575 }),
576 });
577 expect(res.status).toBe(302);
578 const loc = res.headers.get("location") || "";
579 expect(loc).toContain("/login");
580 expect(loc).toContain("success=");
581
582 const updated = store.users.find((r) => r.id === u.id)!;
583 expect(updated.passwordHash).not.toBe("OLD");
584 });
585
586 it("password / confirm mismatch redirects back with an error", async () => {
587 const u = seedUser();
588 const { plaintext, hash } = generateResetToken();
589 store.password_reset_tokens.push({
590 id: crypto.randomUUID(),
591 userId: u.id,
592 tokenHash: hash,
593 expiresAt: new Date(Date.now() + 60_000),
594 usedAt: null,
595 });
596 const res = await app.request("/reset-password", {
597 method: "POST",
598 headers: { "content-type": "application/x-www-form-urlencoded" },
599 body: new URLSearchParams({
600 token: plaintext,
601 password: "newpassword12",
602 confirm: "different12345",
603 }),
604 });
605 expect(res.status).toBe(302);
606 const loc = res.headers.get("location") || "";
607 expect(loc).toContain("/reset-password");
608 expect(loc).toContain("error=");
609 });
610
611 it("an invalid/used token renders the dead-link page", async () => {
612 const res = await app.request("/reset-password", {
613 method: "POST",
614 headers: { "content-type": "application/x-www-form-urlencoded" },
615 body: new URLSearchParams({
616 token: "not-a-real-token",
617 password: "newpassword12",
618 confirm: "newpassword12",
619 }),
620 });
621 expect(res.status).toBe(200);
622 const html = await res.text();
623 expect(html).toContain("This link is no longer valid");
624 });
625});
626
627// ---------------------------------------------------------------------------
628// Rate limit — temporarily flip out of "test" env so the in-memory limiter
629// is actually enforced.
630// ---------------------------------------------------------------------------
631
632describe("rate limit on /forgot-password", () => {
633 const _origNodeEnv = process.env.NODE_ENV;
634 const _origBunEnv = process.env.BUN_ENV;
635
636 afterEach(() => {
637 process.env.NODE_ENV = _origNodeEnv;
638 process.env.BUN_ENV = _origBunEnv;
639 });
640
641 it("returns 429 after 5 requests inside the 60s window", async () => {
642 process.env.NODE_ENV = "development";
643 process.env.BUN_ENV = "development";
644
645 const headers = {
646 "content-type": "application/x-www-form-urlencoded",
647 "x-forwarded-for": "203.0.113.55",
648 } as Record<string, string>;
649 const body = () => new URLSearchParams({ email: "ghost@example.com" });
650
651 for (let i = 0; i < 5; i++) {
652 const res = await app.request("/forgot-password", { method: "POST", headers, body: body() });
653 expect(res.status).toBe(302);
654 }
655 const sixth = await app.request("/forgot-password", { method: "POST", headers, body: body() });
656 expect(sixth.status).toBe(429);
657 });
658});