Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 18 additions & 29 deletions src/components/AddWithdraw/AddWithdrawCountriesList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { getCountryCodeForWithdraw } from '@/utils/withdraw.utils'
import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
import { useAppDispatch } from '@/redux/hooks'
import { bankFormActions } from '@/redux/slices/bank-form-slice'
import KycVerifiedOrReviewModal from '../Global/KycVerifiedOrReviewModal'
import { ActionListCard } from '@/components/ActionListCard'
import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
Expand Down Expand Up @@ -104,25 +103,29 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
(country) => country.type === 'country' && country.path === countrySlugFromUrl
)

// Provider-blind bank-channel deposit gate + "any bank rail pending"
// signal (used to open the under-review status modal AFTER the gate
// already returned `ready`). Reads through useCapabilities's role-aware
// primitives — see utils/capability-gate.ts + utils/rail-channel.ts.
// Provider-blind bank-channel deposit gate, country-scoped to the rail
// jurisdiction of the country the user is on. Reads through
// useCapabilities's role-aware primitives — see utils/capability-gate.ts.
//
// SCOPE: when the user is on /add-money/<country>, narrow the gate to
// that country's rail jurisdiction. Without this, a rejected rail in an
// unrelated jurisdiction (e.g. a Bridge BANK_TRANSFER_MX REJECTED row
// from the 2026-06-01 sync) trips `blocked-rejection` here and the user
// sees "We couldn't unlock this" on a country whose own rail is fine.
// Matches the scoping already in /add-money/[country]/bank/page.tsx.
const { isKycApproved, gateFor, bankRails } = useCapabilities()
// SCOPE rationale: without the country narrowing, a stuck/rejected rail in
// an unrelated jurisdiction (e.g. a Bridge BANK_TRANSFER_MX row from the
// 2026-06-01 sync) trips `blocked-rejection` here and the user sees
// "We couldn't unlock this" on a country whose own rail is fine.
//
// The gate's `kind` is the SOLE go/no-go signal here — same as the sibling
// /add-money/[country]/bank/page.tsx. Do NOT layer a separate "is any bank
// rail pending?" check on top: a `ready` gate already means the user has a
// working in-scope rail, and `deriveGate` deliberately ranks `ready` above
// `pending`/`waiting-on-provider` so a pending sibling rail (a legit
// second-country enrollment, a still-provisioning rail) can't re-block
// them. The prior unscoped `isBankRailUnderReview` check did exactly that
// and dead-ended ready users behind a "You're all set / Go back" modal.
const { isKycApproved, gateFor } = useCapabilities()
const isUserKycApproved = isKycApproved
const bankCountry = useMemo(() => railJurisdictionForBank(currentCountry?.id), [currentCountry?.id])
const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
const isBankRailUnderReview = useMemo(() => bankRails().some((rail) => rail.status === 'pending'), [bankRails])
const { guardWithTos, showBridgeTos, hideTos } = useTosGuard()
const { setIsSupportModalOpen } = useModalsContext()
const [showKycStatusModal, setShowKycStatusModal] = useState(false)

// stores the callback to replay after tos acceptance in the list view
const pendingAfterTosRef = useRef<(() => void) | null>(null)
Expand Down Expand Up @@ -150,13 +153,9 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
}
return true
}
if (isBankRailUnderReview) {
setShowKycStatusModal(true)
return true
}
return false
},
[gate, isBankRailUnderReview, guardWithTos]
[gate, guardWithTos]
)

const handleFormSubmit = async (
Expand Down Expand Up @@ -184,12 +183,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
return { error: 'gate_blocked', silent: true }
}

// bridge kyc still under review — don't initiate a new sumsub flow
if (isBankRailUnderReview) {
setShowKycStatusModal(true)
return { error: 'gate_blocked', silent: true }
}

// scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly
// email and name are now collected by sumsub — no need to check them here
if (isUserKycApproved) {
Expand Down Expand Up @@ -524,10 +517,6 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
isVisible={isSupportedTokensModalOpen}
/>
)}
<KycVerifiedOrReviewModal
isKycApprovedModalOpen={showKycStatusModal}
onClose={() => setShowKycStatusModal(false)}
/>
{sharedModals}
</div>
)
Expand Down
222 changes: 222 additions & 0 deletions src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- jest.mock factories stub component props with `any`; matches the sibling add-money-states.test.tsx style. */
/**
* Regression coverage for the deposit/withdraw method list's bank gate.
*
* P0 (2026-06-01 → 06-06): a user whose own-country bank rail was ENABLED
* (scoped gate = `ready`) but who ALSO had a sibling bank rail in `pending`
* (a second-country enrollment / a still-provisioning rail) got intercepted
* by an unscoped `isBankRailUnderReview` check and dead-ended behind a
* "You're all set / Go back" modal — unable to deposit. The gate already
* ranks `ready` above `pending`; the extra check re-litigated that and lost.
*
* Fix: the gate's `kind` is the sole go/no-go signal (matching the sibling
* /add-money/[country]/bank page). These tests assert (1) a `ready` user with
* a pending sibling rail PROCEEDS, and (2) gating is still enforced when the
* gate is NOT ready — so the fix didn't just delete the guard wholesale.
*/
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import AddWithdrawCountriesList from '../AddWithdrawCountriesList'

// ---- routing ----
const mockPush = jest.fn()
const mockParams: Record<string, string> = { country: 'testland' }
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
useParams: () => mockParams,
useSearchParams: () => new URLSearchParams(),
}))

// ---- consts: one country ('testland', id 'US') with a bank add-method and a
// Bridge bank withdraw-method (the withdraw path also runs checkBridgeGate). ----
jest.mock('@/components/AddMoney/consts', () => ({
countryData: [{ type: 'country', path: 'testland', id: 'US', title: 'Testland', currency: 'usd' }],
COUNTRY_SPECIFIC_METHODS: {
US: {
add: [
{
id: 'bank-add',
title: 'Bank',
description: 'Add via bank transfer',
icon: 'bank',
path: '/add-money/testland/bank',
},
],
// id contains 'default-bank-withdraw' → routes through checkBridgeGate
// (not the Manteca direct path), so it exercises the same gate.
withdraw: [
{
id: 'us-default-bank-withdraw',
title: 'To Bank',
description: 'Withdraw to your bank',
icon: 'bank',
isSoon: false,
},
],
},
},
}))

// ---- capability gate (the unit under test reads gateFor) ----
// `setCapabilities` lets each test pick the gate kind + the rail set so we can
// reproduce the exact bug fixture: ready gate + a pending sibling rail.
const mockUseCapabilities = jest.fn()
jest.mock('@/hooks/useCapabilities', () => ({
useCapabilities: () => mockUseCapabilities(),
}))
function setCapabilities(gateKind: string, rails: Array<{ status: string; channel?: string; country?: string }>) {
mockUseCapabilities.mockReturnValue({
isKycApproved: rails.some((r) => r.status === 'enabled'),
gateFor: () => ({ kind: gateKind }),
// bankRails is intentionally NOT consumed by the component any more;
// expose a faithful (scope-honoring) impl so a future re-introduction
// of an unscoped read is caught rather than silently passing.
bankRails: (opts?: { country?: string }) =>
rails.filter((r) => r.channel === 'bank' && (!opts?.country || r.country === opts.country)),
})
Comment on lines +67 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The gateFor mock currently hides scope-wiring regressions.

gateFor ignores op/scope, so these tests can still pass even if the component stops passing a country-scoped bank gate (the key fix in this PR). Please spy on gateFor and assert the scoped call shape.

Proposed test hardening
 const mockUseCapabilities = jest.fn()
+let mockGateFor = jest.fn()
 jest.mock('`@/hooks/useCapabilities`', () => ({
     useCapabilities: () => mockUseCapabilities(),
 }))
 function setCapabilities(gateKind: string, rails: Array<{ status: string; channel?: string; country?: string }>) {
+    mockGateFor = jest.fn().mockReturnValue({ kind: gateKind })
     mockUseCapabilities.mockReturnValue({
         isKycApproved: rails.some((r) => r.status === 'enabled'),
-        gateFor: () => ({ kind: gateKind }),
+        gateFor: mockGateFor,
         bankRails: (opts?: { country?: string }) =>
             rails.filter((r) => r.channel === 'bank' && (!opts?.country || r.country === opts.country)),
     })
 }
@@
         render(<AddWithdrawCountriesList flow="add" />)
+        expect(mockGateFor).toHaveBeenCalledWith(
+            'deposit',
+            expect.objectContaining({ channel: 'bank', country: expect.any(String) })
+        )
         fireEvent.click(screen.getByTestId('method-bank'))

Also applies to: 145-176

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx`
around lines 54 - 63, The mocked setCapabilities currently returns gateFor as a
no-op that ignores op/scope, which hides regressions in scope-wiring; update
setCapabilities (the mock returned by mockUseCapabilities) to expose gateFor as
a jest spy function and assert the call shape includes the country-scoped args
(e.g., gateFor should be called with { op: 'bank', scope: { country:
'<expected>' } } or similar) in the tests that exercise
AddWithdrawCountriesList; keep bankRails behavior but replace gateFor: () => ({
kind: gateKind }) with a spyable implementation (e.g., gateFor: jest.fn((opts)
=> ({ kind: gateKind, ...opts }))) and add assertions that
mockUseCapabilities().gateFor was called with the expected scoped parameters
wherever those tests verify country-scoping.

}

// ---- light mocks for everything else the component imports ----
jest.mock('@/context/authContext', () => ({
useAuth: () => ({ user: { accounts: [] }, fetchUser: jest.fn() }),
}))
jest.mock('@/context/WithdrawFlowContext', () => ({
useWithdrawFlow: () => ({
setSelectedBankAccount: jest.fn(),
amountToWithdraw: '',
setSelectedMethod: jest.fn(),
setAmountToWithdraw: jest.fn(),
}),
}))
jest.mock('@/context/ModalsContext', () => ({
useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }),
}))
jest.mock('@/hooks/useTosGuard', () => ({
useTosGuard: () => ({ guardWithTos: jest.fn(), showBridgeTos: false, hideTos: jest.fn() }),
}))
jest.mock('@/hooks/useMultiPhaseKycFlow', () => ({
useMultiPhaseKycFlow: () => ({
handleInitiateKyc: jest.fn(),
handleSelfHealResubmit: jest.fn(),
isLoading: false,
error: null,
showWrapper: false,
}),
}))
jest.mock('@/hooks/useSafeBack', () => ({ useSafeBack: () => jest.fn() }))
jest.mock('@/hooks/useGetDeviceType', () => ({
DeviceType: { IOS: 'IOS', ANDROID: 'ANDROID', WEB: 'WEB' },
useDeviceType: () => ({ deviceType: 'WEB' }),
}))
jest.mock('@/redux/hooks', () => ({ useAppDispatch: () => jest.fn() }))
jest.mock('@/redux/slices/bank-form-slice', () => ({ bankFormActions: { clearFormData: () => ({ type: 'noop' }) } }))
jest.mock('@/app/actions/users', () => ({ addBankAccount: jest.fn() }))
jest.mock('@/utils/native-routes', () => ({
rewriteMethodPath: (p: string) => p,
withdrawBankUrl: (p: string) => `/withdraw/${p}`,
}))
jest.mock('@/utils/capacitor', () => ({ isCapacitor: () => false }))
jest.mock('@/utils/color.utils', () => ({ getColorForUsername: () => ({ lightShade: '#fff' }) }))
jest.mock('@/utils/withdraw.utils', () => ({ getCountryCodeForWithdraw: (id: string) => id }))
// bridge.utils + regions.utils are direct util collaborators that transitively
// pull the heavy @/components/AddMoney/consts barrel (regions.utils computes a
// top-level `Object.values(BRIDGE_ALPHA3_TO_ALPHA2)` at import time, which throws
// under jest when consts is stubbed). The gate is mocked, so neither return value
// affects these assertions — stub both so the real consts is never evaluated.
jest.mock('@/utils/bridge.utils', () => ({ railJurisdictionForBank: () => 'US' }))
jest.mock('@/utils/regions.utils', () => ({ getRegionIntent: () => 'STANDARD' }))

jest.mock('@/components/ActionListCard', () => ({
ActionListCard: (props: any) => (
<button data-testid={`method-${props.title?.toLowerCase()}`} onClick={props.onClick}>
{props.title}
</button>
Comment on lines +130 to +133

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replace any in test mocks to satisfy lint errors.

@typescript-eslint/no-explicit-any is currently triggered at Line 110 and Line 128.

Typed mock props (minimal)
+type ActionListCardMockProps = { title?: string; onClick?: () => void }
 jest.mock('`@/components/ActionListCard`', () => ({
-    ActionListCard: (props: any) => (
+    ActionListCard: (props: ActionListCardMockProps) => (
         <button data-testid={`method-${props.title?.toLowerCase()}`} onClick={props.onClick}>
             {props.title}
         </button>
     ),
 }))
@@
+type InitiateKycModalMockProps = { visible: boolean }
 jest.mock('`@/components/Kyc/InitiateKycModal`', () => ({
-    InitiateKycModal: (props: any) => (props.visible ? <div data-testid="initiate-kyc-modal" /> : null),
+    InitiateKycModal: (props: InitiateKycModalMockProps) =>
+        props.visible ? <div data-testid="initiate-kyc-modal" /> : null,
 }))

Also applies to: 128-129

🧰 Tools
🪛 ESLint

[error] 110-110: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/AddWithdraw/__tests__/AddWithdrawCountriesList.test.tsx`
around lines 110 - 113, The test mock for ActionListCard uses the explicit type
any for props which triggers the no-explicit-any lint rule; define a minimal
props type (e.g., interface with title?: string and onClick?: () => void) and
replace the any annotation with that type when declaring the mock component (the
ActionListCard mock in AddWithdrawCountriesList.test.tsx), or explicitly type
the mock as React.FC<YourProps> so props.title and props.onClick are correctly
typed; apply the same change to the other mock usage around lines referencing
the same ActionListCard (also at the second occurrence mentioned).

Source: Linters/SAST tools

),
}))
jest.mock('@/components/Global/NavHeader', () => ({
__esModule: true,
default: () => <div data-testid="nav-header" />,
}))
jest.mock('@/components/Global/Badges/StatusBadge', () => ({ __esModule: true, default: () => <span /> }))
jest.mock('@/components/Profile/AvatarWithBadge', () => ({ __esModule: true, default: () => <span /> }))
jest.mock('@/components/Global/EmptyStates/EmptyState', () => ({ __esModule: true, default: () => <div /> }))
jest.mock('@/components/AddWithdraw/DynamicBankAccountForm', () => ({ DynamicBankAccountForm: () => <div /> }))
jest.mock('@/components/Global/TokenAndNetworkConfirmationModal', () => ({ __esModule: true, default: () => null }))
jest.mock('@/components/Kyc/SumsubKycModals', () => ({ SumsubKycModals: () => null }))
jest.mock('@/components/Kyc/BridgeTosStep', () => ({ BridgeTosStep: () => null }))
jest.mock('@/components/Kyc/InitiateKycModal', () => ({
InitiateKycModal: (props: any) => (props.visible ? <div data-testid="initiate-kyc-modal" /> : null),
}))
jest.mock('next/image', () => ({ __esModule: true, default: () => null }))

describe('AddWithdrawCountriesList — bank gate', () => {
beforeEach(() => {
mockPush.mockClear()
})

it('P0 regression: ready gate + a pending sibling bank rail still lets the user proceed', () => {
// own-country (US) rail enabled → scoped gate = ready; a *second* bank
// rail elsewhere is pending. Pre-fix this opened the dead-end modal.
setCapabilities('ready', [
{ status: 'enabled', channel: 'bank', country: 'US' },
{ status: 'pending', channel: 'bank', country: 'EU' },
])

render(<AddWithdrawCountriesList flow="add" />)
fireEvent.click(screen.getByTestId('method-bank'))

// navigates to the bank deposit page; no KYC/status modal intercept
expect(mockPush).toHaveBeenCalledWith('/add-money/testland/bank')
expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull()
})

it('also proceeds when the pending sibling rail is in the SAME country (country-scoping alone would not fix this)', () => {
// The kyc-2.0 case the documented one-liner missed: a working Manteca-
// style rail and a pending rail share the user's own country.
setCapabilities('ready', [
{ status: 'enabled', channel: 'bank', country: 'US' },
{ status: 'pending', channel: 'bank', country: 'US' },
])

render(<AddWithdrawCountriesList flow="add" />)
fireEvent.click(screen.getByTestId('method-bank'))

expect(mockPush).toHaveBeenCalledWith('/add-money/testland/bank')
expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull()
})

it('still gates: a non-ready gate blocks navigation and surfaces the KYC modal', () => {
setCapabilities('needs-identity', [])

render(<AddWithdrawCountriesList flow="add" />)
fireEvent.click(screen.getByTestId('method-bank'))

expect(mockPush).not.toHaveBeenCalled()
expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument()
})

// checkBridgeGate is shared by BOTH flows — cover the withdraw entry too so
// the removal can't silently regress bank withdrawals.
it('withdraw flow: ready gate + pending sibling proceeds to /withdraw (no dead-end modal)', () => {
setCapabilities('ready', [
{ status: 'enabled', channel: 'bank', country: 'US' },
{ status: 'pending', channel: 'bank', country: 'EU' },
])

render(<AddWithdrawCountriesList flow="withdraw" />)
fireEvent.click(screen.getByText('To Bank'))

expect(mockPush).toHaveBeenCalledWith('/withdraw')
expect(screen.queryByTestId('initiate-kyc-modal')).toBeNull()
})

it('withdraw flow: a non-ready gate still blocks + surfaces the KYC modal', () => {
setCapabilities('needs-identity', [])

render(<AddWithdrawCountriesList flow="withdraw" />)
fireEvent.click(screen.getByText('To Bank'))

expect(mockPush).not.toHaveBeenCalled()
expect(screen.getByTestId('initiate-kyc-modal')).toBeInTheDocument()
})
})
66 changes: 66 additions & 0 deletions src/utils/capability-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,69 @@ describe('deriveGate — ready-first ordering (Alexandre fix)', () => {
expect(gate.kind).toBe('ready')
})
})

describe('deriveGate — country scoping (the Add Money dead-end class)', () => {
// AddWithdrawCountriesList (Add Money → Bank list) regressed by reading raw
// `bankRails()` UNSCOPED to decide whether to open an "under review" modal,
// AFTER this gate had already returned `ready`. These tests pin the
// gate-level invariant the component must rely on instead: a pending
// sibling rail — in ANY country, including the user's own — never downgrades
// a scoped `ready`. The gate is the single source of truth; there is
// nothing left for a consumer to re-check.

test('cross-jurisdiction: enabled US rail + pending EU rail, scoped to US → ready', () => {
const rails = [
bankRail({ id: 'bridge.ach_us', country: 'US', status: 'enabled' }),
bankRail({ id: 'bridge.sepa_eu', method: 'SEPA_EU', country: 'EU', currency: 'EUR', status: 'pending' }),
]
expect(deriveGate(state(rails), 'deposit', { channel: 'bank', country: 'US' }).kind).toBe('ready')
})

test('same-country: enabled + pending in the SAME country → ready (the case country-scoping alone could not fix)', () => {
// Both rails share the country, so narrowing `bankRails({country})` would
// STILL see the pending one. The gate is correct anyway: `ready` outranks
// `pending`. This is why the fix removed the extra check entirely.
const rails = [
bankRail({
id: 'manteca.bank_transfer_ar',
provider: 'manteca',
method: 'BANK_TRANSFER_AR',
country: 'AR',
currency: 'ARS',
status: 'enabled',
}),
bankRail({
id: 'bridge.bank_transfer_ar',
provider: 'bridge',
method: 'BANK_TRANSFER_AR',
country: 'AR',
currency: 'ARS',
status: 'pending',
}),
]
expect(deriveGate(state(rails), 'deposit', { channel: 'bank', country: 'AR' }).kind).toBe('ready')
})

test('verified user, no rail in the requested jurisdiction → needs-enrollment (the "Unlock Bulgaria" class)', () => {
// The cross_region "Unlock <region>" modal is the CORRECT gate output:
// identity verified, but no bank rail in the scoped country yet. (The
// prod Bulgaria bug was downstream — the BE cross-region enroll ignored
// the FE's 4-bucket regionIntent='EU'. The gate classification is right;
// a fix belongs in the enrollment path, not here.)
const rails = [
bankRail({
id: 'manteca.pix_br',
provider: 'manteca',
method: 'PIX_BR',
country: 'BR',
currency: 'BRL',
status: 'enabled',
}),
]
const gate = deriveGate(state(rails, [], /* identityVerified */ true), 'deposit', {
channel: 'bank',
country: 'EU',
})
expect(gate.kind).toBe('needs-enrollment')
})
})
Loading