Files
accounted/extensions/general/skatteverket/__tests__/post-connect-refresh.test.ts
T
MattssonandClaude Fable 5 0e9cca2750 Add/customer mcp (#1055)
* 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>
2026-07-17 14:13:53 +02:00

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' })
})
})