Files
accounted/tests/pg/detach-underlag-duplicate.pg.test.ts
Mattsson 174f315d9c fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach (#1860)
* fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach

Support case 2026-08-24: a verifikat booked from a leverantorsfaktura/utlagg
stayed under 'Saknar underlag' with the PDF attached, and a twice-uploaded
underlag could only be replaced, never removed.

- POST /api/supplier-invoices/[id]/book now calls
  anchorSupplierInvoiceDocument() after the CAS link: the deferred (#967)
  flow was the last booking surface that never anchored the invoice's
  retained source document, so every missing-underlag surface kept flagging
  the registration verifikat until payment.
- Repair migration 20260824150000 re-runs the 20260727180000 sweep for rows
  created since (idempotent, open unlocked periods only).
- New detach_underlag_duplicate RPC (migration 20260824151000): the one
  sanctioned path to detach a redundant duplicate underlag from a posted
  verifikat. Guarded: writer role, open unlocked period, company lock date,
  at least one other anchored underlag must remain (BFL 5 kap 7 par), pinned
  docs (transactions/supplier_invoices.document_id) stay replace-only.
  Audit-logged first, transaction-local gnubok.allow_delete carve-out. The
  file is never deleted: it returns to the unlinked pool.
- POST /api/documents/[id]/detach + 'Koppla bort dubblett' in the verifikat
  attachments blocked-dialog when the entry keeps 2+ direct docs (sv+en).
- Tests: book-route anchor assertions, detach route unit tests, pg-real
  suite for the RPC incl. the direct-UPDATE-stays-blocked invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(documents): harden detach_underlag_duplicate per skeptic findings

- Require sha256 identity: detach only when a remaining anchored sibling
  carries the same immutable sha256_hash, so only byte-identical duplicates
  ever leave a verifikat (two different handlingar both stay behind the WORM
  guards). UI gates the button on the same condition.
- Enforce the documented posted-status guard (reversed/cancelled verifikat
  refuse detach).
- Set company_id on the RPC's audit_log row: the SELECT policy filters on
  company_id, so the provenance row was invisible to every reader (same
  defect 20260528120600 fixed for delete_last_voucher).
- Swedish 403 message on the tenant guard (CodeRabbit).
- pg tests: closed-period case now seeds open and closes via UPDATE (the
  period-lock trigger blocks seeding into a closed period), duplicate pairs
  share a hash, added non-duplicate and reversed-entry refusals, audit
  assertion pins the RPC's own row (description + company_id + actor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 17:05:00 +02:00

261 lines
9.5 KiB
TypeScript

import { randomUUID } from 'crypto'
import { beforeAll, describe, expect, it } from 'vitest'
import { getPool, withUserContext } from './setup'
import {
seedCompany,
insertAuthUser,
insertPostedJournalEntry,
insertTransaction,
} from './fixtures'
/**
* Invariants for detach_underlag_duplicate (support case 2026-08-24): the ONE
* sanctioned path for removing a redundant duplicate underlag from a posted
* verifikat. The RPC must only detach a byte-identical duplicate (sha256
* equality with a remaining anchored sibling), must refuse pinned/last/
* non-duplicate/unposted/locked-period docs, must write a READABLE audit row
* (company_id set: the RLS policy filters on it), and must remain the only
* way past enforce_document_journal_entry_immutability (the direct UPDATE
* stays blocked).
*/
function makeHash(): string {
return randomUUID().replace(/-/g, '').padEnd(64, '0')
}
async function attachDocument(params: {
userId: string
companyId: string
journalEntryId: string | null
fileName?: string
sha256?: 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`,
params.sha256 ?? makeHash(),
],
)
return id
}
async function insertEntry(params: {
userId: string
companyId: string
fiscalPeriodId: string
voucherNumber: number
}): Promise<string> {
return insertPostedJournalEntry({
userId: params.userId,
companyId: params.companyId,
fiscalPeriodId: params.fiscalPeriodId,
voucherNumber: params.voucherNumber,
entryDate: '2026-06-10',
description: `detach test ${params.voucherNumber}`,
lines: [
{ accountNumber: '1930', debitAmount: 100, creditAmount: 0 },
{ accountNumber: '3001', debitAmount: 0, creditAmount: 100 },
],
})
}
/** Entry + an anchored duplicate pair (same sha256). Returns [entryId, keptId, dupId]. */
async function seedDuplicatePair(s: {
userId: string
companyId: string
fiscalPeriodId: string
voucherNumber: number
}): Promise<[string, string, string]> {
const entryId = await insertEntry(s)
const hash = makeHash()
const keptId = await attachDocument({
userId: s.userId, companyId: s.companyId, journalEntryId: entryId,
fileName: 'kept.pdf', sha256: hash,
})
const dupId = await attachDocument({
userId: s.userId, companyId: s.companyId, journalEntryId: entryId,
fileName: 'dup.pdf', sha256: hash,
})
return [entryId, keptId, dupId]
}
describe('detach_underlag_duplicate RPC', () => {
let userId: string
let companyId: string
let fiscalPeriodId: string
let voucherNumber = 0
beforeAll(async () => {
const s = await seedCompany()
userId = s.userId
companyId = s.companyId
fiscalPeriodId = s.fiscalPeriodId
})
it('detaches a duplicate and writes a reader-visible audit row', async () => {
const [entryId, keptId, dupId] = await seedDuplicatePair({
userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber,
})
await withUserContext(userId, async (client) => {
const { rows } = await client.query<{ result: { detached: boolean; remaining_documents: number } }>(
`SELECT public.detach_underlag_duplicate($1, $2) AS result`,
[companyId, dupId],
)
expect(rows[0].result.detached).toBe(true)
expect(rows[0].result.remaining_documents).toBe(1)
const after = await client.query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[dupId],
)
expect(after.rows[0].journal_entry_id).toBeNull()
const kept = await client.query<{ journal_entry_id: string | null }>(
`SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
[keptId],
)
expect(kept.rows[0].journal_entry_id).toBe(entryId)
// The RPC's explicit provenance row must be visible to a company member
// under RLS: that requires company_id set (the SELECT policy filters on
// it), the actor recorded, and the detach description. Matching on the
// description distinguishes it from the generic write_audit_log trigger
// row, which must not be the row this assertion passes on.
const audit = await client.query<{ company_id: string; actor_id: string }>(
`SELECT company_id, actor_id FROM public.audit_log
WHERE table_name = 'document_attachments' AND record_id = $1
AND action = 'UPDATE' AND description LIKE 'Dubblett-underlag%'`,
[dupId],
)
expect(audit.rowCount).toBe(1)
expect(audit.rows[0].company_id).toBe(companyId)
expect(audit.rows[0].actor_id).toBe(userId)
})
})
it('refuses to detach the last anchored underlag', async () => {
const entryId = await insertEntry({ userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber })
const onlyId = await attachDocument({ userId, companyId, journalEntryId: entryId })
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, onlyId]),
).rejects.toThrow(/sista underlaget/)
})
})
it('refuses to detach a non-duplicate (different sha256) even with siblings present', async () => {
const entryId = await insertEntry({ userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber })
await attachDocument({ userId, companyId, journalEntryId: entryId, fileName: 'faktura.pdf' })
const receiptId = await attachDocument({ userId, companyId, journalEntryId: entryId, fileName: 'betalkvitto.pdf' })
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, receiptId]),
).rejects.toThrow(/inte en dubblett/)
})
})
it('refuses to detach from a reversed (storno) verifikat', async () => {
const [entryId, , dupId] = await seedDuplicatePair({
userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber,
})
// posted -> reversed is the transition the immutability trigger permits.
await getPool().query(
`UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`,
[entryId],
)
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, dupId]),
).rejects.toThrow(/bokförda verifikat/)
})
})
it('refuses to detach a document pinned to a bank transaction', async () => {
const entryId = await insertEntry({ userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber })
const hash = makeHash()
await attachDocument({ userId, companyId, journalEntryId: entryId, fileName: 'other.pdf', sha256: hash })
const pinnedId = await attachDocument({ userId, companyId, journalEntryId: entryId, fileName: 'pinned.pdf', sha256: hash })
const txId = await insertTransaction({
userId,
companyId,
amount: -100,
description: 'pinned tx',
})
// NULL -> doc pin is the allowed direction on the transactions side.
await getPool().query(
`UPDATE public.transactions SET document_id = $1 WHERE id = $2`,
[pinnedId, txId],
)
await withUserContext(userId, async (client) => {
await expect(
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, pinnedId]),
).rejects.toThrow(/banktransaktion/)
})
})
it('refuses a caller who is not a member of the company', async () => {
const [, , dupId] = await seedDuplicatePair({
userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber,
})
const outsiderId = await insertAuthUser()
await withUserContext(outsiderId, async (client) => {
await expect(
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [companyId, dupId]),
).rejects.toThrow(/not a member/)
})
})
it('refuses when the fiscal period is closed', async () => {
// Seed open (the period-lock triggers block inserting posted entries and
// anchored docs into an already-closed period), then close the period.
const s = await seedCompany()
const [, , dupId] = await seedDuplicatePair({
userId: s.userId,
companyId: s.companyId,
fiscalPeriodId: s.fiscalPeriodId,
voucherNumber: 1,
})
await getPool().query(
`UPDATE public.fiscal_periods SET is_closed = true, closed_at = now() WHERE id = $1`,
[s.fiscalPeriodId],
)
await withUserContext(s.userId, async (client) => {
await expect(
client.query(`SELECT public.detach_underlag_duplicate($1, $2)`, [s.companyId, dupId]),
).rejects.toThrow(/stängd eller låst/)
})
})
it('keeps the direct UPDATE path blocked by the immutability trigger', async () => {
const [, , dupId] = await seedDuplicatePair({
userId, companyId, fiscalPeriodId, voucherNumber: ++voucherNumber,
})
await withUserContext(userId, async (client) => {
await expect(
client.query(
`UPDATE public.document_attachments SET journal_entry_id = NULL WHERE id = $1`,
[dupId],
),
).rejects.toThrow(/BFL_DOCUMENT_IMMUTABILITY/)
})
})
})