* feat(mcp): kontoplan account tools + verifikat notes exposure Two gaps reported by an MCP-driven user: no account management in the API, and verifikat notes invisible to agents (they exist in the product but MCP could neither read nor write them). - add staged gnubok_create_account / gnubok_update_account (BAS 2026 prefill for catalog numbers; rename/VAT-default/SRU/activate via update; both LOW risk reference data) - add staged gnubok_set_voucher_note (notes-only annotation, legal on posted entries per the 20260608120000 trigger carve-out) and return entry_notes from gnubok_query_journal - new pending_operations types create_account / update_account / set_voucher_note (CHECK migration + validate companion, applied to staging) - tools/list payload ceiling 54K -> 56K (documented; wire contract, descriptions trimmed first) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skatteverket): unstick BankID connect flow and stale connection views - respond to the OAuth callback immediately and run the post-connect refresh after the response (next/server after()): users no longer stare at Skatteverket's consumed consent page for up to 40s - open the consent flow in a full tab instead of a 600x750 popup that hid the approve button below the fold - disable connect buttons while the OAuth tab is open (parallel flows overwrote oauth_state + the PKCE verifier) and recover via a closed-tab watcher plus a delayed status refetch - persist MISSING_SCOPE token health from the post-connect sync and show an actionable "approve all permissions" notice - refetch connection state on tab visibility (settings connect panel, enable-banking panel, /skattekonto) so a connect completed in another tab or after a mobile app-switch shows up without a manual reload; fix /skattekonto never clearing its not-connected state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(article-form): add article number field with validation to ArticleForm * feat(account): enforce account type consistency with BAS class and add validation --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
228 lines
8.3 KiB
TypeScript
228 lines
8.3 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const { warnRecorder } = vi.hoisted(() => ({ warnRecorder: vi.fn() }))
|
|
|
|
// log.warn is suppressed under NODE_ENV=test, so the failure-path tests
|
|
// observe it through this mock instead of a console spy.
|
|
vi.mock('@/lib/logger', () => ({
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
warn: warnRecorder,
|
|
error: vi.fn(),
|
|
child() {
|
|
return this
|
|
},
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/lib/extensions/context-factory', () => ({
|
|
createExtensionContext: vi.fn().mockReturnValue({ stub: 'ctx' }),
|
|
}))
|
|
|
|
vi.mock('@/lib/entitlements/has-capability', () => ({
|
|
hasCapability: vi.fn().mockResolvedValue(true),
|
|
}))
|
|
|
|
vi.mock('../lib/skattekonto-sync', () => ({
|
|
syncSkattekonto: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../lib/agi-kvittens-reconcile', () => ({
|
|
reconcileAgiDeclaration: vi.fn(),
|
|
}))
|
|
|
|
// Real SkatteverketAuthError shape (message, code) without dragging the full
|
|
// api-client module (and its fetch plumbing) into the test.
|
|
vi.mock('../lib/api-client', () => ({
|
|
SkatteverketAuthError: class SkatteverketAuthError extends Error {
|
|
code: string
|
|
constructor(message: string, code: string) {
|
|
super(message)
|
|
this.code = code
|
|
}
|
|
},
|
|
}))
|
|
|
|
vi.mock('../lib/token-store', () => ({
|
|
markNeedsReconsent: vi.fn().mockResolvedValue(undefined),
|
|
RECONSENT_ERROR_CODES: ['SESSION_EXPIRED', 'REFRESH_EXHAUSTED', 'MISSING_SCOPE', 'TOKEN_CORRUPTED'],
|
|
}))
|
|
|
|
import { runPostConnectRefresh } from '../lib/post-connect-refresh'
|
|
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
|
import { hasCapability } from '@/lib/entitlements/has-capability'
|
|
import { syncSkattekonto } from '../lib/skattekonto-sync'
|
|
import { SkatteverketAuthError } from '../lib/api-client'
|
|
import { markNeedsReconsent } from '../lib/token-store'
|
|
import { reconcileAgiDeclaration } from '../lib/agi-kvittens-reconcile'
|
|
|
|
const mockCreateExtensionContext = vi.mocked(createExtensionContext)
|
|
const mockHasCapability = vi.mocked(hasCapability)
|
|
const mockSyncSkattekonto = vi.mocked(syncSkattekonto)
|
|
const mockMarkNeedsReconsent = vi.mocked(markNeedsReconsent)
|
|
const mockReconcile = vi.mocked(reconcileAgiDeclaration)
|
|
|
|
const USER = 'user-1'
|
|
const COMPANY = 'company-1'
|
|
|
|
function pendingDecl(id: string) {
|
|
return {
|
|
id,
|
|
company_id: COMPANY,
|
|
salary_run_id: null,
|
|
period_year: 2026,
|
|
period_month: 5,
|
|
}
|
|
}
|
|
|
|
/** Thenable select chain for the agi_declarations pending lookup. */
|
|
function makeSupabase(result: { data: unknown; error?: unknown }) {
|
|
const chain: any = {}
|
|
for (const method of ['select', 'eq', 'order', 'limit']) {
|
|
chain[method] = vi.fn(() => chain)
|
|
}
|
|
chain.then = (resolve: (v: unknown) => void) =>
|
|
resolve({ data: result.data, error: result.error ?? null })
|
|
return { from: vi.fn(() => chain) } as any
|
|
}
|
|
|
|
describe('runPostConnectRefresh', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockCreateExtensionContext.mockReturnValue({ stub: 'ctx' } as any)
|
|
mockHasCapability.mockResolvedValue(true)
|
|
mockSyncSkattekonto.mockResolvedValue({
|
|
booked: 1,
|
|
upcoming: 0,
|
|
saldoSkatteverket: 100,
|
|
saldoKronofogden: 0,
|
|
syncedAt: '2026-07-12T21:30:00Z',
|
|
} as any)
|
|
})
|
|
|
|
it('syncs and reconciles pending declarations on the happy path', async () => {
|
|
const supabase = makeSupabase({ data: [pendingDecl('d-1'), pendingDecl('d-2')] })
|
|
mockReconcile
|
|
.mockResolvedValueOnce({ status: 'signed', kvittensnummer: 'uuid-1' })
|
|
.mockResolvedValueOnce({ status: 'still_pending' })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result).toEqual({ synced: true, reconciled: 1 })
|
|
expect(mockCreateExtensionContext).toHaveBeenCalledWith(supabase, USER, COMPANY, 'skatteverket')
|
|
expect(mockSyncSkattekonto).toHaveBeenCalledWith({ stub: 'ctx' })
|
|
expect(mockReconcile).toHaveBeenCalledTimes(2)
|
|
expect(mockReconcile).toHaveBeenCalledWith(supabase, pendingDecl('d-1'), {
|
|
reconciledBy: 'post-connect',
|
|
userId: USER,
|
|
})
|
|
})
|
|
|
|
it('does nothing when the skatteverket capability is not entitled', async () => {
|
|
mockHasCapability.mockResolvedValueOnce(false)
|
|
const supabase = makeSupabase({ data: [pendingDecl('d-1')] })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result).toEqual({ synced: false, reconciled: 0 })
|
|
expect(mockSyncSkattekonto).not.toHaveBeenCalled()
|
|
expect(mockReconcile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('still reconciles kvittenser when the sync fails', async () => {
|
|
mockSyncSkattekonto.mockRejectedValueOnce(new Error('SKV timeout'))
|
|
const supabase = makeSupabase({ data: [pendingDecl('d-1')] })
|
|
mockReconcile.mockResolvedValueOnce({ status: 'signed', kvittensnummer: 'uuid-1' })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result).toEqual({ synced: false, reconciled: 1 })
|
|
// A plain network/timeout failure is transient: it must not flag the
|
|
// freshly granted token as needing re-consent.
|
|
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('persists needs_reconsent when the sync hits a terminal auth error (MISSING_SCOPE)', async () => {
|
|
mockSyncSkattekonto.mockRejectedValueOnce(
|
|
new SkatteverketAuthError('The required scopes are not authorized', 'MISSING_SCOPE'),
|
|
)
|
|
const supabase = makeSupabase({ data: [] })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result.synced).toBe(false)
|
|
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(supabase, USER, 'MISSING_SCOPE')
|
|
})
|
|
|
|
it('does not persist needs_reconsent for non-terminal auth error codes', async () => {
|
|
mockSyncSkattekonto.mockRejectedValueOnce(
|
|
new SkatteverketAuthError('temporary auth hiccup', 'NOT_CONNECTED'),
|
|
)
|
|
const supabase = makeSupabase({ data: [] })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result.synced).toBe(false)
|
|
expect(mockMarkNeedsReconsent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('continues with remaining declarations when one reconcile throws', async () => {
|
|
const supabase = makeSupabase({ data: [pendingDecl('d-1'), pendingDecl('d-2')] })
|
|
mockReconcile
|
|
.mockRejectedValueOnce(new Error('SESSION_EXPIRED'))
|
|
.mockResolvedValueOnce({ status: 'signed', kvittensnummer: 'uuid-2' })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result).toEqual({ synced: true, reconciled: 1 })
|
|
expect(mockReconcile).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('never throws when the capability check itself fails', async () => {
|
|
mockHasCapability.mockRejectedValueOnce(new Error('db down'))
|
|
const supabase = makeSupabase({ data: [] })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result).toEqual({ synced: false, reconciled: 0 })
|
|
expect(mockSyncSkattekonto).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('handles an empty pending lookup', async () => {
|
|
const supabase = makeSupabase({ data: null })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result).toEqual({ synced: true, reconciled: 0 })
|
|
expect(mockReconcile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('logs a failed pending lookup instead of treating it as no rows', async () => {
|
|
const supabase = makeSupabase({ data: null, error: { message: 'connection reset' } })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result).toEqual({ synced: true, reconciled: 0 })
|
|
expect(mockReconcile).not.toHaveBeenCalled()
|
|
const warned = warnRecorder.mock.calls.map(c => String(c[0]))
|
|
expect(warned.some(m => m.includes('kvittens lookup failed'))).toBe(true)
|
|
})
|
|
|
|
it('logs non-throw error outcomes from the reconciler', async () => {
|
|
const supabase = makeSupabase({ data: [pendingDecl('d-1'), pendingDecl('d-2')] })
|
|
mockReconcile
|
|
.mockResolvedValueOnce({ status: 'error', error: 'SKV svarade 500' })
|
|
.mockResolvedValueOnce({ status: 'signed', kvittensnummer: 'uuid-2' })
|
|
|
|
const result = await runPostConnectRefresh(supabase, USER, COMPANY)
|
|
|
|
expect(result).toEqual({ synced: true, reconciled: 1 })
|
|
const errorWarn = warnRecorder.mock.calls.find(c =>
|
|
String(c[0]).includes('reconcile returned error'),
|
|
)
|
|
expect(errorWarn).toBeDefined()
|
|
expect(errorWarn?.[1]).toMatchObject({ declarationId: 'd-1', message: 'SKV svarade 500' })
|
|
})
|
|
})
|