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:
169
tests/integration/password_change.test.ts
Normal file
169
tests/integration/password_change.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Password-Change Database Integration Suite
|
||||
*
|
||||
* Exercises `changePasswordForUser` — the logged-in password-change flow behind
|
||||
* `POST /api/auth/change-password`. The core of the hardening: when a user
|
||||
* changes their password, EVERY existing session row for the account must be
|
||||
* deleted, so a device that was already signed in (e.g. one an attacker had
|
||||
* access through) loses access instantly. The route then issues a fresh session
|
||||
* for the current device; that re-issuing lives in the route handler, not here.
|
||||
*
|
||||
* Run with: npx tsx tests/integration/password_change.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 { sessions, users } from "../../src/lib/schema/db";
|
||||
import { changePasswordForUser, createUser, findUserByEmail } from "../../src/lib/auth/accounts";
|
||||
import { hashPassword, verifyPassword } from "../../src/lib/auth/password";
|
||||
import { createSessionRecord } from "../../src/lib/auth/session";
|
||||
import { issueToken } 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("Password change — DB", () => {
|
||||
test("an unknown user id reports invalid", async () => {
|
||||
const outcome = await changePasswordForUser(
|
||||
"usr_nonexistent",
|
||||
"Whatever123!",
|
||||
"NewPassword123!"
|
||||
);
|
||||
expect(outcome.status).toBe("invalid");
|
||||
});
|
||||
|
||||
test("a Google-only account (no password hash) reports no_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 changePasswordForUser(user.id, "Whatever123!", "NewPassword123!");
|
||||
expect(outcome.status).toBe("no_password");
|
||||
|
||||
// Nothing changed for the account.
|
||||
const unchanged = await findUserByEmail(user.email ?? "");
|
||||
expect(unchanged?.passwordHash).toBe(null);
|
||||
});
|
||||
|
||||
test("a wrong current password is rejected and leaves the hash untouched", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-wrong@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
const before = await findUserByEmail(user.email ?? "");
|
||||
|
||||
const outcome = await changePasswordForUser(user.id, "NotThePassword!", "Replacement123!");
|
||||
expect(outcome.status).toBe("wrong_password");
|
||||
|
||||
const after = await findUserByEmail(user.email ?? "");
|
||||
expect(after?.passwordHash).toBe(before?.passwordHash);
|
||||
expect(await verifyPassword("Original123!", after?.passwordHash ?? null)).toBe(true);
|
||||
expect(await verifyPassword("Replacement123!", after?.passwordHash ?? null)).toBe(false);
|
||||
});
|
||||
|
||||
test("success replaces the hash: old password stops verifying, new one works", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-success@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
const outcome = await changePasswordForUser(user.id, "Original123!", "BrandNew456!");
|
||||
expect(outcome.status).toBe("success");
|
||||
// Narrow the discriminated union so TS knows `userId` exists.
|
||||
if (outcome.status !== "success") throw new Error("expected success");
|
||||
expect(outcome.userId).toBe(user.id);
|
||||
|
||||
const updated = await findUserByEmail(user.email ?? "");
|
||||
expect(updated?.passwordHash !== user.passwordHash).toBe(true);
|
||||
expect(await verifyPassword("BrandNew456!", updated?.passwordHash ?? null)).toBe(true);
|
||||
expect(await verifyPassword("Original123!", updated?.passwordHash ?? null)).toBe(false);
|
||||
});
|
||||
|
||||
test("success deletes every existing session row for the account", async () => {
|
||||
const user = await createUser({
|
||||
email: `${LOCAL_PART}-sessions@example.com`,
|
||||
passwordHash: await hashPassword("Original123!"),
|
||||
});
|
||||
|
||||
// Three devices signed in before the password changes: two via the real
|
||||
// session-issuing helper, one inserted directly.
|
||||
await createSessionRecord(user.id, { remember: false, userAgent: "MacBook/Chrome" });
|
||||
await createSessionRecord(user.id, { remember: true, userAgent: "iPhone/Safari" });
|
||||
await db.insert(sessions).values({
|
||||
id: issueToken().hash,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
const seeded = await db.select().from(sessions).where(eq(sessions.userId, user.id));
|
||||
expect(seeded.length).toBe(3);
|
||||
|
||||
const outcome = await changePasswordForUser(user.id, "Original123!", "Rotated789!");
|
||||
expect(outcome.status).toBe("success");
|
||||
|
||||
// Every old token is dead — this is the whole point of the hardening.
|
||||
const remaining = await db.select().from(sessions).where(eq(sessions.userId, user.id));
|
||||
expect(remaining.length).toBe(0);
|
||||
|
||||
// The account row itself survives the rotation.
|
||||
const updated = await findUserByEmail(user.email ?? "");
|
||||
expect(updated).not.toBe(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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/password_change.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("Password change suite crashed:", error);
|
||||
await pool.end().catch(() => undefined);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user