/** * Reset Token Expiry Integration Suite (Task D) * * Verifies the "Expire reset links" hardening requirement: password-reset links * are only valid for a bounded time window, are single-use, and consuming one * invalidates every existing session for the account. * * The implementation under test lives in `src/lib/auth/accounts.ts` * (`issuePasswordResetLink`, `peekPasswordResetToken`, * `resetPasswordWithToken`) plus `PASSWORD_RESET_TTL_MS` in * `src/lib/auth/config.ts`. This suite only verifies — it never modifies those * files (accounts.ts is owned by Task C). * * Run with: npx tsx tests/integration/reset_token_expiry.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 { password_reset_tokens, sessions, users } from "../../src/lib/schema/db"; import { createUser, findUserByEmail, issuePasswordResetLink, peekPasswordResetToken, resetPasswordWithToken, } from "../../src/lib/auth/accounts"; import { hashPassword, verifyPassword } from "../../src/lib/auth/password"; import { hashToken, issueToken } from "../../src/lib/auth/tokens"; import { PASSWORD_RESET_TTL_MS, VERIFICATION_TTL_MS } from "../../src/lib/auth/config"; /** Namespaced so cleanup can never touch a real account. */ const RUN_ID = Date.now().toString(36); const LOCAL_PART = `authtest-${RUN_ID}`; function extractToken(devLink: string | undefined): string { if (!devLink) { throw new Error( "No dev link returned — the mailer tried to actually send. `loadEnv` should have cleared the SMTP variables; check it is still the first import." ); } const token = new URL(devLink).searchParams.get("token"); if (!token) throw new Error(`No token in dev link: ${devLink}`); return token; } 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("Reset token expiry — issuance & TTL window", () => { test("a freshly issued link is valid and expires inside the TTL window", async () => { const user = await createUser({ email: `${LOCAL_PART}-ttl-window@example.com`, passwordHash: await hashPassword("Original123!"), }); const mail = await issuePasswordResetLink(user, "en"); const token = extractToken(mail.devLink); // Peeking reports it live without spending it. expect(await peekPasswordResetToken(token)).toBe("valid"); // The stored row carries an expiry bounded by the configured TTL. const rows = await db .select() .from(password_reset_tokens) .where(eq(password_reset_tokens.id, hashToken(token))); expect(rows.length).toBe(1); const remainingMs = rows[0].expiresAt.getTime() - Date.now(); expect(remainingMs).toBeGreaterThan(0); expect(remainingMs).toBeLessThanOrEqual(PASSWORD_RESET_TTL_MS); // It was issued moments ago, so the window must be ~the full TTL, not a // token that merely "has some time left". expect(remainingMs).toBeGreaterThan(PASSWORD_RESET_TTL_MS - 60_000); }); }); describe("Reset token expiry — single use", () => { test("consuming a valid token succeeds, installs the password, marks it consumed", async () => { const user = await createUser({ email: `${LOCAL_PART}-single-use@example.com`, passwordHash: await hashPassword("Original123!"), }); const mail = await issuePasswordResetLink(user, "en"); const token = extractToken(mail.devLink); const outcome = await resetPasswordWithToken(token, "BrandNew5678!"); expect(outcome.status).toBe("success"); const updated = await findUserByEmail(user.email ?? ""); expect(await verifyPassword("BrandNew5678!", updated?.passwordHash ?? null)).toBe(true); expect(await verifyPassword("Original123!", updated?.passwordHash ?? null)).toBe(false); // The token row is marked consumed so a replay cannot work. const rows = await db .select() .from(password_reset_tokens) .where(eq(password_reset_tokens.id, hashToken(token))); expect(rows.length).toBe(1); expect(rows[0].consumedAt !== null).toBe(true); }); test("a reset token cannot be replayed after being spent", async () => { const user = await createUser({ email: `${LOCAL_PART}-replay@example.com`, passwordHash: await hashPassword("Original123!"), }); const mail = await issuePasswordResetLink(user, "en"); const token = extractToken(mail.devLink); expect((await resetPasswordWithToken(token, "FirstUse123!")).status).toBe("success"); expect((await resetPasswordWithToken(token, "SecondUse123!")).status).toBe("invalid"); expect(await peekPasswordResetToken(token)).toBe("invalid"); // The second attempt must not have taken effect. const updated = await findUserByEmail(user.email ?? ""); expect(await verifyPassword("FirstUse123!", updated?.passwordHash ?? null)).toBe(true); expect(await verifyPassword("SecondUse123!", updated?.passwordHash ?? null)).toBe(false); }); }); describe("Reset token expiry — elapsed tokens", () => { test("an expired token reads expired and cannot change the password", async () => { const user = await createUser({ email: `${LOCAL_PART}-elapsed@example.com`, passwordHash: await hashPassword("Original123!"), }); const { token, hash } = issueToken(); await db.insert(password_reset_tokens).values({ id: hash, userId: user.id, expiresAt: new Date(Date.now() - 60_000), }); expect(await peekPasswordResetToken(token)).toBe("expired"); expect((await resetPasswordWithToken(token, "Replacement123!")).status).toBe("expired"); const unchanged = await findUserByEmail(user.email ?? ""); expect(await verifyPassword("Original123!", unchanged?.passwordHash ?? null)).toBe(true); expect(await verifyPassword("Replacement123!", unchanged?.passwordHash ?? null)).toBe(false); }); }); describe("Reset token expiry — session invalidation", () => { test("consuming a reset link drops every existing session for the account", async () => { const user = await createUser({ email: `${LOCAL_PART}-sessions@example.com`, passwordHash: await hashPassword("Original123!"), }); await db.insert(sessions).values({ id: issueToken().hash, userId: user.id, expiresAt: new Date(Date.now() + 60_000), }); await db.insert(sessions).values({ id: issueToken().hash, userId: user.id, expiresAt: new Date(Date.now() + 60_000), }); // Precondition: the two sessions really exist. const before = await db.select().from(sessions).where(eq(sessions.userId, user.id)); expect(before.length).toBe(2); const mail = await issuePasswordResetLink(user, "en"); const outcome = await resetPasswordWithToken(extractToken(mail.devLink), "AnotherOne901!"); expect(outcome.status).toBe("success"); const remaining = await db.select().from(sessions).where(eq(sessions.userId, user.id)); expect(remaining.length).toBe(0); }); }); describe("Reset token expiry — configuration constants", () => { test("PASSWORD_RESET_TTL_MS is 15–60 minutes and below VERIFICATION_TTL_MS", () => { // The requirement: reset links should be valid ~15–60 minutes, and reset // links must live far shorter than confirmation links. expect(PASSWORD_RESET_TTL_MS).toBeGreaterThanOrEqual(15 * 60 * 1000); expect(PASSWORD_RESET_TTL_MS).toBeLessThanOrEqual(60 * 60 * 1000); expect(PASSWORD_RESET_TTL_MS).toBeLessThan(VERIFICATION_TTL_MS); }); }); } 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/reset_token_expiry.test.ts", "", ].join("\n") ); await pool.end(); return; } // Leftovers from an interrupted earlier run would skew the count 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("Reset token expiry suite crashed:", error); await pool.end().catch(() => undefined); process.exit(1); });