Files
accounted/tests/pg/behandlingshistorik-audit-triggers.pg.test.ts
T
191fb2cfce fix(audit): alias learning is not a rule change, stop logging it (BFNAR noise) (#2134)
Production falsified 20260901103000's exclusion list within 30 minutes
of deploy: 15 of the first 16 UPDATE audit rows on
categorization_templates changed only the learning columns plus
counterparty_aliases, because the learning path
(lib/bookkeeping/counterparty-templates.ts) merges new aliases in the
same write that bumps occurrence_count. Projected ~800 noise rows/day
against ~50/day of real rule changes, each one rendered into the
legally-facing behandlingshistorik as "Konteringsmall aendrad: Alias".

counterparty_aliases joins the trigger's strip list. The trade-off is
explicit: a human editing ONLY aliases is no longer logged. Accepted
because alias growth is overwhelmingly automatic, and a change that also
touches accounts, VAT, pattern or the active flag still logs: the first
real such row (2026-09-01 19:02:17Z, debit/credit/vat accounts changed
by the learning loop, BFN's automatkontering case exactly) was captured
correctly and stays captured under the new WHEN clause.

The pre-fix noise rows stay in audit_log (append-only). The read model
stops labelling the column, so alias-only diffs, historical ones
included, render as no-ops rather than rule changes; a diff that also
carries a real change shows only the real change.

pg-test extended: alias+learning update writes no audit row,
alias+account update still does. Read-model test pins the pre-fix noise
row shape to null.


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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 21:58:02 +02:00

223 lines
11 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
import { getPool, withUserContext } from './setup'
/**
* Behandlingshistorik, part 3 (migrations 20260901103000 + 20260901200000): the behandlingsregler
* tables and the import logs write to the immutable audit_log, learning-only
* updates on categorization_templates are filtered, the global payroll
* constants are logged without a company, and app_releases is an append-only,
* read-for-all, service-role-written version log.
*/
async function setActiveCompany(userId: string, companyId: string): Promise<void> {
await getPool().query(
`INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
[userId, companyId],
)
}
async function auditActions(table: string, recordId: string): Promise<string[]> {
const res = await getPool().query<{ action: string }>(
`SELECT action FROM public.audit_log
WHERE table_name = $1 AND record_id = $2
ORDER BY created_at, id`,
[table, recordId],
)
return res.rows.map((r) => r.action)
}
describe('behandlingshistorik audit triggers (BFNAR 2013:2 p. 9.16)', () => {
it('logs mapping_rules insert, update and delete with the company id', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const ruleId = randomUUID()
await getPool().query(
`INSERT INTO public.mapping_rules (id, user_id, company_id, rule_name, rule_type, debit_account, credit_account)
VALUES ($1, $2, $3, 'Kontorsmaterial', 'merchant_name', '6110', '1930')`,
[ruleId, userId, companyId],
)
await getPool().query(`UPDATE public.mapping_rules SET debit_account = '6540' WHERE id = $1`, [ruleId])
await getPool().query(`DELETE FROM public.mapping_rules WHERE id = $1`, [ruleId])
const rows = await getPool().query<{ action: string; company_id: string | null; old_debit: string | null; new_debit: string | null }>(
`SELECT action, company_id, old_state->>'debit_account' AS old_debit, new_state->>'debit_account' AS new_debit
FROM public.audit_log WHERE table_name = 'mapping_rules' AND record_id = $1
ORDER BY created_at, id`,
[ruleId],
)
expect(rows.rows).toEqual([
{ action: 'INSERT', company_id: companyId, old_debit: null, new_debit: '6110' },
{ action: 'UPDATE', company_id: companyId, old_debit: '6110', new_debit: '6540' },
{ action: 'DELETE', company_id: companyId, old_debit: '6540', new_debit: null },
])
})
it('logs categorization_templates rule changes but not learning-only updates', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const templateId = randomUUID()
await getPool().query(
`INSERT INTO public.categorization_templates (id, user_id, company_id, counterparty_name, debit_account, credit_account)
VALUES ($1, $2, $3, 'Spotify AB', '6540', '1930')`,
[templateId, userId, companyId],
)
// Learning on every booking: occurrence_count / confidence / last_seen_date,
// and counterparty_aliases, which the learning path merges in the same
// write (20260901200000; prod showed 15 of the first 16 audit rows were
// alias+learning noise).
await getPool().query(
`UPDATE public.categorization_templates
SET occurrence_count = occurrence_count + 1, confidence = 0.9, last_seen_date = CURRENT_DATE,
counterparty_aliases = ARRAY['SPOTIFY STOCKHOLM 4711']
WHERE id = $1`,
[templateId],
)
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT'])
// But an account change arriving in the same write as learning columns is
// a behandlingsregel change and must still log (the automatkontering case).
await getPool().query(
`UPDATE public.categorization_templates
SET occurrence_count = occurrence_count + 1, credit_account = '1931',
counterparty_aliases = ARRAY['SPOTIFY STOCKHOLM 4711', 'SPOTIFY AB']
WHERE id = $1`,
[templateId],
)
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT', 'UPDATE'])
// A plain rule change (the account) is logged.
await getPool().query(`UPDATE public.categorization_templates SET debit_account = '6212' WHERE id = $1`, [templateId])
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT', 'UPDATE', 'UPDATE'])
await getPool().query(`DELETE FROM public.categorization_templates WHERE id = $1`, [templateId])
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT', 'UPDATE', 'UPDATE', 'DELETE'])
})
it('logs booking_template_library changes (no user_id column: actor falls back to auth.uid())', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
// btl_insert requires current_user_can_write() and company_id =
// current_active_company_id(), so the membership and the active-company
// preference both have to exist before the authenticated insert.
await insertCompanyMember({ companyId, userId })
await setActiveCompany(userId, companyId)
const templateId = randomUUID()
// Asserted INSIDE the user transaction: withUserContext always rolls back,
// so the audit row the trigger writes is gone by the time an outside
// connection could look for it. The row is what is under test, not its
// persistence, and the trigger fires in the same transaction as the write.
const rows = await withUserContext(userId, async (client) => {
await client.query(
`INSERT INTO public.booking_template_library (id, company_id, created_by, name, lines)
VALUES ($1, $2, $3, 'Hyra', '[{"account":"5010","side":"debit"}]'::jsonb)`,
[templateId, companyId, userId],
)
const audit = await client.query<{ action: string; company_id: string | null; user_id: string | null }>(
`SELECT action, company_id, user_id FROM public.audit_log
WHERE table_name = 'booking_template_library' AND record_id = $1`,
[templateId],
)
return audit.rows
})
// booking_template_library has no user_id column, so write_audit_log()
// falls back to auth.uid(): the actor is the authenticated writer.
expect(rows).toEqual([{ action: 'INSERT', company_id: companyId, user_id: userId }])
})
it('logs the global payroll constants without a company (read via service role by the report)', async () => {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.salary_payroll_config (
id, config_year, avgifter_total, avgifter_alderspension, avgifter_sjukforsakring,
avgifter_foraldraforsakring, avgifter_efterlevandepension, avgifter_arbetsmarknad,
avgifter_arbetsskada, avgifter_allman_loneavgift, avgifter_reduced_65plus,
avgifter_minimum_annual, egenavgifter_total, slp_rate, prisbasbelopp, inkomstbasbelopp,
max_pgi, sgi_ceiling, statlig_skatt_brytpunkt, traktamente_heldag, traktamente_halvdag,
traktamente_natt, milersattning_egen_bil, milersattning_formansbil_fossil,
milersattning_formansbil_el, kostforman_heldag, kostforman_lunch, kostforman_frukost,
friskvard_cap, bilforman_slr, reduced_avgift_age
) VALUES (
$1, 2099, 0.3142, 0.1021, 0.0355, 0.026, 0.006, 0.0264, 0.002, 0.1162, 0.1021,
1000, 0.2897, 0.2426, 60000, 85000, 600000, 500000, 650000, 290, 145, 145, 25, 12, 9.5,
110, 55, 55, 5000, 0.0196, 66
)`,
[id],
)
await getPool().query(`UPDATE public.salary_payroll_config SET prisbasbelopp = 61000 WHERE id = $1`, [id])
const rows = await getPool().query<{ action: string; company_id: string | null; new_pbb: string | null }>(
`SELECT action, company_id, new_state->>'prisbasbelopp' AS new_pbb FROM public.audit_log
WHERE table_name = 'salary_payroll_config' AND record_id = $1 ORDER BY created_at, id`,
[id],
)
expect(rows.rows.map((r) => [r.action, r.company_id])).toEqual([
['INSERT', null],
['UPDATE', null],
])
expect(Number(rows.rows[1].new_pbb)).toBe(61000)
await getPool().query(`DELETE FROM public.salary_payroll_config WHERE id = $1`, [id])
})
it('logs sie_imports and bank_file_imports rows incl. the undo status change', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const sieId = randomUUID()
const bankId = randomUUID()
await getPool().query(
`INSERT INTO public.sie_imports (id, user_id, company_id, filename, file_hash, sie_type, status)
VALUES ($1, $2, $3, 'bokio.se', $4, 4, 'completed')`,
[sieId, userId, companyId, randomUUID()],
)
await getPool().query(
`UPDATE public.sie_imports SET status = 'undone', replaced_at = now() WHERE id = $1`,
[sieId],
)
await getPool().query(
`INSERT INTO public.bank_file_imports (id, user_id, company_id, filename, file_hash, file_format)
VALUES ($1, $2, $3, 'seb.csv', $4, 'seb')`,
[bankId, userId, companyId, randomUUID()],
)
const sie = await getPool().query<{ action: string; new_status: string | null }>(
`SELECT action, new_state->>'status' AS new_status FROM public.audit_log
WHERE table_name = 'sie_imports' AND record_id = $1 ORDER BY created_at, id`,
[sieId],
)
expect(sie.rows).toEqual([
{ action: 'INSERT', new_status: 'completed' },
{ action: 'UPDATE', new_status: 'undone' },
])
expect(await auditActions('bank_file_imports', bankId)).toEqual(['INSERT'])
})
it('app_releases: readable by any signed-in user, writable only by the service role, immutable', async () => {
const userId = await insertAuthUser()
const version = `test-${randomUUID().slice(0, 8)}`
await getPool().query(`INSERT INTO public.app_releases (version) VALUES ($1)`, [version])
const seen = await withUserContext(userId, async (client) => {
const res = await client.query<{ version: string }>(
`SELECT version FROM public.app_releases WHERE version = $1`,
[version],
)
return res.rows.map((r) => r.version)
})
expect(seen).toEqual([version])
await expect(
withUserContext(userId, (client) =>
client.query(`INSERT INTO public.app_releases (version) VALUES ($1)`, [`${version}-user`]),
),
).rejects.toThrow(/row-level security/i)
await expect(
getPool().query(`UPDATE public.app_releases SET source = 'edited' WHERE version = $1`, [version]),
).rejects.toThrow()
await expect(
getPool().query(`DELETE FROM public.app_releases WHERE version = $1`, [version]),
).rejects.toThrow()
})
})