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,225 @@
/**
* Account Deletion Integration Suite
*
* Exercises `deleteUserAccount` — the logged-in account-deletion flow behind
* `DELETE /api/auth/delete-account`. Covers the same shape of guarantees as
* `password_change.test.ts` (unknown user, Google-only account, wrong
* password, success), plus the part specific to deletion: that removing the
* user row actually cascades to every table that depends on it, while
* `licenses` and `security_events` survive with their `userId` nulled out.
*
* Run with: npx tsx tests/integration/account_deletion.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 {
licenses,
line_items,
oauth_accounts,
projects,
receipts,
security_events,
sessions,
users,
} from "../../src/lib/schema/db";
import { createUser, deleteUserAccount, findUserByEmail } from "../../src/lib/auth/accounts";
import { hashPassword, verifyPassword } from "../../src/lib/auth/password";
import { createSessionRecord } from "../../src/lib/auth/session";
import { newId } from "../../src/lib/auth/tokens";
/** Namespaced so cleanup can never touch a real account. */
const RUN_ID = Date.now().toString(36);
const LOCAL_PART = `authtest-${RUN_ID}`;
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("Account deletion — verification", () => {
test("an unknown user id reports invalid", async () => {
const outcome = await deleteUserAccount("usr_nonexistent", "Whatever123!");
expect(outcome.status).toBe("invalid");
});
test("a Google-only account (no password hash) deletes without a password", async () => {
const user = await createUser({
email: `${LOCAL_PART}-google@example.com`,
emailVerified: true, // no passwordHash → Google-only
});
expect(user.passwordHash).toBe(null);
const outcome = await deleteUserAccount(user.id, null);
expect(outcome.status).toBe("success");
expect(await findUserByEmail(user.email ?? "")).toBe(null);
});
test("a wrong password is rejected and the account survives", async () => {
const user = await createUser({
email: `${LOCAL_PART}-wrong@example.com`,
passwordHash: await hashPassword("Original123!"),
});
const outcome = await deleteUserAccount(user.id, "NotThePassword!");
expect(outcome.status).toBe("wrong_password");
const still = await findUserByEmail(user.email ?? "");
expect(still).not.toBe(null);
expect(await verifyPassword("Original123!", still?.passwordHash ?? null)).toBe(true);
});
test("a missing password on a password-protected account is rejected", async () => {
const user = await createUser({
email: `${LOCAL_PART}-missing@example.com`,
passwordHash: await hashPassword("Original123!"),
});
const outcome = await deleteUserAccount(user.id, null);
expect(outcome.status).toBe("wrong_password");
expect(await findUserByEmail(user.email ?? "")).not.toBe(null);
});
test("the correct password deletes the account", async () => {
const user = await createUser({
email: `${LOCAL_PART}-success@example.com`,
passwordHash: await hashPassword("Original123!"),
});
const outcome = await deleteUserAccount(user.id, "Original123!");
expect(outcome.status).toBe("success");
expect(await findUserByEmail(user.email ?? "")).toBe(null);
});
});
describe("Account deletion — cascade", () => {
test("dependent rows cascade away; licenses and security events survive with userId nulled", async () => {
const user = await createUser({
email: `${LOCAL_PART}-cascade@example.com`,
passwordHash: await hashPassword("Original123!"),
});
await createSessionRecord(user.id, { remember: false, userAgent: "MacBook/Chrome" });
await db.insert(oauth_accounts).values({
id: newId("oau"),
userId: user.id,
provider: "google",
providerAccountId: `google-${RUN_ID}`,
});
const projectId = newId("prj");
await db.insert(projects).values({
id: projectId,
userId: user.id,
name: "Reisekosten",
});
const receiptId = newId("rcp");
await db.insert(receipts).values({
id: receiptId,
userId: user.id,
imageHash: `hash-${RUN_ID}`,
projectId,
totalAmount: "42.00",
});
await db.insert(line_items).values({
id: newId("li"),
receiptId,
description: "Kaffee",
price: "3.50",
});
await db.insert(licenses).values({
id: newId("lic"),
userId: user.id,
licenseKey: `key-${RUN_ID}`,
plan: "lifetime",
});
await db.insert(security_events).values({
id: newId("evt"),
type: "login.success",
userId: user.id,
email: user.email,
});
const outcome = await deleteUserAccount(user.id, "Original123!");
expect(outcome.status).toBe("success");
// Everything keyed to the user directly, plus the receipt's own child
// row, is gone.
expect((await db.select().from(sessions).where(eq(sessions.userId, user.id))).length).toBe(0);
expect(
(await db.select().from(oauth_accounts).where(eq(oauth_accounts.userId, user.id))).length
).toBe(0);
expect((await db.select().from(projects).where(eq(projects.userId, user.id))).length).toBe(0);
expect((await db.select().from(receipts).where(eq(receipts.userId, user.id))).length).toBe(0);
expect(
(await db.select().from(line_items).where(eq(line_items.receiptId, receiptId))).length
).toBe(0);
// Billing history and the audit trail survive, disowned rather than deleted.
const licenseRows = await db.select().from(licenses).where(eq(licenses.licenseKey, `key-${RUN_ID}`));
expect(licenseRows.length).toBe(1);
expect(licenseRows[0].userId).toBe(null);
const eventRows = await db
.select()
.from(security_events)
.where(eq(security_events.email, user.email ?? ""));
expect(eventRows.length >= 1).toBe(true);
expect(eventRows.every((row) => row.userId === null)).toBe(true);
});
});
}
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/account_deletion.test.ts",
"",
].join("\n")
);
await pool.end();
return;
}
// Leftovers from an interrupted earlier run would break the 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("Account deletion suite crashed:", error);
await pool.end().catch(() => undefined);
process.exit(1);
});

View File

@@ -0,0 +1,509 @@
/**
* Auth Database Integration Suite
*
* Exercises the parts of the auth system that only mean anything against a real
* Postgres: the unique email key, verification-token lifecycle, Google account
* linking, and session lookup.
*
* Run with: npm run test:auth
*
* 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 { and, eq, gt, like } from "drizzle-orm";
import { describe, test, expect, runAllTests } from "../e2e/runner";
import { db, isDatabaseAvailable, pool } from "../../src/lib/db";
import {
email_verification_tokens,
oauth_accounts,
password_reset_tokens,
sessions,
users,
} from "../../src/lib/schema/db";
import {
consumeVerificationToken,
createUser,
findUserByEmail,
findUserByGoogleId,
isGoogleOnlyAccount,
isUniqueViolation,
issuePasswordResetLink,
issueVerificationLink,
linkGoogleAccount,
peekPasswordResetToken,
resetPasswordWithToken,
} from "../../src/lib/auth/accounts";
import { hashPassword, verifyPassword } from "../../src/lib/auth/password";
import { hashToken, issueToken, newId } from "../../src/lib/auth/tokens";
/** Namespaced so cleanup can never touch a real account. */
const RUN_ID = Date.now().toString(36);
const LOCAL_PART = `authtest-${RUN_ID}`;
const BASE_EMAIL = `${LOCAL_PART}@gmail.com`;
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("Auth DB — one email, one account", () => {
test("a fresh signup creates a non-guest, unverified account", async () => {
const user = await createUser({
email: BASE_EMAIL,
name: "Auth Test",
passwordHash: await hashPassword("Sicher1234!"),
});
expect(user.isGuest).toBe(false);
expect(user.emailVerifiedAt).toBe(null);
expect(user.emailKey).toBe(BASE_EMAIL);
expect(isGoogleOnlyAccount(user)).toBe(false);
});
test("dotted and plus-tagged variants resolve to that same account", async () => {
const dotted = `${LOCAL_PART.split("").join(".")}@gmail.com`;
const tagged = `${LOCAL_PART}+throwaway@googlemail.com`;
const viaDots = await findUserByEmail(dotted);
const viaTag = await findUserByEmail(tagged);
const direct = await findUserByEmail(BASE_EMAIL);
expect(viaDots?.id).toBe(direct?.id);
expect(viaTag?.id).toBe(direct?.id);
});
test("registering an aliased variant is rejected by the unique index", async () => {
let violated = false;
try {
await createUser({
email: `${LOCAL_PART}+second@gmail.com`,
passwordHash: await hashPassword("Sicher1234!"),
});
} catch (error) {
violated = isUniqueViolation(error);
}
expect(violated).toBe(true);
});
test("twenty aliased signup attempts yield exactly one row", async () => {
for (let index = 0; index < 20; index += 1) {
try {
await createUser({
email: `${LOCAL_PART}+bulk${index}@gmail.com`,
passwordHash: "x",
});
} catch (error) {
if (!isUniqueViolation(error)) throw error;
}
}
const rows = await db.select().from(users).where(eq(users.emailKey, BASE_EMAIL));
expect(rows.length).toBe(1);
});
});
describe("Auth DB — confirmation link lifecycle", () => {
test("issuing a link stores exactly one pending token", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
await issueVerificationLink(user, "en");
await issueVerificationLink(user, "en");
const pending = await db
.select()
.from(email_verification_tokens)
.where(eq(email_verification_tokens.userId, user.id));
// Re-issuing replaces the previous link rather than piling them up.
expect(pending.length).toBe(1);
});
test("opening the link verifies the account", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
const mail = await issueVerificationLink(user, "en");
const token = extractToken(mail.devLink);
const outcome = await consumeVerificationToken(token);
expect(outcome.status).toBe("success");
const refreshed = await findUserByEmail(BASE_EMAIL);
expect(refreshed?.emailVerifiedAt !== null).toBe(true);
});
test("a token cannot be replayed", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
const mail = await issueVerificationLink(user, "en");
const token = extractToken(mail.devLink);
// Already-verified accounts short-circuit; the point is it never re-succeeds.
const first = await consumeVerificationToken(token);
const second = await consumeVerificationToken(token);
expect(first.status === "success" || first.status === "already_verified").toBe(true);
expect(second.status === "success").toBe(false);
});
test("an unknown token is rejected", async () => {
const outcome = await consumeVerificationToken(issueToken().token);
expect(outcome.status).toBe("invalid");
});
test("an elapsed token reports as expired, not invalid", async () => {
const expiredUser = await createUser({
email: `${LOCAL_PART}-expired@example.com`,
passwordHash: "x",
});
const { token, hash } = issueToken();
await db.insert(email_verification_tokens).values({
id: hash,
userId: expiredUser.id,
email: expiredUser.email ?? "",
expiresAt: new Date(Date.now() - 60_000),
});
const outcome = await consumeVerificationToken(token);
expect(outcome.status).toBe("expired");
});
test("only the digest is stored — the raw token appears nowhere", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
const mail = await issueVerificationLink(user, "en");
const token = extractToken(mail.devLink);
const byRaw = await db
.select()
.from(email_verification_tokens)
.where(eq(email_verification_tokens.id, token));
const byHash = await db
.select()
.from(email_verification_tokens)
.where(eq(email_verification_tokens.id, hashToken(token)));
expect(byRaw.length).toBe(0);
expect(byHash.length).toBe(1);
});
});
describe("Auth DB — password reset", () => {
test("issuing a reset link replaces any earlier pending one", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
await issuePasswordResetLink(user, "en");
await issuePasswordResetLink(user, "en");
const pending = await db
.select()
.from(password_reset_tokens)
.where(eq(password_reset_tokens.userId, user.id));
expect(pending.length).toBe(1);
});
test("a live token reads as valid without being spent", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
const mail = await issuePasswordResetLink(user, "en");
const token = extractToken(mail.devLink);
expect(await peekPasswordResetToken(token)).toBe("valid");
// Peeking twice must not consume it — the page loads before the form posts.
expect(await peekPasswordResetToken(token)).toBe("valid");
});
test("the new password replaces the old one", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
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(BASE_EMAIL);
expect(await verifyPassword("BrandNew5678!", updated?.passwordHash ?? null)).toBe(true);
expect(await verifyPassword("Sicher1234!", updated?.passwordHash ?? null)).toBe(false);
});
test("resetting signs out every existing session", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
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),
});
const mail = await issuePasswordResetLink(user, "en");
await resetPasswordWithToken(extractToken(mail.devLink), "AnotherOne901!");
const remaining = await db.select().from(sessions).where(eq(sessions.userId, user.id));
expect(remaining.length).toBe(0);
});
test("a reset token cannot be replayed", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
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(BASE_EMAIL);
expect(await verifyPassword("FirstUse123!", updated?.passwordHash ?? null)).toBe(true);
expect(await verifyPassword("SecondUse123!", updated?.passwordHash ?? null)).toBe(false);
});
test("an elapsed reset token reports expired and changes nothing", async () => {
const user = await createUser({
email: `${LOCAL_PART}-reset-expired@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);
});
test("an unknown reset token is rejected", async () => {
const token = issueToken().token;
expect(await peekPasswordResetToken(token)).toBe("invalid");
expect((await resetPasswordWithToken(token, "Whatever123!")).status).toBe("invalid");
});
test("a Google-only account can add a password this way", async () => {
const googleUser = await createUser({
email: `${LOCAL_PART}-google-only@example.com`,
emailVerified: true,
});
expect(isGoogleOnlyAccount(googleUser)).toBe(true);
const mail = await issuePasswordResetLink(googleUser, "en");
const outcome = await resetPasswordWithToken(extractToken(mail.devLink), "AddedLater123!");
expect(outcome.status).toBe("success");
const updated = await findUserByEmail(googleUser.email ?? "");
expect(isGoogleOnlyAccount(updated!)).toBe(false);
expect(await verifyPassword("AddedLater123!", updated?.passwordHash ?? null)).toBe(true);
});
test("resetting also confirms the address", async () => {
const pending = await createUser({
email: `${LOCAL_PART}-unconfirmed@example.com`,
passwordHash: await hashPassword("Original123!"),
});
expect(pending.emailVerifiedAt).toBe(null);
const mail = await issuePasswordResetLink(pending, "en");
await resetPasswordWithToken(extractToken(mail.devLink), "Confirmed123!");
// Opening the emailed link proves inbox control, so the address is verified.
const updated = await findUserByEmail(pending.email ?? "");
expect(updated?.emailVerifiedAt !== null).toBe(true);
});
});
describe("Auth DB — Google identity linking", () => {
test("a Google identity resolves back to the linked account", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
const sub = `google-sub-${RUN_ID}`;
await linkGoogleAccount(user.id, sub);
const found = await findUserByGoogleId(sub);
expect(found?.id).toBe(user.id);
});
test("linking the same identity twice is idempotent", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
const sub = `google-sub-${RUN_ID}`;
await linkGoogleAccount(user.id, sub);
await linkGoogleAccount(user.id, sub);
const rows = await db
.select()
.from(oauth_accounts)
.where(
and(eq(oauth_accounts.provider, "google"), eq(oauth_accounts.providerAccountId, sub))
);
expect(rows.length).toBe(1);
});
test("an unknown Google identity resolves to nothing", async () => {
expect(await findUserByGoogleId(`no-such-sub-${RUN_ID}`)).toBe(null);
});
});
describe("Auth DB — sessions", () => {
test("a live session is found by its token digest", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
const { token, hash } = issueToken();
await db.insert(sessions).values({
id: hash,
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
});
const rows = await db
.select({ user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(and(eq(sessions.id, hashToken(token)), gt(sessions.expiresAt, new Date())));
expect(rows.length).toBe(1);
expect(rows[0].user.id).toBe(user.id);
});
test("an elapsed session is not returned", async () => {
const user = await findUserByEmail(BASE_EMAIL);
if (!user) throw new Error("Fixture user missing");
const { token, hash } = issueToken();
await db.insert(sessions).values({
id: hash,
userId: user.id,
expiresAt: new Date(Date.now() - 60_000),
});
const rows = await db
.select()
.from(sessions)
.where(and(eq(sessions.id, hashToken(token)), gt(sessions.expiresAt, new Date())));
expect(rows.length).toBe(0);
});
test("deleting the account removes its sessions, tokens and links", async () => {
const doomed = await createUser({
email: `${LOCAL_PART}-cascade@example.com`,
passwordHash: "x",
});
await db.insert(sessions).values({
id: issueToken().hash,
userId: doomed.id,
expiresAt: new Date(Date.now() + 60_000),
});
await db.insert(email_verification_tokens).values({
id: issueToken().hash,
userId: doomed.id,
email: doomed.email ?? "",
expiresAt: new Date(Date.now() + 60_000),
});
await db.insert(oauth_accounts).values({
id: newId("oa"),
userId: doomed.id,
provider: "google",
providerAccountId: `cascade-${RUN_ID}`,
});
await db.delete(users).where(eq(users.id, doomed.id));
const leftoverSessions = await db
.select()
.from(sessions)
.where(eq(sessions.userId, doomed.id));
const leftoverTokens = await db
.select()
.from(email_verification_tokens)
.where(eq(email_verification_tokens.userId, doomed.id));
const leftoverLinks = await db
.select()
.from(oauth_accounts)
.where(eq(oauth_accounts.userId, doomed.id));
expect(leftoverSessions.length).toBe(0);
expect(leftoverTokens.length).toBe(0);
expect(leftoverLinks.length).toBe(0);
});
});
}
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",
" npm run test:auth",
"",
].join("\n")
);
await pool.end();
return;
}
// Leftovers from an interrupted earlier run would break the uniqueness tests.
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("Auth DB suite crashed:", error);
await pool.end().catch(() => undefined);
process.exit(1);
});

View File

@@ -0,0 +1,170 @@
/**
* CSRF Flow Integration Suite
*
* End-to-end double-submit CSRF check against a real handler: a logged-in user
* exists (fixture account + session row), and `POST /api/auth/logout` is driven
* with and without the matching `sr_csrf` cookie / `x-csrf-token` header.
*
* Run with: npx tsx tests/integration/csrf_flow.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 { NextRequest } from "next/server";
import { describe, test, expect, beforeAll, runAllTests } from "../e2e/runner";
import { db, isDatabaseAvailable, pool } from "../../src/lib/db";
import { sessions, users } from "../../src/lib/schema/db";
import { createUser } from "../../src/lib/auth/accounts";
import { hashPassword } from "../../src/lib/auth/password";
import { issueToken } from "../../src/lib/auth/tokens";
import { POST as logoutPOST } from "../../src/app/api/auth/logout/route";
import {
CSRF_COOKIE,
CSRF_HEADER,
issueCsrfToken,
} from "../../src/lib/auth/csrf";
/** Namespaced so cleanup can never touch a real account. */
const RUN_ID = Date.now().toString(36);
const LOCAL_PART = `authtest-csrf-${RUN_ID}`;
const BASE_EMAIL = `${LOCAL_PART}@gmail.com`;
async function cleanup() {
// Children cascade from users; the LIKE keeps this scoped to authtest rows.
await db.delete(users).where(like(users.emailKey, `authtest-%`));
}
/** A NextRequest — the same object shape Next.js passes to route handlers. */
function logoutRequest(headers?: HeadersInit): NextRequest {
return new NextRequest("http://localhost:3000/api/auth/logout", {
method: "POST",
...(headers ? { headers } : {}),
});
}
function registerSuites() {
describe("CSRF flow — double-submit guard on POST /api/auth/logout", () => {
beforeAll(async () => {
const user = await createUser({
email: BASE_EMAIL,
name: "CSRF Flow Test",
passwordHash: await hashPassword("Sicher1234!"),
});
await db.insert(sessions).values({
id: issueToken().hash,
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
});
});
test("the fixture user and session exist", async () => {
const userRows = await db.select().from(users).where(eq(users.emailKey, BASE_EMAIL));
expect(userRows.length).toBe(1);
const sessionRows = await db
.select()
.from(sessions)
.where(eq(sessions.userId, userRows[0].id));
expect(sessionRows.length).toBe(1);
});
test("a logout without a CSRF token is blocked with 403 csrf_failed", async () => {
const response = await logoutPOST(logoutRequest());
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: "csrf_failed" });
});
test("a logout with a matching cookie + header succeeds", async () => {
const token = issueCsrfToken();
// This is the one call in the suite that reaches the real route handler
// past the CSRF guard, so it hits `destroySession()` -> `cookies()`.
// Outside actual Next.js request handling there is no request-scoped
// AsyncLocalStorage for `cookies()` to read, so it throws; the route
// already treats that as non-fatal (logout must not "stick" a user
// signed in) and logs it via console.error. That's expected only in
// this direct-handler test harness, so it's muted here rather than in
// the route, which must keep logging real failures in production.
const originalConsoleError = console.error;
console.error = () => {};
let response: Response;
try {
response = await logoutPOST(
logoutRequest({
cookie: `${CSRF_COOKIE}=${token}`,
[CSRF_HEADER]: token,
})
);
} finally {
console.error = originalConsoleError;
}
expect(response.status).toBe(200);
const body = (await response.json()) as { status?: string };
expect(body.status).toBe("signed_out");
});
test("a logout with a mismatched header is blocked with 403", async () => {
const response = await logoutPOST(
logoutRequest({
cookie: `${CSRF_COOKIE}=${issueCsrfToken()}`,
[CSRF_HEADER]: issueCsrfToken(),
})
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: "csrf_failed" });
});
test("a logout with a cookie but no header is blocked with 403", async () => {
const response = await logoutPOST(
logoutRequest({
cookie: `${CSRF_COOKIE}=${issueCsrfToken()}`,
})
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ error: "csrf_failed" });
});
});
}
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/csrf_flow.test.ts",
"",
].join("\n")
);
await pool.end();
return;
}
// Leftovers from an interrupted earlier run would break the fixture.
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("CSRF flow suite crashed:", error);
await pool.end().catch(() => undefined);
process.exit(1);
});

View File

@@ -0,0 +1,36 @@
/**
* Loads `.env.local` as a side effect on import.
*
* This has to be its own module: ES module imports are hoisted, so calling
* `process.loadEnvFile()` at the top of a file that also imports the database
* module would run *after* that module had already read `DATABASE_URL`. Imported
* first, this module is evaluated first.
*
* It matters because the fallback connection string targets port 5432, which on
* a development machine is quite likely another project's database.
*/
try {
process.loadEnvFile(".env.local");
} catch {
// No .env.local (CI, fresh clone): use the environment as given.
}
/**
* Hard guarantee that the suite never sends mail.
*
* The fixtures operate on fabricated addresses (`authtest-…@gmail.com`), and a
* configured SMTP server would dutifully try to deliver to them — bouncing off
* real providers and burning the sending domain's reputation. Clearing these
* puts the mailer into its development fallback, which returns the link instead
* of sending it, which is also how the tests get hold of the raw token.
*/
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASSWORD;
// The dev fallback throws in production rather than silently not sending.
if (process.env.NODE_ENV === "production") {
throw new Error("Refusing to run the auth integration suite with NODE_ENV=production");
}
export {};

View File

@@ -0,0 +1,169 @@
/**
* Password-Change Database Integration Suite
*
* Exercises `changePasswordForUser` — the logged-in password-change flow behind
* `POST /api/auth/change-password`. The core of the hardening: when a user
* changes their password, EVERY existing session row for the account must be
* deleted, so a device that was already signed in (e.g. one an attacker had
* access through) loses access instantly. The route then issues a fresh session
* for the current device; that re-issuing lives in the route handler, not here.
*
* Run with: npx tsx tests/integration/password_change.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 { sessions, users } from "../../src/lib/schema/db";
import { changePasswordForUser, createUser, findUserByEmail } from "../../src/lib/auth/accounts";
import { hashPassword, verifyPassword } from "../../src/lib/auth/password";
import { createSessionRecord } from "../../src/lib/auth/session";
import { issueToken } from "../../src/lib/auth/tokens";
/** Namespaced so cleanup can never touch a real account. */
const RUN_ID = Date.now().toString(36);
const LOCAL_PART = `authtest-${RUN_ID}`;
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("Password change — DB", () => {
test("an unknown user id reports invalid", async () => {
const outcome = await changePasswordForUser(
"usr_nonexistent",
"Whatever123!",
"NewPassword123!"
);
expect(outcome.status).toBe("invalid");
});
test("a Google-only account (no password hash) reports no_password", async () => {
const user = await createUser({
email: `${LOCAL_PART}-google@example.com`,
emailVerified: true, // no passwordHash → Google-only
});
expect(user.passwordHash).toBe(null);
const outcome = await changePasswordForUser(user.id, "Whatever123!", "NewPassword123!");
expect(outcome.status).toBe("no_password");
// Nothing changed for the account.
const unchanged = await findUserByEmail(user.email ?? "");
expect(unchanged?.passwordHash).toBe(null);
});
test("a wrong current password is rejected and leaves the hash untouched", async () => {
const user = await createUser({
email: `${LOCAL_PART}-wrong@example.com`,
passwordHash: await hashPassword("Original123!"),
});
const before = await findUserByEmail(user.email ?? "");
const outcome = await changePasswordForUser(user.id, "NotThePassword!", "Replacement123!");
expect(outcome.status).toBe("wrong_password");
const after = await findUserByEmail(user.email ?? "");
expect(after?.passwordHash).toBe(before?.passwordHash);
expect(await verifyPassword("Original123!", after?.passwordHash ?? null)).toBe(true);
expect(await verifyPassword("Replacement123!", after?.passwordHash ?? null)).toBe(false);
});
test("success replaces the hash: old password stops verifying, new one works", async () => {
const user = await createUser({
email: `${LOCAL_PART}-success@example.com`,
passwordHash: await hashPassword("Original123!"),
});
const outcome = await changePasswordForUser(user.id, "Original123!", "BrandNew456!");
expect(outcome.status).toBe("success");
// Narrow the discriminated union so TS knows `userId` exists.
if (outcome.status !== "success") throw new Error("expected success");
expect(outcome.userId).toBe(user.id);
const updated = await findUserByEmail(user.email ?? "");
expect(updated?.passwordHash !== user.passwordHash).toBe(true);
expect(await verifyPassword("BrandNew456!", updated?.passwordHash ?? null)).toBe(true);
expect(await verifyPassword("Original123!", updated?.passwordHash ?? null)).toBe(false);
});
test("success deletes every existing session row for the account", async () => {
const user = await createUser({
email: `${LOCAL_PART}-sessions@example.com`,
passwordHash: await hashPassword("Original123!"),
});
// Three devices signed in before the password changes: two via the real
// session-issuing helper, one inserted directly.
await createSessionRecord(user.id, { remember: false, userAgent: "MacBook/Chrome" });
await createSessionRecord(user.id, { remember: true, userAgent: "iPhone/Safari" });
await db.insert(sessions).values({
id: issueToken().hash,
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
});
const seeded = await db.select().from(sessions).where(eq(sessions.userId, user.id));
expect(seeded.length).toBe(3);
const outcome = await changePasswordForUser(user.id, "Original123!", "Rotated789!");
expect(outcome.status).toBe("success");
// Every old token is dead — this is the whole point of the hardening.
const remaining = await db.select().from(sessions).where(eq(sessions.userId, user.id));
expect(remaining.length).toBe(0);
// The account row itself survives the rotation.
const updated = await findUserByEmail(user.email ?? "");
expect(updated).not.toBe(null);
});
});
}
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/password_change.test.ts",
"",
].join("\n")
);
await pool.end();
return;
}
// Leftovers from an interrupted earlier run would break the 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("Password change suite crashed:", error);
await pool.end().catch(() => undefined);
process.exit(1);
});

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

View File

@@ -0,0 +1,194 @@
/**
* Security Events Integration Suite
*
* Exercises the audit-log foundation against a real Postgres: events written
* through `logSecurityEvent` come back through `listRecentSecurityEvents` with
* the right type/email, raw IPs are never persisted (only their 64-hex SHA-256
* digest), jsonb metadata round-trips, and a bogus user id (FK violation) is
* swallowed instead of throwing — logging must never break a request.
*
* Run with: node --import tsx tests/integration/security_events.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 { security_events, users } from "../../src/lib/schema/db";
import { createUser } from "../../src/lib/auth/accounts";
import {
logSecurityEvent,
listRecentSecurityEvents,
SecurityEventType,
} from "../../src/lib/auth/securityEvents";
/** Namespaced so cleanup can never touch a real account. */
const RUN_ID = Date.now().toString(36);
const LOCAL_PART = `authtest-${RUN_ID}`;
const BASE_EMAIL = `${LOCAL_PART}@gmail.com`;
const PLAINTEXT_IP = "203.0.113.7";
/** Unique marker in metadata; scopes event cleanup to exactly this run. */
const MARKER = `security-events-${RUN_ID}`;
/** Events written by this suite, so cleanup can delete them explicitly by id. */
const createdEventIds: string[] = [];
async function cleanup() {
// The suite's own event rows, scoped by the unique metadata marker.
if (createdEventIds.length > 0) {
for (const id of createdEventIds) {
await db.delete(security_events).where(eq(security_events.id, id)).catch(() => undefined);
}
createdEventIds.length = 0;
}
// Belt and suspenders: anything this run left behind (e.g. a marker that did
// not round-trip) is swept by the same email namespace the fixtures use.
await db
.delete(security_events)
.where(like(security_events.email, "authtest-%"))
.catch(() => undefined);
// Fixture users; the security_events.user_id FK is ON DELETE SET NULL, which
// is why the event rows above are deleted first.
await db.delete(users).where(like(users.emailKey, "authtest-%")).catch(() => undefined);
}
function registerSuites() {
describe("Security events — persistence and privacy", () => {
test("a logged event appears with the right type/email and hashed IP", async () => {
const user = await createUser({
email: BASE_EMAIL,
name: "Security Event Test",
passwordHash: null,
});
await logSecurityEvent({
type: SecurityEventType.LOGIN_FAILED,
userId: user.id,
email: user.email ?? BASE_EMAIL,
ip: PLAINTEXT_IP,
userAgent: "security-events-test/1.0",
metadata: { marker: MARKER, reason: "bad_password", attempts: 3 },
});
const rows = await db
.select()
.from(security_events)
.where(eq(security_events.userId, user.id));
expect(rows.length).toBe(1);
const row = rows[0];
createdEventIds.push(row.id);
expect(row.type).toBe(SecurityEventType.LOGIN_FAILED);
expect(row.email).toBe(BASE_EMAIL);
expect(row.userId).toBe(user.id);
expect(row.userAgent).toBe("security-events-test/1.0");
// Raw IP is never stored: the column holds the 64-hex SHA-256 digest.
expect(row.ipHash).toMatch(/^[0-9a-f]{64}$/);
expect(row.ipHash).not.toBe(PLAINTEXT_IP);
expect(JSON.stringify(row)).not.toContain(PLAINTEXT_IP);
// jsonb metadata round-trips as a real object.
expect(row.metadataJson).toEqual({ marker: MARKER, reason: "bad_password", attempts: 3 });
// And the read API surfaces it again.
const recent = await listRecentSecurityEvents(50, SecurityEventType.LOGIN_FAILED);
const hit = recent.find((e) => e.email === BASE_EMAIL && e.type === SecurityEventType.LOGIN_FAILED);
expect(hit).toBeDefined();
expect(hit?.metadata).toEqual({ marker: MARKER, reason: "bad_password", attempts: 3 });
});
test("logSecurityEvent never throws on a bogus userId (FK violation)", async () => {
const bogusId = `usr_missing_${RUN_ID}`;
let threw: unknown = null;
try {
await logSecurityEvent({
type: SecurityEventType.SIGNUP_FAILED,
userId: bogusId,
email: `${LOCAL_PART}-missing@example.com`,
ip: PLAINTEXT_IP,
metadata: { marker: MARKER, reason: "bogus-user" },
});
} catch (error) {
threw = error;
}
// The FK error must be swallowed, never propagated to the caller.
expect(threw).toBe(null);
// And no row may exist for the nonexistent user.
const rows = await db
.select()
.from(security_events)
.where(eq(security_events.userId, bogusId));
expect(rows.length).toBe(0);
});
test("an event without a user or IP is still stored with nulls", async () => {
await logSecurityEvent({
type: SecurityEventType.PASSWORD_RESET_REQUESTED,
email: `${LOCAL_PART}-nouser@example.com`,
metadata: { marker: MARKER, reason: "account_not_found" },
});
const rows = await db
.select()
.from(security_events)
.where(eq(security_events.email, `${LOCAL_PART}-nouser@example.com`));
expect(rows.length).toBe(1);
const row = rows[0];
createdEventIds.push(row.id);
expect(row.userId).toBeNull();
expect(row.ipHash).toBeNull();
expect(row.metadataJson).toEqual({ marker: MARKER, reason: "account_not_found" });
});
});
}
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",
" node --import tsx tests/integration/security_events.test.ts",
"",
].join("\n")
);
await pool.end();
return;
}
// Leftovers from an interrupted earlier run would skew the 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("Security events suite crashed:", error);
await pool.end().catch(() => undefined);
process.exit(1);
});

View File

@@ -0,0 +1,98 @@
/**
* ESM hooks for running integration tests without `tsx`.
*
* The sandbox blocks esbuild's pipe-based service-worker spawn (EPERM), so
* this loader performs the TypeScript transform in-process with the locally
* installed `typescript` package (ts.transpileModule — pure JS, no child
* process), resolves the tsconfig `@/*` path alias, probes extensions for
* extensionless relative imports, and falls back to raw package-subpath
* files (`next/headers` etc.) that lack an exports map.
*
* Usage: node --import ./tests/integration/tsconfig-paths-loader.mjs tests/integration/<suite>.test.ts
*/
import { pathToFileURL, fileURLToPath } from "node:url";
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve as pathResolve } from "node:path";
import ts from "typescript";
const ROOT = pathResolve(process.cwd());
const EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
function isFile(p) {
try {
return statSync(p).isFile();
} catch {
return false;
}
}
/** Resolve a specifier target, probing extensions and index files. */
function probe(base) {
if (isFile(base)) return base;
for (const ext of EXTENSIONS) {
if (isFile(base + ext)) return base + ext;
}
for (const ext of EXTENSIONS) {
if (isFile(join(base, "index" + ext))) return join(base, "index" + ext);
}
return null;
}
/** Fallback for bare package subpaths (e.g. `next/headers`) that lack an exports map. */
function probePackage(spec) {
const base = pathResolve(ROOT, "node_modules", spec);
for (const ext of [".js", ".mjs", ".cjs", ".json"]) {
if (isFile(base + ext)) return base + ext;
}
if (isFile(base)) return base;
return null;
}
export async function resolve(specifier, context, nextResolve) {
if (specifier.startsWith("@/")) {
const target = probe(pathResolve(ROOT, "src", specifier.slice(2)));
if (target) return { url: pathToFileURL(target).href, shortCircuit: true };
} else if (
(specifier.startsWith("./") || specifier.startsWith("../")) &&
context.parentURL
) {
const parent = fileURLToPath(context.parentURL);
const target = probe(pathResolve(dirname(parent), specifier));
if (target) return { url: pathToFileURL(target).href, shortCircuit: true };
} else if (!specifier.startsWith("node:")) {
try {
return await nextResolve(specifier, context);
} catch (error) {
const target = probePackage(specifier);
if (target) return { url: pathToFileURL(target).href, shortCircuit: true };
throw error;
}
}
return nextResolve(specifier, context);
}
export async function load(url, context, nextLoad) {
if (url.startsWith("file:")) {
const filePath = fileURLToPath(url);
if (/\.(ts|tsx|mts|cts)$/.test(filePath)) {
const source = readFileSync(filePath, "utf8");
const out = ts.transpileModule(source, {
fileName: filePath,
reportDiagnostics: false,
compilerOptions: {
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
target: ts.ScriptTarget.ES2022,
isolatedModules: true,
esModuleInterop: true,
allowJs: true,
jsx: ts.JsxEmit.ReactJSX,
verbatimModuleSyntax: false,
},
});
return { format: "module", source: out.outputText, shortCircuit: true };
}
}
return nextLoad(url, context);
}

View File

@@ -0,0 +1,7 @@
/**
* Loader entry: registers the path-alias hooks module for the current process.
* Usage: node --import ./tests/integration/tsconfig-paths-loader.mjs tests/integration/<suite>.test.ts
*/
import { register } from "node:module";
register(new URL("./tsconfig-paths-hooks.mjs", import.meta.url));