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>
195 lines
6.7 KiB
TypeScript
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);
|
|
});
|