fix(enable-banking): recover error-state connections, respect PSD2 balance quota, clean error surface (#968)
* fix(enable-banking): recover error-state connections, respect PSD2 balance quota, clean error surface Three defects from the 2026-07-09 production log triage, all in how the enable-banking extension handles upstream (Enable Banking / ASPSP) failures: 1. Retry dead-end: a non-session sync failure parked the connection in status='error', but POST /sync rejected anything not 'active' with 400, so the UI's "Försök igen" button could never succeed and the connection stayed stranded until a full re-auth. /sync now accepts 'error' (while still rejecting 'expired': a dead consent needs re-authorization), and a successful sync restores status='active' and clears error_message. 2. Balance quota burn: every sync (manual or cron) called the BALANCES endpoint although PSD2 unattended consents allow only 4 calls/day (observed 429 "Consent daily limit 4 is exceeded"), and the retry wrapper retried those 429s twice against a daily quota. The sync now skips the balance call while the stored balance_updated_at is fresher than 12 hours, and authenticatedFetchWithRetry fails fast on a 429 whose body signals a daily limit. 3. Raw JSON in UI: sync failures persisted the raw English Enable Banking error body into bank_connections.error_message, which the settings panel renders verbatim. Failures are now mapped to short Swedish user messages (shared constants in api-client.ts); the raw body stays in server logs only. Also ratchets the eslint baseline down by 1: the no-explicit-any disable in the cron route was on the wrong line and never suppressed anything. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(enable-banking): treat future balance timestamps as stale (CodeRabbit) A future balance_updated_at yielded a negative age that always passed the freshness check, suppressing balance refreshes indefinitely; only 0 <= age < BALANCE_MAX_AGE_MS now counts as fresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6a9adcf00a
commit
b06d73c23e
@@ -0,0 +1,183 @@
|
||||
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 } 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 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user