* 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>
126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
/**
|
|
* Tests for POST /api/documents/[id]/detach (detach a duplicate underlag from
|
|
* a posted verifikat via the audited detach_underlag_duplicate RPC).
|
|
*
|
|
* Covers: 401, rule-violation 409 passthrough (Swedish RPC messages verbatim),
|
|
* tenant guard 403, unexpected RPC failure 500, and the happy path.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
createQueuedMockSupabase,
|
|
createMockRequest,
|
|
createMockRouteParams,
|
|
parseJsonResponse,
|
|
} from '@/tests/helpers'
|
|
|
|
const { supabase, reset } = createQueuedMockSupabase()
|
|
|
|
const rpcMock = vi.fn()
|
|
;(supabase as { rpc?: unknown }).rpc = rpcMock
|
|
|
|
const requireAuthMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
|
}))
|
|
|
|
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
|
|
|
import { POST } from '../route'
|
|
|
|
const params = () => createMockRouteParams({ id: 'doc-1' })
|
|
|
|
function makeRequest() {
|
|
return createMockRequest('/api/documents/doc-1/detach', { method: 'POST' })
|
|
}
|
|
|
|
describe('POST /api/documents/[id]/detach', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
|
})
|
|
|
|
it('returns 401 when not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
|
|
const response = await POST(makeRequest(), params())
|
|
expect(response.status).toBe(401)
|
|
expect(rpcMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('passes rule violations through as 409 with the Swedish message', async () => {
|
|
rpcMock.mockResolvedValue({
|
|
data: null,
|
|
error: {
|
|
code: 'P0001',
|
|
message:
|
|
'Verifikationen skulle stå utan underlag: det sista underlaget kan inte kopplas bort. Ersätt det med en ny version i stället.',
|
|
},
|
|
})
|
|
|
|
const response = await POST(makeRequest(), params())
|
|
const { body } = await parseJsonResponse<{ error: string }>(response)
|
|
|
|
expect(response.status).toBe(409)
|
|
expect(body.error).toContain('sista underlaget')
|
|
})
|
|
|
|
it('maps the tenant guard (42501) to 403', async () => {
|
|
rpcMock.mockResolvedValue({
|
|
data: null,
|
|
error: { code: '42501', message: 'unauthorized: caller is not a member of company company-1' },
|
|
})
|
|
|
|
const response = await POST(makeRequest(), params())
|
|
expect(response.status).toBe(403)
|
|
})
|
|
|
|
it('returns 500 on unexpected RPC failure', async () => {
|
|
rpcMock.mockResolvedValue({
|
|
data: null,
|
|
error: { code: '57014', message: 'canceling statement due to statement timeout' },
|
|
})
|
|
|
|
const response = await POST(makeRequest(), params())
|
|
expect(response.status).toBe(500)
|
|
})
|
|
|
|
it('detaches the duplicate and returns the RPC result', async () => {
|
|
rpcMock.mockResolvedValue({
|
|
data: {
|
|
detached: true,
|
|
document_id: 'doc-1',
|
|
journal_entry_id: 'je-1',
|
|
remaining_documents: 1,
|
|
},
|
|
error: null,
|
|
})
|
|
|
|
const response = await POST(makeRequest(), params())
|
|
const { body } = await parseJsonResponse<{
|
|
data: { detached: boolean; journal_entry_id: string; remaining_documents: number }
|
|
}>(response)
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(body.data.detached).toBe(true)
|
|
expect(body.data.remaining_documents).toBe(1)
|
|
expect(rpcMock).toHaveBeenCalledWith('detach_underlag_duplicate', {
|
|
p_company_id: 'company-1',
|
|
p_document_id: 'doc-1',
|
|
p_user_id: 'user-1',
|
|
})
|
|
})
|
|
})
|