Files
accounted/lib/processing-history/__tests__/append.test.ts
T
Jakob Wennberg 088754d61a fix(processing-history): register the missing event types, and strip the PII two of them carry (#2111)
* fix(processing-history): register the missing event types, and strip the PII two of them carry

Ten event types are emitted by code but absent from processing_event_types, so
every append fails the foreign key. Appends are best-effort try/catch, so no
user request fails, but the internal audit trail is empty for ten kinds of
legally motivated act, including the BFL 5 kap 5 § rattelse record when a user
swaps a transaction's underlag (TransactionDocumentReplaced) and the SOC 2
revocation record (OAuthClientRevoked).

Order matters and is deliberate. Two invoice-inbox events, RateLimitedDropped
and AttachmentsTruncated, put the raw sender address and mail subject in their
payload. Registering those types first would start persisting that PII into an
append-only table whose UPDATE is trigger-blocked and which the archive's
erasure path excludes. The strip therefore ships in this same commit, ahead of
the migration.

Only the invoice-inbox emitter was edited. whatsapp-inbox shares the
RateLimitedDropped type name with a payload that carries no phone number.

Closes the class rather than the two logged instances: a TypeScript union makes
an unregistered literal a compile error, and the pg test asserts the database
catalog is a superset of the code's list, generated from the union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

* fix(processing-history): strip the inbound-mail PII in the database, not by deploy ordering

Review finding (superagent-security, P2): shipping the emitter fix and the
catalog migration in one commit is not the same as one instant. Migrations
apply on merge while the replacement build takes minutes, so an old instance
can still write a sender address and mail subject in that window, and such a
row is permanent: processing_history takes no UPDATE and no DELETE, and the
archive export excludes it from the erasure path.

Adds a BEFORE INSERT trigger stripping `from` and `subject` from the
RateLimitedDropped and AttachmentsTruncated payloads, and keeps it afterwards
so the invariant belongs to the table rather than to one emitter's good
behaviour. The jsonb object check is load bearing: `payload - 'key'` raises on
a jsonb array and payload's shape is not constrained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 14:14:09 +02:00

122 lines
4.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
PROCESSING_EVENT_TYPES,
appendProcessingHistoryWithClient,
type ProcessingHistoryEventType,
} from '@/lib/processing-history/append'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
/** Minimal service-role stand-in: records the inserted row, resolves clean. */
function createInsertSpy(error: { message: string } | null = null) {
const insert = vi.fn().mockResolvedValue({ error })
const from = vi.fn().mockReturnValue({ insert })
return { supabase: { from } as never, from, insert }
}
const baseInput = {
companyId: '11111111-1111-4111-8111-111111111111',
correlationId: '22222222-2222-4222-8222-222222222222',
aggregateType: 'System' as const,
aggregateId: '22222222-2222-4222-8222-222222222222',
eventType: 'OAuthClientRevoked' as const,
payload: { client_id: '33333333-3333-4333-8333-333333333333' },
actor: { type: 'user' as const, id: '44444444-4444-4444-8444-444444444444' },
occurredAt: new Date('2026-09-01T10:00:00Z'),
}
describe('PROCESSING_EVENT_TYPES', () => {
it('is sorted and free of duplicates', () => {
// The list is read row-by-row against the migration that registers the same
// strings; keeping it sorted and unique is what makes that diff readable.
const sorted = [...PROCESSING_EVENT_TYPES].sort()
expect([...PROCESSING_EVENT_TYPES]).toEqual(sorted)
expect(new Set(PROCESSING_EVENT_TYPES).size).toBe(PROCESSING_EVENT_TYPES.length)
})
it('rejects an event type no migration registers', () => {
// The real guard is the compiler: processing_history.event_type has an FK
// to processing_event_types and every append is best-effort try/catch, so
// an unregistered literal is an audit record silently lost at runtime.
// If this @ts-expect-error ever goes unused, the union stopped enforcing.
const registered: ProcessingHistoryEventType = 'TransactionDocumentReplaced'
// @ts-expect-error not a member of PROCESSING_EVENT_TYPES
const unregistered: ProcessingHistoryEventType = 'SomethingNobodyRegistered'
expect(registered).toBe('TransactionDocumentReplaced')
expect(unregistered).toBe('SomethingNobodyRegistered')
})
})
describe('appendProcessingHistoryWithClient', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('writes the event row and returns the generated event id', async () => {
const { supabase, from, insert } = createInsertSpy()
const eventId = await appendProcessingHistoryWithClient(supabase, baseInput)
expect(from).toHaveBeenCalledWith('processing_history')
expect(insert).toHaveBeenCalledWith(
expect.objectContaining({
event_id: eventId,
company_id: baseInput.companyId,
correlation_id: baseInput.correlationId,
causation_id: null,
aggregate_type: 'System',
aggregate_id: baseInput.aggregateId,
event_type: 'OAuthClientRevoked',
payload: baseInput.payload,
payload_schema_version: 1,
occurred_at: '2026-09-01T10:00:00.000Z',
})
)
})
it('throws with the event type when the insert fails', async () => {
// The FK violation on an unregistered event type arrives here. Every call
// site swallows it, so the message is the only trace: it must name the type.
const { supabase } = createInsertSpy({
message: 'insert or update on table "processing_history" violates foreign key constraint',
})
await expect(appendProcessingHistoryWithClient(supabase, baseInput)).rejects.toThrow(
/OAuthClientRevoked/
)
})
it('rejects a payload carrying a personnummer', async () => {
const { supabase, insert } = createInsertSpy()
await expect(
appendProcessingHistoryWithClient(supabase, {
...baseInput,
payload: { note: '900101-1234' },
})
).rejects.toThrow()
expect(insert).not.toHaveBeenCalled()
})
it('accepts a payload of UUIDs, counts and booleans', async () => {
const { supabase, insert } = createInsertSpy()
await appendProcessingHistoryWithClient(supabase, {
...baseInput,
eventType: 'AttachmentsTruncated',
payload: { total: 24, processed: 20, dropped: 4 },
})
expect(insert).toHaveBeenCalledWith(
expect.objectContaining({
event_type: 'AttachmentsTruncated',
payload: { total: 24, processed: 20, dropped: 4 },
})
)
})
})