/** * Account Deletion Integration Suite * * Exercises `deleteUserAccount` — the logged-in account-deletion flow behind * `DELETE /api/auth/delete-account`. Covers the same shape of guarantees as * `password_change.test.ts` (unknown user, Google-only account, wrong * password, success), plus the part specific to deletion: that removing the * user row actually cascades to every table that depends on it, while * `licenses` and `security_events` survive with their `userId` nulled out. * * Run with: npx tsx tests/integration/account_deletion.test.ts * * Requires a reachable DATABASE_URL with the migrations applied * (`docker compose up -d postgres && npm run db:push`). Without a database the * script reports a skip and exits 0 rather than pretending to have passed. */ // Keep first: populates DATABASE_URL before the database module below reads it. import "./loadEnv"; import { eq, like } from "drizzle-orm"; import { describe, test, expect, runAllTests } from "../e2e/runner"; import { db, isDatabaseAvailable, pool } from "../../src/lib/db"; import { licenses, line_items, oauth_accounts, projects, receipts, security_events, sessions, users, } from "../../src/lib/schema/db"; import { createUser, deleteUserAccount, findUserByEmail } from "../../src/lib/auth/accounts"; import { hashPassword, verifyPassword } from "../../src/lib/auth/password"; import { createSessionRecord } from "../../src/lib/auth/session"; import { newId } from "../../src/lib/auth/tokens"; /** Namespaced so cleanup can never touch a real account. */ const RUN_ID = Date.now().toString(36); const LOCAL_PART = `authtest-${RUN_ID}`; async function cleanup() { // Children cascade from users; the LIKE keeps this scoped to this run. await db.delete(users).where(like(users.emailKey, `authtest-%`)); } function registerSuites() { describe("Account deletion — verification", () => { test("an unknown user id reports invalid", async () => { const outcome = await deleteUserAccount("usr_nonexistent", "Whatever123!"); expect(outcome.status).toBe("invalid"); }); test("a Google-only account (no password hash) deletes without a password", async () => { const user = await createUser({ email: `${LOCAL_PART}-google@example.com`, emailVerified: true, // no passwordHash → Google-only }); expect(user.passwordHash).toBe(null); const outcome = await deleteUserAccount(user.id, null); expect(outcome.status).toBe("success"); expect(await findUserByEmail(user.email ?? "")).toBe(null); }); test("a wrong password is rejected and the account survives", async () => { const user = await createUser({ email: `${LOCAL_PART}-wrong@example.com`, passwordHash: await hashPassword("Original123!"), }); const outcome = await deleteUserAccount(user.id, "NotThePassword!"); expect(outcome.status).toBe("wrong_password"); const still = await findUserByEmail(user.email ?? ""); expect(still).not.toBe(null); expect(await verifyPassword("Original123!", still?.passwordHash ?? null)).toBe(true); }); test("a missing password on a password-protected account is rejected", async () => { const user = await createUser({ email: `${LOCAL_PART}-missing@example.com`, passwordHash: await hashPassword("Original123!"), }); const outcome = await deleteUserAccount(user.id, null); expect(outcome.status).toBe("wrong_password"); expect(await findUserByEmail(user.email ?? "")).not.toBe(null); }); test("the correct password deletes the account", async () => { const user = await createUser({ email: `${LOCAL_PART}-success@example.com`, passwordHash: await hashPassword("Original123!"), }); const outcome = await deleteUserAccount(user.id, "Original123!"); expect(outcome.status).toBe("success"); expect(await findUserByEmail(user.email ?? "")).toBe(null); }); }); describe("Account deletion — cascade", () => { test("dependent rows cascade away; licenses and security events survive with userId nulled", async () => { const user = await createUser({ email: `${LOCAL_PART}-cascade@example.com`, passwordHash: await hashPassword("Original123!"), }); await createSessionRecord(user.id, { remember: false, userAgent: "MacBook/Chrome" }); await db.insert(oauth_accounts).values({ id: newId("oau"), userId: user.id, provider: "google", providerAccountId: `google-${RUN_ID}`, }); const projectId = newId("prj"); await db.insert(projects).values({ id: projectId, userId: user.id, name: "Reisekosten", }); const receiptId = newId("rcp"); await db.insert(receipts).values({ id: receiptId, userId: user.id, imageHash: `hash-${RUN_ID}`, projectId, totalAmount: "42.00", }); await db.insert(line_items).values({ id: newId("li"), receiptId, description: "Kaffee", price: "3.50", }); await db.insert(licenses).values({ id: newId("lic"), userId: user.id, licenseKey: `key-${RUN_ID}`, plan: "lifetime", }); await db.insert(security_events).values({ id: newId("evt"), type: "login.success", userId: user.id, email: user.email, }); const outcome = await deleteUserAccount(user.id, "Original123!"); expect(outcome.status).toBe("success"); // Everything keyed to the user directly, plus the receipt's own child // row, is gone. expect((await db.select().from(sessions).where(eq(sessions.userId, user.id))).length).toBe(0); expect( (await db.select().from(oauth_accounts).where(eq(oauth_accounts.userId, user.id))).length ).toBe(0); expect((await db.select().from(projects).where(eq(projects.userId, user.id))).length).toBe(0); expect((await db.select().from(receipts).where(eq(receipts.userId, user.id))).length).toBe(0); expect( (await db.select().from(line_items).where(eq(line_items.receiptId, receiptId))).length ).toBe(0); // Billing history and the audit trail survive, disowned rather than deleted. const licenseRows = await db.select().from(licenses).where(eq(licenses.licenseKey, `key-${RUN_ID}`)); expect(licenseRows.length).toBe(1); expect(licenseRows[0].userId).toBe(null); const eventRows = await db .select() .from(security_events) .where(eq(security_events.email, user.email ?? "")); expect(eventRows.length >= 1).toBe(true); expect(eventRows.every((row) => row.userId === null)).toBe(true); }); }); } async function main() { if (!(await isDatabaseAvailable())) { console.log( [ "", " SKIPPED — no database reachable at DATABASE_URL.", "", " Start one and apply the schema, then re-run:", " docker compose up -d postgres", " npm run db:push", " npx tsx tests/integration/account_deletion.test.ts", "", ].join("\n") ); await pool.end(); return; } // Leftovers from an interrupted earlier run would break the assertions. await cleanup(); registerSuites(); let passed = false; try { passed = await runAllTests(); } finally { await cleanup(); await pool.end(); } if (!passed) process.exit(1); } main().catch(async (error) => { console.error("Account deletion suite crashed:", error); await pool.end().catch(() => undefined); process.exit(1); });