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:
170
tests/integration/csrf_flow.test.ts
Normal file
170
tests/integration/csrf_flow.test.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user