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