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>
238 lines
9.0 KiB
TypeScript
238 lines
9.0 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|