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>
226 lines
7.5 KiB
TypeScript
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);
|
|
});
|