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

magic-link.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.

magic-link.test.tsBlame687 lines · 2 contributors
cd4f63bTest User1/**
2 * Block Q2 — Magic-link sign-in.
3 *
4 * Covers `src/lib/magic-link.ts` (token primitives, start, consume) and
5 * the `src/routes/magic-link.tsx` HTTP surface (GET/POST /login/magic,
6 * GET /login/magic/callback).
7 *
8 * Strategy mirrors password-reset.test.ts:
9 * - The library tests stub the `db` module (K1 spread-from-real pattern)
10 * so they don't require Neon. Rows live in in-memory arrays keyed by
11 * `tableName(t)` derived from drizzle column metadata.
12 * - Email sending is replaced via `__setEmailForTests`.
13 * - The HTTP-surface tests use `app.request(...)` so the real Hono
14 * router + csrf middleware answer.
15 *
16 * All `mock.module(...)` calls capture the REAL module first and restore
17 * in `afterAll` so we don't poison test files that run after us.
18 */
19
20import {
21 describe,
22 it,
23 expect,
24 mock,
25 beforeEach,
26 afterEach,
27 afterAll,
28} from "bun:test";
29
30// Capture real modules BEFORE any mock.module() so afterAll can restore.
31const _real_db = await import("../db");
32
33// ---------------------------------------------------------------------------
34// In-memory DB stub
35// ---------------------------------------------------------------------------
36
37interface FakeRow { [k: string]: any; }
38interface FakeStore {
39 users: FakeRow[];
40 magic_link_tokens: FakeRow[];
41 sessions: FakeRow[];
42}
43
44const store: FakeStore = { users: [], magic_link_tokens: [], sessions: [] };
45
46function resetStore() {
47 store.users = [];
48 store.magic_link_tokens = [];
49 store.sessions = [];
50}
51
52function tableName(t: any): keyof FakeStore | "?" {
53 if (!t || typeof t !== "object") return "?";
54 // magic_link_tokens: has tokenHash AND email (P1 reset tokens have no email)
55 if ("tokenHash" in t && "email" in t && "usedAt" in t) return "magic_link_tokens";
56 // sessions: token + expiresAt but no tokenHash
57 if ("token" in t && "expiresAt" in t && !("tokenHash" in t)) return "sessions";
58 // users: passwordHash + username
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 swap `eq` for a
80 * plain marker object. That is a process-wide replacement of a module every
81 * file in the codebase imports, and `mock.module` cannot rebind namespaces an
82 * importer already holds — so restoring it in afterAll did nothing for
83 * anything loaded during the window. It leaked into src/lib/playground.ts
84 * (which imports and/eq/isNotNull/lt), whose queries then matched the wrong
85 * rows: claimPlaygroundAccount and purgeExpiredPlaygroundAccounts failed in a
86 * full run while passing in isolation. Bisected to this file.
87 *
88 * Scoping the mock is not possible either, because static `import` is hoisted
89 * above every top-level statement — the module under test loads before any
90 * mock.module call can run.
91 *
92 * So: no mock. A real `eq(users.email, "x")` is an SQL object whose
93 * queryChunks are [Column, StringChunk(" = "), Param]. Pull the column name
94 * off the first chunk carrying `.name`, and the bound value off the first
95 * carrying `.value`.
96 */
97function readEq(where: any): { key: string; value: unknown } | null {
98 if (!where || typeof where !== "object") return null;
99
100 // Marker form, still produced by the local makeEq() helper for the unit
101 // tests that build predicates directly.
102 if (where.__op === "eq") {
103 const k = colKey(where.lhs);
104 return k ? { key: k, value: where.rhs } : null;
105 }
106
107 // Real shape of eq(users.email, "x"), verified by inspection:
108 // [0] StringChunk { value: "(" }
109 // [1] PgText { name: "email", ... } <- the column
110 // [2] StringChunk { value: " = " }
111 // [3] Param { brand, value, encoder } <- the bound value
112 // [4] StringChunk { value: ")" }
113 //
114 // StringChunk ALSO carries `.value`, so "first object with a value" picks up
115 // "(" — which is what broke the first attempt at this. The Param is the one
116 // carrying an `encoder`.
117 const chunks: any[] = where.queryChunks ?? [];
118 let key: string | null = null;
119 let value: unknown;
120 let sawValue = false;
121 for (const chunk of chunks) {
122 if (!chunk || typeof chunk !== "object") continue;
123 // Composite predicates (and/or) nest SQL objects — recurse into the first
124 // eq we can read rather than silently matching everything.
125 if (Array.isArray(chunk.queryChunks)) {
126 const nested = readEq(chunk);
127 if (nested) return nested;
128 continue;
129 }
130 if (key === null && typeof chunk.name === "string") key = colKey(chunk);
131 if (!sawValue && "encoder" in chunk && "value" in chunk) {
132 value = (chunk as { value: unknown }).value;
133 sawValue = true;
134 }
135 }
136 return key && sawValue ? { key, value } : null;
137}
138
cd4f63bTest User139function applyFilter(rows: FakeRow[], where: any): FakeRow[] {
140 if (!where) return rows;
bdf47f0ccantynz-alt141 const parsed = readEq(where);
142 // An unrecognised predicate must NOT silently match everything — that turns
143 // a broken filter into a passing test.
144 if (!parsed) return rows;
145 return rows.filter((r) => r[parsed.key] === parsed.value);
cd4f63bTest User146}
147
148const _inserted: { table: string; values: any }[] = [];
149
150let _lastSelectTable: keyof FakeStore | "?" = "?";
151let _lastUpdateTable: keyof FakeStore | "?" = "?";
152let _lastDeleteTable: keyof FakeStore | "?" = "?";
153
154const selectChain: any = {
155 from(t: any) { _lastSelectTable = tableName(t); return selectChain; },
156 where(w: any) { _whereFilter = w; return selectChain; },
157 orderBy() { return selectChain; },
158 async limit(_n: number) {
159 const tbl = _lastSelectTable;
160 const where = _whereFilter;
161 _whereFilter = null;
162 if (tbl === "?") return [];
163 const rows = applyFilter(store[tbl] as FakeRow[], where);
164 return rows.slice(0, _n);
165 },
166};
167
168const fakeDb: any = {
169 select(_s?: any) { return selectChain; },
170 insert(t: any) {
171 const tbl = tableName(t);
172 return {
173 async values(v: any) {
174 const row = { ...v, id: v.id || crypto.randomUUID() };
175 if (!row.createdAt) row.createdAt = new Date();
176 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
177 _inserted.push({ table: tbl, values: row });
178 return { rows: [row] };
179 },
180 returning() {
181 return {
182 async values(v: any) {
183 const row = { ...v, id: v.id || crypto.randomUUID() };
184 if (!row.createdAt) row.createdAt = new Date();
185 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
186 _inserted.push({ table: tbl, values: row });
187 return [row];
188 },
189 };
190 },
191 };
192 },
193 update(t: any) {
194 _lastUpdateTable = tableName(t);
195 return {
196 set(s: any) {
197 const tbl = _lastUpdateTable;
198 return {
199 async where(w: any) {
200 if (tbl === "?") return;
201 const rows = applyFilter(store[tbl] as FakeRow[], w);
202 for (const r of rows) Object.assign(r, s);
203 },
204 };
205 },
206 };
207 },
208 delete(t: any) {
209 _lastDeleteTable = tableName(t);
210 return {
211 async where(w: any) {
212 const tbl = _lastDeleteTable;
213 if (tbl === "?") return;
214 const remaining = (store[tbl] as FakeRow[]).filter(
215 (r) => !applyFilter([r], w).length
216 );
217 (store[tbl] as FakeRow[]).length = 0;
218 (store[tbl] as FakeRow[]).push(...remaining);
219 },
220 };
221 },
222};
223
224mock.module("../db", () => ({ ..._real_db, db: fakeDb }));
225
bdf47f0ccantynz-alt226// NOTE: drizzle-orm is deliberately NOT mocked — see readEq() above.
227
cd4f63bTest User228
229// ---------------------------------------------------------------------------
230// Email seam
231// ---------------------------------------------------------------------------
232
233const _emails: { to: string; subject: string; text: string; html?: string }[] = [];
234
235import {
236 generateMagicLinkToken,
237 startMagicLinkSignIn,
238 consumeMagicLinkToken,
239 buildMagicLinkUrl,
240 __setEmailForTests,
241} from "../lib/magic-link";
242
243__setEmailForTests((msg: any) => {
244 _emails.push({ to: msg.to, subject: msg.subject, text: msg.text, html: msg.html });
245 return { ok: true, provider: "log" as const };
246});
247
248afterAll(() => {
249 __setEmailForTests(null);
250 mock.module("../db", () => _real_db);
251});
252
253beforeEach(() => {
254 resetStore();
255 _inserted.length = 0;
256 _emails.length = 0;
257});
258
259// ---------------------------------------------------------------------------
260// Helpers
261// ---------------------------------------------------------------------------
262
263async function sha256Hex(input: string): Promise<string> {
264 const buf = new TextEncoder().encode(input);
265 const digest = await crypto.subtle.digest("SHA-256", buf);
266 return Array.from(new Uint8Array(digest))
267 .map((b) => b.toString(16).padStart(2, "0"))
268 .join("");
269}
270
271function seedUser(overrides: Partial<FakeRow> = {}): FakeRow {
272 const u: FakeRow = {
273 id: crypto.randomUUID(),
274 username: "ada",
275 email: "ada@example.com",
276 passwordHash: "$2b$10$oldhashplaceholder0123456789012345678901234567890123",
277 emailVerifiedAt: null,
278 updatedAt: new Date(),
279 ...overrides,
280 };
281 store.users.push(u);
282 return u;
283}
284
285function seedToken(overrides: Partial<FakeRow> = {}): { row: FakeRow; plaintext: string } {
286 const { plaintext, hash } = generateMagicLinkToken();
287 const row: FakeRow = {
288 id: crypto.randomUUID(),
289 email: "ada@example.com",
290 userId: null,
291 tokenHash: hash,
292 expiresAt: new Date(Date.now() + 60_000),
293 usedAt: null,
294 requestIp: null,
295 createdAt: new Date(),
296 ...overrides,
297 };
298 store.magic_link_tokens.push(row);
299 return { row, plaintext };
300}
301
302// Give the fire-and-forget email send a tick to land in the recorder.
303async function flushMicrotasks() {
304 await new Promise((r) => setTimeout(r, 5));
305}
306
307// ---------------------------------------------------------------------------
308// generateMagicLinkToken
309// ---------------------------------------------------------------------------
310
311describe("generateMagicLinkToken", () => {
312 it("emits 64 hex chars of plaintext + a matching sha256 hash", async () => {
313 const { plaintext, hash } = generateMagicLinkToken();
314 expect(plaintext).toMatch(/^[0-9a-f]{64}$/);
315 expect(hash).toMatch(/^[0-9a-f]{64}$/);
316 expect(hash).toBe(await sha256Hex(plaintext));
317 });
318
319 it("emits unique plaintexts on repeated calls", () => {
320 const a = generateMagicLinkToken();
321 const b = generateMagicLinkToken();
322 expect(a.plaintext).not.toBe(b.plaintext);
323 expect(a.hash).not.toBe(b.hash);
324 });
325});
326
327// ---------------------------------------------------------------------------
328// startMagicLinkSignIn
329// ---------------------------------------------------------------------------
330
331describe("startMagicLinkSignIn", () => {
332 it("creates a token row linked to an existing user and queues an email", async () => {
333 const u = seedUser({ email: "ada@example.com", username: "ada" });
334 const res = await startMagicLinkSignIn("ada@example.com", { requestIp: "127.0.0.1" });
335 expect(res.ok).toBe(true);
336 await flushMicrotasks();
337
338 expect(store.magic_link_tokens.length).toBe(1);
339 const row = store.magic_link_tokens[0]!;
340 expect(row.userId).toBe(u.id);
341 expect(row.email).toBe("ada@example.com");
342 expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/);
343 expect(row.expiresAt instanceof Date).toBe(true);
344 expect(row.requestIp).toBe("127.0.0.1");
345
346 expect(_emails.length).toBe(1);
347 expect(_emails[0]!.to).toBe("ada@example.com");
348 expect(_emails[0]!.subject).toBe("Your Gluecron sign-in link");
349 expect(_emails[0]!.text).toContain("expires in 15 minutes");
350 expect(_emails[0]!.text).toContain("/login/magic/callback?token=");
351 expect(_emails[0]!.html).toContain("Sign in");
352 });
353
354 it("returns ok for non-existent email AND still mints a token row (userId=null) so consume can auto-create", async () => {
355 const res = await startMagicLinkSignIn("ghost@example.com");
356 expect(res.ok).toBe(true);
357 await flushMicrotasks();
358
359 expect(store.magic_link_tokens.length).toBe(1);
360 const row = store.magic_link_tokens[0]!;
361 expect(row.userId).toBeFalsy();
362 expect(row.email).toBe("ghost@example.com");
363 expect(_emails.length).toBe(1);
364 expect(_emails[0]!.to).toBe("ghost@example.com");
365 });
366
367 it("does NOT mint a token when autoCreate=false and email is unknown", async () => {
368 const res = await startMagicLinkSignIn("ghost@example.com", { autoCreate: false });
369 expect(res.ok).toBe(true);
370 await flushMicrotasks();
371 expect(store.magic_link_tokens.length).toBe(0);
372 expect(_emails.length).toBe(0);
373 });
374
375 it("returns ok for garbage inputs without throwing", async () => {
376 expect((await startMagicLinkSignIn("")).ok).toBe(true);
377 expect((await startMagicLinkSignIn("not-an-email")).ok).toBe(true);
378 expect(store.magic_link_tokens.length).toBe(0);
379 expect(_emails.length).toBe(0);
380 });
381
382 it("normalizes email case before lookup", async () => {
383 seedUser({ email: "ada@example.com", username: "ada" });
384 const res = await startMagicLinkSignIn("ADA@Example.COM");
385 expect(res.ok).toBe(true);
386 await flushMicrotasks();
387 expect(store.magic_link_tokens.length).toBe(1);
388 expect(store.magic_link_tokens[0]!.email).toBe("ada@example.com");
389 });
390
391 it("enforces the per-email rate limit (3 mints / hour)", async () => {
392 seedUser({ email: "ada@example.com" });
393 for (let i = 0; i < 3; i++) {
394 const r = await startMagicLinkSignIn("ada@example.com");
395 expect(r.ok).toBe(true);
396 }
397 await flushMicrotasks();
398 expect(store.magic_link_tokens.length).toBe(3);
399
400 // 4th call still returns ok (generic) but does NOT create another token.
401 const r4 = await startMagicLinkSignIn("ada@example.com");
402 expect(r4.ok).toBe(true);
403 await flushMicrotasks();
404 expect(store.magic_link_tokens.length).toBe(3);
405 });
406});
407
408// ---------------------------------------------------------------------------
409// consumeMagicLinkToken
410// ---------------------------------------------------------------------------
411
412describe("consumeMagicLinkToken", () => {
413 it("rejects garbage tokens with reason=invalid", async () => {
414 const res = await consumeMagicLinkToken("not-a-real-token");
415 expect(res.ok).toBe(false);
416 expect(res.reason).toBe("invalid");
417 });
418
419 it("rejects an empty token", async () => {
420 const res = await consumeMagicLinkToken("");
421 expect(res.ok).toBe(false);
422 expect(res.reason).toBe("invalid");
423 });
424
425 it("rejects expired tokens", async () => {
426 const u = seedUser();
427 const { plaintext } = seedToken({
428 userId: u.id,
429 expiresAt: new Date(Date.now() - 60_000),
430 });
431 const res = await consumeMagicLinkToken(plaintext);
432 expect(res.ok).toBe(false);
433 expect(res.reason).toBe("expired");
434 });
435
436 it("rejects already-used tokens", async () => {
437 const u = seedUser();
438 const { plaintext } = seedToken({
439 userId: u.id,
440 usedAt: new Date(Date.now() - 1000),
441 });
442 const res = await consumeMagicLinkToken(plaintext);
443 expect(res.ok).toBe(false);
444 expect(res.reason).toBe("used");
445 });
446
447 it("happy path with existing user: returns userId, marks token used, no account created", async () => {
448 const u = seedUser({ email: "ada@example.com", username: "ada" });
449 const { row, plaintext } = seedToken({ userId: u.id, email: "ada@example.com" });
450
451 const res = await consumeMagicLinkToken(plaintext);
452 expect(res.ok).toBe(true);
453 expect(res.userId).toBe(u.id);
454 expect(res.createdAccount).toBe(false);
455
456 const updated = store.magic_link_tokens.find((r) => r.id === row.id)!;
457 expect(updated.usedAt instanceof Date).toBe(true);
458
459 // No new user row.
460 expect(store.users.length).toBe(1);
461 });
462
463 it("happy path with no existing user: creates account, returns userId + createdAccount=true", async () => {
464 const { plaintext } = seedToken({ userId: null, email: "ghost@example.com" });
465
466 const res = await consumeMagicLinkToken(plaintext);
467 expect(res.ok).toBe(true);
468 expect(res.userId).toBeTruthy();
469 expect(res.createdAccount).toBe(true);
470
471 // A fresh user row was minted for the email.
472 expect(store.users.length).toBe(1);
473 const created = store.users[0]!;
474 expect(created.email).toBe("ghost@example.com");
475 expect(created.username).toMatch(/^user-[a-z0-9]{8,}$/);
476 // The placeholder password hash exists but is not the plaintext.
477 expect(created.passwordHash).toBeTruthy();
478 expect(created.passwordHash).not.toBe(plaintext);
479 // The click verified the address.
480 expect(created.emailVerifiedAt instanceof Date).toBe(true);
481 });
482
483 it("invalidates all other unused magic-link tokens for the same email on success", async () => {
484 const u = seedUser({ email: "ada@example.com", username: "ada" });
485 const { plaintext } = seedToken({ userId: u.id, email: "ada@example.com" });
486 // Two more outstanding tokens for the same email — should be burned.
487 const t2 = seedToken({ userId: u.id, email: "ada@example.com" });
488 const t3 = seedToken({ userId: u.id, email: "ada@example.com" });
489 // A token for a DIFFERENT email — must NOT be touched.
490 const otherUser = seedUser({ email: "bob@example.com", username: "bob" });
491 const tOther = seedToken({ userId: otherUser.id, email: "bob@example.com" });
492
493 const res = await consumeMagicLinkToken(plaintext);
494 expect(res.ok).toBe(true);
495
496 // Every ada@example.com row is now used.
497 const adaRows = store.magic_link_tokens.filter((r) => r.email === "ada@example.com");
498 expect(adaRows.length).toBe(3);
499 for (const r of adaRows) expect(r.usedAt instanceof Date).toBe(true);
500
501 // The bob@example.com row is untouched.
502 const bobRow = store.magic_link_tokens.find((r) => r.id === tOther.row.id)!;
503 expect(bobRow.usedAt).toBe(null);
504
505 // A second consume of one of the burned siblings is rejected as used.
506 const second = await consumeMagicLinkToken(t2.plaintext);
507 expect(second.ok).toBe(false);
508 expect(second.reason).toBe("used");
509 // Same for t3.
510 const third = await consumeMagicLinkToken(t3.plaintext);
511 expect(third.ok).toBe(false);
512 expect(third.reason).toBe("used");
513 });
514});
515
516// ---------------------------------------------------------------------------
517// buildMagicLinkUrl
518// ---------------------------------------------------------------------------
519
520describe("buildMagicLinkUrl", () => {
521 it("includes the token in the callback path", () => {
522 const u = buildMagicLinkUrl("abc123");
523 expect(u).toContain("/login/magic/callback?token=abc123");
524 });
525});
526
527// ---------------------------------------------------------------------------
bdf47f0ccantynz-alt528// HTTP surface — mount ONLY the router under test, after the module mocks.
cd4f63bTest User529// ---------------------------------------------------------------------------
bdf47f0ccantynz-alt530//
531// This previously did `await import("../app")`, which pulls the ENTIRE route
532// graph — ~200 modules including routes/playground -> lib/playground — into
533// memory while `../db` and `drizzle-orm` are mocked. Every one of those
534// modules captured this file's fake `db` at import time.
535//
536// The afterAll restore below cannot undo that. `mock.module` swaps the module
537// in the registry; it does not rebind namespaces that importers already hold.
538// So the fakes leaked for the rest of the run, and playground.test.ts's own
539// "dynamic import after mock.module" trick silently did nothing because
540// lib/playground was already cached bound to OUR fake. That is what made
541// claimPlaygroundAccount and purgeExpiredPlaygroundAccounts fail in a full
542// run while passing in isolation (bisected to this file).
543//
544// Mounting just the magic-link router keeps the fake db confined to the graph
545// actually under test, which is also the better unit boundary: these cases
546// exercise /login/magic, not the other 199 routes.
547const { Hono: TestHono } = await import("hono");
548const { csrfToken, csrfProtect } = await import("../middleware/csrf");
549const magicLinkRouter = (await import("../routes/magic-link")).default;
550
551const { rateLimit } = await import("../middleware/rate-limit");
552
553const app = new TestHono();
554// Mirror the middleware app.tsx installs for this route, or these assertions
555// stop meaning anything: the page renders a CSRF field, the POST path is
556// CSRF-protected, and the throttle is what the 429 case exercises. Keep the
557// limit identical to app.tsx's `rateLimit(5, 60_000, "magic-link")`.
558app.use("*", csrfToken);
559app.use("*", csrfProtect);
560app.use("/login/magic", rateLimit(5, 60_000, "magic-link"));
561app.route("/", magicLinkRouter);
cd4f63bTest User562
563describe("GET /login/magic", () => {
564 it("renders the email-entry form with a CSRF input", async () => {
565 const res = await app.request("/login/magic");
566 expect(res.status).toBe(200);
567 const html = await res.text();
568 expect(html).toContain("Sign in with a magic link");
569 expect(html).toContain('name="email"');
570 expect(html).toContain('name="_csrf"');
571 expect(html).toContain("Send me a sign-in link");
572 });
573
574 it("?sent=1 renders the generic success page", async () => {
575 const res = await app.request("/login/magic?sent=1");
576 expect(res.status).toBe(200);
577 const html = await res.text();
578 expect(html).toContain("Check your inbox");
579 expect(html).toContain("expires in 15 minutes");
580 });
581});
582
583describe("POST /login/magic", () => {
584 it("always redirects to ?sent=1 regardless of whether the email exists", async () => {
585 seedUser({ email: "ada@example.com" });
586 const res1 = await app.request("/login/magic", {
587 method: "POST",
588 headers: { "content-type": "application/x-www-form-urlencoded" },
589 body: new URLSearchParams({ email: "ada@example.com" }),
590 });
591 expect(res1.status).toBe(302);
592 expect(res1.headers.get("location")).toContain("/login/magic?sent=1");
593
594 const res2 = await app.request("/login/magic", {
595 method: "POST",
596 headers: { "content-type": "application/x-www-form-urlencoded" },
597 body: new URLSearchParams({ email: "ghost@example.com" }),
598 });
599 expect(res2.status).toBe(302);
600 expect(res2.headers.get("location")).toContain("/login/magic?sent=1");
601 });
602});
603
604describe("GET /login/magic/callback", () => {
605 it("happy path with existing user → 302 to /dashboard with a session cookie", async () => {
606 const u = seedUser({ email: "ada@example.com", username: "ada" });
607 const { plaintext } = seedToken({ userId: u.id, email: "ada@example.com" });
608
609 const res = await app.request(`/login/magic/callback?token=${encodeURIComponent(plaintext)}`);
610 expect(res.status).toBe(302);
611 expect(res.headers.get("location")).toBe("/dashboard");
612 const setCookie = res.headers.get("set-cookie") || "";
613 expect(setCookie).toContain("session=");
614 });
615
616 it("happy path with no existing user → 302 to /onboarding?welcome=1 and creates account", async () => {
617 const { plaintext } = seedToken({ userId: null, email: "ghost@example.com" });
618
619 const res = await app.request(`/login/magic/callback?token=${encodeURIComponent(plaintext)}`);
620 expect(res.status).toBe(302);
621 expect(res.headers.get("location")).toBe("/onboarding?welcome=1");
622 expect(res.headers.get("set-cookie") || "").toContain("session=");
623
624 expect(store.users.length).toBe(1);
625 expect(store.users[0]!.email).toBe("ghost@example.com");
626 });
627
628 it("invalid/garbage token → renders the dead-link page", async () => {
629 const res = await app.request("/login/magic/callback?token=garbage");
630 expect(res.status).toBe(200);
631 const html = await res.text();
632 expect(html).toContain("This link is no longer valid");
633 expect(html).toContain("/login/magic");
634 });
635
636 it("no token query → renders the dead-link page", async () => {
637 const res = await app.request("/login/magic/callback");
638 expect(res.status).toBe(200);
639 const html = await res.text();
640 expect(html).toContain("This link is no longer valid");
641 });
642
643 it("expired token → renders the dead-link page", async () => {
644 const u = seedUser();
645 const { plaintext } = seedToken({
646 userId: u.id,
647 expiresAt: new Date(Date.now() - 60_000),
648 });
649 const res = await app.request(`/login/magic/callback?token=${encodeURIComponent(plaintext)}`);
650 expect(res.status).toBe(200);
651 const html = await res.text();
652 expect(html).toContain("This link is no longer valid");
653 });
654});
655
656// ---------------------------------------------------------------------------
657// Rate limit — temporarily flip out of "test" env so the in-memory limiter
658// is actually enforced.
659// ---------------------------------------------------------------------------
660
661describe("rate limit on /login/magic", () => {
662 const _origNodeEnv = process.env.NODE_ENV;
663 const _origBunEnv = process.env.BUN_ENV;
664
665 afterEach(() => {
666 process.env.NODE_ENV = _origNodeEnv;
667 process.env.BUN_ENV = _origBunEnv;
668 });
669
670 it("returns 429 after 5 requests inside the 60s window", async () => {
671 process.env.NODE_ENV = "development";
672 process.env.BUN_ENV = "development";
673
674 const headers = {
675 "content-type": "application/x-www-form-urlencoded",
676 "x-forwarded-for": "203.0.113.77",
677 } as Record<string, string>;
678 const body = () => new URLSearchParams({ email: "ghost@example.com" });
679
680 for (let i = 0; i < 5; i++) {
681 const res = await app.request("/login/magic", { method: "POST", headers, body: body() });
682 expect(res.status).toBe(302);
683 }
684 const sixth = await app.request("/login/magic", { method: "POST", headers, body: body() });
685 expect(sixth.status).toBe(429);
686 });
687});