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.
| c63b860 | 1 | /** |
| 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 | ||
| 18 | import { | |
| 19 | describe, | |
| 20 | it, | |
| 21 | expect, | |
| 22 | mock, | |
| 23 | beforeEach, | |
| 24 | afterAll, | |
| 25 | } from "bun:test"; | |
| 26 | ||
| 27 | const _real_db = await import("../db"); | |
| 28 | const _real_notify = await import("../lib/notify"); | |
| 29 | ||
| 30 | type 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 | ||
| 40 | type FakeSession = { id: string; userId: string; token: string }; | |
| 41 | ||
| 42 | const _state = { | |
| 43 | users: [] as FakeUser[], | |
| 44 | sessions: [] as FakeSession[], | |
| 45 | auditInserts: [] as any[], | |
| 46 | }; | |
| 47 | ||
| 48 | function resetState() { | |
| 49 | _state.users = []; | |
| 50 | _state.sessions = []; | |
| 51 | _state.auditInserts = []; | |
| 52 | } | |
| 53 | ||
| 54 | function 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 | ||
| 62 | let _filterUserId: string | null = null; | |
| 63 | let _filterPurge = false; | |
| 64 | let _lastFromTable: string = "?"; | |
| 65 | ||
| 66 | function 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 | ||
| 96 | function 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 | ||
| 120 | function 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 | ||
| 149 | function 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 | ||
| 177 | function 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 | ||
| 190 | const _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 | ||
| 203 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 204 | ||
| 205 | function _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 | ||
| 213 | function _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 | ||
| 221 | afterAll(() => { | |
| 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. | |
| 231 | const _ad = await import("../lib/account-deletion"); | |
| 232 | const scheduleAccountDeletion = _ad.scheduleAccountDeletion; | |
| 233 | const cancelAccountDeletion = _ad.cancelAccountDeletion; | |
| 234 | const purgeScheduledAccounts = _ad.purgeScheduledAccounts; | |
| 235 | const daysUntilPurge = _ad.daysUntilPurge; | |
| 236 | const renderScheduledEmail = _ad.renderScheduledEmail; | |
| 237 | const renderRestoredEmail = _ad.renderRestoredEmail; | |
| 238 | const GRACE_PERIOD_DAYS = _ad.GRACE_PERIOD_DAYS; | |
| 239 | ||
| 240 | const 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 | ||
| 249 | function seedAlice(overrides: Partial<FakeUser> = {}): FakeUser { | |
| 250 | const u = { ...ALICE, ...overrides }; | |
| 251 | _state.users.push(u); | |
| 252 | return u; | |
| 253 | } | |
| 254 | ||
| 255 | beforeEach(() => { | |
| 256 | _reinstallDbMock(); | |
| 257 | _restoreRealNotify(); | |
| 258 | resetState(); | |
| 259 | _filterUserId = null; | |
| 260 | _filterPurge = false; | |
| 261 | }); | |
| 262 | ||
| 263 | describe("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 | ||
| 301 | describe("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 | ||
| 326 | describe("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 | ||
| 393 | describe("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 | ||
| 418 | describe("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 | ||
| 436 | describe("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 | }); |