Files
scan-receipts/tests/integration/account_deletion.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

226 lines
7.5 KiB
TypeScript

/**
* 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);
});