fa69174aa0
* 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>
307 lines
9.9 KiB
TypeScript
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()
|
|
})
|
|
})
|