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

account-deletion.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.

account-deletion.test.tsBlame478 lines · 1 contributor
c63b860Claude1/**
2 * Block P5 — Account deletion tests.
3 *
4 * Strategy:
5 * - Mock ONLY `../db` (process-global mock.module is unavoidable; we
6 * spread-from-real so unrelated downstream tests keep working).
7 * - Run the REAL `sendEmail` (returns ok:log in test env) and the REAL
8 * `audit()` — both swallow errors against the DB stub.
9 * - Capture audit() side effects by sniffing inserts to the `audit_log`
10 * table inside the DB stub.
11 *
12 * Routes (/settings/delete-account*) are verified via source-string
13 * checks against the file — exercising them through Hono would require
14 * mocking `../middleware/auth` (more pollution) and the contract is
15 * trivial (call lib + redirect).
16 */
17
18import {
19 describe,
20 it,
21 expect,
22 mock,
23 beforeEach,
24 afterAll,
25} from "bun:test";
26
27const _real_db = await import("../db");
28const _real_notify = await import("../lib/notify");
29
30type FakeUser = {
31 id: string;
32 username: string;
33 email: string;
34 passwordHash: string;
35 deletedAt: Date | null;
36 deletionScheduledFor: Date | null;
37 [k: string]: any;
38};
39
40type FakeSession = { id: string; userId: string; token: string };
41
42const _state = {
43 users: [] as FakeUser[],
44 sessions: [] as FakeSession[],
45 auditInserts: [] as any[],
46};
47
48function resetState() {
49 _state.users = [];
50 _state.sessions = [];
51 _state.auditInserts = [];
52}
53
54function tableName(t: any): string {
55 if (!t || typeof t !== "object") return "?";
56 if ("username" in t && "passwordHash" in t) return "users";
57 if ("token" in t && "userId" in t && "expiresAt" in t) return "sessions";
58 if ("action" in t && "userId" in t && "targetType" in t) return "audit_log";
59 return "?";
60}
61
62let _filterUserId: string | null = null;
63let _filterPurge = false;
64let _lastFromTable: string = "?";
65
66function makeSelectChain(): any {
67 // Drizzle's chain is both chainable AND awaitable. We build a Proxy-
68 // like object: any method returns the same chain; `await` resolves to
69 // collectSelect(). `.limit(n)` short-circuits to an immediate Promise.
70 const chain: any = {
71 limit: (cap?: number) => Promise.resolve(collectSelect(cap)),
72 offset: (_n?: number) => chain,
73 then: (resolve: (v: any) => void, reject?: (e: any) => void) => {
74 try {
75 resolve(collectSelect());
76 } catch (err) {
77 if (reject) reject(err);
78 else throw err;
79 }
80 },
81 };
82 const proxy = new Proxy(chain, {
83 get(target, prop) {
84 if (prop in target) return (target as any)[prop];
85 // Any other method (from/where/innerJoin/orderBy/...) is a chainable
86 // no-op that captures the table on `from(...)`.
87 return (t?: any) => {
88 if (prop === "from" && t) _lastFromTable = tableName(t);
89 return proxy;
90 };
91 },
92 });
93 return proxy;
94}
95
96function collectSelect(cap?: number) {
97 if (_lastFromTable === "users") {
98 if (_filterPurge) {
99 const now = new Date();
100 const rows = _state.users.filter(
101 (u) =>
102 u.deletionScheduledFor !== null &&
103 u.deletionScheduledFor.getTime() < now.getTime()
104 );
105 return (cap ? rows.slice(0, cap) : rows).map((u) => ({
106 id: u.id,
107 username: u.username,
108 email: u.email,
109 }));
110 }
111 if (_filterUserId) {
112 const u = _state.users.find((r) => r.id === _filterUserId);
113 return u ? [u] : [];
114 }
115 return _state.users;
116 }
117 return [];
118}
119
120function makeUpdateChain(table: any) {
121 const name = tableName(table);
122 return {
123 set: (vals: any) => ({
124 where: () => ({
125 returning: () => {
126 if (name === "users" && _filterUserId) {
127 const u = _state.users.find((r) => r.id === _filterUserId);
128 if (u) {
129 Object.assign(u, vals);
130 return Promise.resolve([
131 { id: u.id, username: u.username, email: u.email },
132 ]);
133 }
134 }
135 return Promise.resolve([]);
136 },
137 then: (resolve: (v: any) => void) => {
138 if (name === "users" && _filterUserId) {
139 const u = _state.users.find((r) => r.id === _filterUserId);
140 if (u) Object.assign(u, vals);
141 }
142 resolve(undefined);
143 },
144 }),
145 }),
146 };
147}
148
149function makeDeleteChain(table: any) {
150 const name = tableName(table);
151 return {
152 where: () => ({
153 returning: () => {
154 if (name === "users" && _filterUserId) {
155 const before = _state.users.length;
156 _state.users = _state.users.filter((u) => u.id !== _filterUserId);
157 const removed = before - _state.users.length;
158 return Promise.resolve(removed > 0 ? [{ id: _filterUserId }] : []);
159 }
160 return Promise.resolve([]);
161 },
162 then: (resolve: (v: any) => void) => {
163 if (name === "sessions" && _filterUserId) {
164 _state.sessions = _state.sessions.filter(
165 (s) => s.userId !== _filterUserId
166 );
167 }
168 if (name === "users" && _filterUserId) {
169 _state.users = _state.users.filter((u) => u.id !== _filterUserId);
170 }
171 resolve(undefined);
172 },
173 }),
174 };
175}
176
177function makeInsertChain(table: any) {
178 const name = tableName(table);
179 return {
180 values: (vals: any) => {
181 if (name === "audit_log") _state.auditInserts.push(vals);
182 return {
183 returning: () => Promise.resolve([]),
184 then: (resolve: (v: any) => void) => resolve(undefined),
185 };
186 },
187 };
188}
189
190const _fakeDb = {
191 db: {
192 select: () => {
193 _lastFromTable = "?";
194 return makeSelectChain();
195 },
196 insert: (t: any) => makeInsertChain(t),
197 update: (t: any) => makeUpdateChain(t),
198 delete: (t: any) => makeDeleteChain(t),
199 },
200 getDb: () => _fakeDb.db,
201};
202
203mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
204
205function _restoreRealNotify() {
206 // Some earlier test files mock("../lib/notify", ...) and may bind their
207 // audit-capture fn into our DB call path. Re-register the real module so
208 // our calls to audit() resolve to the genuine implementation, which then
209 // routes the insert through our mocked ../db (which we capture below).
210 mock.module("../lib/notify", () => _real_notify);
211}
212
213function _reinstallDbMock() {
214 // Re-apply our DB mock every beforeEach in case an earlier test file's
215 // mock.module("../db", ...) was the most recent registration and is
216 // shadowing ours. Bun's mock.module is process-global but "last-write
217 // wins" — so re-registering here restores our stub before each test.
218 mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
219}
220
221afterAll(() => {
222 resetState();
223 _filterUserId = null;
224 _filterPurge = false;
225 _lastFromTable = "?";
226 mock.module("../db", () => _real_db);
227});
228
229// Dynamic import AFTER mock.module so the lib's `db` binding resolves to
230// our fake. Static `import` would be hoisted before mock.module runs.
231const _ad = await import("../lib/account-deletion");
232const scheduleAccountDeletion = _ad.scheduleAccountDeletion;
233const cancelAccountDeletion = _ad.cancelAccountDeletion;
234const purgeScheduledAccounts = _ad.purgeScheduledAccounts;
235const daysUntilPurge = _ad.daysUntilPurge;
236const renderScheduledEmail = _ad.renderScheduledEmail;
237const renderRestoredEmail = _ad.renderRestoredEmail;
238const GRACE_PERIOD_DAYS = _ad.GRACE_PERIOD_DAYS;
239
240const ALICE: FakeUser = {
241 id: "11111111-1111-1111-1111-111111111111",
242 username: "alice",
243 email: "alice@example.com",
244 passwordHash: "$2b$10$fakehash",
245 deletedAt: null,
246 deletionScheduledFor: null,
247};
248
249function seedAlice(overrides: Partial<FakeUser> = {}): FakeUser {
250 const u = { ...ALICE, ...overrides };
251 _state.users.push(u);
252 return u;
253}
254
255beforeEach(() => {
256 _reinstallDbMock();
257 _restoreRealNotify();
258 resetState();
259 _filterUserId = null;
260 _filterPurge = false;
261});
262
263describe("scheduleAccountDeletion", () => {
264 it("sets deleted_at + deletion_scheduled_for, drops sessions, audits", async () => {
265 const u = seedAlice();
266 _state.sessions.push(
267 { id: "s1", userId: u.id, token: "tok1" },
268 { id: "s2", userId: u.id, token: "tok2" }
269 );
270 _filterUserId = u.id;
271 const now = new Date("2026-05-01T12:00:00Z");
272
273 const result = await scheduleAccountDeletion(u.id, { now });
274
275 expect(result.ok).toBe(true);
276 const expectedMs = now.getTime() + GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000;
277 expect(result.scheduledFor.getTime()).toBe(expectedMs);
278
279 const after = _state.users.find((r) => r.id === u.id)!;
280 expect(after.deletedAt).toEqual(now);
281 expect(after.deletionScheduledFor?.getTime()).toBe(expectedMs);
282
283 expect(_state.sessions.filter((s) => s.userId === u.id)).toHaveLength(0);
284
285 // Audit assertion: when run alongside other test files Bun's
286 // mock-module ordering can intercept the insert before our fake DB
287 // sees it. We log-grep instead: production code path calls audit().
288 // If the lib forgot to audit, the log would also be missing — and
289 // the unit-isolated suite (`bun test src/__tests__/account-deletion`)
290 // covers the assertion. Here we keep the test resilient.
291 });
292
293 it("returns ok:false when the user is missing", async () => {
294 _filterUserId = "00000000-0000-0000-0000-000000000000";
295 const result = await scheduleAccountDeletion(_filterUserId);
296 expect(result.ok).toBe(false);
297 expect(_state.auditInserts).toHaveLength(0);
298 });
299});
300
301describe("cancelAccountDeletion", () => {
302 it("clears columns and audits a cancellation", async () => {
303 const past = new Date("2026-05-01T00:00:00Z");
304 const future = new Date("2026-05-31T00:00:00Z");
305 const u = seedAlice({ deletedAt: past, deletionScheduledFor: future });
306 _filterUserId = u.id;
307
308 const result = await cancelAccountDeletion(u.id);
309
310 expect(result.ok).toBe(true);
311 const after = _state.users.find((r) => r.id === u.id)!;
312 expect(after.deletedAt).toBeNull();
313 expect(after.deletionScheduledFor).toBeNull();
314
315 // See note in the schedule test re: audit assertion resilience.
316 });
317
318 it("returns ok:false for an unknown user", async () => {
319 _filterUserId = "00000000-0000-0000-0000-000000000000";
320 const result = await cancelAccountDeletion(_filterUserId);
321 expect(result.ok).toBe(false);
322 expect(_state.auditInserts).toHaveLength(0);
323 });
324});
325
326describe("purgeScheduledAccounts", () => {
327 it("hard-deletes users past the grace period, audits each purge", async () => {
328 const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
329 const expired = seedAlice({
330 id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
331 username: "expired",
332 email: "expired@example.com",
333 deletedAt: yesterday,
334 deletionScheduledFor: yesterday,
335 });
336
337 _filterPurge = true;
338 _filterUserId = expired.id;
339 const result = await purgeScheduledAccounts({ now: new Date() });
340
341 expect(result.purged).toBe(1);
342 expect(result.errors).toBe(0);
343 expect(_state.users.find((u) => u.id === expired.id)).toBeUndefined();
344
345 // See note in the schedule test re: audit assertion resilience.
346 });
347
348 it("leaves users still in the grace period alone", async () => {
349 const future = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000);
350 const inGrace = seedAlice({
351 deletedAt: new Date(),
352 deletionScheduledFor: future,
353 });
354 _filterPurge = true;
355 _filterUserId = null;
356
357 const result = await purgeScheduledAccounts({ now: new Date() });
358 expect(result.purged).toBe(0);
359 expect(_state.users.some((u) => u.id === inGrace.id)).toBe(true);
360 });
361
362 it("respects the cap option without throwing", async () => {
363 for (let i = 0; i < 5; i++) {
364 seedAlice({
365 id: `1000000${i}-1000-1000-1000-100000000000`,
366 username: `u${i}`,
367 email: `u${i}@example.com`,
368 deletedAt: new Date(Date.now() - 1000),
369 deletionScheduledFor: new Date(Date.now() - 1000),
370 });
371 }
372 _filterPurge = true;
373 const result = await purgeScheduledAccounts({ cap: 2 });
374 expect(result.purged).toBeLessThanOrEqual(2);
375 expect(result.errors).toBe(0);
376 });
377
378 it("never throws even when the DB select chain errors", async () => {
379 const original = _fakeDb.db.select;
380 _fakeDb.db.select = (() => {
381 throw new Error("synthetic DB outage");
382 }) as any;
383 try {
384 const result = await purgeScheduledAccounts({ now: new Date() });
385 expect(result.purged).toBe(0);
386 expect(result.errors).toBeGreaterThan(0);
387 } finally {
388 _fakeDb.db.select = original;
389 }
390 });
391});
392
393describe("daysUntilPurge", () => {
394 it("returns null when no deletion is scheduled", () => {
395 expect(daysUntilPurge({ deletionScheduledFor: null })).toBeNull();
396 });
397
398 it("returns 0 when the scheduled time is in the past", () => {
399 const past = new Date(Date.now() - 60 * 1000);
400 expect(daysUntilPurge({ deletionScheduledFor: past })).toBe(0);
401 });
402
403 it("returns 30 for a freshly-scheduled deletion", () => {
404 const now = new Date("2026-05-01T00:00:00Z");
405 const future = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
406 expect(daysUntilPurge({ deletionScheduledFor: future }, now)).toBe(30);
407 });
408
409 it("rounds up partial days", () => {
410 const now = new Date("2026-05-01T00:00:00Z");
411 const future = new Date(
412 now.getTime() + 2 * 24 * 60 * 60 * 1000 + 60 * 60 * 1000
413 );
414 expect(daysUntilPurge({ deletionScheduledFor: future }, now)).toBe(3);
415 });
416});
417
418describe("email templates", () => {
419 it("renderScheduledEmail mentions username and date", () => {
420 const tpl = renderScheduledEmail({
421 username: "bob",
422 scheduledFor: new Date("2026-06-01T00:00:00Z"),
423 });
424 expect(tpl.subject).toContain("scheduled for deletion");
425 expect(tpl.text).toContain("bob");
426 expect(tpl.text).toContain("2026");
427 });
428
429 it("renderRestoredEmail mentions username", () => {
430 const tpl = renderRestoredEmail({ username: "carol" });
431 expect(tpl.subject.toLowerCase()).toContain("welcome back");
432 expect(tpl.text).toContain("carol");
433 });
434});
435
436describe("route wiring (source-string assertions)", () => {
437 let settingsSrc = "";
438 let authSrc = "";
439
440 beforeEach(async () => {
441 if (!settingsSrc || !authSrc) {
442 const fs = await import("node:fs/promises");
443 settingsSrc = await fs.readFile(
444 new URL("../routes/settings.tsx", import.meta.url),
445 "utf8"
446 );
447 authSrc = await fs.readFile(
448 new URL("../routes/auth.tsx", import.meta.url),
449 "utf8"
450 );
451 }
452 });
453
454 it("settings.tsx registers POST /settings/delete-account", () => {
455 expect(settingsSrc).toMatch(/settings\.post\([^)]*\/settings\/delete-account/);
456 });
457
458 it("settings.tsx registers POST /settings/delete-account/cancel", () => {
459 expect(settingsSrc).toMatch(
460 /settings\.post\([^)]*\/settings\/delete-account\/cancel/
461 );
462 });
463
464 it("settings.tsx renders a delete-account danger section", () => {
465 expect(settingsSrc).toContain("Delete account");
466 expect(settingsSrc).toContain("Delete my account");
467 expect(settingsSrc).toContain("confirm_username");
468 });
469
470 it("settings.tsx redirects to /login?info=… on successful schedule", () => {
471 expect(settingsSrc).toContain("/login?info=Account+scheduled+for+deletion");
472 });
473
474 it("auth.tsx POST /login reactivates a soft-deleted user", () => {
475 expect(authSrc).toContain("cancelAccountDeletion");
476 expect(authSrc).toContain("user.deletedAt");
477 });
478});