Files
accounted/extensions/general/enable-banking/lib/__tests__/session-sharing.test.ts
T
Mattsson fa69174aa0 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>
2026-09-01 15:24:10 +02:00

307 lines
9.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/cash-accounts/service', () => ({
normalizeIban: (iban?: string | null) => {
if (!iban) return null
const normalized = iban.replace(/\s+/g, '').toUpperCase()
return normalized || null
},
}))
import type { SupabaseClient } from '@supabase/supabase-js'
import {
fetchCrossCompanyAccountContext,
unclaimedAccountsFor,
} from '../session-sharing'
import type { StoredAccount } from '../../types'
interface TableResult {
data?: unknown
error?: { message: string } | null
}
type MockChain = Record<string, ReturnType<typeof vi.fn>> & {
then: (resolve: (v: unknown) => void) => void
}
/**
* Chainable mock resolving per table: every filter method returns the chain,
* awaiting it yields the preset result for that table. Chains are recorded so
* tests can assert which filters were applied (the mock does not filter).
*/
function makeSupabase(results: Record<string, TableResult>): {
supabase: SupabaseClient
chainsByTable: Map<string, MockChain[]>
} {
const chainsByTable = new Map<string, MockChain[]>()
const supabase = {
from: (table: string) => {
const result = results[table] ?? { data: [], error: null }
const chain = {} as MockChain
for (const m of ['select', 'eq', 'neq', 'in', 'not', 'is', 'order', 'limit', 'range']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.then = (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
const bucket = chainsByTable.get(table)
if (bucket) bucket.push(chain)
else chainsByTable.set(table, [chain])
return chain
},
} as unknown as SupabaseClient
return { supabase, chainsByTable }
}
describe('fetchCrossCompanyAccountContext', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('claims enabled accounts of sibling companies, remembers deselections, resolves names', async () => {
const { supabase } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: {
data: [
{
id: 'conn-sibling',
company_id: 'company-2',
accounts_data: [
{ uid: 'a1', iban: 'SE11', currency: 'SEK', enabled: true },
{ uid: 'a2', iban: 'SE22', currency: 'SEK', enabled: false },
],
},
{
id: 'conn-own-other-row',
company_id: 'company-1',
// The active company's own account never becomes a claim; its
// enabled accounts are its standing set and its deselections are
// remembered.
accounts_data: [
{ uid: 'a3', iban: 'SE33', currency: 'SEK', enabled: true },
{ uid: 'a4', iban: 'SE44', currency: 'SEK', enabled: false },
],
},
],
},
cash_accounts: { data: [] },
companies: { data: [{ id: 'company-2', name: 'Sibling AB' }] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context).not.toBeNull()
expect(context!.claims.get('SE11')).toEqual({
companyId: 'company-2',
companyName: 'Sibling AB',
})
expect(context!.claims.has('SE33')).toBe(false)
expect(context!.activeCompanyIbans).toEqual(new Set(['SE33']))
expect(context!.deselectedIbans).toEqual(new Set(['SE22', 'SE44']))
})
it("lets the active company's own standing state outrank a sibling claim", async () => {
// The bank-list renewal case: the active company's old row (about to be
// superseded) and its cash_accounts row still book the IBAN. A sibling
// claim on the same IBAN must not win, or a renewal arriving on a fresh
// row would switch a working feed off.
const { supabase } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: {
data: [
{
id: 'conn-sibling',
company_id: 'company-2',
accounts_data: [{ uid: 'a1', iban: 'SE11', currency: 'SEK', enabled: true }],
},
],
},
cash_accounts: {
data: [{ company_id: 'company-1', iban: 'SE11' }],
},
companies: { data: [] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context!.claims.has('SE11')).toBe(false)
expect(context!.activeCompanyIbans.has('SE11')).toBe(true)
expect(context!.deselectedIbans.has('SE11')).toBe(false)
})
it('claims IBANs held by sibling companies via cash_accounts too', async () => {
const { supabase } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: { data: [] },
cash_accounts: { data: [{ company_id: 'company-2', iban: 'SE55' }] },
companies: { data: [] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context!.claims.get('SE55')).toEqual({ companyId: 'company-2', companyName: null })
})
it('lets a claim outrank a remembered deselection for the same IBAN', async () => {
const { supabase } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: {
data: [
{
id: 'conn-a',
company_id: 'company-2',
accounts_data: [{ uid: 'a1', iban: 'SE11', currency: 'SEK', enabled: true }],
},
{
id: 'conn-b',
company_id: 'company-1',
accounts_data: [{ uid: 'a2', iban: 'SE11', currency: 'SEK', enabled: false }],
},
],
},
cash_accounts: { data: [] },
companies: { data: [] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context!.claims.has('SE11')).toBe(true)
expect(context!.deselectedIbans.has('SE11')).toBe(false)
})
it('treats pending_selection rows asymmetrically: enabled accounts claim, disabled flags are ignored', async () => {
// An attach-created row is pending_selection with deliberately-offered
// enabled accounts and NO cash rows until its picker is saved: those must
// claim, or a second company can take the same physical account inside
// that window. Its disabled flags are unconfirmed callback output
// (including the guard's own fail-closed writes) and must NOT feed the
// deselection memory.
const { supabase, chainsByTable } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: {
data: [
{
id: 'conn-attached',
company_id: 'company-2',
status: 'pending_selection',
accounts_data: [
{ uid: 'a1', iban: 'SE11', currency: 'SEK', enabled: true },
{ uid: 'a2', iban: 'SE22', currency: 'SEK', enabled: false },
],
},
],
},
cash_accounts: { data: [] },
companies: { data: [] },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context!.claims.has('SE11')).toBe(true)
expect(context!.deselectedIbans.has('SE22')).toBe(false)
const connectionChain = chainsByTable.get('bank_connections')![0]
expect(connectionChain.in).toHaveBeenCalledWith('status', [
'active',
'pending_selection',
'expired',
'error',
])
// Paged reads must order on a unique column or rows can be silently
// skipped at page boundaries (a skipped row is a missed claim).
expect(connectionChain.order).toHaveBeenCalledWith('id')
expect(chainsByTable.get('cash_accounts')![0].order).toHaveBeenCalledWith('id')
})
it('reads cash_accounts for ALL member companies (active included)', async () => {
const { supabase, chainsByTable } = makeSupabase({
company_members: {
data: [{ company_id: 'company-1' }, { company_id: 'company-2' }],
},
bank_connections: { data: [] },
cash_accounts: { data: [] },
companies: { data: [] },
})
await fetchCrossCompanyAccountContext(supabase, 'user-1', 'company-1', 'conn-active')
const cashChain = chainsByTable.get('cash_accounts')![0]
expect(cashChain.in).toHaveBeenCalledWith('company_id', ['company-1', 'company-2'])
})
it('returns null when a lookup fails, so the caller can fail closed', async () => {
const { supabase } = makeSupabase({
bank_connections: { data: null, error: { message: 'boom' } },
})
const context = await fetchCrossCompanyAccountContext(
supabase,
'user-1',
'company-1',
'conn-active',
)
expect(context).toBeNull()
})
})
describe('unclaimedAccountsFor', () => {
it('strips stale claimed_by and deselected flags from offered accounts', () => {
const accounts: StoredAccount[] = [
{
uid: 'a1',
iban: 'SE11',
currency: 'SEK',
enabled: false,
ledger_account: '1938',
claimed_by_company_id: 'company-9',
claimed_by_company_name: 'Stale AB',
deselected_elsewhere: true,
},
]
const offered = unclaimedAccountsFor(accounts, new Set())
expect(offered).toHaveLength(1)
expect(offered[0].enabled).toBe(true)
expect(offered[0].ledger_account).toBeUndefined()
expect(offered[0].claimed_by_company_id).toBeUndefined()
expect(offered[0].claimed_by_company_name).toBeUndefined()
expect(offered[0].deselected_elsewhere).toBeUndefined()
})
})