* fix(enable-banking): only pin hidden, psu-applicable decoupled auth methods PR #854 pinned the first DECOUPLED auth method for every bank to fix Handelsbanken corporate consents (its Mobile BankID is a hidden_method that Enable Banking only uses when requested explicitly). The blanket pin also hit banks whose decoupled method is visible and whose default flow already worked: Lunar users were asked for personnummer on the hosted page, told to approve in the app, and no approval ever arrived. Now a method is pinned only when pinning is necessary (hidden_method is true, so the method is unreachable by default) and applicable (psu_types missing/empty or containing the consent's psu_type). Otherwise undefined is returned and the ASPSP default runs, matching the stated intent of #854. The connect log now records the chosen method's approach, hidden_method and psu_types so per-bank behavior can be verified in prod after deploy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(enable-banking): bind pinned auth method to the outgoing /auth request Review follow-up on the auth-method pinning branch, two findings: 1. No test bound the selection result to the outgoing request: mutating the connect route to stop forwarding the pinned method's name into startAuthorization (the exact Handelsbanken-corporate regression this code exists to prevent) passed all 157 tests. Add route-level wiring tests asserting startAuthorization receives 'BANKID' in the auth_method argument position (index 5) on both the fresh-connect and reconnect call sites, plus the inverse: unpinned resolves to an undefined auth_method. 2. The auth_method_psu_types log field printed '(aspsp default)' when a method WAS pinned but carried no psu_types (the documented real Handelsbanken shape), contradicting auth_method='BANKID' on the same line. A pinned method without psu_types now logs '(all)'; '(aspsp default)' is reserved for the unpinned case. Both fixes are mutation-verified: reverting either makes the new tests fail (wiring mutation fails 2 tests, sentinel revert fails 1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
274 lines
9.6 KiB
TypeScript
274 lines
9.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// Mock the Enable Banking API client so revoking the PSD2 session never makes
|
|
// a network call. SessionExpiredError must stay a real class: index.ts uses it
|
|
// in an instanceof check.
|
|
vi.mock('../lib/api-client', () => ({
|
|
startAuthorization: vi.fn(),
|
|
getASPSPs: vi.fn(),
|
|
getPreferredAuthMethod: vi.fn(),
|
|
getPreferredAuthMethodDetails: vi.fn(),
|
|
deleteSession: vi.fn().mockResolvedValue(undefined),
|
|
isSandboxMode: vi.fn(() => true),
|
|
SessionExpiredError: class SessionExpiredError extends Error {},
|
|
}))
|
|
|
|
// A PSD2 session can be shared by several of a user's companies, so the route
|
|
// refcounts before revoking. That count runs on a service-role client (RLS
|
|
// would hide a sibling in a company the user has since left), which without
|
|
// this mock tries to build a real client and fails on missing env vars.
|
|
const { siblingState } = vi.hoisted(() => ({
|
|
siblingState: { count: 0, error: null as { message: string } | null },
|
|
}))
|
|
vi.mock('@/lib/supabase/server', () => ({
|
|
createClient: vi.fn(),
|
|
createServiceClient: vi.fn(async () => ({
|
|
from: vi.fn(() => {
|
|
const chain: Record<string, unknown> = {}
|
|
for (const method of ['select', 'eq', 'neq']) {
|
|
chain[method] = vi.fn(() => chain)
|
|
}
|
|
chain.then = (onFulfilled: (value: unknown) => unknown) =>
|
|
Promise.resolve({ count: siblingState.count, error: siblingState.error }).then(onFulfilled)
|
|
return chain
|
|
}),
|
|
})),
|
|
}))
|
|
|
|
import { enableBankingExtension } from '../index'
|
|
import { deleteSession } from '../lib/api-client'
|
|
import type { ExtensionContext } from '@/lib/extensions/types'
|
|
|
|
const mockedDeleteSession = vi.mocked(deleteSession)
|
|
|
|
const disconnectRoute = enableBankingExtension.apiRoutes?.find(
|
|
r => r.method === 'DELETE' && r.path === '/disconnect'
|
|
)
|
|
|
|
if (!disconnectRoute) {
|
|
throw new Error('DELETE /disconnect route not registered on enable-banking extension')
|
|
}
|
|
|
|
interface DisconnectStub {
|
|
authUser: { id: string } | null
|
|
connectionRow: {
|
|
id: string
|
|
session_id: string | null
|
|
status: string
|
|
bank_name?: string | null
|
|
} | null
|
|
connectionError?: { message: string } | null
|
|
connUpdateError?: { message: string } | null
|
|
cashUpdateError?: { message: string } | null
|
|
/** Captured bank_connections update payloads. */
|
|
connUpdates: Array<Record<string, unknown>>
|
|
/** Captured cash_accounts update payloads + their eq() filters, in order. */
|
|
cashUpdates: Array<{ payload: Record<string, unknown>; filters: Array<[string, unknown]> }>
|
|
}
|
|
|
|
function makeStub(partial: Partial<DisconnectStub> = {}): DisconnectStub {
|
|
return {
|
|
authUser: { id: 'user-1' },
|
|
connectionRow: {
|
|
id: 'conn-1',
|
|
session_id: 'sess-1',
|
|
status: 'active',
|
|
bank_name: 'Lunar',
|
|
},
|
|
connUpdates: [],
|
|
cashUpdates: [],
|
|
...partial,
|
|
}
|
|
}
|
|
|
|
function buildSupabase(stub: DisconnectStub) {
|
|
return {
|
|
auth: {
|
|
getUser: vi.fn().mockResolvedValue({ data: { user: stub.authUser }, error: null }),
|
|
},
|
|
from: vi.fn((table: string) => {
|
|
if (table === 'cash_accounts') {
|
|
return {
|
|
update: vi.fn((payload: Record<string, unknown>) => {
|
|
const filters: Array<[string, unknown]> = []
|
|
stub.cashUpdates.push({ payload, filters })
|
|
const result = Promise.resolve({ error: stub.cashUpdateError ?? null })
|
|
const builder = {
|
|
eq: vi.fn((col: string, val: unknown) => {
|
|
filters.push([col, val])
|
|
return builder
|
|
}),
|
|
then: result.then.bind(result),
|
|
catch: result.catch.bind(result),
|
|
}
|
|
return builder
|
|
}),
|
|
}
|
|
}
|
|
// bank_connections
|
|
return {
|
|
select: vi.fn().mockReturnThis(),
|
|
eq: vi.fn().mockReturnThis(),
|
|
single: vi.fn().mockResolvedValue({
|
|
data: stub.connectionRow,
|
|
error: stub.connectionError ?? null,
|
|
}),
|
|
update: vi.fn((payload: Record<string, unknown>) => {
|
|
stub.connUpdates.push(payload)
|
|
return { eq: vi.fn().mockResolvedValue({ error: stub.connUpdateError ?? null }) }
|
|
}),
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
function makeContext(supabase: ReturnType<typeof buildSupabase>): ExtensionContext {
|
|
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(body: unknown): Request {
|
|
return new Request('http://localhost/api/extensions/ext/enable-banking/disconnect', {
|
|
method: 'DELETE',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
}
|
|
|
|
describe('DELETE /disconnect (enable-banking)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockedDeleteSession.mockResolvedValue(undefined)
|
|
siblingState.count = 0
|
|
siblingState.error = null
|
|
})
|
|
|
|
it('leaves the shared consent alone while another company still uses it', async () => {
|
|
// Sessions are shared across a user's companies (lib/session-sharing.ts).
|
|
// Revoking here would take down a sibling company's bank feed, which is the
|
|
// exact failure cross-company session reuse exists to remove.
|
|
siblingState.count = 1
|
|
const stub = makeStub()
|
|
const ctx = makeContext(buildSupabase(stub))
|
|
|
|
const res = await disconnectRoute.handler(makeRequest({ connection_id: 'conn-1' }), ctx)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(mockedDeleteSession).not.toHaveBeenCalled()
|
|
// This company is still fully disconnected: only the upstream consent
|
|
// survives, and it lapses on its own within 90 days.
|
|
expect(stub.connUpdates).toEqual([{ status: 'revoked', session_id: null }])
|
|
expect(stub.cashUpdates).toHaveLength(1)
|
|
})
|
|
|
|
it('treats a failed sibling count as shared rather than risk a live feed', async () => {
|
|
siblingState.error = { message: 'timeout' }
|
|
const stub = makeStub()
|
|
const ctx = makeContext(buildSupabase(stub))
|
|
|
|
const res = await disconnectRoute.handler(makeRequest({ connection_id: 'conn-1' }), ctx)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(mockedDeleteSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 401 when unauthenticated', async () => {
|
|
const stub = makeStub({ authUser: null })
|
|
const ctx = makeContext(buildSupabase(stub))
|
|
|
|
const res = await disconnectRoute.handler(makeRequest({ connection_id: 'conn-1' }), ctx)
|
|
expect(res.status).toBe(401)
|
|
})
|
|
|
|
it('returns 404 when the connection is not found', async () => {
|
|
const stub = makeStub({ connectionRow: null, connectionError: { message: 'not found' } })
|
|
const ctx = makeContext(buildSupabase(stub))
|
|
|
|
const res = await disconnectRoute.handler(makeRequest({ connection_id: 'conn-1' }), ctx)
|
|
expect(res.status).toBe(404)
|
|
})
|
|
|
|
it('revokes the connection AND releases its cash_accounts ledger claims (issue #916)', async () => {
|
|
const stub = makeStub()
|
|
const ctx = makeContext(buildSupabase(stub))
|
|
|
|
const res = await disconnectRoute.handler(makeRequest({ connection_id: 'conn-1' }), ctx)
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json()
|
|
expect(body.success).toBe(true)
|
|
|
|
// PSD2 consent revoked upstream.
|
|
expect(mockedDeleteSession).toHaveBeenCalledWith('sess-1')
|
|
|
|
// Connection marked revoked.
|
|
expect(stub.connUpdates).toEqual([{ status: 'revoked', session_id: null }])
|
|
|
|
// The connection's cash_accounts rows are demoted to manual (NOT deleted):
|
|
// transactions and ledger history reference them, and upsertFromPsd2
|
|
// promotes manual holders in place on reconnect so the same bank lands
|
|
// back on its original BAS account.
|
|
expect(stub.cashUpdates).toHaveLength(1)
|
|
expect(stub.cashUpdates[0].payload).toEqual({ bank_connection_id: null })
|
|
expect(stub.cashUpdates[0].filters).toEqual([
|
|
['company_id', 'company-1'],
|
|
['bank_connection_id', 'conn-1'],
|
|
])
|
|
|
|
expect(ctx.emit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
type: 'bank_connection.revoked',
|
|
payload: expect.objectContaining({ connectionId: 'conn-1', companyId: 'company-1' }),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('still succeeds when the ledger claim release fails (self-heal covers it)', async () => {
|
|
// The connection is already revoked at that point; the allocator and the
|
|
// picker-save collision guard both skip revoked connections, so orphaned
|
|
// rows recover on the next picker save.
|
|
const stub = makeStub({ cashUpdateError: { message: 'transient' } })
|
|
const ctx = makeContext(buildSupabase(stub))
|
|
|
|
const res = await disconnectRoute.handler(makeRequest({ connection_id: 'conn-1' }), ctx)
|
|
|
|
expect(res.status).toBe(200)
|
|
const body = await res.json()
|
|
expect(body.success).toBe(true)
|
|
expect(ctx.log.error).toHaveBeenCalledWith(
|
|
expect.stringContaining('release cash_accounts'),
|
|
expect.objectContaining({ connectionId: 'conn-1' })
|
|
)
|
|
})
|
|
|
|
it('skips PSD2 session revocation when the connection has no session', async () => {
|
|
const stub = makeStub({
|
|
connectionRow: { id: 'conn-1', session_id: null, status: 'expired', bank_name: 'Lunar' },
|
|
})
|
|
const ctx = makeContext(buildSupabase(stub))
|
|
|
|
const res = await disconnectRoute.handler(makeRequest({ connection_id: 'conn-1' }), ctx)
|
|
|
|
expect(res.status).toBe(200)
|
|
expect(mockedDeleteSession).not.toHaveBeenCalled()
|
|
// Ledger claims are still released.
|
|
expect(stub.cashUpdates).toHaveLength(1)
|
|
})
|
|
})
|