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