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

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.tsBlame596 lines · 1 contributor
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
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 (!row.createdAt) row.createdAt = new Date();
111 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
112 _inserted.push({ table: tbl, values: row });
113 return { rows: [row] };
114 },
115 returning() {
116 return {
117 async values(v: any) {
118 const row = { ...v, id: v.id || crypto.randomUUID() };
119 if (!row.createdAt) row.createdAt = new Date();
120 if (tbl !== "?") (store[tbl] as FakeRow[]).push(row);
121 _inserted.push({ table: tbl, values: row });
122 return [row];
123 },
124 };
125 },
126 };
127 },
128 update(t: any) {
129 _lastUpdateTable = tableName(t);
130 return {
131 set(s: any) {
132 const tbl = _lastUpdateTable;
133 return {
134 async where(w: any) {
135 if (tbl === "?") return;
136 const rows = applyFilter(store[tbl] as FakeRow[], w);
137 for (const r of rows) Object.assign(r, s);
138 },
139 };
140 },
141 };
142 },
143 delete(t: any) {
144 _lastDeleteTable = tableName(t);
145 return {
146 async where(w: any) {
147 const tbl = _lastDeleteTable;
148 if (tbl === "?") return;
149 const remaining = (store[tbl] as FakeRow[]).filter(
150 (r) => !applyFilter([r], w).length
151 );
152 (store[tbl] as FakeRow[]).length = 0;
153 (store[tbl] as FakeRow[]).push(...remaining);
154 },
155 };
156 },
157};
158
159mock.module("../db", () => ({ ..._real_db, db: fakeDb }));
160
161const _real_drizzle = await import("drizzle-orm");
162mock.module("drizzle-orm", () => ({
163 ..._real_drizzle,
164 eq: (lhs: any, rhs: any) => makeEq(lhs, rhs),
165}));
166
167// ---------------------------------------------------------------------------
168// Email seam
169// ---------------------------------------------------------------------------
170
171const _emails: { to: string; subject: string; text: string; html?: string }[] = [];
172
173import {
174 generateMagicLinkToken,
175 startMagicLinkSignIn,
176 consumeMagicLinkToken,
177 buildMagicLinkUrl,
178 __setEmailForTests,
179} from "../lib/magic-link";
180
181__setEmailForTests((msg: any) => {
182 _emails.push({ to: msg.to, subject: msg.subject, text: msg.text, html: msg.html });
183 return { ok: true, provider: "log" as const };
184});
185
186afterAll(() => {
187 __setEmailForTests(null);
188 mock.module("../db", () => _real_db);
189 mock.module("drizzle-orm", () => _real_drizzle);
190});
191
192beforeEach(() => {
193 resetStore();
194 _inserted.length = 0;
195 _emails.length = 0;
196});
197
198// ---------------------------------------------------------------------------
199// Helpers
200// ---------------------------------------------------------------------------
201
202async function sha256Hex(input: string): Promise<string> {
203 const buf = new TextEncoder().encode(input);
204 const digest = await crypto.subtle.digest("SHA-256", buf);
205 return Array.from(new Uint8Array(digest))
206 .map((b) => b.toString(16).padStart(2, "0"))
207 .join("");
208}
209
210function seedUser(overrides: Partial<FakeRow> = {}): FakeRow {
211 const u: FakeRow = {
212 id: crypto.randomUUID(),
213 username: "ada",
214 email: "ada@example.com",
215 passwordHash: "$2b$10$oldhashplaceholder0123456789012345678901234567890123",
216 emailVerifiedAt: null,
217 updatedAt: new Date(),
218 ...overrides,
219 };
220 store.users.push(u);
221 return u;
222}
223
224function seedToken(overrides: Partial<FakeRow> = {}): { row: FakeRow; plaintext: string } {
225 const { plaintext, hash } = generateMagicLinkToken();
226 const row: FakeRow = {
227 id: crypto.randomUUID(),
228 email: "ada@example.com",
229 userId: null,
230 tokenHash: hash,
231 expiresAt: new Date(Date.now() + 60_000),
232 usedAt: null,
233 requestIp: null,
234 createdAt: new Date(),
235 ...overrides,
236 };
237 store.magic_link_tokens.push(row);
238 return { row, plaintext };
239}
240
241// Give the fire-and-forget email send a tick to land in the recorder.
242async function flushMicrotasks() {
243 await new Promise((r) => setTimeout(r, 5));
244}
245
246// ---------------------------------------------------------------------------
247// generateMagicLinkToken
248// ---------------------------------------------------------------------------
249
250describe("generateMagicLinkToken", () => {
251 it("emits 64 hex chars of plaintext + a matching sha256 hash", async () => {
252 const { plaintext, hash } = generateMagicLinkToken();
253 expect(plaintext).toMatch(/^[0-9a-f]{64}$/);
254 expect(hash).toMatch(/^[0-9a-f]{64}$/);
255 expect(hash).toBe(await sha256Hex(plaintext));
256 });
257
258 it("emits unique plaintexts on repeated calls", () => {
259 const a = generateMagicLinkToken();
260 const b = generateMagicLinkToken();
261 expect(a.plaintext).not.toBe(b.plaintext);
262 expect(a.hash).not.toBe(b.hash);
263 });
264});
265
266// ---------------------------------------------------------------------------
267// startMagicLinkSignIn
268// ---------------------------------------------------------------------------
269
270describe("startMagicLinkSignIn", () => {
271 it("creates a token row linked to an existing user and queues an email", async () => {
272 const u = seedUser({ email: "ada@example.com", username: "ada" });
273 const res = await startMagicLinkSignIn("ada@example.com", { requestIp: "127.0.0.1" });
274 expect(res.ok).toBe(true);
275 await flushMicrotasks();
276
277 expect(store.magic_link_tokens.length).toBe(1);
278 const row = store.magic_link_tokens[0]!;
279 expect(row.userId).toBe(u.id);
280 expect(row.email).toBe("ada@example.com");
281 expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/);
282 expect(row.expiresAt instanceof Date).toBe(true);
283 expect(row.requestIp).toBe("127.0.0.1");
284
285 expect(_emails.length).toBe(1);
286 expect(_emails[0]!.to).toBe("ada@example.com");
287 expect(_emails[0]!.subject).toBe("Your Gluecron sign-in link");
288 expect(_emails[0]!.text).toContain("expires in 15 minutes");
289 expect(_emails[0]!.text).toContain("/login/magic/callback?token=");
290 expect(_emails[0]!.html).toContain("Sign in");
291 });
292
293 it("returns ok for non-existent email AND still mints a token row (userId=null) so consume can auto-create", async () => {
294 const res = await startMagicLinkSignIn("ghost@example.com");
295 expect(res.ok).toBe(true);
296 await flushMicrotasks();
297
298 expect(store.magic_link_tokens.length).toBe(1);
299 const row = store.magic_link_tokens[0]!;
300 expect(row.userId).toBeFalsy();
301 expect(row.email).toBe("ghost@example.com");
302 expect(_emails.length).toBe(1);
303 expect(_emails[0]!.to).toBe("ghost@example.com");
304 });
305
306 it("does NOT mint a token when autoCreate=false and email is unknown", async () => {
307 const res = await startMagicLinkSignIn("ghost@example.com", { autoCreate: false });
308 expect(res.ok).toBe(true);
309 await flushMicrotasks();
310 expect(store.magic_link_tokens.length).toBe(0);
311 expect(_emails.length).toBe(0);
312 });
313
314 it("returns ok for garbage inputs without throwing", async () => {
315 expect((await startMagicLinkSignIn("")).ok).toBe(true);
316 expect((await startMagicLinkSignIn("not-an-email")).ok).toBe(true);
317 expect(store.magic_link_tokens.length).toBe(0);
318 expect(_emails.length).toBe(0);
319 });
320
321 it("normalizes email case before lookup", async () => {
322 seedUser({ email: "ada@example.com", username: "ada" });
323 const res = await startMagicLinkSignIn("ADA@Example.COM");
324 expect(res.ok).toBe(true);
325 await flushMicrotasks();
326 expect(store.magic_link_tokens.length).toBe(1);
327 expect(store.magic_link_tokens[0]!.email).toBe("ada@example.com");
328 });
329
330 it("enforces the per-email rate limit (3 mints / hour)", async () => {
331 seedUser({ email: "ada@example.com" });
332 for (let i = 0; i < 3; i++) {
333 const r = await startMagicLinkSignIn("ada@example.com");
334 expect(r.ok).toBe(true);
335 }
336 await flushMicrotasks();
337 expect(store.magic_link_tokens.length).toBe(3);
338
339 // 4th call still returns ok (generic) but does NOT create another token.
340 const r4 = await startMagicLinkSignIn("ada@example.com");
341 expect(r4.ok).toBe(true);
342 await flushMicrotasks();
343 expect(store.magic_link_tokens.length).toBe(3);
344 });
345});
346
347// ---------------------------------------------------------------------------
348// consumeMagicLinkToken
349// ---------------------------------------------------------------------------
350
351describe("consumeMagicLinkToken", () => {
352 it("rejects garbage tokens with reason=invalid", async () => {
353 const res = await consumeMagicLinkToken("not-a-real-token");
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 consumeMagicLinkToken("");
360 expect(res.ok).toBe(false);
361 expect(res.reason).toBe("invalid");
362 });
363
364 it("rejects expired tokens", async () => {
365 const u = seedUser();
366 const { plaintext } = seedToken({
367 userId: u.id,
368 expiresAt: new Date(Date.now() - 60_000),
369 });
370 const res = await consumeMagicLinkToken(plaintext);
371 expect(res.ok).toBe(false);
372 expect(res.reason).toBe("expired");
373 });
374
375 it("rejects already-used tokens", async () => {
376 const u = seedUser();
377 const { plaintext } = seedToken({
378 userId: u.id,
379 usedAt: new Date(Date.now() - 1000),
380 });
381 const res = await consumeMagicLinkToken(plaintext);
382 expect(res.ok).toBe(false);
383 expect(res.reason).toBe("used");
384 });
385
386 it("happy path with existing user: returns userId, marks token used, no account created", async () => {
387 const u = seedUser({ email: "ada@example.com", username: "ada" });
388 const { row, plaintext } = seedToken({ userId: u.id, email: "ada@example.com" });
389
390 const res = await consumeMagicLinkToken(plaintext);
391 expect(res.ok).toBe(true);
392 expect(res.userId).toBe(u.id);
393 expect(res.createdAccount).toBe(false);
394
395 const updated = store.magic_link_tokens.find((r) => r.id === row.id)!;
396 expect(updated.usedAt instanceof Date).toBe(true);
397
398 // No new user row.
399 expect(store.users.length).toBe(1);
400 });
401
402 it("happy path with no existing user: creates account, returns userId + createdAccount=true", async () => {
403 const { plaintext } = seedToken({ userId: null, email: "ghost@example.com" });
404
405 const res = await consumeMagicLinkToken(plaintext);
406 expect(res.ok).toBe(true);
407 expect(res.userId).toBeTruthy();
408 expect(res.createdAccount).toBe(true);
409
410 // A fresh user row was minted for the email.
411 expect(store.users.length).toBe(1);
412 const created = store.users[0]!;
413 expect(created.email).toBe("ghost@example.com");
414 expect(created.username).toMatch(/^user-[a-z0-9]{8,}$/);
415 // The placeholder password hash exists but is not the plaintext.
416 expect(created.passwordHash).toBeTruthy();
417 expect(created.passwordHash).not.toBe(plaintext);
418 // The click verified the address.
419 expect(created.emailVerifiedAt instanceof Date).toBe(true);
420 });
421
422 it("invalidates all other unused magic-link tokens for the same email on success", async () => {
423 const u = seedUser({ email: "ada@example.com", username: "ada" });
424 const { plaintext } = seedToken({ userId: u.id, email: "ada@example.com" });
425 // Two more outstanding tokens for the same email — should be burned.
426 const t2 = seedToken({ userId: u.id, email: "ada@example.com" });
427 const t3 = seedToken({ userId: u.id, email: "ada@example.com" });
428 // A token for a DIFFERENT email — must NOT be touched.
429 const otherUser = seedUser({ email: "bob@example.com", username: "bob" });
430 const tOther = seedToken({ userId: otherUser.id, email: "bob@example.com" });
431
432 const res = await consumeMagicLinkToken(plaintext);
433 expect(res.ok).toBe(true);
434
435 // Every ada@example.com row is now used.
436 const adaRows = store.magic_link_tokens.filter((r) => r.email === "ada@example.com");
437 expect(adaRows.length).toBe(3);
438 for (const r of adaRows) expect(r.usedAt instanceof Date).toBe(true);
439
440 // The bob@example.com row is untouched.
441 const bobRow = store.magic_link_tokens.find((r) => r.id === tOther.row.id)!;
442 expect(bobRow.usedAt).toBe(null);
443
444 // A second consume of one of the burned siblings is rejected as used.
445 const second = await consumeMagicLinkToken(t2.plaintext);
446 expect(second.ok).toBe(false);
447 expect(second.reason).toBe("used");
448 // Same for t3.
449 const third = await consumeMagicLinkToken(t3.plaintext);
450 expect(third.ok).toBe(false);
451 expect(third.reason).toBe("used");
452 });
453});
454
455// ---------------------------------------------------------------------------
456// buildMagicLinkUrl
457// ---------------------------------------------------------------------------
458
459describe("buildMagicLinkUrl", () => {
460 it("includes the token in the callback path", () => {
461 const u = buildMagicLinkUrl("abc123");
462 expect(u).toContain("/login/magic/callback?token=abc123");
463 });
464});
465
466// ---------------------------------------------------------------------------
467// HTTP surface — pull in the app AFTER the module mocks are installed.
468// ---------------------------------------------------------------------------
469
470const app = (await import("../app")).default;
471
472describe("GET /login/magic", () => {
473 it("renders the email-entry form with a CSRF input", async () => {
474 const res = await app.request("/login/magic");
475 expect(res.status).toBe(200);
476 const html = await res.text();
477 expect(html).toContain("Sign in with a magic link");
478 expect(html).toContain('name="email"');
479 expect(html).toContain('name="_csrf"');
480 expect(html).toContain("Send me a sign-in link");
481 });
482
483 it("?sent=1 renders the generic success page", async () => {
484 const res = await app.request("/login/magic?sent=1");
485 expect(res.status).toBe(200);
486 const html = await res.text();
487 expect(html).toContain("Check your inbox");
488 expect(html).toContain("expires in 15 minutes");
489 });
490});
491
492describe("POST /login/magic", () => {
493 it("always redirects to ?sent=1 regardless of whether the email exists", async () => {
494 seedUser({ email: "ada@example.com" });
495 const res1 = await app.request("/login/magic", {
496 method: "POST",
497 headers: { "content-type": "application/x-www-form-urlencoded" },
498 body: new URLSearchParams({ email: "ada@example.com" }),
499 });
500 expect(res1.status).toBe(302);
501 expect(res1.headers.get("location")).toContain("/login/magic?sent=1");
502
503 const res2 = await app.request("/login/magic", {
504 method: "POST",
505 headers: { "content-type": "application/x-www-form-urlencoded" },
506 body: new URLSearchParams({ email: "ghost@example.com" }),
507 });
508 expect(res2.status).toBe(302);
509 expect(res2.headers.get("location")).toContain("/login/magic?sent=1");
510 });
511});
512
513describe("GET /login/magic/callback", () => {
514 it("happy path with existing user → 302 to /dashboard with a session cookie", async () => {
515 const u = seedUser({ email: "ada@example.com", username: "ada" });
516 const { plaintext } = seedToken({ userId: u.id, email: "ada@example.com" });
517
518 const res = await app.request(`/login/magic/callback?token=${encodeURIComponent(plaintext)}`);
519 expect(res.status).toBe(302);
520 expect(res.headers.get("location")).toBe("/dashboard");
521 const setCookie = res.headers.get("set-cookie") || "";
522 expect(setCookie).toContain("session=");
523 });
524
525 it("happy path with no existing user → 302 to /onboarding?welcome=1 and creates account", async () => {
526 const { plaintext } = seedToken({ userId: null, email: "ghost@example.com" });
527
528 const res = await app.request(`/login/magic/callback?token=${encodeURIComponent(plaintext)}`);
529 expect(res.status).toBe(302);
530 expect(res.headers.get("location")).toBe("/onboarding?welcome=1");
531 expect(res.headers.get("set-cookie") || "").toContain("session=");
532
533 expect(store.users.length).toBe(1);
534 expect(store.users[0]!.email).toBe("ghost@example.com");
535 });
536
537 it("invalid/garbage token → renders the dead-link page", async () => {
538 const res = await app.request("/login/magic/callback?token=garbage");
539 expect(res.status).toBe(200);
540 const html = await res.text();
541 expect(html).toContain("This link is no longer valid");
542 expect(html).toContain("/login/magic");
543 });
544
545 it("no token query → renders the dead-link page", async () => {
546 const res = await app.request("/login/magic/callback");
547 expect(res.status).toBe(200);
548 const html = await res.text();
549 expect(html).toContain("This link is no longer valid");
550 });
551
552 it("expired token → renders the dead-link page", async () => {
553 const u = seedUser();
554 const { plaintext } = seedToken({
555 userId: u.id,
556 expiresAt: new Date(Date.now() - 60_000),
557 });
558 const res = await app.request(`/login/magic/callback?token=${encodeURIComponent(plaintext)}`);
559 expect(res.status).toBe(200);
560 const html = await res.text();
561 expect(html).toContain("This link is no longer valid");
562 });
563});
564
565// ---------------------------------------------------------------------------
566// Rate limit — temporarily flip out of "test" env so the in-memory limiter
567// is actually enforced.
568// ---------------------------------------------------------------------------
569
570describe("rate limit on /login/magic", () => {
571 const _origNodeEnv = process.env.NODE_ENV;
572 const _origBunEnv = process.env.BUN_ENV;
573
574 afterEach(() => {
575 process.env.NODE_ENV = _origNodeEnv;
576 process.env.BUN_ENV = _origBunEnv;
577 });
578
579 it("returns 429 after 5 requests inside the 60s window", async () => {
580 process.env.NODE_ENV = "development";
581 process.env.BUN_ENV = "development";
582
583 const headers = {
584 "content-type": "application/x-www-form-urlencoded",
585 "x-forwarded-for": "203.0.113.77",
586 } as Record<string, string>;
587 const body = () => new URLSearchParams({ email: "ghost@example.com" });
588
589 for (let i = 0; i < 5; i++) {
590 const res = await app.request("/login/magic", { method: "POST", headers, body: body() });
591 expect(res.status).toBe(302);
592 }
593 const sixth = await app.request("/login/magic", { method: "POST", headers, body: body() });
594 expect(sixth.status).toBe(429);
595 });
596});