Files
accounted/extensions/general/enable-banking/__tests__/error-status-retry.test.ts
T
MattssonandClaude Fable 5.1 1e1e1d5d17 fix(enable-banking): connector-hop failures no longer park a connection in error (#2296)
* fix(enable-banking): connector-hop failures no longer park a connection in error

On 2026-09-04 the daily cron flipped four canary companies (bank sync routed
through Accounted Connect, PR #2205) to 'error' with "Banksynkningen
misslyckades ... forny anslutningen" because the service answered 200 with a
body that fails bankSyncResponseSchema. The PSD2 sessions were fine; users
re-authorized Danske, SEB and Revolut for nothing, and the bare "unexpected
shape" message left the contract mismatch undiagnosable.

- New ConnectorSyncError (status, code, body, Zod issue paths) thrown for
  every connector-hop failure: transport, timeout, error envelope other than
  a dead session, and wire-contract mismatch. The failing field paths are
  logged at the throw site.
- Cron: AspspUnavailableError and ConnectorSyncError are transient. The row
  is left untouched (no status flip, no renewal advice) and logged at warn
  level; the health probe on the same run still catches a dead session.
- Manual sync (POST /sync) and agent sync (triggerConnectionSync) answer
  retryable with CONNECTOR_UNAVAILABLE_MESSAGE, which says explicitly that
  the connection does not need renewing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSet8McjShABUeoATNnh9L

* fix(enable-banking): keep the connector response body out of the sync log line

Superagent P2 and CodeRabbit: POST /sync logged up to 500 chars of the raw
connector body next to user and connection ids. A connector response can
carry transaction and personal data, so the log line now carries only code,
status, the Zod issue paths and the body length. The BAD_SHAPE message no
longer falls back to the body either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSet8McjShABUeoATNnh9L

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 00:28:27 +02:00

209 lines
7.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { StoredAccount } from '../types'
// Mock the JWT signer so api-client can be imported without real
// ENABLE_BANKING credentials.
vi.mock('../lib/jwt', () => ({
getAuthorizationHeader: () => 'Bearer test-token',
}))
// Mock the sync orchestrator so the /sync handler tests can force success or
// failure without hitting the network.
vi.mock('../lib/sync', () => ({
syncAccountTransactions: vi.fn(),
}))
vi.mock('@/lib/entitlements/has-capability', () => ({
requireCapability: vi.fn().mockResolvedValue(null),
}))
import { SYNC_FAILED_MESSAGE, CONNECTOR_UNAVAILABLE_MESSAGE, ConnectorSyncError } from '../lib/api-client'
import { enableBankingExtension } from '../index'
import { syncAccountTransactions } from '../lib/sync'
const syncRoute = enableBankingExtension.apiRoutes?.find(
r => r.method === 'POST' && r.path === '/sync'
)
if (!syncRoute) {
throw new Error('POST /sync route not registered on enable-banking extension')
}
const RAW_EB_ERROR =
'Failed to get transactions (400): {"code":400,"message":"Error interacting with ASPSP","detail":null,"error":"ASPSP_ERROR"}'
function makeContext(connection: Record<string, unknown>, updateSpy: Mock): ExtensionContext {
// One universal chainable per from() call (same shape as the other /sync
// handler tests): bank_connections terminates on single(), sie_imports /
// company_members on maybeSingle(), and update() records its payload while
// returning the chain so trailing .eq() calls resolve.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chain: any = {}
chain.select = vi.fn(() => chain)
chain.eq = vi.fn(() => chain)
chain.gte = vi.fn(() => chain)
chain.limit = vi.fn(() => chain)
chain.order = vi.fn(() => chain)
chain.single = vi.fn().mockResolvedValue({ data: connection, error: null })
chain.maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null })
chain.update = vi.fn((payload: unknown) => {
updateSpy(payload)
return chain
})
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
},
from: vi.fn(() => chain),
}
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'enable-banking',
requestId: 'req_test',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: supabase as any,
emit: vi.fn().mockResolvedValue(undefined),
settings: { get: vi.fn(), set: vi.fn(), getAll: vi.fn() } as never,
storage: {} as never,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never,
services: {} as never,
}
}
function makeRequest(): Request {
return new Request('http://localhost/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: 'conn-1' }),
})
}
function makeConnection(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'conn-1',
company_id: 'company-1',
bank_name: 'SEB',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }] as StoredAccount[],
...overrides,
}
}
describe('POST /sync (enable-banking): retry from error status', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('allows sync from status=error and restores active + clears error_message on success', async () => {
// Regression: a transient ASPSP failure parked the connection in 'error',
// but the old status gate rejected everything but 'active', so the UI's
// "Försök igen" button always got 400 and the connection was stranded
// until a full re-auth.
;(syncAccountTransactions as unknown as Mock).mockResolvedValue({
imported: 0,
duplicates: 0,
errors: 0,
})
const updateSpy = vi.fn()
const ctx = makeContext(
makeConnection({ status: 'error', error_message: RAW_EB_ERROR }),
updateSpy
)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(200)
expect(syncAccountTransactions).toHaveBeenCalledTimes(1)
expect(updateSpy).toHaveBeenCalledWith(
expect.objectContaining({
status: 'active',
error_message: null,
last_synced_at: expect.any(String),
})
)
})
it('still rejects expired connections with 400 (re-auth required, not retry)', async () => {
const updateSpy = vi.fn()
const ctx = makeContext(makeConnection({ status: 'expired' }), updateSpy)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/not active/i)
expect(syncAccountTransactions).not.toHaveBeenCalled()
expect(updateSpy).not.toHaveBeenCalled()
})
it('does not touch status for an active connection but clears a leftover error_message', async () => {
;(syncAccountTransactions as unknown as Mock).mockResolvedValue({
imported: 0,
duplicates: 0,
errors: 0,
})
const updateSpy = vi.fn()
const ctx = makeContext(
makeConnection({ status: 'active', error_message: RAW_EB_ERROR }),
updateSpy
)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(200)
const payload = updateSpy.mock.calls[0][0]
expect(payload).not.toHaveProperty('status')
expect(payload).toMatchObject({ error_message: null })
})
it('maps a non-session failure to the Swedish user message and refreshes the stored error_message', async () => {
// The raw Enable Banking body is an English JSON envelope: it belongs in
// server logs, never in the toast or the settings panel.
;(syncAccountTransactions as unknown as Mock).mockRejectedValue(new Error(RAW_EB_ERROR))
const updateSpy = vi.fn()
const ctx = makeContext(
makeConnection({ status: 'error', error_message: RAW_EB_ERROR }),
updateSpy
)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error).toBe(SYNC_FAILED_MESSAGE)
expect(body.error).not.toContain('ASPSP_ERROR')
expect(updateSpy).toHaveBeenCalledWith({ error_message: SYNC_FAILED_MESSAGE })
})
it('answers 503 retryable without renewal advice when the connector hop fails, and leaves the row alone', async () => {
;(syncAccountTransactions as unknown as Mock).mockRejectedValue(
new ConnectorSyncError(200, 'CONNECTOR_BAD_SHAPE', '{}', ['transactions.0.amount: Invalid input'])
)
const updateSpy = vi.fn()
const ctx = makeContext(makeConnection({ status: 'error', error_message: SYNC_FAILED_MESSAGE }), updateSpy)
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(503)
const body = await res.json()
expect(body).toMatchObject({ error: CONNECTOR_UNAVAILABLE_MESSAGE, code: 'CONNECTOR_UNAVAILABLE', retryable: true })
expect(body.error).not.toContain('Förnya')
expect(updateSpy).not.toHaveBeenCalled()
expect(ctx.log.warn).toHaveBeenCalledWith(
'[enable-banking] Sync: connector hop failed',
expect.objectContaining({ code: 'CONNECTOR_BAD_SHAPE', issues: ['transactions.0.amount: Invalid input'] })
)
// The raw connector body can carry bank data: never in the log line.
const logged = (ctx.log.warn as Mock).mock.calls.find((c) => c[0] === '[enable-banking] Sync: connector hop failed')?.[1]
expect(logged).not.toHaveProperty('body')
expect(logged).toHaveProperty('bodyLength', 2)
})
})