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:
parent
948a218d86
commit
98d3e925e5
2 changed files with 115 additions and 0 deletions
60
lib/services/phishing-audit.test.ts
Normal file
60
lib/services/phishing-audit.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
55
lib/services/phishing-audit.ts
Normal file
55
lib/services/phishing-audit.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue