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