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>
This commit is contained in:
225
tests/integration/account_deletion.test.ts
Normal file
225
tests/integration/account_deletion.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user