* fix(git): pin LF on generated extension registry and vitest snapshots setup:extensions and vitest write these files with LF; with core.autocrlf=true git expects CRLF and flags them as phantom modifications on every dev/build run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt. Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route transactions endpoints through withRouteContext Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route SIE import and bank reconciliation through withRouteContext Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route salary endpoints through withRouteContext Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route report endpoints through withRouteContext Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route documents, events, team and account endpoints through withRouteContext Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(api): route settings and pending-operations endpoints through withRouteContext Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil" Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by upload instead of typing every ruta into the form. Extract buildFiledAmounts() as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so the XML file and the manual-filing PDF can never disagree. Adds the /eskd API route, an XML option in the report export menu, and the upload button on the manual-filing card. Strings in sv + en. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(vat): add 'vat_settlement' source type and update related components * fix(booking): adjust search input layout and enable autofocus * fix(vat): support 12-digit org numbers and adjust emission order for eSKD file * fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
317 lines
14 KiB
TypeScript
317 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { NextResponse } from 'next/server'
|
|
import {
|
|
parseJsonResponse,
|
|
createMockRouteParams,
|
|
createQueuedMockSupabase,
|
|
} from '@/tests/helpers'
|
|
|
|
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
|
|
|
const requireAuthMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-auth', () => ({
|
|
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/company/context', () => ({
|
|
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
|
}))
|
|
|
|
const requireWriteMock = vi.fn()
|
|
vi.mock('@/lib/auth/require-write', () => ({
|
|
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
|
}))
|
|
|
|
vi.mock('@/lib/init', () => ({
|
|
ensureInitialized: vi.fn(),
|
|
}))
|
|
|
|
import { POST, DELETE } from '../route'
|
|
|
|
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
reset()
|
|
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
|
|
requireWriteMock.mockResolvedValue({ ok: true })
|
|
})
|
|
|
|
function makeReq(body: unknown, method: 'POST' | 'DELETE' = 'POST') {
|
|
return new Request('http://localhost/api/transactions/tx-1/attach-document', {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: method === 'POST' ? JSON.stringify(body) : undefined,
|
|
})
|
|
}
|
|
|
|
describe('POST /api/transactions/[id]/attach-document', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase: mockSupabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
const res = await POST(makeReq({ document_id: 'doc-1' }), createMockRouteParams({ id: 'tx-1' }))
|
|
const { status, body } = await parseJsonResponse(res)
|
|
expect(status).toBe(401)
|
|
expect(body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('returns 403 when the caller is a viewer', async () => {
|
|
requireWriteMock.mockResolvedValue({
|
|
ok: false,
|
|
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
|
})
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse(res)
|
|
expect(status).toBe(403)
|
|
expect(body).toEqual({ error: 'Forbidden' })
|
|
})
|
|
|
|
it('returns 400 when document_id missing', async () => {
|
|
const res = await POST(makeReq({}), createMockRouteParams({ id: 'tx-1' }))
|
|
const { status } = await parseJsonResponse(res)
|
|
expect(status).toBe(400)
|
|
})
|
|
|
|
it('returns 404 when transaction not in company', async () => {
|
|
enqueue({ data: null, error: null }) // tx fetch
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse(res)
|
|
expect(status).toBe(404)
|
|
expect(body).toEqual({ error: 'Transaction not found' })
|
|
})
|
|
|
|
it('returns 404 when document not in company', async () => {
|
|
enqueue({ data: { id: 'tx-1' }, error: null }) // tx fetch
|
|
enqueue({ data: null, error: null }) // doc fetch
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse(res)
|
|
expect(status).toBe(404)
|
|
expect(body).toEqual({ error: 'Document not found' })
|
|
})
|
|
|
|
it('attaches when both rows exist', async () => {
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
|
|
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{ data: { transaction_id: string; document_id: string; journal_entry_id: string | null } }>(res)
|
|
expect(status).toBe(200)
|
|
expect(body.data.transaction_id).toBe('tx-1')
|
|
expect(body.data.document_id).toBe('11111111-1111-4111-8111-111111111111')
|
|
expect(body.data.journal_entry_id).toBeNull()
|
|
// Unbooked tx: document_attachments is only read (doc fetch), never
|
|
// written: no journal entry to propagate to.
|
|
const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0])
|
|
expect(fromCalls.filter((t) => t === 'document_attachments')).toHaveLength(1)
|
|
})
|
|
|
|
it('propagates the link onto the verifikation when the transaction is booked', async () => {
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING)
|
|
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
|
enqueue({ data: null, error: null }) // document_attachments propagation
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{ data: { journal_entry_id: string } }>(res)
|
|
expect(status).toBe(200)
|
|
expect(body.data.journal_entry_id).toBe('je-1')
|
|
// doc fetch + propagation write
|
|
const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0])
|
|
expect(fromCalls.filter((t) => t === 'document_attachments')).toHaveLength(2)
|
|
})
|
|
|
|
it('skips propagation when the doc already points at the same verifikation (idempotent re-attach)', async () => {
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-1' }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING)
|
|
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status } = await parseJsonResponse(res)
|
|
expect(status).toBe(200)
|
|
// No propagation write: only the doc fetch touched document_attachments.
|
|
const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0])
|
|
expect(fromCalls.filter((t) => t === 'document_attachments')).toHaveLength(1)
|
|
})
|
|
|
|
it('returns 409 when the document already belongs to a different verifikation', async () => {
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: 'je-OTHER' }, error: null }) // doc fetch
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
|
expect(status).toBe(409)
|
|
expect(body.error).toContain('annan verifikation')
|
|
})
|
|
|
|
it('returns 409 when the verifikation period is locked during propagation', async () => {
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING)
|
|
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
|
enqueue({ data: null, error: { message: 'cannot link document in a locked/closed fiscal period' } }) // propagation blocked
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
|
expect(status).toBe(409)
|
|
expect(body.error).toContain('låst')
|
|
})
|
|
|
|
it('returns 500 with the idempotent-retry message when propagation fails', async () => {
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1' }, error: null }) // tx fetch
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // transactions update (RETURNING)
|
|
enqueue({ data: null, error: null }) // inbox-link best-effort update
|
|
enqueue({ data: null, error: { message: 'boom' } }) // propagation fails
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
|
expect(status).toBe(500)
|
|
expect(body.error).toContain('idempotent')
|
|
spy.mockRestore()
|
|
})
|
|
|
|
it('returns 404 when the update matches no row (concurrent delete)', async () => {
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: null, error: null }) // transactions update returns no row
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status } = await parseJsonResponse(res)
|
|
expect(status).toBe(404)
|
|
})
|
|
|
|
it('attempts to update invoice_inbox_items.matched_transaction_id after successful attach', async () => {
|
|
// The side effect lets the inbox UI flip an item from "needs action" to
|
|
// "Kopplad till transaktion" without an extra round-trip.
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
|
|
enqueue({ data: null, error: null }) // inbox-link update
|
|
|
|
await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
// Verify the inbox_items table was touched.
|
|
const fromCalls = mockSupabase.from.mock.calls.map((c) => c[0])
|
|
expect(fromCalls).toContain('invoice_inbox_items')
|
|
})
|
|
|
|
it('tolerates a failing inbox-link update: the document attach is the primary effect', async () => {
|
|
enqueue({ data: { id: 'tx-1', journal_entry_id: null }, error: null }) // tx fetch
|
|
enqueue({ data: { id: 'doc-1', journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: { journal_entry_id: null }, error: null }) // transactions update (RETURNING)
|
|
enqueue({ data: null, error: { message: 'rls denied' } }) // inbox-link fails
|
|
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const res = await POST(
|
|
makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }),
|
|
createMockRouteParams({ id: 'tx-1' }),
|
|
)
|
|
const { status, body } = await parseJsonResponse<{ data: { transaction_id: string } }>(res)
|
|
// Side-effect failure must not roll back the (compliant) document attach.
|
|
expect(status).toBe(200)
|
|
expect(body.data.transaction_id).toBe('tx-1')
|
|
// The Supabase client resolves with { error } rather than rejecting, so
|
|
// we additionally assert that the error was actually inspected and logged
|
|
// (not silently dropped by a try/catch that never fires).
|
|
expect(spy).toHaveBeenCalledWith(
|
|
'[attach-document] Failed to link inbox item:',
|
|
expect.objectContaining({ message: 'rls denied' }),
|
|
)
|
|
spy.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe('DELETE /api/transactions/[id]/attach-document', () => {
|
|
it('returns 401 when not authenticated', async () => {
|
|
requireAuthMock.mockResolvedValue({
|
|
user: null,
|
|
supabase: mockSupabase,
|
|
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
|
})
|
|
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
|
const { status, body } = await parseJsonResponse(res)
|
|
expect(status).toBe(401)
|
|
expect(body).toEqual({ error: 'Unauthorized' })
|
|
})
|
|
|
|
it('returns 403 when the caller is a viewer', async () => {
|
|
requireWriteMock.mockResolvedValue({
|
|
ok: false,
|
|
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
|
|
})
|
|
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
|
const { status, body } = await parseJsonResponse(res)
|
|
expect(status).toBe(403)
|
|
expect(body).toEqual({ error: 'Forbidden' })
|
|
})
|
|
|
|
it('returns 404 when transaction not in company', async () => {
|
|
enqueue({ data: null, error: null }) // tx fetch
|
|
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
|
const { status, body } = await parseJsonResponse(res)
|
|
expect(status).toBe(404)
|
|
expect(body).toEqual({ error: 'Transaction not found' })
|
|
})
|
|
|
|
it('returns 409 when document is already on a journal entry', async () => {
|
|
enqueue({ data: { id: 'tx-1', document_id: 'doc-1' }, error: null }) // tx fetch
|
|
enqueue({ data: { journal_entry_id: 'je-1' }, error: null }) // doc fetch
|
|
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
|
const { status, body } = await parseJsonResponse<{ error: string }>(res)
|
|
expect(status).toBe(409)
|
|
expect(body.error).toContain('verifikation')
|
|
})
|
|
|
|
it('clears document_id when no journal entry link', async () => {
|
|
enqueue({ data: { id: 'tx-1', document_id: 'doc-1' }, error: null }) // tx fetch
|
|
enqueue({ data: { journal_entry_id: null }, error: null }) // doc fetch
|
|
enqueue({ data: null, error: null }) // update
|
|
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
|
const { status, body } = await parseJsonResponse<{ data: { document_id: string | null } }>(res)
|
|
expect(status).toBe(200)
|
|
expect(body.data.document_id).toBeNull()
|
|
})
|
|
|
|
it('clears document_id when no doc was attached', async () => {
|
|
enqueue({ data: { id: 'tx-1', document_id: null }, error: null }) // tx fetch
|
|
enqueue({ data: null, error: null }) // update
|
|
const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' }))
|
|
const { status } = await parseJsonResponse(res)
|
|
expect(status).toBe(200)
|
|
})
|
|
})
|