feat(20-01): add audit-event writer (writeAuditEvent)

- Single parameterized append-only INSERT into audit_events
- Supports optional injected transaction client for atomic writes
- Documents the four canonical event_type strings for this phase
This commit is contained in:
lorentz 2026-07-16 10:35:35 -04:00
parent 948a218d86
commit 98d3e925e5
2 changed files with 115 additions and 0 deletions

View file

@ -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();
});
});

View file

@ -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<string, unknown>;
}
/**
* 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<string> {
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;
}