Files
scan-receipts/tests/integration/reset_token_expiry.test.ts
Timo 84b9987c49 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>
2026-08-19 20:59:04 +02:00

238 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 1560 minutes and below VERIFICATION_TTL_MS", () => {
// The requirement: reset links should be valid ~1560 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);
});