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:
Timo
2026-08-19 20:59:04 +02:00
parent 650a74da97
commit 84b9987c49
415 changed files with 96619 additions and 0 deletions

View 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 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);
});