diff --git a/lib/services/phishing-audit.test.ts b/lib/services/phishing-audit.test.ts new file mode 100644 index 0000000..60de06f --- /dev/null +++ b/lib/services/phishing-audit.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock postgresClient BEFORE importing the module under test. +const queryMock = vi.fn(); +vi.mock('./postgres-client', () => ({ + postgresClient: { + query: (...args: unknown[]) => queryMock(...args), + }, +})); + +// eslint-disable-next-line import/first -- imported after vi.mock hoisting +import { writeAuditEvent } from './phishing-audit'; + +describe('writeAuditEvent', () => { + beforeEach(() => { + queryMock.mockReset(); + }); + + it('issues one parameterized INSERT into audit_events and returns the RETURNING id when no client is passed', async () => { + queryMock.mockResolvedValueOnce({ rows: [{ id: 'audit-1' }] }); + + const id = await writeAuditEvent({ + campaignId: 'campaign-1', + actor: 'operator@example.com', + eventType: 'remediation_approved', + payload: { actionType: 'block_sender' }, + }); + + expect(id).toBe('audit-1'); + expect(queryMock).toHaveBeenCalledTimes(1); + const [sql, params] = queryMock.mock.calls[0]; + expect(sql).toContain('INSERT INTO audit_events'); + expect(sql).toContain('RETURNING id::text AS id'); + expect(params).toEqual([ + 'campaign-1', + 'operator@example.com', + 'remediation_approved', + JSON.stringify({ actionType: 'block_sender' }), + ]); + }); + + it('routes the query through an explicit client instead of postgresClient when one is supplied', async () => { + const clientQueryMock = vi.fn().mockResolvedValueOnce({ rows: [{ id: 'audit-2' }] }); + const fakeClient = { query: clientQueryMock }; + + const id = await writeAuditEvent( + { + campaignId: 'campaign-2', + actor: null, + eventType: 'remediation_completed', + payload: { actionId: 'action-1' }, + }, + fakeClient + ); + + expect(id).toBe('audit-2'); + expect(clientQueryMock).toHaveBeenCalledTimes(1); + expect(queryMock).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/services/phishing-audit.ts b/lib/services/phishing-audit.ts new file mode 100644 index 0000000..858fdcb --- /dev/null +++ b/lib/services/phishing-audit.ts @@ -0,0 +1,55 @@ +/** + * Phishing Audit Trail (Phase 20) + * + * Single, append-only audit-event writer used by every state-changing + * phishing-triage service call (remediation-service.ts). Every write is a + * parameterized INSERT into `audit_events` — no ON CONFLICT, no updates, + * no deletes. This is the ONLY place that inserts into `audit_events`. + * + * Canonical `event_type` strings emitted across this phase (Phase 19 + 20): + * - 'campaign_classified' (Phase 19 — not emitted by this file) + * - 'remediation_approved' (remediation-service.ts, Task 2) + * - 'remediation_completed' (remediation-service.ts, Task 2) + * - 'campaign_marked_false_positive' (remediation-service.ts, Task 3) + * + * `writeAuditEvent` accepts an optional transaction `client` so a caller can + * write its state change and its audit row inside the SAME + * `postgresClient.transaction(...)` block — a rollback discards both + * together, guaranteeing no state change can commit without its audit row + * (REMED-06 / T-20-03). + */ + +import { postgresClient } from './postgres-client'; + +export interface AuditEventInput { + campaignId: string; + actor: string | null; + eventType: string; + payload: Record; +} + +/** + * Minimal shape shared by both `postgresClient` and a transaction `PoolClient` + * — only the `query` method this module needs, so a fake test client or a + * real `pg.PoolClient` both satisfy it without importing `pg` here. + */ +export interface AuditQueryClient { + query: (text: string, params?: unknown[]) => Promise<{ rows: Array<{ id: string }> }>; +} + +/** + * Inserts one row into `audit_events` and returns its id. Pass `client` + * (a transaction client) to make the write commit/rollback atomically with + * the caller's own state change; omit it to write standalone via the + * `postgresClient` singleton. + */ +export async function writeAuditEvent(input: AuditEventInput, client?: AuditQueryClient): Promise { + const target = client ?? postgresClient; + const result = await target.query( + `INSERT INTO audit_events (campaign_id, actor, event_type, payload) + VALUES ($1, $2, $3, $4::jsonb) + RETURNING id::text AS id`, + [input.campaignId, input.actor, input.eventType, JSON.stringify(input.payload)] + ); + return result.rows[0].id; +}