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:
237
tests/integration/reset_token_expiry.test.ts
Normal file
237
tests/integration/reset_token_expiry.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user