Files
accounted/tests/pg/document-integrity-checks.pg.test.ts
T
Jakob Wennberg 77becf3d65 fix(documents): record archive integrity checks in their own ledger so the nightly control advances again (#2108)
The 03:00 WORM verification cron stamped last_integrity_check_at on
document_attachments. enforce_period_lock_documents() fires on any UPDATE of a
row whose journal entry sits in a closed or locked period, without checking
whether the entry link actually changed, so a read-only integrity stamp was
rejected. The queue orders last_integrity_check_at ASC NULLS FIRST, so the
rejected rows re-sorted to the head every night and the batch became
permanently 200/200 blocked. Both call sites discarded the update error, so
nothing logged and nothing alerted.

Prod state: 34 557 current-version documents, 24 083 never checked, last
successful stamp 2026-08-31 03:00, nightly successes already decayed to
single digits.

Migration 017's enforcement triggers are legally required and never-touch, so
this does not narrow the trigger. The verification outcome moves to its own
document_integrity_checks table and the cron stops writing document_attachments
altogether, which takes the trigger off the write path. The legacy column stays
in place. Failures are now counted, logged and reported in the route's summary:
the silence is why this went unnoticed for weeks.


Claude-Session: https://claude.ai/code/session_016ifKg6Ec67A39oxfGPU1yc

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

414 lines
16 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { beforeAll, describe, expect, it } from 'vitest'
import { getPool, withUserContext } from './setup'
import { insertAuthUser, insertPostedJournalEntry, seedCompany } from './fixtures'
/**
* pg-real for migration 20260901130000: the WORM integrity ledger that
* replaces document_attachments.last_integrity_check_at.
*
* The bug being closed: enforce_period_lock_documents() (migration 017) is
* BEFORE INSERT OR UPDATE FOR EACH ROW with no OLD/NEW comparison, so the
* nightly cron's stamp UPDATE was rejected for every document linked to an
* entry in a closed or locked period. Because the queue sorted on that same
* column NULLS FIRST, the rejected rows returned to the head of the queue
* every night and the batch sat at 200 of 200 rejected. Migration 017 is
* legally required and stays untouched, so the check moved to its own table:
* the assertions below pin BOTH halves, that the old UPDATE is still blocked
* and that the same document can now be integrity-checked.
*/
function makeHash(): string {
return randomUUID().replace(/-/g, '').padEnd(64, '0')
}
async function attachDocument(params: {
userId: string
companyId: string
journalEntryId: string | null
fileName?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.document_attachments
(id, user_id, company_id, journal_entry_id, file_name, mime_type,
file_size_bytes, storage_path, sha256_hash, upload_source)
VALUES ($1, $2, $3, $4, $5, 'application/pdf', 1024, $6, $7, 'file_upload')`,
[
id,
params.userId,
params.companyId,
params.journalEntryId,
params.fileName ?? 'underlag.pdf',
`documents/${params.companyId}/${id}.pdf`,
makeHash(),
],
)
return id
}
async function recordCheck(params: {
companyId: string
documentId: string
checkedAt?: string
result?: 'passed' | 'hash_mismatch' | 'object_missing'
}): Promise<string> {
const { rows } = await getPool().query<{ id: string }>(
`INSERT INTO public.document_integrity_checks
(company_id, document_id, checked_at, expected_sha256, computed_sha256,
storage_path, result)
VALUES ($1, $2, COALESCE($3::timestamptz, now()), $4, $4, 'documents/x.pdf', $5)
RETURNING id`,
[
params.companyId,
params.documentId,
params.checkedAt ?? null,
makeHash(),
params.result ?? 'passed',
],
)
return rows[0].id
}
/** Company + posted entry + anchored document, with the period then sealed. */
async function seedSealedDocument(seal: 'closed' | 'locked'): Promise<{
userId: string
companyId: string
documentId: string
}> {
// Seed open: the period-lock triggers block inserting a posted entry and an
// anchored document into an already-sealed period.
const s = await seedCompany()
const entryId = await insertPostedJournalEntry({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
voucherNumber: 1,
})
const documentId = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: entryId,
})
await getPool().query(
seal === 'closed'
? `UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`
: `UPDATE public.fiscal_periods SET locked_at = now() WHERE id = $1`,
[s.fiscalPeriodId],
)
return { userId: s.userId, companyId: s.companyId, documentId }
}
describe('document_integrity_checks: the locked-period write path', () => {
it('still refuses the legacy stamp on a document in a CLOSED period', async () => {
const { documentId } = await seedSealedDocument('closed')
await expect(
getPool().query(
`UPDATE public.document_attachments SET last_integrity_check_at = now() WHERE id = $1`,
[documentId],
),
).rejects.toThrow(/locked\/closed fiscal period/)
})
it('still refuses the legacy stamp on a document in a LOCKED period', async () => {
const { documentId } = await seedSealedDocument('locked')
await expect(
getPool().query(
`UPDATE public.document_attachments SET last_integrity_check_at = now() WHERE id = $1`,
[documentId],
),
).rejects.toThrow(/locked\/closed fiscal period/)
})
it('lets the same closed-period document be integrity-checked', async () => {
const { companyId, documentId } = await seedSealedDocument('closed')
await recordCheck({ companyId, documentId })
const { rows } = await getPool().query<{ result: string; document_id: string }>(
`SELECT result, document_id FROM public.document_integrity_checks WHERE document_id = $1`,
[documentId],
)
expect(rows).toEqual([{ result: 'passed', document_id: documentId }])
})
it('lets the same locked-period document be integrity-checked', async () => {
const { companyId, documentId } = await seedSealedDocument('locked')
await recordCheck({ companyId, documentId, result: 'hash_mismatch' })
const { rows } = await getPool().query<{ result: string }>(
`SELECT result FROM public.document_integrity_checks WHERE document_id = $1`,
[documentId],
)
expect(rows.map((r) => r.result)).toEqual(['hash_mismatch'])
})
it('keeps the sealed document in the queue until a check row exists', async () => {
const { documentId } = await seedSealedDocument('closed')
const queued = async () => {
const { rows } = await getPool().query<{ id: string }>(
`SELECT id FROM public.next_documents_for_integrity_check(1000) WHERE id = $1`,
[documentId],
)
return rows.length
}
expect(await queued()).toBe(1)
const { rows: doc } = await getPool().query<{ company_id: string }>(
`SELECT company_id FROM public.document_attachments WHERE id = $1`,
[documentId],
)
await recordCheck({ companyId: doc[0].company_id, documentId })
// Still in the table (the queue is least-recently-checked, not
// check-once), but it now carries a checked_at instead of NULL.
const { rows } = await getPool().query<{ last_checked_at: string | null }>(
`SELECT last_checked_at FROM public.next_documents_for_integrity_check(1000) WHERE id = $1`,
[documentId],
)
expect(rows[0]?.last_checked_at ?? null).not.toBeNull()
})
})
describe('next_documents_for_integrity_check ordering', () => {
it('returns never-checked documents before the least recently checked', async () => {
const s = await seedCompany()
const never = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
fileName: 'never.pdf',
})
const stale = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
fileName: 'stale.pdf',
})
const fresh = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
fileName: 'fresh.pdf',
})
await recordCheck({ companyId: s.companyId, documentId: stale, checkedAt: '2026-01-01' })
await recordCheck({ companyId: s.companyId, documentId: fresh, checkedAt: '2026-08-31' })
const { rows } = await getPool().query<{ id: string }>(
`SELECT id FROM public.next_documents_for_integrity_check(1000)`,
)
const mine = rows.map((r) => r.id).filter((id) => [never, stale, fresh].includes(id))
expect(mine).toEqual([never, stale, fresh])
})
it('reads only the newest check per document', async () => {
const s = await seedCompany()
const documentId = await attachDocument({
userId: s.userId,
companyId: s.companyId,
journalEntryId: null,
})
await recordCheck({ companyId: s.companyId, documentId, checkedAt: '2026-01-01' })
await recordCheck({ companyId: s.companyId, documentId, checkedAt: '2026-08-31' })
const { rows } = await getPool().query<{ last_checked_at: Date }>(
`SELECT last_checked_at FROM public.next_documents_for_integrity_check(1000) WHERE id = $1`,
[documentId],
)
expect(new Date(rows[0].last_checked_at).toISOString().slice(0, 10)).toBe('2026-08-31')
})
it('honours p_limit and clamps it to a sane range', async () => {
const one = await getPool().query(`SELECT * FROM public.next_documents_for_integrity_check(1)`)
expect(one.rows.length).toBeLessThanOrEqual(1)
// 0 and negatives clamp up to 1 rather than returning nothing, and the
// upper clamp keeps a runaway env override from scanning the world.
const zero = await getPool().query(`SELECT * FROM public.next_documents_for_integrity_check(0)`)
expect(zero.rows.length).toBeLessThanOrEqual(1)
const huge = await getPool().query(
`SELECT * FROM public.next_documents_for_integrity_check(999999)`,
)
expect(huge.rows.length).toBeLessThanOrEqual(1000)
})
it('is executable by service_role only', async () => {
const { rows } = await getPool().query<{ role: string; ok: boolean }>(
`SELECT r.role,
has_function_privilege(r.role, 'public.next_documents_for_integrity_check(integer)', 'execute') AS ok
FROM (VALUES ('anon'), ('authenticated'), ('service_role')) AS r(role)`,
)
expect(Object.fromEntries(rows.map((r) => [r.role, r.ok]))).toEqual({
anon: false,
authenticated: false,
service_role: true,
})
})
})
describe('document_integrity_checks: RLS and grants', () => {
let userId: string
let companyId: string
let documentId: string
beforeAll(async () => {
const s = await seedCompany()
userId = s.userId
companyId = s.companyId
documentId = await attachDocument({
userId,
companyId,
journalEntryId: null,
fileName: 'rls.pdf',
})
await recordCheck({ companyId, documentId })
})
it('lets a company member read its own ledger rows', async () => {
const rows = await withUserContext(userId, async (client) => {
const res = await client.query<{ document_id: string }>(
`SELECT document_id FROM public.document_integrity_checks WHERE company_id = $1`,
[companyId],
)
return res.rows
})
expect(rows).toEqual([{ document_id: documentId }])
})
it('hides them from a user outside the company', async () => {
const outsiderId = await insertAuthUser()
const rows = await withUserContext(outsiderId, async (client) => {
const res = await client.query(
`SELECT id FROM public.document_integrity_checks WHERE company_id = $1`,
[companyId],
)
return res.rows
})
expect(rows).toHaveLength(0)
})
it('has no INSERT/UPDATE/DELETE policy: only the service role appends', async () => {
const { rows } = await getPool().query<{ polcmd: string }>(
`SELECT polcmd FROM pg_policy
WHERE polrelid = 'public.document_integrity_checks'::regclass`,
)
// 'r' = SELECT. Anything else would be a write policy.
expect(rows.map((r) => r.polcmd)).toEqual(['r'])
const rls = await getPool().query<{ relrowsecurity: boolean }>(
`SELECT relrowsecurity FROM pg_class WHERE oid = 'public.document_integrity_checks'::regclass`,
)
expect(rls.rows[0].relrowsecurity).toBe(true)
})
it('grants only what each role needs: member reads, cron appends, nobody updates', async () => {
const { rows } = await getPool().query<{
role: string
can_select: boolean
can_insert: boolean
can_update: boolean
can_delete: boolean
}>(
`SELECT r.role,
has_table_privilege(r.role, 'public.document_integrity_checks', 'SELECT') AS can_select,
has_table_privilege(r.role, 'public.document_integrity_checks', 'INSERT') AS can_insert,
has_table_privilege(r.role, 'public.document_integrity_checks', 'UPDATE') AS can_update,
has_table_privilege(r.role, 'public.document_integrity_checks', 'DELETE') AS can_delete
FROM (VALUES ('anon'), ('authenticated'), ('service_role')) AS r(role)`,
)
const byRole = Object.fromEntries(
rows.map((r) => [
r.role,
{
select: r.can_select,
insert: r.can_insert,
update: r.can_update,
delete: r.can_delete,
},
]),
)
expect(byRole).toEqual({
anon: { select: false, insert: false, update: false, delete: false },
authenticated: { select: true, insert: false, update: false, delete: false },
// Append-only: even the cron's role cannot rewrite or erase a check.
service_role: { select: true, insert: true, update: false, delete: false },
})
})
it('rejects an authenticated INSERT even inside the user own company', async () => {
await withUserContext(userId, async (client) => {
await expect(
client.query(
`INSERT INTO public.document_integrity_checks
(company_id, document_id, expected_sha256, storage_path, result)
VALUES ($1, $2, $3, 'documents/x.pdf', 'passed')`,
[companyId, documentId, makeHash()],
),
).rejects.toThrow(/permission denied|row-level security/i)
})
})
it('is append-only: an UPDATE raises in this table own voice', async () => {
// Not the shared audit_log_immutable() message. "Audit log entries cannot
// be modified" from a table that is not the audit log misleads whoever
// reads the log, and app/api/transactions/[id]/route.ts pattern-matches
// that exact string as an audit-log signal.
await expect(
getPool().query(
`UPDATE public.document_integrity_checks SET result = 'passed' WHERE document_id = $1`,
[documentId],
),
).rejects.toThrow(/Document integrity check entries cannot be modified or deleted/i)
})
it('rejects an unknown result value', async () => {
await expect(
getPool().query(
`INSERT INTO public.document_integrity_checks
(company_id, document_id, expected_sha256, storage_path, result)
VALUES ($1, $2, $3, 'documents/x.pdf', 'probably_fine')`,
[companyId, documentId, makeHash()],
),
).rejects.toThrow(/document_integrity_checks_result_check|violates check constraint/)
})
it('carries the indexes the queue and the tenant read need', async () => {
const { rows } = await getPool().query<{ indexname: string }>(
`SELECT indexname FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'document_integrity_checks'
ORDER BY indexname`,
)
expect(rows.map((r) => r.indexname)).toContain('idx_document_integrity_checks_document')
expect(rows.map((r) => r.indexname)).toContain('idx_document_integrity_checks_company')
})
it('retires the index that served the old queue', async () => {
// idx_document_attachments_integrity_check (20260330120000) indexed
// last_integrity_check_at for the cron's old ORDER BY. That query is gone
// and the column is frozen, so the index was pure write cost on the
// busiest document table. If it comes back, so has a writer to the legacy
// column, which is the thing migration 017 rejects.
const { rows } = await getPool().query<{ indexname: string }>(
`SELECT indexname FROM pg_indexes
WHERE schemaname = 'public' AND tablename = 'document_attachments'`,
)
expect(rows.map((r) => r.indexname)).not.toContain('idx_document_attachments_integrity_check')
})
it('keeps the legacy column itself: historical values are evidence', async () => {
const { rows } = await getPool().query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'document_attachments'
AND column_name = 'last_integrity_check_at'`,
)
expect(rows).toHaveLength(1)
})
})