fix(bank): never pre-check or mirror another company's accounts in the EB callback (#2116)

* fix(bank): never pre-check or mirror another company's accounts in the EB callback

At one-session banks (SEB) the PSU's single consent can cover accounts a
sibling company books. The OAuth callback stored whatever the session
returned into the active company: all pre-enabled, mirrored into its
cash_accounts, ledgers allocated from its chart: one 'Spara val' away
from booking another aktiebolag's transactions (user report F1,
2026-09-01).

The deliberate reuse path (findReusableSessions) already guards claimed
IBANs; the callback now runs the same check via
fetchCrossCompanyAccountContext:

- accounts claimed by another of the user's companies are stored
  disabled + flagged (claimed_by_company_*), skipped by the
  cash_accounts mirror, and the picker names the claiming company
- a 'Synkas ej' deselection made on any other connection row is carried
  onto fresh rows (the recurring came-back-pre-checked complaint, C2)
- lookup failure fails closed: new accounts stored deselected
- accounts the row itself already carried keep their own state, so a
  renewal can never switch a working feed off

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

* fix(bank): close the skeptic-found holes in the cross-company claim guard

Consolidated fixes from the three-skeptic review of PR #2116 (all three
refuted the first cut):

- Active-company standing state (enabled cash_accounts + enabled
  accounts on its live-ish connection rows) now outranks sibling claims
  company-wide, not row-wide: a bank-list renewal arrives on a FRESH row
  with no priors, and the old row-local check would have let a sibling
  claim switch a working feed off while supersede demoted its cash row.
- pending_selection rows no longer claim accounts or feed deselection
  memory: their flags are unconfirmed callback output (including this
  guard's own fail-closed writes), so an abandoned picker or a transient
  lookup error can no longer poison later connects.
- Guard-disabled accounts are never mirrored from the callback:
  upsertFromPsd2 with enabled:false for a new-to-row account could
  promote the seeded primary 1930 manual row and flip it to disabled
  under a foreign identity.
- The selection save skips ledger allocation and the cash_accounts
  mirror for disabled never-mirrored accounts, so 'no cash row, no 19xx
  slot burned' holds past the mandatory Spara val, and strips the
  claimed_by_*/deselected flags when the user deliberately enables an
  account.
- Deselection carry is no longer silent: deselected_elsewhere flag +
  picker note 'Tidigare bortvald'.
- Claim lookups paginate via fetchAllRows: the bare select's silent
  1000-row PostgREST cap failed open for exactly the multi-company
  consultants the guard exists for.

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

* fix(bank): claim-guard round 2: pending_selection claims asymmetrically, paged reads ordered

Skeptic re-verification of 25a339810 found two holes:

- Excluding pending_selection rows from claims reopened the
  attach-to-picker window: an attach-created row holds deliberately
  offered enabled accounts with no cash_accounts rows until its picker
  is saved, and a full-OAuth connect in another company inside that
  window could take the same physical account. Enabled accounts on
  pending_selection rows claim again; their disabled flags still stay
  out of the deselection memory (unconfirmed callback output, including
  the guard's own fail-closed writes).
- Both fetchAllRows claim queries now order('id'): unordered .range()
  pagination can silently skip rows at page boundaries, and a skipped
  row is a missed claim, failing open at exactly the 1000+-row scale
  the pagination was added for.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 15:24:10 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b1f7116231
commit fa69174aa0
8 changed files with 900 additions and 12 deletions
@@ -9,13 +9,15 @@ vi.mock('@/extensions/general/enable-banking/lib/api-client', () => ({
}))
// Use hoisted to safely create mock objects referenced in vi.mock factories
const { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede } = vi.hoisted(() => {
const mockFrom = vi.fn()
const mockUpsertFromPsd2 = vi.fn()
const mockAllocate = vi.fn()
const mockSupersede = vi.fn()
return { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede }
})
const { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede, mockCrossCompanyContext } =
vi.hoisted(() => {
const mockFrom = vi.fn()
const mockUpsertFromPsd2 = vi.fn()
const mockAllocate = vi.fn()
const mockSupersede = vi.fn()
const mockCrossCompanyContext = vi.fn()
return { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede, mockCrossCompanyContext }
})
// The supersede pass has its own unit tests (extensions/general/enable-banking/
// __tests__/supersede.test.ts); here it is mocked so these tests assert the
@@ -24,6 +26,19 @@ vi.mock('@/extensions/general/enable-banking/lib/supersede', () => ({
supersedeSiblingConnections: (...args: unknown[]) => mockSupersede(...args),
}))
// The cross-company claim lookup has its own unit tests (extensions/general/
// enable-banking/lib/__tests__/session-sharing.test.ts); mocked here so the
// per-test mockFrom scripts don't have to answer its queries too. Everything
// else in session-sharing stays real.
vi.mock('@/extensions/general/enable-banking/lib/session-sharing', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@/extensions/general/enable-banking/lib/session-sharing')>()
return {
...actual,
fetchCrossCompanyAccountContext: (...args: unknown[]) => mockCrossCompanyContext(...args),
}
})
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn().mockResolvedValue({
from: mockFrom,
@@ -91,6 +106,12 @@ describe('GET /api/extensions/enable-banking/callback', () => {
vi.clearAllMocks()
mockUpsertFromPsd2.mockResolvedValue(undefined)
mockSupersede.mockResolvedValue({ supersededIds: [], dedupScopeByIban: new Map() })
// No sibling company claims anything by default; individual tests override.
mockCrossCompanyContext.mockResolvedValue({
claims: new Map(),
deselectedIbans: new Set(),
activeCompanyIbans: new Set(),
})
// Allocator stand-in mirroring the real behavior: currency default first,
// then the next free 1931–1959 slot (skipping other currency defaults).
mockAllocate.mockImplementation(
@@ -271,6 +292,195 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(mirrorLedgers).toEqual(['1930', '1931'])
})
// The F1 scenario: at a one-session bank the PSU's single consent covers
// accounts another of the user's companies books (and, before this guard,
// stored them pre-enabled in the wrong company and mirrored them into its
// cash_accounts, one save away from cross-company bookkeeping).
function mockConnectionFlow(pendingRow: Record<string, unknown>) {
const capturedUpdates: Record<string, unknown>[] = []
let callIndex = 0
mockFrom.mockImplementation(() => {
callIndex++
if (callIndex === 1) {
return mockChain({ data: pendingRow, error: null })
}
const chain: Record<string, unknown> = {}
chain.update = vi.fn((payload: Record<string, unknown>) => {
capturedUpdates.push(payload)
return chain
})
chain.eq = vi.fn().mockReturnValue(chain)
chain.select = vi.fn().mockReturnValue(chain)
chain.single = vi.fn().mockResolvedValue({
data: { id: 'conn-1', bank_name: 'SEB', company_id: 'company-1', user_id: 'user-1' },
error: null,
})
chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null })
return chain
})
return capturedUpdates
}
it('stores accounts claimed by a sibling company disabled + flagged and never mirrors them', async () => {
const capturedUpdates = mockConnectionFlow({
id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'SEB', status: 'pending',
})
mockCrossCompanyContext.mockResolvedValue({
claims: new Map([
['SE9999', { companyId: 'company-2', companyName: 'Other Energy AB' }],
]),
deselectedIbans: new Set(),
activeCompanyIbans: new Set(),
})
mockCreateSession.mockResolvedValue({
session_id: 'sess-1',
accounts: [
{ uid: 'acc-own', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
{ uid: 'acc-foreign', account_id: { iban: 'SE9999' }, name: 'Annat bolags konto', currency: 'SEK' },
],
access: { valid_until: '2027-12-31T00:00:00Z' },
aspsp: { name: 'SEB', country: 'SE' },
})
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(200)
// Drain the stream: the finalize work completes behind the interim page.
await response.text()
const accountsData = capturedUpdates[0].accounts_data as Array<{
uid: string
enabled: boolean
claimed_by_company_id?: string
claimed_by_company_name?: string
}>
const own = accountsData.find(a => a.uid === 'acc-own')
const foreign = accountsData.find(a => a.uid === 'acc-foreign')
expect(own?.enabled).toBe(true)
expect(own?.claimed_by_company_id).toBeUndefined()
expect(foreign?.enabled).toBe(false)
expect(foreign?.claimed_by_company_id).toBe('company-2')
expect(foreign?.claimed_by_company_name).toBe('Other Energy AB')
// The claimed account gets NO cash_accounts row and NO 19xx slot in this
// company's chart: only the own account is mirrored.
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
expect((mockUpsertFromPsd2.mock.calls[0][2] as { external_uid: string }).external_uid).toBe('acc-own')
expect(mockAllocate).toHaveBeenCalledTimes(1)
})
it('keeps an account this row already carried enabled even when a sibling claims its IBAN', async () => {
// A standing feed in the active company outranks a sibling's claim: a
// renewal must never switch a working account off. (Legacy double-claims
// from the pre-session-sharing era make this overlap real.)
const capturedUpdates = mockConnectionFlow({
id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'SEB', status: 'expired',
accounts_data: [
{ uid: 'acc-old', iban: 'SE1234', name: 'Företagskonto', currency: 'SEK', enabled: true },
],
})
mockCrossCompanyContext.mockResolvedValue({
claims: new Map([['SE1234', { companyId: 'company-2', companyName: 'Other AB' }]]),
deselectedIbans: new Set(),
activeCompanyIbans: new Set(),
})
mockCreateSession.mockResolvedValue({
session_id: 'sess-2',
accounts: [
{ uid: 'acc-new', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
],
access: { valid_until: '2027-12-31T00:00:00Z' },
aspsp: { name: 'SEB', country: 'SE' },
})
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
// Drain the stream: the finalize work completes behind the interim page.
await response.text()
const accountsData = capturedUpdates[0].accounts_data as Array<{
uid: string
enabled: boolean
claimed_by_company_id?: string
}>
expect(accountsData[0].enabled).toBe(true)
expect(accountsData[0].claimed_by_company_id).toBeUndefined()
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
})
it('carries a deselection made on another connection row onto a fresh connect', async () => {
// C2: "Synkas ej" chosen under one company must not come back pre-checked
// when a new connection row (any company) sees the same IBAN.
const capturedUpdates = mockConnectionFlow({
id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'SEB', status: 'pending',
})
mockCrossCompanyContext.mockResolvedValue({
claims: new Map(),
deselectedIbans: new Set(['SE5555']),
activeCompanyIbans: new Set(),
})
mockCreateSession.mockResolvedValue({
session_id: 'sess-1',
accounts: [
{ uid: 'acc-card', account_id: { iban: 'SE5555' }, name: 'Privat kreditkort', currency: 'SEK' },
],
access: { valid_until: '2027-12-31T00:00:00Z' },
aspsp: { name: 'SEB', country: 'SE' },
})
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
// Drain the stream: the finalize work completes behind the interim page.
await response.text()
const accountsData = capturedUpdates[0].accounts_data as Array<{
uid: string
enabled: boolean
claimed_by_company_id?: string
deselected_elsewhere?: boolean
}>
expect(accountsData[0].enabled).toBe(false)
// Not a claim (no company books it), but flagged so the picker can say
// WHY the box is unchecked instead of leaving a silent gap.
expect(accountsData[0].claimed_by_company_id).toBeUndefined()
expect(accountsData[0].deselected_elsewhere).toBe(true)
// Guard-disabled accounts are never mirrored from the callback: writing
// enabled:false for a new-to-row account can promote an existing manual
// holder (the seeded primary 1930) and flip it to disabled.
expect(mockUpsertFromPsd2).not.toHaveBeenCalled()
})
it('fails closed when the claim lookup errors: new accounts stored deselected, unflagged', async () => {
const capturedUpdates = mockConnectionFlow({
id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'SEB', status: 'pending',
})
mockCrossCompanyContext.mockResolvedValue(null)
mockCreateSession.mockResolvedValue({
session_id: 'sess-1',
accounts: [
{ uid: 'acc-1', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
],
access: { valid_until: '2027-12-31T00:00:00Z' },
aspsp: { name: 'SEB', country: 'SE' },
})
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
// The connect itself still succeeds: fail-closed costs a checkbox, not
// the connection.
expect(response.status).toBe(200)
// Drain the stream: the finalize work completes behind the interim page.
await response.text()
const accountsData = capturedUpdates[0].accounts_data as Array<{
uid: string
enabled: boolean
claimed_by_company_id?: string
}>
expect(accountsData[0].enabled).toBe(false)
expect(accountsData[0].claimed_by_company_id).toBeUndefined()
// Fail-closed accounts are not mirrored either: enabled:false for a
// new-to-row account can promote and disable an existing manual holder.
// The selection save mirrors whatever the user enables.
expect(mockUpsertFromPsd2).not.toHaveBeenCalled()
})
it('preserves existing mirrored ledgers on reconnect instead of re-deriving them', async () => {
let callIndex = 0
mockFrom.mockImplementation((table: string) => {