Files
scan-receipts/tests/integration/security_events.test.ts
Timo 84b9987c49 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>
2026-08-19 20:59:04 +02:00

195 lines
6.7 KiB
TypeScript

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