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

email-verification.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.

email-verification.test.tsBlame527 lines · 1 contributor
c63b860Claude1/**
2 * Block P2 — email verification + welcome email tests.
3 *
4 * We stub `../db` via `mock.module` (K1 spread-from-real pattern) so the
5 * lib's drizzle calls land on an in-memory fake instead of Neon. The email
6 * sender is swapped out via the lib's `__setEmailForTests` test seam.
7 *
8 * Bun 1.3's `bun test` shares a single module registry across every test
9 * file in a run and `mock.restore()` does NOT un-mock `mock.module(...)`
10 * registrations. To stay neighbourly:
11 * - we capture the real `../db` module before overriding so unrelated
12 * downstream tests can fall back to it via the spread;
13 * - we re-install our mock in `beforeEach` so an earlier test file's
14 * `mock.module("../db", ...)` doesn't shadow ours mid-run;
15 * - we restore the real DB module in `afterAll` so the next file's
16 * suite sees the prod contract again.
17 */
18
19import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
20import { Hono } from "hono";
21import { createHash } from "node:crypto";
22
23// Capture the real `../db` module so we can spread-from-real and so we
24// can restore it in `afterAll` for downstream test files.
25const _real_db = await import("../db");
26
27// ---------------------------------------------------------------------------
28// Fake DB — narrowly scoped to what `email-verification.ts` + the route call.
29// ---------------------------------------------------------------------------
30
31interface FakeUser {
32 id: string;
33 username: string;
34 email: string;
35 emailVerifiedAt: Date | null;
36}
37
38interface FakeToken {
39 id: string;
40 userId: string;
41 email: string;
42 tokenHash: string;
43 expiresAt: Date;
44 usedAt: Date | null;
45 createdAt: Date;
46}
47
48const _state = {
49 users: [] as FakeUser[],
50 tokens: [] as FakeToken[],
51 _lastFromTable: "" as "users" | "tokens" | "",
52};
53
54function resetState() {
55 _state.users = [];
56 _state.tokens = [];
57 _state._lastFromTable = "";
58}
59
60function tableName(t: any): "users" | "tokens" | "" {
61 if (!t || typeof t !== "object") return "";
62 if ("emailVerifiedAt" in t && "passwordHash" in t) return "users";
63 if ("emailVerifiedAt" in t && "username" in t) return "users";
64 if ("tokenHash" in t && "expiresAt" in t && "email" in t) return "tokens";
65 return "";
66}
67
68let _nextWhereFilter: ((row: any) => boolean) | null = null;
69function setNextWhereFilter(fn: (row: any) => boolean) {
70 _nextWhereFilter = fn;
71}
72
73// The drizzle chain is awaited two ways in code we proxy through:
74// await db.select(...).from(...).where(...).limit(N)
75// await db.select(...).from(...).where(...) // no limit
76// We support both by exposing both `.limit()` AND a thenable on the chain
77// itself. The thenable yields the same shape `.limit(N)` would, with
78// N=Infinity (no cap) — that's the convention drizzle uses for awaited
79// chains without an explicit limit.
80function resolveSelect(n: number = Infinity): any[] {
81 const rows =
82 _state._lastFromTable === "users"
83 ? _state.users
84 : _state._lastFromTable === "tokens"
85 ? _state.tokens
86 : [];
87 const filtered = _nextWhereFilter ? rows.filter(_nextWhereFilter) : rows;
88 _nextWhereFilter = null;
89 return filtered.slice(0, n);
90}
91
92// We model the drizzle chain as a function-target Proxy that:
93// - returns itself for any chained method (`.from`, `.where`,
94// `.orderBy`, `.leftJoin`, `.innerJoin`, etc.) so chain calls compose;
95// - exposes a `then` that resolves to the current row array, making
96// `await selectChain` work without an explicit `.limit()`;
97// - exposes `.limit(N)` for callers that DO cap the row count.
98//
99// The proxy is built on a function target rather than `{}` so the
100// resulting object reads as a thenable to V8's await machinery (some
101// edge cases with `{}` + `then` were observed where the runtime read
102// `then` through a different path and ignored it). Function targets
103// always present a clean own-property `then` slot.
104function makeSelectChain(): any {
105 function chainFn() {}
106 const handler: ProxyHandler<any> = {
107 get(_t, prop, receiver) {
108 if (prop === "then") {
109 // Standard thenable contract: `(resolve, reject) => …`. We
110 // resolve synchronously since the fake never actually waits on
111 // anything — V8 promotes us into a microtask anyway.
112 return (resolve: (v: any[]) => void) => resolve(resolveSelect());
113 }
114 if (prop === "limit") {
115 return (n: number) => Promise.resolve(resolveSelect(n));
116 }
117 if (prop === "from") {
118 return (table: any) => {
119 _state._lastFromTable = tableName(table);
120 return receiver;
121 };
122 }
123 // Any other method (where / orderBy / groupBy / leftJoin / innerJoin
124 // / rightJoin / etc.) returns the same proxy so we keep chaining.
125 // Symbols / unknown non-string keys pass through as undefined.
126 if (typeof prop !== "string") return undefined;
127 return () => receiver;
128 },
129 };
130 return new Proxy(chainFn, handler);
131}
132
133const selectChain: any = makeSelectChain();
134
135const insertChain: any = {
136 values(v: any) {
137 if (_state._lastFromTable === "tokens") {
138 _state.tokens.push({
139 id: `tok-${_state.tokens.length + 1}`,
140 userId: v.userId,
141 email: v.email,
142 tokenHash: v.tokenHash,
143 expiresAt:
144 v.expiresAt instanceof Date ? v.expiresAt : new Date(v.expiresAt),
145 usedAt: null,
146 createdAt: new Date(),
147 });
148 }
149 return Promise.resolve();
150 },
151};
152
153const updateChain: any = {
154 _pendingSet: null as any,
155 set(values: any) {
156 updateChain._pendingSet = values;
157 return updateChain;
158 },
159 async where(_clause: any) {
160 const target =
161 _state._lastFromTable === "tokens"
162 ? _state.tokens
163 : _state._lastFromTable === "users"
164 ? _state.users
165 : [];
166 const filter = _nextWhereFilter || (() => true);
167 _nextWhereFilter = null;
168 for (const row of target) {
169 if (filter(row)) Object.assign(row, updateChain._pendingSet);
170 }
171 },
172};
173
174const _fakeDb = {
175 db: {
176 select: (_proj?: any) => {
177 _state._lastFromTable = "";
178 return selectChain;
179 },
180 insert: (t: any) => {
181 _state._lastFromTable = tableName(t);
182 return insertChain;
183 },
184 update: (t: any) => {
185 _state._lastFromTable = tableName(t);
186 updateChain._pendingSet = null;
187 return updateChain;
188 },
189 delete: (_t: any) => ({ where: async () => {} }),
190 },
191 getDb: () => _fakeDb.db,
192};
193
194// Spread the real module first so functions like `getDb` keep working
195// when downstream code paths reach for them — only the names we override
196// take the fake. Same K1 pattern account-deletion.test.ts uses.
197mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
198
199function _reinstallDbMock() {
200 mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
201}
202
203// ---------------------------------------------------------------------------
204// Email recorder — installed via the lib's test seam.
205// ---------------------------------------------------------------------------
206
207interface RecordedEmail {
208 to: string;
209 subject: string;
210 text: string;
211 html?: string;
212}
213const _emails: RecordedEmail[] = [];
214async function recordEmail(msg: RecordedEmail) {
215 _emails.push({ ...msg });
216 return { ok: true as const, provider: "log" as const };
217}
218
219// Load the lib AFTER mock.module so the import picks up the fake DB.
220const {
221 generateVerificationToken,
222 hashToken,
223 startEmailVerification,
224 consumeVerificationToken,
225 sendWelcomeEmail,
226 renderVerificationEmail,
227 renderWelcomeEmail,
228 __setEmailForTests,
229} = await import("../lib/email-verification");
230
231const { default: emailVerificationRoutes, __resetResendRateLimitForTests } =
232 await import("../routes/email-verification");
233const authRoutesModule = await import("../routes/auth");
234const authRoutes = authRoutesModule.default;
235
236function buildApp() {
237 const app = new Hono();
238 app.route("/", emailVerificationRoutes);
239 return app;
240}
241
242let _restoreEmail: ReturnType<typeof __setEmailForTests> | null = null;
243
244beforeEach(() => {
245 _reinstallDbMock();
246 resetState();
247 _emails.length = 0;
248 __resetResendRateLimitForTests();
249 _restoreEmail = __setEmailForTests(recordEmail);
250});
251
252afterAll(() => {
253 if (_restoreEmail) __setEmailForTests(_restoreEmail);
254 resetState();
255 _emails.length = 0;
256 // Restore the real DB module so downstream test files see prod semantics.
257 mock.module("../db", () => _real_db);
258});
259
260// ---------------------------------------------------------------------------
261// Pure helpers
262// ---------------------------------------------------------------------------
263
264describe("generateVerificationToken", () => {
265 it("produces a 64-char hex plaintext and a matching sha256 hash", () => {
266 const { plaintext, hash } = generateVerificationToken();
267 expect(plaintext).toMatch(/^[0-9a-f]{64}$/);
268 expect(hash).toMatch(/^[0-9a-f]{64}$/);
269 expect(hash).toBe(createHash("sha256").update(plaintext).digest("hex"));
270 expect(hashToken(plaintext)).toBe(hash);
271 });
272
273 it("returns different tokens on successive calls", () => {
274 const a = generateVerificationToken();
275 const b = generateVerificationToken();
276 expect(a.plaintext).not.toBe(b.plaintext);
277 expect(a.hash).not.toBe(b.hash);
278 });
279});
280
281describe("renderVerificationEmail / renderWelcomeEmail", () => {
282 it("verification email subject + html escape username", () => {
283 const m = renderVerificationEmail({
284 username: "<bob>",
285 link: "https://example.com/verify-email?token=abc",
286 });
287 expect(m.subject).toBe("Confirm your email for Gluecron");
288 expect(m.text).toContain("Hi <bob>");
289 expect(m.html).toContain("&lt;bob&gt;");
290 expect(m.html).toContain("https://example.com/verify-email?token=abc");
291 });
292
293 it("welcome email mentions all four next-step links", () => {
294 const m = renderWelcomeEmail({ username: "alice" });
295 expect(m.subject).toContain("Welcome to Gluecron");
296 expect(m.text).toContain("Welcome aboard, alice!");
297 expect(m.text).toContain("/new");
298 expect(m.text).toContain("/import");
299 expect(m.text).toContain("/demo");
300 expect(m.text).toContain("/install");
301 expect(m.html).toContain("Welcome aboard,");
302 });
303});
304
305// ---------------------------------------------------------------------------
306// startEmailVerification + consumeVerificationToken (DB path)
307// ---------------------------------------------------------------------------
308
309describe("startEmailVerification", () => {
310 it("inserts a token row and sends a verification email", async () => {
311 _state.users.push({
312 id: "u1",
313 username: "alice",
314 email: "alice@example.com",
315 emailVerifiedAt: null,
316 });
317 setNextWhereFilter((u: FakeUser) => u.id === "u1");
318 const r = await startEmailVerification("u1", "alice@example.com");
319 expect(r.ok).toBe(true);
320 expect(_state.tokens.length).toBe(1);
321 expect(_state.tokens[0].userId).toBe("u1");
322 expect(_state.tokens[0].email).toBe("alice@example.com");
323 expect(_state.tokens[0].tokenHash).toMatch(/^[0-9a-f]{64}$/);
324 expect(_emails.length).toBe(1);
325 expect(_emails[0].to).toBe("alice@example.com");
326 expect(_emails[0].subject).toBe("Confirm your email for Gluecron");
327 expect(_emails[0].text).toContain("alice");
328 });
329});
330
331describe("consumeVerificationToken", () => {
332 it("rejects garbage / empty input", async () => {
333 expect((await consumeVerificationToken("")).ok).toBe(false);
334 expect(
335 (await consumeVerificationToken("not-a-real-token")).ok
336 ).toBe(false);
337 });
338
339 it("happy path: marks token used + sets users.emailVerifiedAt", async () => {
340 _state.users.push({
341 id: "u2",
342 username: "bob",
343 email: "bob@example.com",
344 emailVerifiedAt: null,
345 });
346 const { plaintext, hash } = generateVerificationToken();
347 _state.tokens.push({
348 id: "t-1",
349 userId: "u2",
350 email: "bob@example.com",
351 tokenHash: hash,
352 expiresAt: new Date(Date.now() + 1000 * 60 * 60),
353 usedAt: null,
354 createdAt: new Date(),
355 });
356 setNextWhereFilter(
357 (t: FakeToken) =>
358 t.tokenHash === hash &&
359 t.usedAt === null &&
360 t.expiresAt.getTime() > Date.now()
361 );
362 const r = await consumeVerificationToken(plaintext);
363 expect(r.ok).toBe(true);
364 expect(r.userId).toBe("u2");
365 expect(_state.tokens[0].usedAt).toBeInstanceOf(Date);
366 expect(_state.users[0].emailVerifiedAt).toBeInstanceOf(Date);
367 });
368
369 it("rejects expired tokens", async () => {
370 const { plaintext, hash } = generateVerificationToken();
371 _state.tokens.push({
372 id: "t-2",
373 userId: "u3",
374 email: "expired@example.com",
375 tokenHash: hash,
376 expiresAt: new Date(Date.now() - 1000),
377 usedAt: null,
378 createdAt: new Date(),
379 });
380 setNextWhereFilter(
381 (t: FakeToken) =>
382 t.tokenHash === hash &&
383 t.usedAt === null &&
384 t.expiresAt.getTime() > Date.now()
385 );
386 const r = await consumeVerificationToken(plaintext);
387 expect(r.ok).toBe(false);
388 });
389
390 it("rejects already-used tokens", async () => {
391 const { plaintext, hash } = generateVerificationToken();
392 _state.tokens.push({
393 id: "t-3",
394 userId: "u4",
395 email: "used@example.com",
396 tokenHash: hash,
397 expiresAt: new Date(Date.now() + 1000 * 60 * 60),
398 usedAt: new Date(),
399 createdAt: new Date(),
400 });
401 setNextWhereFilter(
402 (t: FakeToken) =>
403 t.tokenHash === hash &&
404 t.usedAt === null &&
405 t.expiresAt.getTime() > Date.now()
406 );
407 const r = await consumeVerificationToken(plaintext);
408 expect(r.ok).toBe(false);
409 });
410});
411
412// ---------------------------------------------------------------------------
413// sendWelcomeEmail
414// ---------------------------------------------------------------------------
415
416describe("sendWelcomeEmail", () => {
417 it("sends a welcome email for the resolved user", async () => {
418 _state.users.push({
419 id: "u5",
420 username: "carol",
421 email: "carol@example.com",
422 emailVerifiedAt: new Date(),
423 });
424 setNextWhereFilter((u: FakeUser) => u.id === "u5");
425 await sendWelcomeEmail("u5");
426 expect(_emails.length).toBe(1);
427 expect(_emails[0].to).toBe("carol@example.com");
428 expect(_emails[0].subject).toContain("Welcome to Gluecron");
429 expect(_emails[0].text).toContain("carol");
430 });
431
432 it("no-ops + does not throw for unknown user", async () => {
433 setNextWhereFilter(() => false);
434 await sendWelcomeEmail("nope");
435 expect(_emails.length).toBe(0);
436 });
437});
438
439// ---------------------------------------------------------------------------
440// GET /verify-email — HTTP-level
441// ---------------------------------------------------------------------------
442
443describe("GET /verify-email", () => {
444 it("valid token → 302 /dashboard?verified=1 + fires welcome email", async () => {
445 _state.users.push({
446 id: "u6",
447 username: "dave",
448 email: "dave@example.com",
449 emailVerifiedAt: null,
450 });
451 const { plaintext, hash } = generateVerificationToken();
452 _state.tokens.push({
453 id: "t-6",
454 userId: "u6",
455 email: "dave@example.com",
456 tokenHash: hash,
457 expiresAt: new Date(Date.now() + 1000 * 60 * 60),
458 usedAt: null,
459 createdAt: new Date(),
460 });
461 setNextWhereFilter(
462 (t: FakeToken) =>
463 t.tokenHash === hash &&
464 t.usedAt === null &&
465 t.expiresAt.getTime() > Date.now()
466 );
467 const app = buildApp();
468 const res = await app.request(
469 `/verify-email?token=${encodeURIComponent(plaintext)}`
470 );
471 expect(res.status).toBe(302);
472 expect(res.headers.get("location")).toBe("/dashboard?verified=1");
473 // Welcome email is fire-and-forget — give the microtask queue a tick.
474 await new Promise((r) => setTimeout(r, 10));
475 expect(_emails.some((e) => e.subject.includes("Welcome"))).toBe(true);
476 });
477
478 it("invalid token → 200 with 'Link expired' page", async () => {
479 const app = buildApp();
480 const res = await app.request("/verify-email?token=not-a-real-token");
481 expect(res.status).toBe(200);
482 const body = await res.text();
483 expect(body).toContain("Link expired");
484 });
485});
486
487// ---------------------------------------------------------------------------
488// Rate limit smoke
489// ---------------------------------------------------------------------------
490
491describe("POST /verify-email/resend rate limit", () => {
492 it("__resetResendRateLimitForTests is exported + idempotent", async () => {
493 const mod = await import("../routes/email-verification");
494 expect(typeof mod.__resetResendRateLimitForTests).toBe("function");
495 mod.__resetResendRateLimitForTests();
496 mod.__resetResendRateLimitForTests();
497 });
498});
499
500// ---------------------------------------------------------------------------
501// Register POST wires through to startEmailVerification
502// ---------------------------------------------------------------------------
503
504describe("POST /register → startEmailVerification wiring", () => {
505 it("startEmailVerification produces a token row (the wire-up's payload)", async () => {
506 _state.users.push({
507 id: "u-reg",
508 username: "newbie",
509 email: "newbie@example.com",
510 emailVerifiedAt: null,
511 });
512 setNextWhereFilter((u: FakeUser) => u.id === "u-reg");
513 const r = await startEmailVerification("u-reg", "newbie@example.com");
514 expect(r.ok).toBe(true);
515 expect(_state.tokens.length).toBe(1);
516 expect(_state.tokens[0].userId).toBe("u-reg");
517 expect(typeof startEmailVerification).toBe("function");
518 });
519
520 it("auth.tsx /register handler imports email-verification", async () => {
521 const m = await import("../lib/email-verification");
522 expect(typeof m.startEmailVerification).toBe("function");
523 expect(typeof m.consumeVerificationToken).toBe("function");
524 expect(typeof m.sendWelcomeEmail).toBe("function");
525 expect(authRoutes).toBeDefined();
526 });
527});