Files
accounted/extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts
T
18c20e68e6 fix(scoping): Skatteverket per företag + nåbara startkort + företags-scopade val (#1610)
* fix(scoping): skatteverket per company + true pristine gates + scoped dismissals

Skatteverket connections become per (user, company): the token table
carried BOTH UNIQUE(user_id) and UNIQUE(company_id) (two stacked half
migrations), so one connection leaked "connected" onto every company the
user belongs to, sync ran the token against the wrong orgnr (behorighet
403), and reconnecting from another company silently moved the row and
went dark on the first company's crons. Token reads/writes are now scoped
by company through the whole chain (token-store, api-client refresh
coalescing, skvRequest and its 21 call sites, resolve-auth, crons, MCP),
/skattekonto/saldo answers 401 NOT_CONNECTED for companies without their
own row (which is what the page's startkort keys on), and the dashboard
connect-nudge counts only the active company's row.

Bookkeeping's pristine start card now keys on all-years emptiness via a
count probe instead of "no active filters": the default fiscal-year
selection counted as a filter, which made the card unreachable on
brand-new companies (it showed "inga traffar" instead).

Two browser-global localStorage keys become company-scoped with legacy
fallbacks: the inbox onboarding dismissal (dismissing on one company hid
the card everywhere) and the periodisering auto-detect toggle.

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

* fix(scoping): dedupe cron work per (user, company) + guard the ledger probe

CodeRabbit findings on #1610: the skattekonto sync cron still deduped
token rows by user_id alone, which would drop every company but one for
multi-company operators (the exact scenario the PR fixes); and the
all-years ledger probe could leave a stale false behind on a failed
refetch, letting the pristine card render unconfirmed. The probe now
resets to unknown in flight and carries the fetch generation guard.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:21:57 +02:00

296 lines
13 KiB
TypeScript

/**
* Safety tests for the Skatteverket MCP tools (PR5).
*
* Five tools wrap the skatteverket extension lib: two read tools hit SKV live
* (validate, status) and two submit tools stage high-risk ops whose commit
* dispatches into the extension (covered separately in
* lib/pending-operations/__tests__/skatteverket-executors.test.ts). The
* cross-extension lib modules are mocked so no real SKV call is made.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP, findStageApproveConflict } from '@/lib/auth/api-keys'
const mockSkvRequest = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/api-client', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return { ...actual, skvRequest: (...a: unknown[]) => mockSkvRequest(...a) }
})
const mockBuildMomsuppgift = vi.fn()
const mockResolveRedovisare = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/declaration-prep', () => ({
buildMomsuppgift: (...a: unknown[]) => mockBuildMomsuppgift(...a),
resolveRedovisare: (...a: unknown[]) => mockResolveRedovisare(...a),
}))
const mockKvittenser = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/agi-client', () => ({
agiGetKvittenser: (...a: unknown[]) => mockKvittenser(...a),
}))
// Audit writes are exercised in the extension; mock them out here so the test
// supabase queue only has to account for staging reads.
vi.mock('@/extensions/general/skatteverket/lib/audit', () => ({
writeSkatteverketAudit: vi.fn(),
}))
import { tools } from '../server'
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
const validate = tools.find((t) => t.name === 'gnubok_vat_declaration_validate')!
const vatSubmit = tools.find((t) => t.name === 'gnubok_vat_declaration_submit')!
const vatStatus = tools.find((t) => t.name === 'gnubok_vat_declaration_status')!
const agiSubmit = tools.find((t) => t.name === 'gnubok_agi_submit')!
const agiStatus = tools.find((t) => t.name === 'gnubok_agi_status')!
const ALL = [validate, vatSubmit, vatStatus, agiSubmit, agiStatus]
let prevEnv: string | undefined
beforeEach(() => {
vi.clearAllMocks()
prevEnv = process.env.SKATTEVERKET_ENABLED
process.env.SKATTEVERKET_ENABLED = 'true'
})
afterEach(() => {
if (prevEnv === undefined) delete process.env.SKATTEVERKET_ENABLED
else process.env.SKATTEVERKET_ENABLED = prevEnv
})
describe('Skatteverket tools: catalog', () => {
it('registers all five tools', () => {
expect(ALL.every(Boolean)).toBe(true)
})
it('has Title Case titles with the Swedish law term inline', () => {
expect(validate.title).toBe('Validate VAT Declaration (Momsdeklaration)')
expect(vatSubmit.title).toBe('Submit VAT Declaration (Momsdeklaration)')
expect(agiSubmit.title).toBe('Submit AGI Declaration (Arbetsgivardeklaration)')
})
it('all are openWorldHint (external system); reads are read-only, submits are not', () => {
for (const t of ALL) expect(t.annotations.openWorldHint).toBe(true)
expect(validate.annotations.readOnlyHint).toBe(true)
expect(vatStatus.annotations.readOnlyHint).toBe(true)
expect(agiStatus.annotations.readOnlyHint).toBe(true)
expect(vatSubmit.annotations.readOnlyHint).toBe(false)
expect(agiSubmit.annotations.readOnlyHint).toBe(false)
})
})
describe('Skatteverket tools: EXTENSION_DISABLED gate', () => {
it('every tool throws EXTENSION_DISABLED with the env off, making zero SKV calls', async () => {
delete process.env.SKATTEVERKET_ENABLED
const { supabase } = createQueuedMockSupabase()
const fetchSpy = vi.spyOn(globalThis, 'fetch')
for (const t of ALL) {
const args = t.name.includes('agi') ? { salary_run_id: 'sr-1' } : { period_type: 'monthly', year: 2025, period: 3 }
let thrown: unknown
try {
await t.execute(args, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
} catch (err) {
thrown = err
}
expect((thrown as Error & { code?: string })?.code, t.name).toBe('EXTENSION_DISABLED')
}
expect(mockSkvRequest).not.toHaveBeenCalled()
expect(mockKvittenser).not.toHaveBeenCalled()
expect(fetchSpy).not.toHaveBeenCalled()
fetchSpy.mockRestore()
})
})
describe('gnubok_vat_declaration_validate', () => {
it('maps a SkatteverketAuthError(NOT_CONNECTED) to SKATTEVERKET_NOT_CONNECTED', async () => {
mockBuildMomsuppgift.mockResolvedValue({ redovisare: '165560000000', redovisningsperiod: '202503', momsuppgift: {} })
mockSkvRequest.mockRejectedValue(new SkatteverketAuthError('ingen anslutning', 'NOT_CONNECTED'))
const { supabase } = createQueuedMockSupabase()
let thrown: unknown
try {
await validate.execute({ period_type: 'monthly', year: 2025, period: 3 }, 'company-1', 'user-1', supabase as never, { type: 'api_key' })
} catch (err) {
thrown = err
}
expect((thrown as Error & { code?: string })?.code).toBe('SKATTEVERKET_NOT_CONNECTED')
})
it('happy path returns kontrollresultat', async () => {
mockBuildMomsuppgift.mockResolvedValue({ redovisare: '165560000000', redovisningsperiod: '202503', momsuppgift: { summaMoms: 100 } })
mockSkvRequest.mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 'OK', resultat: [] }) })
const { supabase } = createQueuedMockSupabase()
const result = (await validate.execute(
{ period_type: 'monthly', year: 2025, period: 3 }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { kontrollresultat: { status: string }; redovisningsperiod: string }
expect(result.kontrollresultat.status).toBe('OK')
expect(result.redovisningsperiod).toBe('202503')
// Only /kontrollera was called: nothing was saved at SKV.
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/kontrollera\//)
})
})
describe('gnubok_vat_declaration_submit', () => {
it('validates via /kontrollera then stages: never touches /utkast', async () => {
mockBuildMomsuppgift.mockResolvedValue({ redovisare: '165560000000', redovisningsperiod: '202503', momsuppgift: { summaMoms: 100 } })
mockSkvRequest.mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 'OK' }) })
const { supabase, enqueue } = createQueuedMockSupabase()
// stagePendingOperation: resolvePeriodStatusForDate (company_settings + fiscal_periods) then insert
enqueue({ data: null })
enqueue({ data: null })
enqueue({ data: { id: 'op-1' }, error: null })
const result = (await vatSubmit.execute(
{ period_type: 'monthly', year: 2025, period: 3 }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { staged: boolean; risk_level: string; preview: { commit_action: string } }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('high')
expect(result.preview.commit_action).toMatch(/signering/i)
// Exactly one SKV call (the stage-time /kontrollera); no /utkast.
expect(mockSkvRequest).toHaveBeenCalledTimes(1)
expect(mockSkvRequest.mock.calls[0][4]).toMatch(/^\/kontrollera\//)
})
})
describe('gnubok_agi_submit', () => {
it('stages from local preconditions with zero SKV calls', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'sr-1', status: 'booked', period_year: 2026, period_month: 3, payment_date: '2026-03-25' } }) // salary_runs
enqueue({ data: { id: 'decl-1', status: 'generated', xml_content: '<agi/>' } }) // agi_declarations
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
enqueue({ data: { id: 'op-1' }, error: null }) // insert
const result = (await agiSubmit.execute(
{ salary_run_id: 'sr-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { staged: boolean; risk_level: string }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('high')
expect(mockSkvRequest).not.toHaveBeenCalled()
expect(mockKvittenser).not.toHaveBeenCalled()
})
it('throws when no AGI XML exists yet', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'sr-1', status: 'booked', period_year: 2026, period_month: 3, payment_date: '2026-03-25' } })
enqueue({ data: null }) // no agi_declarations row
await expect(
agiSubmit.execute({ salary_run_id: 'sr-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' }),
).rejects.toThrow(/AGI-underlag saknas/)
})
})
describe('gnubok_agi_status: run-scoped filing state', () => {
// The period-keyed extension_data record is shared by every salary run of
// the month (a correction coexists with the run it corrects, migration
// 20260414130000). The tool must resolve it per run via
// lib/salary/agi-submission-state.ts, exactly like AGIPanel: a correction
// run reports unfiled-for-this-run even though the ORIGINAL's record says
// 'signed', so the filing action stays visible.
const ORIGINAL_RECORD = {
status: 'signed',
kvittensnummer: 'KV-ORIG-1',
salaryRunId: 'sr-original',
signeradTid: '2026-07-01T09:00:00Z',
updatedAt: '2026-07-01T09:00:00Z',
}
function enqueueStatusReads(
enqueue: (r: { data?: unknown; error?: unknown }) => void,
run: Record<string, unknown>,
record: Record<string, unknown> | null,
) {
enqueue({ data: run }) // salary_runs
enqueue({ data: record ? { value: JSON.stringify(record) } : null }) // extension_data
}
beforeEach(() => {
mockResolveRedovisare.mockResolvedValue('165560000000')
// Nothing retrievable live: the run-scoping under test is purely local.
mockKvittenser.mockResolvedValue({ ok: false, status: 404 })
})
it('a correction run reports generated (unfiled) although the period record is the original run\'s signed receipt', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueStatusReads(enqueue, {
id: 'sr-correction',
period_year: 2026,
period_month: 6,
agi_generated_at: '2026-07-05T10:00:00Z',
agi_submitted_at: null,
}, ORIGINAL_RECORD)
const result = (await agiStatus.execute(
{ salary_run_id: 'sr-correction' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as {
filing_state: string
kvittensnummer: string | null
local_state: Record<string, unknown> | null
}
// The correction has its own XML but no submission of its own: the
// original's receipt must not render it as filed.
expect(result.filing_state).toBe('generated')
expect(result.kvittensnummer).toBeNull()
expect(result.local_state).toBeNull()
})
it('the run that owns the record reports signed with its kvittensnummer', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueStatusReads(enqueue, {
id: 'sr-original',
period_year: 2026,
period_month: 6,
agi_generated_at: '2026-06-28T08:00:00Z',
agi_submitted_at: '2026-07-01T09:00:00Z',
}, ORIGINAL_RECORD)
const result = (await agiStatus.execute(
{ salary_run_id: 'sr-original' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as {
filing_state: string
kvittensnummer: string | null
local_state: Record<string, unknown> | null
}
expect(result.filing_state).toBe('signed')
expect(result.kvittensnummer).toBe('KV-ORIG-1')
expect(result.local_state).toMatchObject({ status: 'signed', salaryRunId: 'sr-original' })
})
it('a run with neither XML nor record reports none', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueStatusReads(enqueue, {
id: 'sr-fresh',
period_year: 2026,
period_month: 6,
agi_generated_at: null,
agi_submitted_at: null,
}, null)
const result = (await agiStatus.execute(
{ salary_run_id: 'sr-fresh' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { filing_state: string; kvittensnummer: string | null; local_state: unknown }
expect(result.filing_state).toBe('none')
expect(result.kvittensnummer).toBeNull()
expect(result.local_state).toBeNull()
})
})
describe('Skatteverket tools: scopes', () => {
it('maps the five tools to the right scopes', () => {
expect(TOOL_SCOPE_MAP.gnubok_vat_declaration_validate).toBe('compliance:read')
expect(TOOL_SCOPE_MAP.gnubok_vat_declaration_status).toBe('compliance:read')
expect(TOOL_SCOPE_MAP.gnubok_agi_status).toBe('compliance:read')
expect(TOOL_SCOPE_MAP.gnubok_vat_declaration_submit).toBe('skatteverket:write')
expect(TOOL_SCOPE_MAP.gnubok_agi_submit).toBe('skatteverket:write')
})
it('skatteverket:write is a staging scope → SoD conflict with approve', () => {
expect(findStageApproveConflict(['skatteverket:write', 'pending_operations:approve'])).toBe('skatteverket:write')
expect(findStageApproveConflict(['skatteverket:write'])).toBeNull()
})
})