Files
accounted/extensions/general/mcp-server/__tests__/skatteverket-tools.test.ts
T
Jakob WennbergandClaude Opus 4.8 679b154ad2 feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5) (#692)
* feat(skatteverket): MCP wrappers for momsdeklaration + AGI filing (P0-5)

Expose the complete Skatteverket extension as five MCP tools so VAT
(momsdeklaration) and employer (AGI/arbetsgivardeklaration) filing can be
driven from Claude. Commit = "send for BankID signing" (returns a signing
link), never "file" — the user's signature in the browser is the irreversible
act, kept outside the tooling.

Tools (extensions/general/mcp-server/server.ts):
- gnubok_vat_declaration_validate  (compliance:read) — live POST /kontrollera
- gnubok_vat_declaration_submit    (skatteverket:write) — stages submit_vat_declaration
- gnubok_vat_declaration_status    (compliance:read) — GET /inlamnat + /beslutat
- gnubok_agi_submit                (skatteverket:write) — stages submit_agi
- gnubok_agi_status                (compliance:read) — local state + live kvittenser

Architecture:
- Core (lib/pending-operations/commit.ts) cannot import @/extensions (CI guard),
  so the two submit ops dispatch into the extension via the new
  Extension.services channel (first use): registry-resolved
  commitSubmitVatDeclaration / commitSubmitAgi run the SKV chain and return a
  shared SkvSubmitResult (lib/pending-operations/skatteverket-commit.ts).
- Recoverable failures (extension disabled, no connection, rate-limited, still
  processing) release the op back to 'pending' via SkatteverketRecoverableError
  — same contract as AccountsNotInChartError — so the user reconnects and
  re-approves the SAME op. SKV business rejections reject the op.
- No-drift: parseDeclarationRequest / loadAGIXml extracted to
  lib/declaration-prep.ts (buildMomsuppgift / buildAgiUnderlag / resolveRedovisare)
  so route, preview, and commit file identical figures. writeSkatteverketAudit
  hoisted to lib/audit.ts; read tools + executors write BFL audit rows too.
- New scope skatteverket:write (opt-in, in STAGING_SCOPES so SoD ack fires),
  4 structured error codes, sv/en strings, ApiKeysPanel row.
- Migration 20260620120000 adds submit_vat_declaration / submit_agi to the
  pending_operations.operation_type CHECK (must apply to prod post-merge).

Tests: 42 new across executors, MCP tools, declaration-prep, error-map, and the
VAT commit chain. Full suite green (5287), build clean, lint-ratchet at baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: add PR-Agent AI review (SHA-pinned, dedicated Bedrock key)

Greptile went silent after #682 (app/account-side, not repo config). Add the
open-source PR-Agent GitHub Action as a replacement, hardened for supply chain:

- Pinned to the v0.36.0 commit SHA (ffe1f89), not the movable tag — the repo
  was recently transferred to a new, unverified org (The-PR-Agent), though it's
  the genuine original pr-agent (repo id 662766482, 11.5k stars).
- Runs on a DEDICATED, minimal IAM key (bedrock:InvokeModel only) via
  PR_AGENT_AWS_* secrets — never the app's general AWS credentials.
- Only /review runs automatically; /describe and /improve are disabled so PR
  descriptions are never overwritten.

Requires three new secrets before it functions: PR_AGENT_AWS_ACCESS_KEY_ID,
PR_AGENT_AWS_SECRET_ACCESS_KEY, PR_AGENT_AWS_REGION (EU region).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(pr-agent): handle push events + restrict push to /review

PR-Agent skips synchronize (push) events by default, so the bot ran green but
posted nothing. Enable handle_push_trigger and scope push_commands to /review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(pr-agent): fix pr_actions (event list, not commands) + add synchronize

pr_actions is the list of PR event actions to handle, not slash-commands.
Setting it to ["/review"] removed every real event from the allowlist, so the
bot skipped everything. Restore the default events + synchronize; command
selection stays on the auto_review/describe/improve booleans (review-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(pr-agent): raise max_model_tokens to 64k for fuller diff coverage

Default ~32k input window truncated large PRs. Sonnet 4.6 has 200k context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(pr-agent): use Claude Opus 4.8 (Sonnet 4.6 fallback)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skatteverket): scope AGI status flips by salary_run_id

Bot review (swedish-compliance) caught that commitSubmitAgi flipped
agi_declarations status by (company_id, period) only. A correction run sharing
the period would have its still-valid declaration co-flipped to rejected/
pending_signature. Scope both updates by salary_run_id (in scope from params) —
more precise than the period-only route handler, which has no run id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(pg): fix gen_random_bytes assertion for modern pgcrypto

OpenSSL-backed pgcrypto (CI Postgres image) rejects gen_random_bytes(0) with
'Length not in range' rather than returning empty bytea, so the pre-existing
'returns empty bytea' assertion fails on every pg-real run (repo-wide, not
specific to this PR). Assert the real contract — exactly n bytes for a positive
n — instead of the version-dependent 0-byte edge case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:43:15 +02:00

198 lines
9.3 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][3]).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][3]).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('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()
})
})