Files
accounted/extensions/general/enable-banking/__tests__/connect-cleanup.test.ts
T
3de5dee553 fix(enable-banking): reconnect supersedes the old connection and stops duplicate imports (#1728)
* fix(enable-banking): supersede the old connection on bank reconnect and stop renewal duplicates

A renewal performed via the bank list ("Anslut ny bank") created a second
bank_connections row and left the old one parked in 'expired' forever: an
eternal "Åtgärd krävs" card, a red status chip, transactions stranded on the
dead row (so the picker's gap-fill probe read the renewal as a first
connect), and re-imported history for no-IBAN accounts whose provider uids
change on re-authorization.

- New migration: additive superseded_by uuid (FK, ON DELETE SET NULL) +
  superseded_at + partial index on bank_connections. Status 'revoked' is
  reused for superseded rows (no CHECK change); superseded_by disambiguates
  a supersede from a user disconnect. File only: not applied anywhere yet.
- New lib/supersede.ts: after the OAuth callback finalizes, park same-bank
  siblings matched by IBAN overlap (an ACTIVE sibling without overlap is
  never touched; no-IBAN fallback only for dead siblings when neither side
  has IBANs), revoke their EB session only when countLiveSiblings says
  nobody shares it, re-point their transactions in id batches, demote
  leftover cash_accounts claims (the mirror then promotes them by IBAN),
  carry last_synced_at + initial_sync_* onto the survivor, and emit the new
  bank_connection.superseded audit event.
- /connect fresh path: 409 { code: 'EXISTING_CONNECTION',
  existing_connection_id } when a non-revoked same-bank row exists, unless
  the body carries force_new: true (escape hatch for a second login at the
  same bank). Runs after the zombie sweep; reconnect-in-place unaffected.
- Dedup scope stability: StoredAccount.dedup_scope pins the external_id
  account scope at first ingest (normalized IBAN, else the uid of that
  moment), is carried across in-place reconnects and supersedes by IBAN
  match, and sync.ts uses dedup_scope ?? IBAN ?? uid (stamping legacy rows
  lazily). The external_id FORMAT is untouched.
- AccountPickerDialog gap-fill probe also includes superseded connection
  ids so the renewal default never races the transaction re-point.
- Sync toast (BankSyncNowButton) now also reports skipped duplicates
  (sv+en strings) so a correctly deduped renewal does not look broken.

Tests: supersede unit tests, /connect 409 + force_new, callback supersede
wiring + dedup-scope carry, sync external_id stability across uid changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): scope the connect 409 to dead siblings and harden supersede ordering

- POST /connect only 409s when the same-bank sibling is expired/error/
  pending_selection: an active row (a second legitimate login at the same
  bank) never blocks a fresh connect; force_new bypass kept. The 409 text
  now names the bank and points at Fornya samtycke.
- supersede parks the sibling row BEFORE revoking its EB session, and skips
  the revoke entirely (logged) when the park update fails, so a failed park
  can no longer leave a live-looking row with a dead session.
- callback keeps a survivor account's explicit dedup_scope instead of
  letting a carried sibling scope clobber it; carried scopes only apply
  when the survivor's scope was derived (IBAN/uid fallback).
- sync-now toast joins its two sentences with '. ' so the imported and
  skipped-duplicates messages no longer run together.

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>
2026-08-20 10:08:04 +02:00

494 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
// Entitlement gate passes in these tests; the gate itself is covered by
// capability-gate.test.ts.
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, requireCapability: vi.fn() }
})
const { mockStartAuthorization, mockGetPreferredAuthMethod } = vi.hoisted(() => ({
mockStartAuthorization: vi.fn(),
mockGetPreferredAuthMethod: vi.fn(),
}))
vi.mock('../lib/api-client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/api-client')>()
return {
...actual,
startAuthorization: (...args: unknown[]) => mockStartAuthorization(...args),
// index.ts resolves the pinned auth method (with metadata for logging)
// through the details variant; both point at one mock for simplicity.
getPreferredAuthMethod: (...args: unknown[]) => mockGetPreferredAuthMethod(...args),
getPreferredAuthMethodDetails: (...args: unknown[]) => mockGetPreferredAuthMethod(...args),
}
})
import { enableBankingExtension } from '../index'
import { requireCapability } from '@/lib/entitlements/has-capability'
import type { ExtensionContext } from '@/lib/extensions/types'
interface RecordedCall {
method: string
args: unknown[]
}
interface RecordedChain {
_calls: RecordedCall[]
[key: string]: unknown
}
function makeChain(result: { data?: unknown; error?: unknown }): RecordedChain {
const calls: RecordedCall[] = []
const chain: Record<string, unknown> = { _calls: calls }
for (const m of ['select', 'eq', 'neq', 'in', 'is', 'order', 'limit', 'update', 'delete', 'insert']) {
chain[m] = vi.fn((...args: unknown[]) => {
calls.push({ method: m, args })
return chain
})
}
chain.maybeSingle = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
chain.then = (resolve: (v: unknown) => void) => resolve({ data: result.data ?? null, error: result.error ?? null })
return chain as RecordedChain
}
function makeContext(fromImpl: (table: string) => unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'enable-banking',
requestId: 'req_test',
supabase: {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
},
from: vi.fn(fromImpl),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
settings: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
function connectRoute() {
const route = enableBankingExtension.apiRoutes?.find(
(r) => r.method === 'POST' && r.path === '/connect',
)
expect(route, 'POST /connect must be registered').toBeDefined()
return route!
}
function makeConnectRequest() {
return new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aspsp_name: 'Nordea', aspsp_country: 'SE', psu_type: 'business' }),
})
}
describe('POST /connect never-activated row cleanup', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireCapability).mockResolvedValue(null)
mockGetPreferredAuthMethod.mockResolvedValue(undefined)
mockStartAuthorization.mockResolvedValue({
url: 'https://bank.example/auth',
authorization_id: 'auth-1',
})
})
it('deletes stale pending and error zombies instead of parking them in error', async () => {
const chains: RecordedChain[] = []
let call = 0
const ctx = makeContext(() => {
call++
let chain: RecordedChain
if (call === 1) {
// Latest pending row is stale (45s > 30s threshold): not a live attempt.
chain = makeChain({
data: { id: 'stale-1', created_at: new Date(Date.now() - 45_000).toISOString() },
})
} else if (call === 2) {
// The sweep: returns the deleted never-activated rows.
chain = makeChain({ data: [{ id: 'stale-1' }, { id: 'old-error' }] })
} else if (call === 3) {
// Existing-connection guard: nothing established remains post-sweep.
chain = makeChain({ data: null })
} else {
// Insert of the fresh connection row.
chain = makeChain({ data: { id: 'new-conn' } })
}
chains.push(chain)
return chain
})
const response = await connectRoute().handler(makeConnectRequest(), ctx)
expect(response.status).toBe(200)
const body = (await response.json()) as { connection_id: string; authorization_url: string }
expect(body.connection_id).toBe('new-conn')
expect(body.authorization_url).toBe('https://bank.example/auth')
// The sweep DELETEs never-activated rows: stale pendings and error rows
// from failed attempts, guarded so established connections (session_id or
// accounts_data present) are untouched.
const sweep = chains[1]
const methods = sweep._calls.map((c) => c.method)
expect(methods).toContain('delete')
const inCall = sweep._calls.find((c) => c.method === 'in')
expect(inCall?.args).toEqual(['status', ['pending', 'error']])
const isCalls = sweep._calls.filter((c) => c.method === 'is')
expect(isCalls.map((c) => c.args)).toEqual(
expect.arrayContaining([
['session_id', null],
['accounts_data', null],
]),
)
// Nothing gets parked as status='error' anymore: no update on any chain.
for (const chain of chains) {
expect(chain._calls.some((c) => c.method === 'update')).toBe(false)
}
})
it('still rejects a duplicate connect while a recent pending attempt is live', async () => {
const chains: RecordedChain[] = []
const ctx = makeContext(() => {
// Latest pending row is 5s old: the user is mid-redirect at the bank.
const chain = makeChain({
data: { id: 'live-1', created_at: new Date(Date.now() - 5_000).toISOString() },
})
chains.push(chain)
return chain
})
const response = await connectRoute().handler(makeConnectRequest(), ctx)
expect(response.status).toBe(409)
// No sweep while an attempt is live: the live pending row must survive.
for (const chain of chains) {
expect(chain._calls.some((c) => c.method === 'delete')).toBe(false)
}
expect(mockStartAuthorization).not.toHaveBeenCalled()
})
it('sweeps error zombies even when no pending row exists', async () => {
const chains: RecordedChain[] = []
let call = 0
const ctx = makeContext(() => {
call++
let chain: RecordedChain
if (call === 1) {
chain = makeChain({ data: null })
} else if (call === 2) {
chain = makeChain({ data: [{ id: 'old-error' }] })
} else if (call === 3) {
// Existing-connection guard: the swept zombie is gone, nothing remains.
chain = makeChain({ data: null })
} else {
chain = makeChain({ data: { id: 'new-conn' } })
}
chains.push(chain)
return chain
})
const response = await connectRoute().handler(makeConnectRequest(), ctx)
expect(response.status).toBe(200)
const sweep = chains[1]
expect(sweep._calls.some((c) => c.method === 'delete')).toBe(true)
})
})
describe('POST /connect auth-method pinning wired into startAuthorization', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireCapability).mockResolvedValue(null)
mockStartAuthorization.mockResolvedValue({
url: 'https://bank.example/auth',
authorization_id: 'auth-1',
})
})
// Fresh-connect from() sequence: recent-pending check, zombie sweep,
// existing-connection guard, insert. Explicit psu_type in the body skips
// the companies entity_type lookup.
function makeFreshConnectContext() {
let call = 0
return makeContext(() => {
call++
if (call === 1) return makeChain({ data: null })
if (call === 2) return makeChain({ data: [] })
if (call === 3) return makeChain({ data: null })
return makeChain({ data: { id: 'new-conn' } })
})
}
function makeHandelsbankenRequest() {
return new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aspsp_name: 'Handelsbanken', aspsp_country: 'SE', psu_type: 'business' }),
})
}
it('forwards the pinned method name into the auth_method argument (Handelsbanken corporate regression)', async () => {
// The documented real Handelsbanken shape: Mobile BankID is a hidden
// DECOUPLED method carrying NO psu_types restriction.
mockGetPreferredAuthMethod.mockResolvedValue({
name: 'BANKID',
approach: 'DECOUPLED',
hidden_method: true,
title: 'Bank ID',
})
const ctx = makeFreshConnectContext()
const response = await connectRoute().handler(makeHandelsbankenRequest(), ctx)
expect(response.status).toBe(200)
expect(mockGetPreferredAuthMethod).toHaveBeenCalledWith('Handelsbanken', 'SE', 'business')
// startAuthorization(aspspName, aspspCountry, redirectUrl, state, psuType,
// authMethod): the pinned method's NAME must land in the auth_method
// position (index 5). This binds selection to the outgoing request: the
// selection tests alone cannot catch a route that resolves 'BANKID' and
// then starts a default REDIRECT authorization anyway.
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
const args = mockStartAuthorization.mock.calls[0]
expect(args[0]).toBe('Handelsbanken')
expect(args[1]).toBe('SE')
expect(args[4]).toBe('business')
expect(args[5]).toBe('BANKID')
// A pinned method without psu_types applies to all PSU types: the log must
// say '(all)', never '(aspsp default)', which would contradict
// auth_method='BANKID' on the same line.
expect(ctx.log.info).toHaveBeenCalledWith(
'[enable-banking] Starting bank connection',
expect.objectContaining({
auth_method: 'BANKID',
auth_method_psu_types: '(all)',
}),
)
})
it('passes undefined auth_method when no method is pinned (ASPSP default flow)', async () => {
mockGetPreferredAuthMethod.mockResolvedValue(undefined)
const ctx = makeFreshConnectContext()
const response = await connectRoute().handler(makeHandelsbankenRequest(), ctx)
expect(response.status).toBe(200)
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
const args = mockStartAuthorization.mock.calls[0]
expect(args[5]).toBeUndefined()
// Only the unpinned case logs the '(aspsp default)' sentinel.
expect(ctx.log.info).toHaveBeenCalledWith(
'[enable-banking] Starting bank connection',
expect.objectContaining({
auth_method: '(aspsp default)',
auth_method_psu_types: '(aspsp default)',
}),
)
})
it('forwards the pinned method name on the reconnect path too', async () => {
mockGetPreferredAuthMethod.mockResolvedValue({
name: 'BANKID',
approach: 'DECOUPLED',
hidden_method: true,
title: 'Bank ID',
})
let call = 0
const ctx = makeContext(() => {
call++
if (call === 1) {
// The existing connection loaded up front: reconnect derives the bank
// identity and psu_type from this row. session_id null skips the
// sibling check + revoke.
return makeChain({
data: {
id: 'conn-1',
bank_name: 'Handelsbanken',
provider: 'handelsbanken-se',
session_id: null,
psu_type: 'business',
},
})
}
// CSRF-state staging update and the authorization_id follow-up write.
return makeChain({ data: null })
})
const req = new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: 'conn-1' }),
})
const response = await connectRoute().handler(req, ctx)
expect(response.status).toBe(200)
// The reconnect branch calls startAuthorization from its own call site:
// the pinned name must reach the auth_method position there as well.
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
const args = mockStartAuthorization.mock.calls[0]
expect(args[0]).toBe('Handelsbanken')
expect(args[1]).toBe('SE')
expect(args[4]).toBe('business')
expect(args[5]).toBe('BANKID')
})
})
describe('POST /connect existing-connection guard', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireCapability).mockResolvedValue(null)
mockGetPreferredAuthMethod.mockResolvedValue(undefined)
mockStartAuthorization.mockResolvedValue({
url: 'https://bank.example/auth',
authorization_id: 'auth-1',
})
})
it('returns 409 EXISTING_CONNECTION when a dead (expired) row for the same bank exists', async () => {
const chains: RecordedChain[] = []
let call = 0
const ctx = makeContext(() => {
call++
let chain: RecordedChain
if (call === 1) {
// No live pending attempt.
chain = makeChain({ data: null })
} else if (call === 2) {
// Sweep finds nothing to delete.
chain = makeChain({ data: [] })
} else {
// Guard: an established expired row for this bank survives the sweep.
chain = makeChain({ data: { id: 'existing-1', status: 'expired' } })
}
chains.push(chain)
return chain
})
const response = await connectRoute().handler(makeConnectRequest(), ctx)
expect(response.status).toBe(409)
const body = (await response.json()) as {
code: string
existing_connection_id: string
error: string
}
expect(body.code).toBe('EXISTING_CONNECTION')
expect(body.existing_connection_id).toBe('existing-1')
// The message names the bank and points at Förnya samtycke.
expect(body.error).toContain('Nordea')
expect(body.error).toContain('behöver förnyas')
expect(body.error).toContain('Förnya samtycke')
// The bank flow is never started and no duplicate row is inserted.
expect(mockStartAuthorization).not.toHaveBeenCalled()
for (const chain of chains) {
expect(chain._calls.some((c) => c.method === 'insert')).toBe(false)
}
// The guard only matches DEAD-BUT-ESTABLISHED rows: an active row (a
// legitimate second login at the same bank) and revoked rows never 409.
const guard = chains[2]
const inCall = guard._calls.find((c) => c.method === 'in')
expect(inCall?.args).toEqual(['status', ['expired', 'error', 'pending_selection']])
})
it('never 409s over an ACTIVE same-bank connection: the guard status filter excludes it', async () => {
const chains: RecordedChain[] = []
let call = 0
const ctx = makeContext(() => {
call++
let chain: RecordedChain
if (call === 1) {
chain = makeChain({ data: null })
} else if (call === 2) {
chain = makeChain({ data: [] })
} else if (call === 3) {
// Guard: only an ACTIVE row exists for this bank; the dead-status
// filter matches nothing, so the query returns null.
chain = makeChain({ data: null })
} else {
chain = makeChain({ data: { id: 'second-login' } })
}
chains.push(chain)
return chain
})
const response = await connectRoute().handler(makeConnectRequest(), ctx)
// The second legitimate login at the same bank goes through.
expect(response.status).toBe(200)
const body = (await response.json()) as { connection_id: string }
expect(body.connection_id).toBe('second-login')
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
// The guard queried with the dead-status filter (so an active row can
// never be returned) rather than neq('status', 'revoked').
const guard = chains[2]
const inCall = guard._calls.find((c) => c.method === 'in')
expect(inCall?.args).toEqual(['status', ['expired', 'error', 'pending_selection']])
expect(guard._calls.some((c) => c.method === 'neq')).toBe(false)
})
it('force_new: true bypasses the guard and inserts a fresh row', async () => {
const chains: RecordedChain[] = []
let call = 0
const ctx = makeContext(() => {
call++
let chain: RecordedChain
if (call === 1) {
chain = makeChain({ data: null })
} else if (call === 2) {
chain = makeChain({ data: [] })
} else {
// With force_new the guard query is skipped entirely: call 3 is the
// insert of the fresh connection row.
chain = makeChain({ data: { id: 'new-conn' } })
}
chains.push(chain)
return chain
})
const req = new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aspsp_name: 'Nordea',
aspsp_country: 'SE',
psu_type: 'business',
force_new: true,
}),
})
const response = await connectRoute().handler(req, ctx)
expect(response.status).toBe(200)
const body = (await response.json()) as { connection_id: string }
expect(body.connection_id).toBe('new-conn')
expect(mockStartAuthorization).toHaveBeenCalledTimes(1)
// No guard query ran: nothing filtered on the guard's dead-status set
// (the sweep's in() uses ['pending', 'error'] and is expected).
for (const chain of chains) {
const guardIn = chain._calls.find(
(c) =>
c.method === 'in' &&
JSON.stringify(c.args) === JSON.stringify(['status', ['expired', 'error', 'pending_selection']]),
)
expect(guardIn).toBeUndefined()
}
})
})