import { describe, it, expect, vi, beforeEach } from 'vitest' // Mock dependencies: factory must not reference outer variables const mockCreateSession = vi.fn() const mockGetAccountBalance = vi.fn() vi.mock('@/extensions/general/enable-banking/lib/api-client', () => ({ createSession: (...args: unknown[]) => mockCreateSession(...args), getAccountBalance: (...args: unknown[]) => mockGetAccountBalance(...args), })) // Use hoisted to safely create mock objects referenced in vi.mock factories const { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede, mockCrossCompanyContext, mockGetUser, } = vi.hoisted(() => { const mockFrom = vi.fn() const mockUpsertFromPsd2 = vi.fn() const mockAllocate = vi.fn() const mockSupersede = vi.fn() const mockCrossCompanyContext = vi.fn() // The cookie session the callback binds the completion to. Every pending // row in this suite belongs to 'user-1', so that is the default session. const mockGetUser = vi.fn() return { mockFrom, mockUpsertFromPsd2, mockAllocate, mockSupersede, mockCrossCompanyContext, mockGetUser, } }) // 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 // callback WIRES it correctly without scripting its internal queries. 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() return { ...actual, fetchCrossCompanyAccountContext: (...args: unknown[]) => mockCrossCompanyContext(...args), } }) vi.mock('@/lib/supabase/server', () => ({ createServiceClient: vi.fn().mockResolvedValue({ from: mockFrom, }), createClient: vi.fn().mockResolvedValue({ auth: { getUser: mockGetUser }, }), })) const CURRENCY_DEFAULTS: Record = { SEK: '1930', EUR: '1932', USD: '1933', GBP: '1934', } vi.mock('@/lib/cash-accounts/service', () => ({ upsertFromPsd2: (...args: unknown[]) => mockUpsertFromPsd2(...args), // The route resolves ledgers through resolvePsd2LedgerAccount (IBAN match // first, allocation second). mockAllocate remains the allocation stand-in; // the wrapper puts its answer in the resolver's envelope so the existing // "did we allocate?" assertions keep their meaning. Tests that exercise the // IBAN path override resolvePsd2LedgerAccount's outcome via mockAllocate's // own implementation. resolvePsd2LedgerAccount: async (...args: unknown[]) => { const ledgerAccount = await mockAllocate(...args) if (!ledgerAccount) return null if (typeof ledgerAccount === 'object') return ledgerAccount return { ledgerAccount, reuseCashAccountId: null, source: 'allocated' } }, defaultLedgerForCurrency: (currency: string) => CURRENCY_DEFAULTS[currency.toUpperCase()] ?? '1930', // Real (trivial) implementation: the route normalizes IBANs when stamping // dedup scopes onto accounts_data. normalizeIban: (iban?: string | null) => { if (!iban) return null const normalized = iban.replace(/\s+/g, '').toUpperCase() return normalized || null }, })) vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000') import { GET } from '../route' import { eventBus } from '@/lib/events/bus' function makeRequest(params: Record) { const url = new URL('http://localhost:3000/api/extensions/enable-banking/callback') for (const [k, v] of Object.entries(params)) { url.searchParams.set(k, v) } return new Request(url.toString()) } function mockChain(result: { data?: unknown; error?: unknown }) { const chain: Record = {} for (const m of ['select', 'eq', 'in', 'is', 'single', 'update', 'delete', 'order', 'limit']) { chain[m] = vi.fn().mockReturnValue(chain) } chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null }) // For chains ending without .single() chain.then = (resolve: (v: unknown) => void) => resolve({ data: result.data ?? null, error: result.error ?? null }) return chain } describe('GET /api/extensions/enable-banking/callback', () => { beforeEach(() => { vi.clearAllMocks() mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }) 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( async ( _supabase: unknown, _companyId: unknown, _userId: unknown, input: { currency: string; exclude?: ReadonlySet }, ) => { const preferred = CURRENCY_DEFAULTS[input.currency.toUpperCase()] ?? '1930' const exclude = input.exclude ?? new Set() if (!exclude.has(preferred)) return preferred const reserved = new Set(Object.values(CURRENCY_DEFAULTS)) for (let n = 1931; n <= 1959; n++) { const candidate = String(n) if (!reserved.has(candidate) && !exclude.has(candidate)) return candidate } return null }, ) }) it('rejects when state does not match any pending connection', async () => { mockFrom.mockImplementation(() => mockChain({ data: null, error: { message: 'not found' } }) ) const response = await GET(makeRequest({ code: 'auth-code', state: 'unknown-state' })) expect(response.status).toBe(307) const location = response.headers.get('location') || '' expect(location).toContain('/settings/banking?') // The raw 'invalid_state' token used to be shown verbatim: the banner now // carries the Swedish explanation instead (issue #1716). expect(decodeURIComponent(location)).toContain('Starta bankkopplingen på nytt') }) // The state token proves the callback belongs to a flow WE started, not that // the browser completing it is the initiator's. A victim lured into // approving a consent someone else started must not have their bank // attached to that someone's company. describe('initiator binding', () => { const PENDING_ROW = { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending', session_id: null, accounts_data: null, } it('refuses a consent completed by a different user and leaves the row untouched', async () => { const chain = mockChain({ data: PENDING_ROW, error: null }) mockFrom.mockReturnValue(chain) mockGetUser.mockResolvedValue({ data: { user: { id: 'user-2' } }, error: null }) const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' })) expect(response.status).toBe(307) const location = new URL(response.headers.get('location') || '') expect(location.pathname).toBe('/settings/banking') expect(location.searchParams.get('bank_error')).toContain('annat användarkonto') expect(location.searchParams.get('bank_name')).toBe('TestBank') expect(location.searchParams.has('select_accounts')).toBe(false) // The code was never exchanged and nothing was written: the row keeps // waiting for its initiator (only the lookup touched the table). expect(mockCreateSession).not.toHaveBeenCalled() expect(mockFrom).toHaveBeenCalledTimes(1) expect(chain.update).not.toHaveBeenCalled() expect(chain.delete).not.toHaveBeenCalled() expect(mockUpsertFromPsd2).not.toHaveBeenCalled() }) it('sends an anonymous browser to /login with the callback URL preserved', async () => { const chain = mockChain({ data: PENDING_ROW, error: null }) mockFrom.mockReturnValue(chain) mockGetUser.mockResolvedValue({ data: { user: null }, error: null }) const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' })) expect(response.status).toBe(307) const location = new URL(response.headers.get('location') || '') expect(location.origin).toBe('http://localhost:3000') expect(location.pathname).toBe('/login') expect(location.searchParams.get('next')).toBe( '/api/extensions/enable-banking/callback?code=auth-code&state=valid-state', ) expect(mockCreateSession).not.toHaveBeenCalled() expect(chain.update).not.toHaveBeenCalled() expect(chain.delete).not.toHaveBeenCalled() }) it('finalizes as before when the session belongs to the initiator', async () => { // mockConnectionFlow is the suite's standard script for the finalize // path (function declaration below, hoisted into this scope). mockConnectionFlow(PENDING_ROW) mockGetUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }) mockCreateSession.mockResolvedValue({ session_id: 'sess-1', accounts: [], access: { valid_until: '2027-12-31T00:00:00Z' }, aspsp: { name: 'TestBank', country: 'SE' }, }) const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' })) expect(response.status).toBe(200) expect(mockGetUser).toHaveBeenCalledTimes(1) expect(mockCreateSession).toHaveBeenCalledWith('auth-code', undefined) expect(await response.text()).toContain('select_accounts=conn-1') }) it('does not consult the session for an unknown state (nothing to bind to)', async () => { mockFrom.mockImplementation(() => mockChain({ data: null, error: { message: 'not found' } })) await GET(makeRequest({ code: 'auth-code', state: 'unknown-state' })) expect(mockGetUser).not.toHaveBeenCalled() }) it('leaves the hosted connector bounce alone (server-to-server, HMAC-verified)', async () => { // The hosted proxy callback never finalizes anything: it verifies the // signed connector state and bounces the browser to the instance, whose // own callback then runs the binding against ITS session. vi.stubEnv('CONNECTOR_STATE_SECRET', 'test-connector-secret') const { signConnectorState } = await import('@/lib/connect/hosted/state') const connectorState = signConnectorState({ kid: 'key-1', svc: 'bank', ret: 'https://instance.example.se/api/extensions/enable-banking/callback', st: 'instance-state', cref: 'company-ref', }) const response = await GET(makeRequest({ code: 'auth-code', state: connectorState })) expect(response.status).toBe(307) const location = new URL(response.headers.get('location') || '') expect(location.origin).toBe('https://instance.example.se') expect(location.searchParams.get('state')).toBe('instance-state') expect(location.searchParams.get('code')).toBe('auth-code') expect(mockGetUser).not.toHaveBeenCalled() expect(mockFrom).not.toHaveBeenCalled() vi.unstubAllEnvs() vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000') }) }) it('threads connector_state from the query into createSession (connector mode)', async () => { // In connector mode the hosted callback bounces the browser back here with // the signed connector_state echoed alongside code + the instance's own // oauth_state. createSession must forward it so the bank proxy binds the // /sessions exchange to the pending ledger row it signed at /auth time. mockFrom.mockImplementation(() => mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' }, error: null, }), ) mockCreateSession.mockResolvedValue({ session_id: 'sess-1', accounts: [], access: { valid_until: '2027-12-31T00:00:00Z' }, aspsp: { name: 'TestBank', country: 'SE' }, }) await GET(makeRequest({ code: 'auth-code', state: 'valid-state', connector_state: 'signed-connector-state' })) expect(mockCreateSession).toHaveBeenCalledWith('auth-code', 'signed-connector-state') }) it('passes undefined connector_state on the direct path (no connector_state in the query)', async () => { mockFrom.mockImplementation(() => mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' }, error: null, }), ) mockCreateSession.mockResolvedValue({ session_id: 'sess-1', accounts: [], access: { valid_until: '2027-12-31T00:00:00Z' }, aspsp: { name: 'TestBank', country: 'SE' }, }) await GET(makeRequest({ code: 'auth-code', state: 'valid-state' })) expect(mockCreateSession).toHaveBeenCalledWith('auth-code', undefined) }) it('writes pending_selection and streams a finalizing page that redirects to the picker', async () => { const capturedUpdates: Record[] = [] let callIndex = 0 mockFrom.mockImplementation(() => { callIndex++ if (callIndex === 1) { // Find pending connection by oauth_state return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' }, error: null, }) } // Update connection: capture the payload, then chain returns the // updated row via .select().single() for the audit event emission. const chain: Record = {} chain.update = vi.fn((payload: Record) => { 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: 'TestBank', company_id: 'company-1', user_id: 'user-1', }, error: null, }) // Back-compat fallthrough for chains that aren't terminated by .single() chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null }) return chain }) mockCreateSession.mockResolvedValue({ session_id: 'sess-1', accounts: [ { uid: 'acc-1', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' }, { uid: 'acc-2', account_id: { iban: 'SE5678' }, name: 'Privatkonto', currency: 'SEK' }, ], access: { valid_until: '2024-12-31T00:00:00Z' }, aspsp: { name: 'TestBank', country: 'SE' }, }) mockGetAccountBalance.mockRejectedValue(new Error('skip balance fetch')) const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' })) // Success streams an interim page (instant feedback during the session // exchange) that ends with a client-side redirect to the account picker. expect(response.status).toBe(200) expect(response.headers.get('content-type')).toContain('text/html') expect(response.headers.get('cache-control')).toBe('no-store') const body = await response.text() // Shell flushed with the bank name, then the redirect to the picker. expect(body).toContain('TestBank') expect(body).toContain('window.location.replace') expect(body).toContain('select_accounts=conn-1') expect(body).not.toContain('bank_error') // ASVS V3.3: inline scripts are nonce-bound. The response-level CSP // declares the nonce and BOTH chunks (shell watchdog + redirect) carry // it; no un-nonced inline script may exist on this page. const csp = response.headers.get('content-security-policy') ?? '' const nonceMatch = /script-src 'nonce-([^']+)'/.exec(csp) expect(nonceMatch).not.toBeNull() const nonce = nonceMatch![1] expect(body.split(`