fix(import): SIE bulk-delete on service client + provider/reporting/b… (#724)
* fix(import): SIE bulk-delete on service client + provider/reporting/banking fixes Rebuilt branch onto main as a single commit. - import: run SIE bulk-delete RPCs on the service client to escape the 8s statement_timeout; undo_sie_import now takes an explicit actor (p_user_id) so its owner/admin gate works when auth.uid() is NULL on the service client (migration 20260624120000) + pg-real regression test - providers: distinguish missing Fortnox license from expired connection; provider_consent_tokens PK regression test - reports: include unmapped BAS expense groups in the income statement - enable-banking: reconnect closed/expired bank sessions in place - bookkeeping: surface linked invoices as underlag on the verifikat view - scripts: track BL cleanup/diagnostic tooling; data files (*.csv) are git-ignored and consentId is now a required arg with no silent default Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): add Cache-Control header to journal entry references response --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
db8983ba9e
commit
43925bc2d3
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
// Mock the JWT signer so api-client can build a request header without real
|
||||
// ENABLE_BANKING credentials (part 2 stubs fetch directly).
|
||||
vi.mock('../lib/jwt', () => ({
|
||||
getAuthorizationHeader: () => 'Bearer test-token',
|
||||
}))
|
||||
|
||||
// Mock the sync orchestrator so the /sync handler test can force a dead-session
|
||||
// failure without hitting the network.
|
||||
vi.mock('../lib/sync', () => ({
|
||||
syncAccountTransactions: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
isSessionExpiredResponse,
|
||||
SessionExpiredError,
|
||||
getAllTransactionsWithRaw,
|
||||
} from '../lib/api-client'
|
||||
import { enableBankingExtension } from '../index'
|
||||
import { syncAccountTransactions } from '../lib/sync'
|
||||
|
||||
const CLOSED_SESSION_BODY = JSON.stringify({
|
||||
code: 401,
|
||||
message: 'Session is closed',
|
||||
error: 'CLOSED_SESSION',
|
||||
detail: null,
|
||||
})
|
||||
|
||||
describe('isSessionExpiredResponse', () => {
|
||||
it('matches the CLOSED_SESSION 401 from the screenshot', () => {
|
||||
expect(isSessionExpiredResponse(401, CLOSED_SESSION_BODY)).toBe(true)
|
||||
})
|
||||
|
||||
it('matches the lowercase session_expired variant', () => {
|
||||
expect(isSessionExpiredResponse(401, '{"error":"session_expired"}')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'{"error":"EXPIRED_SESSION"}',
|
||||
'{"error":"INVALID_SESSION"}',
|
||||
'{"error":"SESSION_NOT_FOUND"}',
|
||||
'{"error":"WRONG_SESSION_STATUS"}',
|
||||
'{"message":"Session is closed"}',
|
||||
])('matches session-dead body %s', (body) => {
|
||||
expect(isSessionExpiredResponse(401, body)).toBe(true)
|
||||
// 403 is also a valid session-rejection status from some ASPSPs.
|
||||
expect(isSessionExpiredResponse(403, body)).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT match a bare 401 Unauthorized (app-credential problem, not a dead session)', () => {
|
||||
expect(isSessionExpiredResponse(401, '{"error":"Unauthorized"}')).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT match a non-401/403 status even with a session code in the body', () => {
|
||||
expect(isSessionExpiredResponse(500, CLOSED_SESSION_BODY)).toBe(false)
|
||||
expect(isSessionExpiredResponse(400, '{"error":"ASPSP_ERROR"}')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAllTransactionsWithRaw — dead session', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('throws SessionExpiredError on a CLOSED_SESSION 401', async () => {
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => CLOSED_SESSION_BODY,
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(
|
||||
getAllTransactionsWithRaw('acc-1', '2026-01-01', '2026-06-01')
|
||||
).rejects.toBeInstanceOf(SessionExpiredError)
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
function makeContext(connection: Record<string, unknown>, updateSpy: Mock, insertSpy?: Mock): ExtensionContext {
|
||||
// One universal chainable per from() call. Each table only ever terminates on
|
||||
// single() (bank_connections lookup) OR maybeSingle() (sie_imports /
|
||||
// company_members), so a single shared resolver is unambiguous. update()/
|
||||
// insert() record their payloads and return the chain so trailing
|
||||
// .eq()/.select() 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.insert = vi.fn((payload: unknown) => {
|
||||
insertSpy?.(payload)
|
||||
return 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' }),
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /sync (enable-banking) — dead session reconnect', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('flips the connection to expired and returns a reauth-required 409', async () => {
|
||||
;(syncAccountTransactions as unknown as Mock).mockRejectedValue(
|
||||
new SessionExpiredError(401, CLOSED_SESSION_BODY)
|
||||
)
|
||||
|
||||
const updateSpy = vi.fn()
|
||||
const ctx = makeContext(
|
||||
{
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
status: 'active',
|
||||
bank_name: 'Nordea',
|
||||
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }] as StoredAccount[],
|
||||
},
|
||||
updateSpy
|
||||
)
|
||||
|
||||
const res = await syncRoute.handler(makeRequest(), ctx)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.reauth_required).toBe(true)
|
||||
expect(body.code).toBe('SESSION_EXPIRED')
|
||||
expect(body.connection_id).toBe('conn-1')
|
||||
|
||||
// The connection must be marked 'expired' so the UI surfaces the reconnect
|
||||
// affordance instead of looping on the dead session.
|
||||
expect(updateSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'expired' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const connectRoute = enableBankingExtension.apiRoutes?.find(
|
||||
r => r.method === 'POST' && r.path === '/connect'
|
||||
)
|
||||
|
||||
if (!connectRoute) {
|
||||
throw new Error('POST /connect route not registered on enable-banking extension')
|
||||
}
|
||||
|
||||
describe('POST /connect (enable-banking) — reconnect in place', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reuses the existing row (UPDATE, no INSERT) and keeps it out of the stale-pending sweep', async () => {
|
||||
// startAuthorization() POSTs to /auth — stub it (jwt is already mocked).
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => ({ url: 'https://bank.example/auth', authorization_id: 'auth-123' }),
|
||||
text: async () => '',
|
||||
}))
|
||||
)
|
||||
|
||||
const updateSpy = vi.fn()
|
||||
const insertSpy = vi.fn()
|
||||
const ctx = makeContext(
|
||||
{
|
||||
id: 'conn-1',
|
||||
company_id: 'company-1',
|
||||
bank_name: 'Nordea',
|
||||
provider: 'nordea-se',
|
||||
session_id: null, // null → skip the best-effort revoke call
|
||||
status: 'expired',
|
||||
},
|
||||
updateSpy,
|
||||
insertSpy
|
||||
)
|
||||
|
||||
const req = new Request('http://localhost/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: 'conn-1', aspsp_name: 'Nordea', aspsp_country: 'SE' }),
|
||||
})
|
||||
|
||||
const res = await connectRoute.handler(req, ctx)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.connection_id).toBe('conn-1')
|
||||
expect(body.authorization_url).toBe('https://bank.example/auth')
|
||||
|
||||
// In-place: a fresh authorization on the SAME row, never a new INSERT.
|
||||
expect(insertSpy).not.toHaveBeenCalled()
|
||||
|
||||
// The CSRF state is staged on the row FIRST — before startAuthorization, so
|
||||
// before authorization_id even exists — guaranteeing the callback can always
|
||||
// find the row by oauth_state and the bank session can never be orphaned.
|
||||
// It stays 'expired' (not 'pending') so the cron's stale-pending cleanup
|
||||
// can't delete an established connection mid-reconnect.
|
||||
const firstUpdate = updateSpy.mock.calls[0][0]
|
||||
expect(firstUpdate).toMatchObject({
|
||||
oauth_state: expect.any(String),
|
||||
status: 'expired',
|
||||
session_id: null,
|
||||
error_message: null,
|
||||
})
|
||||
expect(firstUpdate).not.toHaveProperty('authorization_id')
|
||||
|
||||
// The bank's authorization_id is recorded in a follow-up write (audit only;
|
||||
// the callback never reads it, so a failure here can't break the reconnect).
|
||||
expect(updateSpy.mock.calls[1][0]).toEqual({ authorization_id: 'auth-123' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user