From 655bacf3c14561104653590a6c2d05a2923ebe06 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Thu, 15 Oct 2026 23:04:42 -0400 Subject: [PATCH 1/5] SLOP: implement AddToDigitalWallet MFA scenario --- .../AddToWalletButton/index.native.tsx | 15 +- ...AuthenticationComposedContextProviders.tsx | 32 +- .../MultifactorAuthenticationMainContext.tsx | 104 ++- .../Context/types.ts | 1 + .../config/scenarios/AddToDigitalWallet.ts | 63 ++ .../config/scenarios/index.ts | 4 + .../config/scenarios/names.ts | 1 + .../MultifactorAuthentication/config/types.ts | 1 + .../parameters/CreateDigitalWalletParams.ts | 6 +- .../shared/VALUES.ts | 1 + src/libs/Wallet/WalletProvisioningError.ts | 10 + src/libs/Wallet/index.android.ts | 21 +- src/libs/Wallet/index.ios.ts | 45 +- src/libs/Wallet/index.ts | 4 +- src/libs/Wallet/types.ts | 15 + .../MultifactorAuthentication/processing.ts | 3 +- src/libs/actions/Wallet.ts | 97 ++- src/libs/sanitizeLogParams.ts | 8 + .../components/AddToWalletButton.test.tsx | 162 +++++ ...tifactorAuthenticationMainContext.test.tsx | 639 ++++++++++++++++++ .../scenarios/AddToDigitalWallet.test.ts | 105 +++ .../config/scenarios/index.test.ts | 17 + tests/unit/libs/Wallet.test.ts | 358 ++++++++++ tests/unit/sanitizeLogParamsTest.ts | 23 + 24 files changed, 1684 insertions(+), 51 deletions(-) create mode 100644 src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts create mode 100644 src/libs/Wallet/WalletProvisioningError.ts create mode 100644 src/libs/Wallet/types.ts create mode 100644 tests/unit/components/AddToWalletButton.test.tsx create mode 100644 tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx create mode 100644 tests/unit/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.test.ts create mode 100644 tests/unit/libs/Wallet.test.ts diff --git a/src/components/AddToWalletButton/index.native.tsx b/src/components/AddToWalletButton/index.native.tsx index 1603c2242ffa..11119ff04d6e 100644 --- a/src/components/AddToWalletButton/index.native.tsx +++ b/src/components/AddToWalletButton/index.native.tsx @@ -1,8 +1,10 @@ import ActivityIndicator from '@components/ActivityIndicator'; +import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext'; import useThemeStyles from '@hooks/useThemeStyles'; import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import {isUkEuExpensifyCard} from '@libs/CardUtils'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -14,7 +16,7 @@ import ROUTES from '@src/ROUTES'; import type {TokenizationStatus} from '@expensify/react-native-wallet'; import {AddToWalletButton as RNAddToWalletButton} from '@expensify/react-native-wallet'; -import React, {useCallback, useState} from 'react'; +import React, {useState} from 'react'; import {Alert} from 'react-native'; import type AddToWalletButtonProps from './types'; @@ -26,11 +28,16 @@ const isIOS = getPlatform() === CONST.PLATFORM.IOS; function AddToWalletButton({card, cardHolderName, cardDescription, style}: AddToWalletButtonProps) { const [isLoading, setIsLoading] = useState(false); const styles = useThemeStyles(); + const {executeScenario} = useMultifactorAuthentication(); const {isInWallet, isLoading: isCardLoading, isCardAvailable, isWalletAvailable} = useIsCardInWallet(card); - const handleOnPress = useCallback(() => { + const handleOnPress = () => { + if (isUkEuExpensifyCard(card)) { + executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, {card, cardHolderName, cardDescription}); + return; + } setIsLoading(true); - handleAddCardToWallet(card, cardHolderName, cardDescription, () => setIsLoading(false)) + handleAddCardToWallet(card, cardHolderName, cardDescription) .then((status: TokenizationStatus) => { if (status === 'success') { Log.info('Card added to wallet'); @@ -47,7 +54,7 @@ function AddToWalletButton({card, cardHolderName, cardDescription, style}: AddTo Log.warn(`Error while adding card to wallet: ${error}`); Alert.alert('Failed to add card to wallet', 'Please try again later.'); }); - }, [card, cardDescription, cardHolderName]); + }; if (!isWalletAvailable || isInWallet == null || isInWallet || !isCardAvailable) { return null; diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx index bcebfbf8e4ac..9505e890a66e 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx @@ -1,6 +1,8 @@ import type {ReactNode} from 'react'; -import React, {useMemo, useReducer} from 'react'; +import React, {useEffect, useReducer, useRef} from 'react'; + +import type {Action} from './types'; import MultifactorAuthenticationActionsContext from './MultifactorAuthenticationActionsContext'; import {MultifactorAuthenticationContextProvider} from './MultifactorAuthenticationMainContext'; @@ -12,8 +14,32 @@ type MultifactorAuthenticationProviderProps = { }; function MultifactorAuthenticationStateProvider({children}: MultifactorAuthenticationProviderProps) { - const [state, dispatch] = useReducer(stateReducer, DEFAULT_STATE); - const actions = useMemo(() => ({dispatch}), [dispatch]); + const [state, reducerDispatch] = useReducer(stateReducer, DEFAULT_STATE); + const executionRef = useRef({controller: new AbortController(), started: false}); + useEffect(() => { + // StrictMode replays setup after cleanup; never reuse its aborted generation. + if (executionRef.current.controller.signal.aborted) { + executionRef.current = {controller: new AbortController(), started: false}; + } + return () => { + executionRef.current.controller.abort(); + // Also invalidate cancellation outcome callbacks, which intentionally run with an aborted signal. + executionRef.current = {...executionRef.current}; + }; + }, []); + const dispatch = (action: Action) => { + // Invalidate synchronously, including direct offline close and resets outside the main provider. + // Duplicate INIT remains the reducer's responsibility and must not abort the active attempt. + if (action.type === 'RESET' || action.type === 'REREGISTER') { + executionRef.current.controller.abort(); + executionRef.current = {controller: new AbortController(), started: false}; + } else if (action.type === 'CLOSE_MODAL') { + executionRef.current.controller.abort(); + } + reducerDispatch(action); + }; + const getActionExecution = () => executionRef.current; + const actions = {dispatch, getActionExecution}; return ( diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 8b0453a4538f..c67cd146b9bb 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -65,7 +65,7 @@ type MultifactorAuthenticationContextProviderProps = { function MultifactorAuthenticationContextProvider({children}: MultifactorAuthenticationContextProviderProps) { const state = useMultifactorAuthenticationState(); - const {dispatch} = useMultifactorAuthenticationActions(); + const {dispatch, getActionExecution} = useMultifactorAuthenticationActions(); const biometrics = useBiometrics(); const {accountID} = useCurrentUserPersonalDetails(); @@ -110,9 +110,13 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent */ const handleCallback = useCallback( async (isSuccessful: boolean) => { + const execution = getActionExecution(); + const wasAborted = execution.controller.signal.aborted; + // Cancellation still needs its failure outcome, but reset/unmount must invalidate it too. + const isCurrentExecution = () => getActionExecution() === execution && (!execution.controller.signal.aborted || (!isSuccessful && wasAborted)); const {error, scenario, scenarioResponse, payload} = state; - if (!scenario) { + if (!scenario || !isCurrentExecution()) { return; } @@ -124,6 +128,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }; const callbackResponse = await scenarioConfig.callback?.(isSuccessful, callbackInput, payload); + if (!isCurrentExecution()) { + return; + } addMFABreadcrumb('Flow completed', { isSuccessful, @@ -134,6 +141,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }); const endState = await captureCredentialsState(); + if (!isCurrentExecution()) { + return; + } trackMFAFlowOutcome({ isSuccessful, @@ -160,9 +170,14 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Wait out any callback-initiated transition (e.g. RHP goBack) before pushing the outcome screen. // Android raced the two animations and the RHP slide-out leaked through the outgoing screen. const outcomeScreen = isSuccessful ? SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS : SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE; - Navigation.runAfterTransition(() => mfaNavigate(outcomeScreen)); + Navigation.runAfterTransition(() => { + if (!isCurrentExecution()) { + return; + } + mfaNavigate(outcomeScreen); + }); }, - [captureCredentialsState, dispatch, state], + [captureCredentialsState, dispatch, getActionExecution, state], ); /** @@ -170,6 +185,8 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent * Uses if statements to determine and execute the next step in the flow. */ const process = useCallback(async () => { + // Capture before any prerequisite awaits so an old continuation cannot adopt a reset's controller. + const execution = getActionExecution(); const { error, continuableError, @@ -206,7 +223,11 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } addMFABreadcrumb('Flow error', error, 'error'); - handleCallback(false); + await handleCallback(false); + return; + } + + if (execution.controller.signal.aborted) { return; } @@ -225,7 +246,11 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } // 2b. Check if the device can actually perform the allowed authentication method - if (!(await biometrics.doesDeviceSupportAuthenticationMethod())) { + const doesDeviceSupportAuthenticationMethod = await biometrics.doesDeviceSupportAuthenticationMethod(); + if (execution.controller.signal.aborted) { + return; + } + if (!doesDeviceSupportAuthenticationMethod) { const reason = biometrics.deviceCheckFailureReason; const message = `Device check failed (deviceVerificationType: ${biometrics.deviceVerificationType})`; addMFABreadcrumb('Device check failed', {reason, deviceVerificationType: biometrics.deviceVerificationType, message}, 'warning'); @@ -235,6 +260,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // 3. Check if registration is required (local credentials not known to server yet) const isRegistrationRequired = !(await biometrics.areLocalCredentialsKnownToServer()) && !isRegistrationComplete; + if (execution.controller.signal.aborted) { + return; + } if (isRegistrationRequired) { // Need validate code before registration @@ -248,6 +276,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Request registration challenge after validateCode is set if (!registrationChallenge) { const {challenge, httpStatusCode, reason, message} = await requestRegistrationChallenge(validateCode); + if (execution.controller.signal.aborted) { + return; + } if (!isHttpSuccess(httpStatusCode) || !challenge) { const challengeError = createMFAErrorFromApiResponse(httpStatusCode, reason, message); @@ -269,6 +300,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } await biometrics.register(async (result: RegisterResult) => { + if (execution.controller.signal.aborted) { + return; + } addMFABreadcrumb('Biometric registration completed', result.success ? {success: true} : result.error, result.success ? 'info' : 'error'); if (!result.success) { @@ -279,6 +313,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const registrationResponse = await processRegistration({ keyInfo: result.keyInfo, }); + if (execution.controller.signal.aborted) { + return; + } addMFABreadcrumb( 'Backend registration completed', @@ -313,6 +350,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Request authorization challenge if not already fetched if (!authorizationChallenge) { const {challenge, httpStatusCode, reason, message} = await requestAuthorizationChallenge(); + if (execution.controller.signal.aborted) { + return; + } if (!isHttpSuccess(httpStatusCode) || !challenge) { const challengeError = createMFAErrorFromApiResponse(httpStatusCode, reason, message); @@ -326,11 +366,17 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } + if (execution.controller.signal.aborted || execution.started) { + return; + } await biometrics.authorize( { challenge: authorizationChallenge, }, async (result: AuthorizeResult) => { + if (execution.controller.signal.aborted || execution.started) { + return; + } addMFABreadcrumb( 'Biometric authorization completed', result.success ? {success: true, authMethod: result.authenticationMethod.code} : result.error, @@ -349,6 +395,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent ) { addMFABreadcrumb('Authorization key reset', result.error, 'warning'); await biometrics.deleteLocalKeysForAccount(); + if (execution.controller.signal.aborted) { + return; + } dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: false}); dispatch({type: 'SET_AUTHORIZATION_CHALLENGE', payload: undefined}); } else { @@ -358,11 +407,20 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } // Call backend with signed challenge - const scenarioAPIResponse = await processScenarioAction(scenario.action, { - signedChallenge: result.signedChallenge, - authenticationMethod: result.authenticationMethod.marqetaValue, - ...payload, - }); + execution.started = true; + const scenarioAPIResponse = await processScenarioAction( + scenario.action, + { + signedChallenge: result.signedChallenge, + authenticationMethod: result.authenticationMethod.marqetaValue, + ...payload, + }, + {signal: execution.controller.signal}, + ); + + if (execution.controller.signal.aborted) { + return; + } addMFABreadcrumb( 'Scenario action completed', @@ -395,8 +453,8 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } // 5. All steps completed - invoke callback to determine whether to show the outcome screen - handleCallback(true); - }, [biometrics, dispatch, handleCallback, isOffline, state, promptType]); + await handleCallback(true); + }, [biometrics, dispatch, getActionExecution, handleCallback, isOffline, state, promptType]); /** * Drives the MFA state machine forward whenever relevant state changes occur. @@ -414,7 +472,11 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } + const execution = getActionExecution(); process().catch((error: unknown) => { + if (execution.controller.signal.aborted) { + return; + } addMFABreadcrumb('Unhandled error', {message: getErrorMessage(error)}, 'error'); dispatch({ type: 'SET_ERROR', @@ -470,7 +532,12 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } - startStateRef.current = await captureCredentialsState(); + const execution = getActionExecution(); + const startState = await captureCredentialsState(); + if (execution.controller.signal.aborted) { + return; + } + startStateRef.current = startState; const breadcrumbData = { scenario, @@ -495,7 +562,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }, }); }, - [captureCredentialsState, dispatch, isOffline, platform, state.scenario], + [captureCredentialsState, dispatch, getActionExecution, isOffline, platform, state.scenario], ); /** @@ -504,6 +571,8 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent * close the modal directly instead to avoid getting stuck. */ const cancel = useCallback(async () => { + const execution = getActionExecution(); + execution.controller.abort(); if (!state.scenario || isOffline) { addMFABreadcrumb('Flow cancelled - closing directly', {hasScenario: !!state.scenario, isOffline}, 'warning'); dispatch({type: 'CLOSE_MODAL'}); @@ -512,6 +581,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent if (state.scenario.onCancel) { const error = await state.scenario.onCancel(state.payload); + if (getActionExecution() !== execution) { + return; + } addMFABreadcrumb('Flow cancelled with onCancel', error, 'warning'); dispatch({type: 'SET_ERROR', payload: error}); return; @@ -522,7 +594,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent type: 'SET_ERROR', payload: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'User cancelled the MFA flow'), }); - }, [dispatch, isOffline, state.scenario, state.payload]); + }, [dispatch, getActionExecution, isOffline, state.scenario, state.payload]); const requestCancel = () => { if (state.isFlowComplete) { diff --git a/src/components/MultifactorAuthentication/Context/types.ts b/src/components/MultifactorAuthentication/Context/types.ts index cf896b034a08..f3b46fbd2d1a 100644 --- a/src/components/MultifactorAuthentication/Context/types.ts +++ b/src/components/MultifactorAuthentication/Context/types.ts @@ -91,6 +91,7 @@ type MultifactorAuthenticationStateContextType = MultifactorAuthenticationState; /** Context value for actions - dispatch to update state */ type MultifactorAuthenticationActionsContextType = { dispatch: (action: Action) => void; + getActionExecution: () => {controller: AbortController; started: boolean}; }; export type {MultifactorAuthenticationState, Action, MultifactorAuthenticationStateContextType, MultifactorAuthenticationActionsContextType}; diff --git a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts new file mode 100644 index 000000000000..e3ed008bbb00 --- /dev/null +++ b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts @@ -0,0 +1,63 @@ +/** Native wallet provisioning starts only after the MFA provider signs a challenge. */ +import type {MultifactorAuthenticationScenarioCustomConfig} from '@components/MultifactorAuthentication/config/types'; + +import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import getPlatform from '@libs/getPlatform'; +import Navigation from '@libs/Navigation/Navigation'; +import {handleAddCardToWallet} from '@libs/Wallet'; +import WalletProvisioningError from '@libs/Wallet/WalletProvisioningError'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Card} from '@src/types/onyx'; + +type Payload = { + card: Card; + cardHolderName: string; + cardDescription: string; +}; + +export default { + allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM], + action: async ({card, cardHolderName, cardDescription, signedChallenge, authenticationMethod}, context) => { + try { + const status = await handleAddCardToWallet(card, cardHolderName, cardDescription, { + authentication: {signedChallenge, authenticationMethod}, + signal: context?.signal, + }); + if (status === 'success' && !context?.signal.aborted) { + return {httpStatusCode: 200, reason: undefined, message: undefined}; + } + return { + httpStatusCode: undefined, + reason: + status === 'canceled' || context?.signal.aborted + ? CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED + : CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED, + message: undefined, + }; + } catch (error) { + if (error instanceof WalletProvisioningError) { + return error.response; + } + // Native exceptions can contain card data; the shared MFA failure UI needs only a reason. + return { + httpStatusCode: undefined, + reason: context?.signal.aborted ? CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED : CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION, + message: undefined, + }; + } + }, + callback: async (isSuccessful, _input, payload) => { + if (!isSuccessful || !payload || !('card' in payload)) { + return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN; + } + getPaymentMethods(); + if (getPlatform() === CONST.PLATFORM.IOS) { + Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_ADDED_TO_WALLET.getRoute(String(payload.card.cardID))); + } + return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN; + }, +} as const satisfies MultifactorAuthenticationScenarioCustomConfig; + +export type {Payload}; diff --git a/src/components/MultifactorAuthentication/config/scenarios/index.ts b/src/components/MultifactorAuthentication/config/scenarios/index.ts index 076b6bf8cf5f..c00fc86ceed5 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/index.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/index.ts @@ -4,12 +4,14 @@ import CONST from '@src/CONST'; import type {EmptyObject} from 'type-fest'; +import type {Payload as AddToDigitalWalletPayload} from './AddToDigitalWallet'; import type {Payload as AuthorizeTransactionPayload} from './AuthorizeTransaction'; import type {Payload as ChangePINPayload} from './ChangePIN'; import type {Payload as RevealPINPayload} from './RevealPIN'; import type {Payload as SetPersonalDetailsAndRevealCardDetailsPayload} from './SetPersonalDetailsAndRevealCardDetails'; import type {Payload as SetPINOrderCardPayload} from './SetPINOrderCard'; +import AddToDigitalWallet from './AddToDigitalWallet'; import AuthorizeTransaction from './AuthorizeTransaction'; import BiometricsTest from './BiometricsTest'; import ChangePIN from './ChangePIN'; @@ -23,6 +25,7 @@ import SetPINOrderCard from './SetPINOrderCard'; * Each scenario that requires additional parameters should have its payload type defined here. */ type Payloads = { + [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET]: AddToDigitalWalletPayload; [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]: EmptyObject; [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PIN_ORDER_CARD]: SetPINOrderCardPayload; [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]: AuthorizeTransactionPayload; @@ -35,6 +38,7 @@ type Payloads = { * Configuration records for all multifactor authentication scenarios. */ const Configs = { + [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET]: customConfig(AddToDigitalWallet), [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST]: customConfig(BiometricsTest), [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.SET_PIN_ORDER_CARD]: customConfig(SetPINOrderCard), [CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]: customConfig(AuthorizeTransaction), diff --git a/src/components/MultifactorAuthentication/config/scenarios/names.ts b/src/components/MultifactorAuthentication/config/scenarios/names.ts index d6cd1270e1fb..1d90cb4fff5a 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/names.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/names.ts @@ -12,6 +12,7 @@ const SCENARIO_NAMES = { AUTHORIZE_TRANSACTION: 'AUTHORIZE-TRANSACTION', REVEAL_PIN: 'REVEAL-PIN', CHANGE_PIN: 'CHANGE-PIN', + ADD_TO_DIGITAL_WALLET: 'ADD-TO-DIGITAL-WALLET', SET_PERSONAL_DETAILS_AND_REVEAL_CARD_DETAILS: 'SET-PERSONAL-DETAILS-AND-REVEAL-CARD-DETAILS', } as const; diff --git a/src/components/MultifactorAuthentication/config/types.ts b/src/components/MultifactorAuthentication/config/types.ts index 40fd533b4bba..ab56dd0dd4a9 100644 --- a/src/components/MultifactorAuthentication/config/types.ts +++ b/src/components/MultifactorAuthentication/config/types.ts @@ -82,6 +82,7 @@ type MultifactorAuthenticationScenarioResponse = { */ type MultifactorAuthenticationScenarioPureMethod> = ( params: MultifactorAuthenticationActionParams, + context?: {signal: AbortSignal}, ) => Promise; /** diff --git a/src/libs/API/parameters/CreateDigitalWalletParams.ts b/src/libs/API/parameters/CreateDigitalWalletParams.ts index 6aa9978758fe..713926022bbf 100644 --- a/src/libs/API/parameters/CreateDigitalWalletParams.ts +++ b/src/libs/API/parameters/CreateDigitalWalletParams.ts @@ -1,3 +1,5 @@ +import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; + type CreateDigitalAppleWalletParams = { platform: 'ios'; appVersion: string; @@ -16,6 +18,8 @@ type CreateDigitalGoogleWalletParams = { cardID: number; }; -type CreateDigitalWalletParams = CreateDigitalAppleWalletParams | CreateDigitalGoogleWalletParams; +type WalletAuthenticationParams = {signedChallenge: string; authenticationMethod: AuthTypeInfo['marqetaValue']} | {signedChallenge?: never; authenticationMethod?: never}; + +type CreateDigitalWalletParams = (CreateDigitalAppleWalletParams | CreateDigitalGoogleWalletParams) & WalletAuthenticationParams; export default CreateDigitalWalletParams; diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index dc4dd597cade..77f239de3062 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -157,6 +157,7 @@ const API_RESPONSE_MAP = { /** No custom error handling — all responses rely on category fallback reasons. */ CHANGE_CARD_PIN: {}, + CREATE_DIGITAL_WALLET: {}, } as const; type DeepLeafValues = T extends Record ? DeepLeafValues : T; diff --git a/src/libs/Wallet/WalletProvisioningError.ts b/src/libs/Wallet/WalletProvisioningError.ts new file mode 100644 index 000000000000..216c4d6e272d --- /dev/null +++ b/src/libs/Wallet/WalletProvisioningError.ts @@ -0,0 +1,10 @@ +/** Carries only the normalized failure, never encrypted card data or a challenge. */ +import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types'; + +class WalletProvisioningError extends Error { + constructor(readonly response: MultifactorAuthenticationScenarioResponse) { + super('Digital wallet provisioning failed'); + } +} + +export default WalletProvisioningError; diff --git a/src/libs/Wallet/index.android.ts b/src/libs/Wallet/index.android.ts index d5bc4498a723..dfa06f8ce126 100644 --- a/src/libs/Wallet/index.android.ts +++ b/src/libs/Wallet/index.android.ts @@ -7,14 +7,27 @@ import type {AndroidCardData, AndroidWalletData, CardStatus, TokenizationStatus} import {addCardToGoogleWallet, checkWalletAvailability, getCardStatusBySuffix, getSecureWalletInfo} from '@expensify/react-native-wallet'; +import type {AddCardToWalletOptions} from './types'; + function checkIfWalletIsAvailable(): Promise { return checkWalletAvailability(); } -function handleAddCardToWallet(card: Card, cardHolderName: string): Promise { - return getSecureWalletInfo().then((walletData: AndroidWalletData) => - createDigitalGoogleWallet({cardID: card.cardID, cardHolderName, ...walletData}).then((cardData: AndroidCardData) => addCardToGoogleWallet(cardData)), - ); +function handleAddCardToWallet(card: Card, cardHolderName: string, _cardDescription: string, {authentication, signal}: AddCardToWalletOptions = {}): Promise { + if (signal?.aborted) { + return Promise.resolve('canceled'); + } + return getSecureWalletInfo().then((walletData: AndroidWalletData) => { + if (signal?.aborted) { + return 'canceled'; + } + return createDigitalGoogleWallet({cardID: card.cardID, cardHolderName, ...walletData}, authentication).then((cardData: AndroidCardData) => { + if (signal?.aborted) { + return 'canceled'; + } + return addCardToGoogleWallet(cardData); + }); + }); } function isCardInWallet(card: Card): Promise { diff --git a/src/libs/Wallet/index.ios.ts b/src/libs/Wallet/index.ios.ts index ea99f021f162..7213ee712e51 100644 --- a/src/libs/Wallet/index.ios.ts +++ b/src/libs/Wallet/index.ios.ts @@ -8,11 +8,13 @@ import type {IOSCardData, TokenizationStatus} from '@expensify/react-native-wall import {addCardToAppleWallet, checkWalletAvailability, getCardStatusByIdentifier, getCardStatusBySuffix} from '@expensify/react-native-wallet'; +import type {AddCardToWalletOptions} from './types'; + function checkIfWalletIsAvailable(): Promise { return checkWalletAvailability(); } -function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescription: string): Promise { +async function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescription: string, {authentication, signal}: AddCardToWalletOptions = {}): Promise { const data = { network: CONST.COMPANY_CARDS.CARD_TYPE.VISA, lastDigits: card.lastFourPAN, @@ -20,7 +22,46 @@ function handleAddCardToWallet(card: Card, cardHolderName: string, cardDescripti cardHolderName, } as IOSCardData; - return addCardToAppleWallet(data, (nonce, nonceSignature, certificates) => issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID)); + if (!authentication) { + return addCardToAppleWallet(data, (nonce, nonceSignature, certificates) => issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID)); + } + + if (signal?.aborted) { + return 'canceled'; + } + + let didRequestProvisioning = false; + let provisioningFailure: unknown; + try { + const status = await addCardToAppleWallet(data, async (nonce, nonceSignature, certificates) => { + try { + // Apple's Try Again callback must not replay a consumed signed challenge. + // Wallet 0.1.22 has no public abort API; rejection cannot dismiss its sheet. + if (didRequestProvisioning || signal?.aborted) { + throw new Error('Wallet attempt requires fresh authentication'); + } + didRequestProvisioning = true; + const payload = await issuerEncryptPayloadCallback(nonce, nonceSignature, certificates, card.cardID, authentication); + if (signal?.aborted) { + throw new Error('Wallet attempt canceled'); + } + return payload; + } catch (error) { + provisioningFailure ??= error; + throw error; + } + }); + if (provisioningFailure) { + throw provisioningFailure; + } + if (status === 'success' && !didRequestProvisioning) { + throw new Error('Wallet completed without provisioning'); + } + return signal?.aborted ? 'canceled' : status; + } catch (error) { + // A subsequent native rejection must not replace the original server failure. + throw provisioningFailure ?? error; + } } function isCardInWallet(card: Card): Promise { diff --git a/src/libs/Wallet/index.ts b/src/libs/Wallet/index.ts index b8b2ba7a45c7..2e362b9edcbd 100644 --- a/src/libs/Wallet/index.ts +++ b/src/libs/Wallet/index.ts @@ -2,12 +2,14 @@ import type {Card} from '@src/types/onyx'; import type {TokenizationStatus} from '@expensify/react-native-wallet'; +import type {AddCardToWalletOptions} from './types'; + function checkIfWalletIsAvailable(): Promise { return Promise.resolve(false); } // eslint-disable-next-line @typescript-eslint/no-unused-vars -function handleAddCardToWallet(_card: Card, _cardHolderName: string, _cardDescription: string, _onFinished?: () => void): Promise { +function handleAddCardToWallet(_card: Card, _cardHolderName: string, _cardDescription: string, _options?: AddCardToWalletOptions): Promise { return Promise.reject(new Error('Add to wallet is not supported on this platform')); } diff --git a/src/libs/Wallet/types.ts b/src/libs/Wallet/types.ts new file mode 100644 index 000000000000..a2a2f4a0a229 --- /dev/null +++ b/src/libs/Wallet/types.ts @@ -0,0 +1,15 @@ +/** Transient authorization for one native wallet provisioning attempt. */ +import type {SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; + +type WalletAuthentication = { + signedChallenge: SignedChallenge; + authenticationMethod: AuthTypeInfo['marqetaValue']; +}; + +type AddCardToWalletOptions = { + authentication?: WalletAuthentication; + signal?: AbortSignal; +}; + +export type {AddCardToWalletOptions, WalletAuthentication}; diff --git a/src/libs/actions/MultifactorAuthentication/processing.ts b/src/libs/actions/MultifactorAuthentication/processing.ts index 848d029abfdf..f2d6cb7ad81f 100644 --- a/src/libs/actions/MultifactorAuthentication/processing.ts +++ b/src/libs/actions/MultifactorAuthentication/processing.ts @@ -33,6 +33,7 @@ async function processRegistration(params: RegistrationParams): Promise[0], + context?: {signal: AbortSignal}, ): Promise> { if (!params.signedChallenge) { return { @@ -41,7 +42,7 @@ async function processScenarioAction( }; } - const {httpStatusCode, reason, message, body} = await action(params); + const {httpStatusCode, reason, message, body} = await (context ? action(params, context) : action(params)); if (isHttpSuccess(httpStatusCode)) { return { diff --git a/src/libs/actions/Wallet.ts b/src/libs/actions/Wallet.ts index 2102aa1cece2..ae2b198b9578 100644 --- a/src/libs/actions/Wallet.ts +++ b/src/libs/actions/Wallet.ts @@ -1,7 +1,10 @@ import * as API from '@libs/API'; -import type {AcceptWalletTermsParams, AnswerQuestionsForWalletParams, UpdatePersonalDetailsForWalletParams, VerifyIdentityParams} from '@libs/API/parameters'; +import type {AcceptWalletTermsParams, AnswerQuestionsForWalletParams, CreateDigitalWalletParams, UpdatePersonalDetailsForWalletParams, VerifyIdentityParams} from '@libs/API/parameters'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import Log from '@libs/Log'; +import {isHttpSuccess, parseHttpResponse} from '@libs/MultifactorAuthentication/shared/helpers'; +import type {WalletAuthentication} from '@libs/Wallet/types'; +import WalletProvisioningError from '@libs/Wallet/WalletProvisioningError'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -303,18 +306,31 @@ function clearWalletAdditionalDetailsErrors(currentErrorCode?: string | null) { }); } -function issuerEncryptPayloadCallback(nonce: string, nonceSignature: string, certificates: string[], cardID: number): Promise { - // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, { +function issuerEncryptPayloadCallback(nonce: string, nonceSignature: string, certificates: string[], cardID: number, authentication?: WalletAuthentication): Promise { + const params: CreateDigitalWalletParams = { platform: 'ios', appVersion: pkg.version, certificates: JSON.stringify({certificates}), nonce, nonceSignature, cardID, - }) + }; + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects( + SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, + authentication ? {...params, signedChallenge: JSON.stringify(authentication.signedChallenge), authenticationMethod: authentication.authenticationMethod} : params, + ) .then((response) => { const data = response as unknown as IOSEncryptPayload; + if (authentication) { + const parsed = parseHttpResponse(response?.jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.CREATE_DIGITAL_WALLET, response?.message); + if (!isHttpSuccess(parsed.httpStatusCode)) { + throw new WalletProvisioningError(parsed); + } + if (![data?.encryptedPassData, data?.activationData, data?.ephemeralPublicKey].every((value) => typeof value === 'string' && value.length > 0)) { + throw new WalletProvisioningError({httpStatusCode: undefined, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED, message: undefined}); + } + } return { encryptedPassData: data.encryptedPassData, activationData: data.activationData, @@ -322,6 +338,11 @@ function issuerEncryptPayloadCallback(nonce: string, nonceSignature: string, cer } as IOSEncryptPayload; }) .catch((error) => { + if (authentication) { + throw error instanceof WalletProvisioningError + ? error + : new WalletProvisioningError(parseHttpResponse(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.CREATE_DIGITAL_WALLET, undefined)); + } Log.warn(`issuerEncryptPayloadCallback error: ${error}`); return {} as IOSEncryptPayload; }); @@ -333,27 +354,60 @@ function issuerEncryptPayloadCallback(nonce: string, nonceSignature: string, cer * @param walletAccountID ID of the wallet on user's phone * @param deviceID ID of user's phone */ -function createDigitalGoogleWallet({ - walletAccountID, - deviceID, - cardID, - cardHolderName, -}: { - deviceID: string; - walletAccountID: string; - cardID: number; - cardHolderName: string; -}): Promise { - // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, { +function createDigitalGoogleWallet( + { + walletAccountID, + deviceID, + cardID, + cardHolderName, + }: { + deviceID: string; + walletAccountID: string; + cardID: number; + cardHolderName: string; + }, + authentication?: WalletAuthentication, +): Promise { + const params: CreateDigitalWalletParams = { platform: 'android', appVersion: pkg.version, walletAccountID, deviceID, cardID, - }) + }; + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects( + SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, + authentication ? {...params, signedChallenge: JSON.stringify(authentication.signedChallenge), authenticationMethod: authentication.authenticationMethod} : params, + ) .then((response) => { const data = response as unknown as ProvisioningCardData; + if (authentication) { + const parsed = parseHttpResponse(response?.jsonCode, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.CREATE_DIGITAL_WALLET, response?.message); + if (!isHttpSuccess(parsed.httpStatusCode)) { + throw new WalletProvisioningError(parsed); + } + const address = data?.userAddress; + const requiredFields = [ + data?.network, + data?.opaquePaymentCard, + data?.lastDigits, + address?.name, + address?.address1, + address?.city, + address?.country, + address?.postal_code, + address?.phone, + ]; + // UK addresses can have an empty province, but the native bridge still requires a string. + if ( + !requiredFields.every((value) => typeof value === 'string' && value.length > 0) || + typeof address?.state !== 'string' || + (address?.address2 !== undefined && typeof address.address2 !== 'string') + ) { + throw new WalletProvisioningError({httpStatusCode: undefined, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED, message: undefined}); + } + } return { network: data.network, opaquePaymentCard: data.opaquePaymentCard, @@ -372,6 +426,11 @@ function createDigitalGoogleWallet({ } as AndroidCardData; }) .catch((error) => { + if (authentication) { + throw error instanceof WalletProvisioningError + ? error + : new WalletProvisioningError(parseHttpResponse(undefined, CONST.MULTIFACTOR_AUTHENTICATION.API_RESPONSE_MAP.CREATE_DIGITAL_WALLET, undefined)); + } Log.warn(`createDigitalGoogleWallet error: ${error}`); return {} as AndroidCardData; }); diff --git a/src/libs/sanitizeLogParams.ts b/src/libs/sanitizeLogParams.ts index 6949f6aa8330..4b1ec38edb10 100644 --- a/src/libs/sanitizeLogParams.ts +++ b/src/libs/sanitizeLogParams.ts @@ -17,6 +17,14 @@ const SENSITIVE_KEYS = new Set([ 'clientSecret', 'cardNumber', 'cardCVV', + 'signedChallenge', + 'nonce', + 'nonceSignature', + 'certificates', + 'encryptedPassData', + 'activationData', + 'ephemeralPublicKey', + 'opaquePaymentCard', ]); const REDACTED = ''; diff --git a/tests/unit/components/AddToWalletButton.test.tsx b/tests/unit/components/AddToWalletButton.test.tsx new file mode 100644 index 000000000000..2148d3e42213 --- /dev/null +++ b/tests/unit/components/AddToWalletButton.test.tsx @@ -0,0 +1,162 @@ +/** Explicit native entrypoint: the web component intentionally renders nothing. */ +import {fireEvent, render, screen, waitFor} from '@testing-library/react-native'; + +import AddToWalletButton from '@components/AddToWalletButton/index.native'; +import useIsCardInWallet from '@components/AddToWalletButton/useIsCardInWallet'; +import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext'; + +import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import Navigation from '@libs/Navigation/Navigation'; +import {handleAddCardToWallet} from '@libs/Wallet/index'; + +import CONST from '@src/CONST'; +import type {Card} from '@src/types/onyx'; + +import type ReactNative from 'react-native'; + +import React from 'react'; +import {Alert} from 'react-native'; + +import createMock from '../../utils/createMock'; + +jest.mock('@expensify/react-native-wallet', () => { + const {Pressable, Text} = jest.requireActual('react-native'); + return { + AddToWalletButton: ({onPress}: {onPress: () => void}) => ( + + Add to wallet + + ), + }; +}); +jest.mock('@components/ActivityIndicator', () => { + const {Text} = jest.requireActual('react-native'); + return () => Loading wallet; +}); +jest.mock('@components/AddToWalletButton/useIsCardInWallet'); +jest.mock('@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext'); +jest.mock('@libs/Wallet/index', () => ({handleAddCardToWallet: jest.fn()})); +jest.mock('@libs/actions/PaymentMethods', () => ({getPaymentMethods: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@libs/getPlatform', () => ({__esModule: true, default: () => 'ios'})); +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({addToWalletButtonStyles: {}})})); + +const card = createMock({cardID: 123, bank: CONST.EXPENSIFY_CARD.BANK, nameValuePairs: {feedCountry: CONST.COUNTRY.GB}}); +const mockExecuteScenario = jest.fn().mockResolvedValue(undefined); +const available = {isInWallet: false, isLoading: false, isCardAvailable: true, isWalletAvailable: true}; + +describe('Native AddToWalletButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useIsCardInWallet).mockReturnValue(available); + jest.mocked(useMultifactorAuthentication).mockReturnValue(createMock>({executeScenario: mockExecuteScenario})); + }); + + it('starts MFA for the actual UK/EU program card without latching a button spinner', () => { + // Given a GB-feed Expensify card with display metadata. + render( + , + ); + + // When its native badge is pressed (initialization is not authorization completion). + fireEvent.press(screen.getByText('Add to wallet')); + + // Then MFA owns native launch and success, while the badge stays usable after initialization. + expect(mockExecuteScenario).toHaveBeenCalledWith(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, {card, cardHolderName: 'Jane', cardDescription: 'Virtual card'}); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(screen.queryByText('Loading wallet')).toBeNull(); + }); + + it.each([createMock({...card, nameValuePairs: {feedCountry: CONST.COUNTRY.US}}), createMock({...card, bank: CONST.PERSONAL_CARDS.BANK_NAME.CHASE})])( + 'keeps unsigned provisioning for cards outside the UK/EU program', + async (legacyCard) => { + // Given a US-feed or non-Expensify card, irrespective of user location/currency. + jest.mocked(handleAddCardToWallet).mockResolvedValue('success'); + render( + , + ); + + // When its badge is pressed. + fireEvent.press(screen.getByText('Add to wallet')); + + // Then the legacy helper and success side effects stay unchanged. + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(handleAddCardToWallet).toHaveBeenCalledWith(legacyCard, 'Jane', 'Card'); + expect(mockExecuteScenario).not.toHaveBeenCalled(); + expect(Navigation.navigate).toHaveBeenCalled(); + }, + ); + + it.each(['canceled', 'error'] as const)('restores the legacy badge after native %s', async (status) => { + // Given native legacy tokenization did not succeed. + jest.mocked(handleAddCardToWallet).mockResolvedValue(status); + render( + ({...card, nameValuePairs: {feedCountry: CONST.COUNTRY.US}})} + cardHolderName="Jane" + cardDescription="Card" + />, + ); + + // When that attempt finishes. + fireEvent.press(screen.getByText('Add to wallet')); + + // Then retry is available without refresh or added-card navigation. + expect(await screen.findByText('Add to wallet')).toBeTruthy(); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('retains the legacy rejection alert and retry', async () => { + // Given the unsigned native flow rejects. + const alert = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); + jest.mocked(handleAddCardToWallet).mockRejectedValue(new Error('failed')); + render( + ({...card, nameValuePairs: {feedCountry: CONST.COUNTRY.US}})} + cardHolderName="Jane" + cardDescription="Card" + />, + ); + + // When the attempt ends. + fireEvent.press(screen.getByText('Add to wallet')); + + // Then the existing alert remains and the badge is restored. + await waitFor(() => expect(alert).toHaveBeenCalled()); + expect(screen.getByText('Add to wallet')).toBeTruthy(); + alert.mockRestore(); + }); + + it.each([ + {...available, isWalletAvailable: false}, + {...available, isInWallet: null}, + {...available, isInWallet: true}, + {...available, isCardAvailable: false}, + ])('retains wallet/card visibility rules', (status) => { + // Given a card or wallet that is not eligible to show a badge. + jest.mocked(useIsCardInWallet).mockReturnValue(status); + // When the native component renders. + render( + , + ); + // Then neither a new MFA entry nor a legacy badge is exposed. + expect(screen.queryByText('Add to wallet')).toBeNull(); + }); +}); diff --git a/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx b/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx new file mode 100644 index 000000000000..425e3a6977cb --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx @@ -0,0 +1,639 @@ +/** Composed providers exercise signing, long-running action cancellation, and reset isolation. */ +import {act, render, waitFor} from '@testing-library/react-native'; + +import type {AuthorizeResult, RegisterResult} from '@components/MultifactorAuthentication/biometrics/shared/types'; +import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from '@components/MultifactorAuthentication/config'; +import {useMultifactorAuthenticationActions} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext'; +import MultifactorAuthenticationContextProviders from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders'; +import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext'; +import {useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationStateContext'; +import {navigate as mfaNavigate} from '@components/MultifactorAuthentication/mfaNavigation'; + +import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import {requestValidateCodeAction} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; +import {handleAddCardToWallet} from '@libs/Wallet'; + +import {authorizeTransaction, denyTransaction, registerAuthenticationKey} from '@userActions/MultifactorAuthentication'; + +import CONST from '@src/CONST'; +import type {Card} from '@src/types/onyx'; + +import type {TokenizationStatus} from '@expensify/react-native-wallet'; + +import React, {useEffect} from 'react'; + +import createMock from '../../../utils/createMock'; + +const signedResult: AuthorizeResult = { + success: true, + signedChallenge: {rawId: 'key', type: 'public-key', response: {authenticatorData: 'auth', clientDataJSON: 'client', signature: 'signature'}}, + authenticationMethod: {name: 'Face ID', marqetaValue: 'BIOMETRIC_FACE'}, +}; +let mockIsOffline = false; +const mockBiometrics = { + deviceVerificationType: 'BIOMETRICS_HSM', + serverKnownCredentialIDs: ['key'], + doesDeviceSupportAuthenticationMethod: jest.fn().mockResolvedValue(true), + areLocalCredentialsKnownToServer: jest.fn().mockResolvedValue(true), + authorize: jest.fn(async (_params: unknown, callback: (result: AuthorizeResult) => Promise) => callback(signedResult)), + register: jest.fn(async (callback: (result: RegisterResult) => Promise) => + callback({ + success: true, + keyInfo: {rawId: 'key', type: 'public-key', response: {clientDataJSON: 'client', attestationObject: 'attestation'}}, + }), + ), +}; + +jest.mock('@components/MultifactorAuthentication/biometrics/useBiometrics', () => ({__esModule: true, default: () => mockBiometrics})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: () => ({accountID: 123})})); +jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: mockIsOffline})})); +jest.mock('@libs/Wallet', () => ({handleAddCardToWallet: jest.fn()})); +jest.mock('@libs/actions/PaymentMethods', () => ({getPaymentMethods: jest.fn()})); +jest.mock('@libs/actions/User', () => ({requestValidateCodeAction: jest.fn()})); +jest.mock('@libs/getPlatform', () => ({__esModule: true, default: () => 'ios'})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn(), runAfterTransition: jest.fn()}})); +jest.mock('@components/MultifactorAuthentication/mfaNavigation', () => ({navigate: jest.fn()})); +jest.mock('@components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@components/MultifactorAuthentication/observability/breadcrumbs', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@components/MultifactorAuthentication/observability/trackMFAFlowStart', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@components/MultifactorAuthentication/observability/trackMFAFlowOutcome', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@userActions/MultifactorAuthentication', () => ({ + getDeviceBiometricsOnyxKey: () => 'deviceBiometrics_123', + requestAuthorizationChallenge: jest.fn().mockResolvedValue({httpStatusCode: 200, challenge: {allowCredentials: [], rpId: 'test', challenge: 'challenge'}}), + requestRegistrationChallenge: jest.fn().mockResolvedValue({httpStatusCode: 200, challenge: {rp: {}, user: {}, challenge: 'registration'}}), + registerAuthenticationKey: jest.fn().mockResolvedValue({httpStatusCode: 200}), + clearLocalMFAPublicKeyList: jest.fn(), + authorizeTransaction: jest.fn(), + denyTransaction: jest.fn(), + fireAndForgetDenyTransaction: jest.fn(), +})); + +let controls: ReturnType; +let actions: ReturnType; +let state: ReturnType; +function Probe() { + const currentControls = useMultifactorAuthentication(); + const currentActions = useMultifactorAuthenticationActions(); + const currentState = useMultifactorAuthenticationState(); + useEffect(() => { + controls = currentControls; + actions = currentActions; + state = currentState; + }, [currentControls, currentActions, currentState]); + return null; +} +const payload = {card: createMock({cardID: 123}), cardHolderName: 'Jane', cardDescription: 'Card'}; +async function startScenario(scenarioPayload = payload) { + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, scenarioPayload); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); +} + +describe('Wallet MFA composed providers', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsOffline = false; + mockBiometrics.deviceVerificationType = 'BIOMETRICS_HSM'; + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(true); + mockBiometrics.doesDeviceSupportAuthenticationMethod.mockResolvedValue(true); + mockBiometrics.authorize.mockImplementation(async (_params, callback) => callback(signedResult)); + jest.mocked(handleAddCardToWallet).mockResolvedValue('success'); + jest.mocked(authorizeTransaction).mockReset().mockResolvedValue({httpStatusCode: 200, reason: undefined, message: undefined}); + jest.mocked(denyTransaction) + .mockReset() + .mockResolvedValue({httpStatusCode: 200, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED, message: undefined}); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('does not launch a wallet until authorization signs, then completes through the scenario callback', async () => { + // Given the real providers and authorization waiting for user authentication. + let finishAuthorization: ((result: AuthorizeResult) => Promise) | undefined; + mockBiometrics.authorize.mockImplementation(async (_params, callback) => { + finishAuthorization = callback; + }); + render( + + + , + ); + + // When scenario initialization resolves but signing is still pending. + await startScenario(); + await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalled()); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + await act(async () => { + await finishAuthorization?.(signedResult); + }); + + // Then native success alone triggers refresh and the MFA modal closes without its success screen. + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(handleAddCardToWallet).toHaveBeenCalledTimes(1); + expect(state.isFlowComplete).toBe(true); + expect(state.isModalOpen).toBe(false); + expect(state.scenarioResponse?.body).toBeUndefined(); + }); + + it('requires the existing enrollment flow before wallet provisioning', async () => { + // Given a device without an enrolled credential. + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + render( + + + , + ); + + // When a UK/EU card starts wallet authentication. + await startScenario(); + + // Then existing validate-code enrollment starts and no wallet/native authorization runs prematurely. + await waitFor(() => expect(requestValidateCodeAction).toHaveBeenCalled()); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + expect(mockBiometrics.authorize).not.toHaveBeenCalled(); + }); + + it('continues existing enrollment into signing and wallet provisioning', async () => { + // Given the cardholder starts without an enrolled device credential. + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + render( + + + , + ); + await startScenario(); + await waitFor(() => expect(requestValidateCodeAction).toHaveBeenCalled()); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + + // When the existing enrollment flow receives its validate code and registers the device. + await act(async () => actions.dispatch({type: 'SET_VALIDATE_CODE', payload: '123456'})); + + // Then registration and authorization finish before the wallet action and successful refresh. + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(mockBiometrics.register).toHaveBeenCalledTimes(1); + expect(mockBiometrics.authorize).toHaveBeenCalledTimes(1); + expect(state.isRegistrationComplete).toBe(true); + }); + + it('rejects unsupported authentication methods before native wallet work', async () => { + // Given this native-only scenario is offered a passkey authenticator. + mockBiometrics.deviceVerificationType = 'PASSKEYS'; + render( + + + , + ); + // When the real provider checks the scenario allowlist. + await startScenario(); + // Then existing failure UX is used without invoking native wallet provisioning. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.AUTHENTICATION_TYPE_NOT_SUPPORTED); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + }); + + it('never provisions after authentication cancellation', async () => { + // Given the native authenticator returns the existing canceled result. + mockBiometrics.authorize.mockImplementation(async (_params, callback) => + callback({success: false, error: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, message: undefined}}), + ); + render( + + + , + ); + + // When the real provider completes authorization. + await startScenario(); + + // Then no wallet launches and existing failure handling completes the flow. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + expect(getPaymentMethods).not.toHaveBeenCalled(); + }); + + it.each(['cancel', 'offline close', 'reset'] as const)('invalidates a pending wallet action on %s and ignores its late result', async (ending) => { + // Given tokenization is still pending in the composed MFA providers. + let finishWallet: ((status: TokenizationStatus) => void) | undefined; + jest.mocked(handleAddCardToWallet).mockImplementation( + () => + new Promise((resolve) => { + finishWallet = resolve; + }), + ); + const view = render( + + + , + ); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When cancellation or reset ends that generation, before native completion. + await act(async () => { + if (ending === 'reset') { + actions.dispatch({type: 'RESET'}); + } else if (ending === 'offline close') { + mockIsOffline = true; + view.rerender( + + + , + ); + } else { + controls.confirmCancel(); + } + }); + if (ending === 'offline close') { + await act(async () => controls.requestCancel()); + } + expect(signal?.aborted).toBe(true); + if (ending === 'reset') { + // A newer flow must not inherit the previous attempt's eventual success. + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + await startScenario(); + } + await act(async () => { + finishWallet?.('success'); + }); + + // Then late tokenization cannot refresh, navigate, or mark a reset/new flow authorized. + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(state.isAuthorizationComplete).toBe(false); + expect(state.scenarioResponse).toBeUndefined(); + if (ending === 'reset') { + expect(state.error).toBeUndefined(); + expect(state.isFlowComplete).toBe(false); + } + if (ending === 'offline close') { + expect(state.isModalOpen).toBe(false); + } + }); + + it.each(['doesDeviceSupportAuthenticationMethod', 'areLocalCredentialsKnownToServer'] as const)( + 'does not adopt a replacement card generation after a pending %s', + async (prerequisite) => { + // Given the first card already has a challenge, but a repeated prerequisite check is pending. + mockBiometrics.authorize.mockImplementation(async () => {}); + render( + + + , + ); + await startScenario(); + await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalledTimes(1)); + let finishPrerequisite: ((value: boolean) => void) | undefined; + mockBiometrics[prerequisite].mockImplementationOnce( + () => + new Promise((resolve) => { + finishPrerequisite = resolve; + }), + ); + await act(async () => actions.dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: true})); + expect(finishPrerequisite).toBeDefined(); + const oldExecution = actions.getActionExecution(); + + // When cancellation finishes and a different card starts before the old check resolves. + await act(async () => controls.confirmCancel()); + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + await act(async () => actions.dispatch({type: 'RESET'})); + const replacementPayload = {...payload, card: createMock({cardID: 456})}; + await startScenario(replacementPayload); + await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalledTimes(2)); + const finishReplacement = mockBiometrics.authorize.mock.calls.at(-1)?.[1]; + const replacementExecution = actions.getActionExecution(); + const replacementState = state; + const navigationCount = jest.mocked(mfaNavigate).mock.calls.length; + await act(async () => { + finishPrerequisite?.(true); + }); + + // Then the old continuation cannot sign, provision, or change the replacement; its own signing still works. + expect(oldExecution.controller.signal.aborted).toBe(true); + expect(mockBiometrics.authorize).toHaveBeenCalledTimes(2); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + expect(replacementExecution.started).toBe(false); + expect(state).toBe(replacementState); + expect(mfaNavigate).toHaveBeenCalledTimes(navigationCount); + await act(async () => { + await finishReplacement?.(signedResult); + }); + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(handleAddCardToWallet).toHaveBeenCalledTimes(1); + expect(jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[0].cardID).toBe(456); + }, + ); + + it('aborts pending wallet work when the composed providers unmount', async () => { + // Given signed wallet provisioning has not completed before AuthScreens is removed. + let finishWallet: ((status: TokenizationStatus) => void) | undefined; + jest.mocked(handleAddCardToWallet).mockImplementation( + () => + new Promise((resolve) => { + finishWallet = resolve; + }), + ); + const view = render( + + + , + ); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When the provider unmounts and the abandoned action eventually reports success. + view.unmount(); + await act(async () => { + finishWallet?.('success'); + }); + + // Then its signal is aborted and neither success refresh nor success navigation can run. + expect(signal?.aborted).toBe(true); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('keeps a usable execution after StrictMode effect cleanup is replayed', async () => { + // Given provider setup and cleanup are replayed by React StrictMode. + const signals: AbortSignal[] = []; + function LifecycleProbe() { + const {getActionExecution} = useMultifactorAuthenticationActions(); + useEffect(() => { + signals.push(getActionExecution().controller.signal); + }, [getActionExecution]); + return ; + } + render( + + + + + , + {concurrentRoot: true}, + ); + expect(signals.some((signal) => signal.aborted)).toBe(true); + expect(actions.getActionExecution().controller.signal.aborted).toBe(false); + + // When a card starts after the replay. + await startScenario(); + + // Then the active generation can still sign, provision and run its success callback exactly once. + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(handleAddCardToWallet).toHaveBeenCalledTimes(1); + expect(state.isFlowComplete).toBe(true); + }); + + it.each(['callback', 'credentials'] as const)('ignores a completed outcome after reset while its %s await is pending', async (stage) => { + // Given an existing scenario reaches its callback, with either callback or telemetry credentials still pending. + let finishOutcome: (() => void) | undefined; + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]; + jest.spyOn(config, 'callback').mockImplementationOnce(async () => { + if (stage === 'callback') { + await new Promise((resolve) => { + finishOutcome = resolve; + }); + } else { + mockBiometrics.areLocalCredentialsKnownToServer.mockImplementationOnce( + () => + new Promise((resolve) => { + finishOutcome = () => resolve(true); + }), + ); + } + return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN; + }); + render( + + + , + ); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(finishOutcome).toBeDefined()); + + // When a new card is waiting for enrollment before the original outcome resumes. + await act(async () => actions.dispatch({type: 'RESET'})); + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + await startScenario(); + await act(async () => { + finishOutcome?.(); + }); + + // Then the old callback cannot complete or navigate the new flow. + expect(state.isFlowComplete).toBe(false); + expect(state.error).toBeUndefined(); + expect(Navigation.runAfterTransition).not.toHaveBeenCalled(); + }); + + it('ignores a transaction cancellation response after reset into a different card', async () => { + // Given a scenario-specific asynchronous cancellation has not returned yet. + let finishCancel: ((response: Awaited>) => void) | undefined; + jest.mocked(denyTransaction).mockImplementationOnce( + () => + new Promise((resolve) => { + finishCancel = resolve; + }), + ); + mockBiometrics.authorize.mockImplementation(async () => {}); + render( + + + , + ); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalledTimes(1)); + await act(async () => controls.confirmCancel()); + + // When another flow starts before the previous transaction denial arrives. + await act(async () => actions.dispatch({type: 'RESET'})); + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + await startScenario(); + await act(async () => { + finishCancel?.({httpStatusCode: 200, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED, message: undefined}); + }); + + // Then cancellation cannot fail or complete the replacement enrollment flow. + expect(state.error).toBeUndefined(); + expect(state.isFlowComplete).toBe(false); + }); + + it('invalidates a pending cancellation outcome callback on provider unmount', async () => { + // Given cancellation intentionally runs its failure callback even though the action signal is aborted. + let finishCallback: (() => void) | undefined; + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]; + jest.spyOn(config, 'callback').mockImplementationOnce(async () => { + await new Promise((resolve) => { + finishCallback = resolve; + }); + return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN; + }); + mockBiometrics.authorize.mockImplementation(async () => {}); + const view = render( + + + , + ); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalledTimes(1)); + await act(async () => controls.confirmCancel()); + await waitFor(() => expect(finishCallback).toBeDefined()); + + // When AuthScreens unmounts before that failure callback completes. + view.unmount(); + await act(async () => { + finishCallback?.(); + }); + + // Then even an already-aborted cancellation generation cannot navigate after teardown. + expect(Navigation.runAfterTransition).not.toHaveBeenCalled(); + }); + + it('ignores an existing scenario action that rejects after reset into another flow', async () => { + // Given a non-wallet scenario action can reject outside the wallet action error normalization. + let rejectAction: ((error: Error) => void) | undefined; + jest.mocked(authorizeTransaction).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectAction = reject; + }), + ); + render( + + + , + ); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(authorizeTransaction).toHaveBeenCalledTimes(1)); + + // When reset starts a new enrollment flow before the original action rejects into the outer effect catch. + await act(async () => actions.dispatch({type: 'RESET'})); + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + await startScenario(); + await waitFor(() => expect(requestValidateCodeAction).toHaveBeenCalled()); + await act(async () => { + rejectAction?.(new Error('late action failure')); + }); + + // Then neither a late error nor a completed failure outcome contaminates the replacement flow. + expect(state.error).toBeUndefined(); + expect(state.isFlowComplete).toBe(false); + expect(state.isAuthorizationComplete).toBe(false); + expect(state.scenarioResponse).toBeUndefined(); + expect(handleAddCardToWallet).not.toHaveBeenCalled(); + }); + + it('re-enrolls and signs exactly once more when an existing scenario requires registration', async () => { + // Given a signed non-wallet action reports that the server no longer recognizes the credential. + jest.mocked(authorizeTransaction).mockImplementationOnce(async () => { + mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); + return {httpStatusCode: 400, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.REGISTRATION_REQUIRED, message: undefined}; + }); + render( + + + , + ); + const execution = actions.getActionExecution(); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(requestValidateCodeAction).toHaveBeenCalled()); + expect(execution.controller.signal.aborted).toBe(true); + expect(actions.getActionExecution().started).toBe(false); + + // When the user completes the existing validate-code and soft-prompt enrollment steps. + await act(async () => { + actions.dispatch({type: 'SET_VALIDATE_CODE', payload: '123456'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + + // Then the replacement execution registers, re-signs and completes without an extra action. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(registerAuthenticationKey).toHaveBeenCalledTimes(1); + expect(mockBiometrics.authorize).toHaveBeenCalledTimes(2); + expect(authorizeTransaction).toHaveBeenCalledTimes(2); + expect(state.error).toBeUndefined(); + expect(state.isAuthorizationComplete).toBe(true); + }); + + it('lets asynchronous transaction cancellation win over an action that ignores its signal', async () => { + // Given both the signed action and the scenario-specific cancellation request can stay pending. + let finishAction: ((response: Awaited>) => void) | undefined; + let finishCancel: ((response: Awaited>) => void) | undefined; + jest.mocked(authorizeTransaction).mockImplementationOnce( + () => + new Promise((resolve) => { + finishAction = resolve; + }), + ); + jest.mocked(denyTransaction).mockImplementationOnce( + () => + new Promise((resolve) => { + finishCancel = resolve; + }), + ); + render( + + + , + ); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(authorizeTransaction).toHaveBeenCalledTimes(1)); + + // When the user cancels, the action returns success before the denial response arrives. + await act(async () => controls.confirmCancel()); + await act(async () => { + finishAction?.({httpStatusCode: 200, reason: undefined, message: undefined}); + }); + expect(state.isAuthorizationComplete).toBe(false); + expect(state.isFlowComplete).toBe(false); + await act(async () => { + finishCancel?.({httpStatusCode: 200, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED, message: undefined}); + }); + + // Then cancellation still reaches its intentional outcome rather than authorizing the transaction. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED); + expect(state.isAuthorizationComplete).toBe(false); + expect(state.scenarioResponse).toBeUndefined(); + }); + + it('does not execute a pending wallet action twice when provider effects rerun', async () => { + // Given wallet tokenization remains pending after signing. + jest.mocked(handleAddCardToWallet).mockImplementation(() => new Promise(() => {})); + render( + + + , + ); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When a relevant state change makes the process effect run again. + await act(async () => actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: false})); + await act(async () => actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true})); + await act(async () => actions.dispatch({type: 'INIT', payload: {scenario: CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, payload}})); + + // Then the action execution guard prevents a second wallet sheet/request. + expect(handleAddCardToWallet).toHaveBeenCalledTimes(1); + expect(signal?.aborted).toBe(false); + }); +}); diff --git a/tests/unit/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.test.ts new file mode 100644 index 000000000000..fc0a87ae8872 --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.test.ts @@ -0,0 +1,105 @@ +/** Wallet scenario completion owns refresh/navigation, never the initialization promise. */ +import AddToDigitalWallet from '@components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet'; + +import {getPaymentMethods} from '@libs/actions/PaymentMethods'; +import getPlatform from '@libs/getPlatform'; +import Navigation from '@libs/Navigation/Navigation'; +import {handleAddCardToWallet} from '@libs/Wallet'; +import WalletProvisioningError from '@libs/Wallet/WalletProvisioningError'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Card} from '@src/types/onyx'; + +import createMock from '../../../../../utils/createMock'; + +jest.mock('@libs/Wallet', () => ({handleAddCardToWallet: jest.fn()})); +jest.mock('@libs/actions/PaymentMethods', () => ({getPaymentMethods: jest.fn()})); +jest.mock('@libs/getPlatform', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); + +const params = { + card: createMock({cardID: 123}), + cardHolderName: 'Jane', + cardDescription: 'Card', + signedChallenge: {rawId: 'key', type: 'public-key' as const, response: {authenticatorData: 'auth', clientDataJSON: 'client', signature: 'signature'}}, + authenticationMethod: 'BIOMETRIC_FACE' as const, +}; + +describe('AddToDigitalWallet scenario', () => { + beforeEach(() => jest.clearAllMocks()); + + it.each(['success', 'canceled', 'error'] as const)('normalizes native %s without exposing provisioning data', async (status) => { + // Given authorization has produced a structured signed challenge. + jest.mocked(handleAddCardToWallet).mockResolvedValue(status); + const context = {signal: new AbortController().signal}; + + // When the scenario action owns the native wallet handoff. + const response = await AddToDigitalWallet.action(params, context); + + // Then only actual native success is successful, with no secrets in the response body. + expect(handleAddCardToWallet).toHaveBeenCalledWith(params.card, 'Jane', 'Card', { + authentication: {signedChallenge: params.signedChallenge, authenticationMethod: 'BIOMETRIC_FACE'}, + signal: context.signal, + }); + expect(response.httpStatusCode).toBe(status === 'success' ? 200 : undefined); + const expectedReasons = { + success: undefined, + canceled: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, + error: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED, + }; + expect(response.reason).toBe(expectedReasons[status]); + expect(response).not.toHaveProperty('body'); + expect(getPaymentMethods).not.toHaveBeenCalled(); + }); + + it('preserves normalized server failures', async () => { + // Given provisioning failed before the native bridge rejected. + const failure = {httpStatusCode: 403, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.UNRECOGNIZED, message: 'Rejected'}; + jest.mocked(handleAddCardToWallet).mockRejectedValue(new WalletProvisioningError(failure)); + + // When the scenario catches that failure. + const response = await AddToDigitalWallet.action(params, undefined); + + // Then the server status and reason survive without an invented successful status. + expect(response).toEqual(failure); + }); + + it('sanitizes native exceptions instead of exposing card data', async () => { + // Given native code rejected with a potentially sensitive error. + jest.mocked(handleAddCardToWallet).mockRejectedValue(new Error('sensitive native data')); + + // When the scenario normalizes it. + const response = await AddToDigitalWallet.action(params, undefined); + + // Then the existing generic failure screen receives only a local reason. + expect(response).toEqual({httpStatusCode: undefined, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION, message: undefined}); + }); + + it.each([CONST.PLATFORM.IOS, CONST.PLATFORM.ANDROID])('refreshes successful %s tokenization and skips the MFA success screen', async (platform) => { + // Given native tokenization actually succeeded. + jest.mocked(getPlatform).mockReturnValue(platform); + + // When the provider invokes the scenario completion callback. + const result = await AddToDigitalWallet.callback(true, {httpStatusCode: 200}, params); + + // Then refresh occurs once and only iOS retains the added-card navigation. + expect(getPaymentMethods).toHaveBeenCalledTimes(1); + if (platform === CONST.PLATFORM.IOS) { + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_WALLET_CARD_ADDED_TO_WALLET.getRoute('123')); + } else { + expect(Navigation.navigate).not.toHaveBeenCalled(); + } + expect(result).toBe(CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SKIP_OUTCOME_SCREEN); + }); + + it('keeps existing failure UX without refresh or added-card navigation', async () => { + // Given authentication, provisioning, or native tokenization failed or was canceled. + // When the provider completes the failed scenario. + const result = await AddToDigitalWallet.callback(false, {httpStatusCode: undefined}, params); + // Then the shared failure UI owns dismissal/retry and success side effects never run. + expect(result).toBe(CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts index 9be45405a684..0775d39bbbe5 100644 --- a/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts +++ b/tests/unit/components/MultifactorAuthentication/config/scenarios/index.test.ts @@ -4,6 +4,23 @@ import type {MultifactorAuthenticationScenarioConfigRecord} from '@components/Mu import CONST from '@src/CONST'; describe('MultifactorAuthentication Scenarios Config', () => { + it('registers native-only digital wallet authentication with inherited failure UX', () => { + // Given the resolved scenario registry used by the MFA provider. + const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as Record; + + // When a UK/EU card starts wallet authentication. + const scenario = config['ADD-TO-DIGITAL-WALLET']; + + // Then native HSM authentication owns provisioning and retains the shared failure screens. + expect(scenario).toBeDefined(); + expect(scenario.allowedAuthenticationMethods).toEqual([CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM]); + expect(scenario.action).toBeDefined(); + expect(scenario.callback).toBeDefined(); + expect(scenario.defaultClientFailureScreen).toBeDefined(); + expect(scenario.defaultServerFailureScreen).toBeDefined(); + expect(scenario.modals.cancelConfirmation).toBeDefined(); + }); + it('should have all required properties for every scenario config', () => { const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG as MultifactorAuthenticationScenarioConfigRecord; diff --git a/tests/unit/libs/Wallet.test.ts b/tests/unit/libs/Wallet.test.ts new file mode 100644 index 000000000000..5f3d7999fa8c --- /dev/null +++ b/tests/unit/libs/Wallet.test.ts @@ -0,0 +1,358 @@ +import {render, waitFor} from '@testing-library/react-native'; + +import {useMultifactorAuthenticationActions} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext'; +import {MultifactorAuthenticationStateProvider} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders'; + +import * as API from '@libs/API'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; +import type * as WalletModule from '@libs/Wallet'; +import {handleAddCardToWallet as addAndroid} from '@libs/Wallet/index.android'; +import {handleAddCardToWallet as addIOS} from '@libs/Wallet/index.ios'; +import type {WalletAuthentication} from '@libs/Wallet/types'; + +import CONST from '@src/CONST'; +import type {Card, ProvisioningCardData} from '@src/types/onyx'; + +/** Exercises the actual platform handoffs with the API and native bridge mocked. */ +import {addCardToAppleWallet, addCardToGoogleWallet, getSecureWalletInfo} from '@expensify/react-native-wallet'; +import React, {useEffect} from 'react'; + +import pkg from '../../../package.json'; +import createMock from '../../utils/createMock'; + +jest.mock('@libs/API'); +jest.mock('@expensify/react-native-wallet', () => ({ + addCardToAppleWallet: jest.fn(), + addCardToGoogleWallet: jest.fn(), + getSecureWalletInfo: jest.fn(), + checkWalletAvailability: jest.fn(), + getCardStatusByIdentifier: jest.fn(), + getCardStatusBySuffix: jest.fn(), +})); + +type APIResponse = NonNullable>>; +const card = createMock({cardID: 123, lastFourPAN: '1234'}); +const authentication: WalletAuthentication = { + signedChallenge: {rawId: 'key', type: 'public-key', response: {authenticatorData: 'auth', clientDataJSON: 'client', signature: 'signature'}}, + authenticationMethod: 'BIOMETRIC_FACE', +}; +const iosPayload = {encryptedPassData: 'encrypted', activationData: 'activation', ephemeralPublicKey: 'ephemeral'}; +const androidPayload: Pick = { + network: 'VISA', + opaquePaymentCard: 'opaque', + lastDigits: '1234', + userAddress: { + name: 'Jane', + address1: 'Street', + address2: '', + state: 'London', + city: 'London', + country: 'GB', + // eslint-disable-next-line @typescript-eslint/naming-convention -- Match the backend field so the test exercises its native address mapping. + postal_code: 'SW1A', + phone: '+441234', + }, +}; +const mockRequest = jest.mocked(API.makeRequestWithSideEffects); +const mockApple = jest.mocked(addCardToAppleWallet); +const mockGoogle = jest.mocked(addCardToGoogleWallet); +const {handleAddCardToWallet: addWeb} = jest.requireActual('@libs/Wallet/index.ts'); + +describe('Wallet platform provisioning', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(getSecureWalletInfo).mockResolvedValue({deviceID: 'device', walletAccountID: 'wallet'}); + mockGoogle.mockResolvedValue('success'); + mockApple.mockImplementation(async (_data, issuer) => { + await issuer('nonce', 'nonce-signature', ['certificate']); + return 'success'; + }); + }); + + it('waits for the iOS issuer callback and sends signed fields only on CreateDigitalWallet', async () => { + // Given a native sheet that has not yet supplied its nonce and certificates. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...iosPayload})); + let issuerCallback: Parameters[1] | undefined; + let finish: ((status: 'success') => void) | undefined; + mockApple.mockImplementation((_data, issuer) => { + issuerCallback = issuer; + return new Promise((resolve) => { + finish = resolve; + }); + }); + const pending = addIOS(card, 'Jane', 'Card', {authentication}); + expect(mockRequest).not.toHaveBeenCalled(); + + // When Apple supplies the actual issuer inputs after presentation. + const payload = await issuerCallback?.('actual-nonce', 'actual-signature', ['actual-certificate']); + finish?.('success'); + await expect(pending).resolves.toBe('success'); + + // Then serialization happens once at the API boundary, without UI metadata. + expect(payload).toEqual(iosPayload); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, { + platform: 'ios', + appVersion: pkg.version, + cardID: 123, + nonce: 'actual-nonce', + nonceSignature: 'actual-signature', + certificates: JSON.stringify({certificates: ['actual-certificate']}), + signedChallenge: JSON.stringify(authentication.signedChallenge), + authenticationMethod: authentication.authenticationMethod, + }); + }); + + it('maps signed Android provisioning into Google native card data', async () => { + // Given a successful provisioning response and secure device/wallet identifiers. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...androidPayload})); + + // When signed provisioning completes before tokenization. + await expect(addAndroid(card, 'Jane', 'Card', {authentication})).resolves.toBe('success'); + + // Then only API fields are sent and the existing native address mapping is retained. + expect(mockRequest).toHaveBeenCalledWith(SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, { + platform: 'android', + appVersion: pkg.version, + cardID: 123, + deviceID: 'device', + walletAccountID: 'wallet', + signedChallenge: JSON.stringify(authentication.signedChallenge), + authenticationMethod: authentication.authenticationMethod, + }); + expect(mockGoogle).toHaveBeenCalledWith({ + network: 'VISA', + opaquePaymentCard: 'opaque', + cardHolderName: 'Jane', + lastDigits: '1234', + userAddress: { + name: 'Jane', + addressOne: 'Street', + addressTwo: '', + administrativeArea: 'London', + locality: 'London', + countryCode: 'GB', + postalCode: 'SW1A', + phoneNumber: '+441234', + }, + }); + }); + + it.each(['ios', 'android'] as const)('keeps %s legacy provisioning unsigned', async (platform) => { + // Given a legacy card with no authentication context. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...(platform === 'ios' ? iosPayload : androidPayload)})); + + // When its existing platform helper is used. + await (platform === 'ios' ? addIOS : addAndroid)(card, 'Jane', 'Card'); + + // Then the command stays unchanged and no authentication fields are introduced. + expect(mockRequest).toHaveBeenCalledTimes(1); + const params = mockRequest.mock.calls.at(0)?.[1]; + expect(params).not.toHaveProperty('signedChallenge'); + expect(params).not.toHaveProperty('authenticationMethod'); + }); + + it('accepts a UK address without a province or second address line', async () => { + // Given a valid UK address where the province is empty and address2 is absent. + const payload = {...androidPayload, userAddress: {...androidPayload.userAddress, address2: undefined, state: ''}}; + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...payload})); + // When signed provisioning maps that address. + await expect(addAndroid(card, 'Jane', 'Card', {authentication})).resolves.toBe('success'); + // Then optional address content is preserved without rejecting valid UK data. + expect(mockGoogle).toHaveBeenCalledWith(expect.objectContaining({userAddress: expect.objectContaining({administrativeArea: '', addressTwo: undefined})})); + }); + + it.each([400, 500])('preserves API %s when the iOS bridge subsequently rejects', async (jsonCode) => { + // Given an issuer rejection followed by a separate native rejection. + mockRequest.mockResolvedValue(createMock({jsonCode, message: 'Rejected'})); + mockApple.mockImplementation(async (_data, issuer) => { + await issuer('nonce', 'signature', []).catch(() => undefined); + throw new Error('native rejection'); + }); + + // When the signed issuer request fails. + const pending = addIOS(card, 'Jane', 'Card', {authentication}); + + // Then the original normalized API failure survives and no unsigned retry occurs. + await expect(pending).rejects.toMatchObject({ + response: { + httpStatusCode: jsonCode, + reason: jsonCode === 400 ? CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.UNRECOGNIZED : CONST.MULTIFACTOR_AUTHENTICATION.REASON.SERVER_ERRORS.UNRECOGNIZED, + }, + }); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it.each(['ios', 'android'] as const)('rejects malformed %s success data without native continuation', async (platform) => { + // Given a successful HTTP result without usable provisioning fields. + mockRequest.mockResolvedValue(createMock({jsonCode: 200})); + + // When secure provisioning runs. + const pending = (platform === 'ios' ? addIOS : addAndroid)(card, 'Jane', 'Card', {authentication}); + + // Then the helper fails closed instead of handing empty data to the native SDK. + await expect(pending).rejects.toMatchObject({response: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED}}); + expect(mockGoogle).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['ios', {...iosPayload, activationData: ''}], + ['ios', {...iosPayload, encryptedPassData: 123}], + ['android', {...androidPayload, opaquePaymentCard: ''}], + ['android', {...androidPayload, userAddress: {...androidPayload.userAddress, state: undefined}}], + ] as const)('validates individual %s provisioning fields', async (platform, payload) => { + // Given an otherwise successful response has one unusable field. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...payload})); + // When the signed platform helper validates native input. + const pending = (platform === 'ios' ? addIOS : addAndroid)(card, 'Jane', 'Card', {authentication}); + // Then malformed data is rejected before native continuation. + await expect(pending).rejects.toMatchObject({response: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNRECOGNIZED}}); + expect(mockGoogle).not.toHaveBeenCalled(); + }); + + it.each(['ios', 'android'] as const)('rejects transport failures on %s without unsigned fallback', async (platform) => { + // Given a rejected provisioning transport. + mockRequest.mockRejectedValue(new Error('transport failed')); + + // When the signed platform helper requests card data. + const pending = (platform === 'ios' ? addIOS : addAndroid)(card, 'Jane', 'Card', {authentication}); + + // Then a normalized failure replaces raw transport details. + await expect(pending).rejects.toMatchObject({response: {reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_API_RESPONSE}}); + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockGoogle).not.toHaveBeenCalled(); + }); + + it('rejects a second iOS issuer callback instead of replaying a signed challenge', async () => { + // Given Apple's Try Again reenters the issuer callback after successful provisioning. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...iosPayload})); + mockApple.mockImplementation(async (_data, issuer) => { + await issuer('first', 'signature', []); + await issuer('second', 'signature', []); + return 'success'; + }); + + // When the native wrapper attempts to reuse that authentication. + const pending = addIOS(card, 'Jane', 'Card', {authentication}); + + // Then a fresh user authentication is required and no second request is sent. + await expect(pending).rejects.toThrow('fresh authentication'); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it.each([400, 500])('does not continue Google tokenization after API %s', async (jsonCode) => { + // Given the server rejects signed Android provisioning. + mockRequest.mockResolvedValue(createMock({jsonCode})); + // When the Android helper receives that response. + const pending = addAndroid(card, 'Jane', 'Card', {authentication}); + // Then no native tokenization or unsigned retry occurs. + await expect(pending).rejects.toMatchObject({response: {httpStatusCode: jsonCode}}); + expect(mockGoogle).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('does not accept an iOS success status without an issuer request', async () => { + // Given native code unexpectedly reports success without issuer provisioning. + mockApple.mockResolvedValue('success'); + // When the secure helper receives that status. + const pending = addIOS(card, 'Jane', 'Card', {authentication}); + // Then no successful MFA result can be synthesized without provisioning. + await expect(pending).rejects.toThrow('without provisioning'); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('does not return issuer data after an in-flight iOS attempt is aborted', async () => { + // Given cancellation occurs while the issuer request is pending. + const controller = new AbortController(); + mockRequest.mockImplementation(async () => { + controller.abort(); + return createMock({jsonCode: 200, ...iosPayload}); + }); + // When the response arrives for the abandoned attempt. + const pending = addIOS(card, 'Jane', 'Card', {authentication, signal: controller.signal}); + // Then the callback rejects rather than passing issuer data to native code (dismissal remains deferred). + await expect(pending).rejects.toThrow('canceled'); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it.each(['canceled', 'error'] as const)('preserves native Google %s after valid provisioning', async (status) => { + // Given provisioning succeeds but Google tokenization does not. + mockRequest.mockResolvedValue(createMock({jsonCode: 200, ...androidPayload})); + mockGoogle.mockResolvedValue(status); + // When the native Google flow completes. + const result = await addAndroid(card, 'Jane', 'Card', {authentication}); + // Then tokenization status, not the earlier API success, controls the scenario outcome. + expect(result).toBe(status); + }); + + it.each(['canceled', 'error'] as const)('preserves native iOS %s without inventing provisioning success', async (status) => { + // Given native presentation ends before any issuer callback. + mockApple.mockResolvedValue(status); + + // When the signed helper runs. + const result = await addIOS(card, 'Jane', 'Card', {authentication}); + + // Then native cancellation and failure stay distinct and no API request occurs. + expect(result).toBe(status); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('does not start Google tokenization after cancellation while provisioning is pending', async () => { + // Given a request that will finish after the MFA attempt is canceled. + const controller = new AbortController(); + mockRequest.mockImplementation(async () => { + controller.abort(); + return createMock({jsonCode: 200, ...androidPayload}); + }); + + // When cancellation occurs before native continuation. + const result = await addAndroid(card, 'Jane', 'Card', {authentication, signal: controller.signal}); + + // Then Google receives no card data from the abandoned attempt. + expect(result).toBe('canceled'); + expect(mockGoogle).not.toHaveBeenCalled(); + }); + + it('does not launch Google Wallet after the owning provider unmounts during signed provisioning', async () => { + // Given the real Android helper awaits its signed API response using the provider-owned signal. + let signal: AbortSignal | undefined; + function Probe() { + const {getActionExecution} = useMultifactorAuthenticationActions(); + useEffect(() => { + signal = getActionExecution().controller.signal; + }, [getActionExecution]); + return null; + } + let finishRequest: ((response: APIResponse) => void) | undefined; + mockRequest.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRequest = resolve; + }), + ); + const view = render(React.createElement(MultifactorAuthenticationStateProvider, null, React.createElement(Probe))); + const pending = addAndroid(card, 'Jane', 'Card', {authentication, signal}); + await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); + + // When AuthScreens removes its provider before the response arrives. + view.unmount(); + finishRequest?.(createMock({jsonCode: 200, ...androidPayload})); + + // Then the late signed response cannot launch native Google tokenization. + await expect(pending).resolves.toBe('canceled'); + expect(signal?.aborted).toBe(true); + expect(mockGoogle).not.toHaveBeenCalled(); + }); + + it('keeps the web fallback inert', async () => { + // Given a non-native platform. + // When a helper is called defensively with signed authentication. + const pending = addWeb(card, 'Jane', 'Card', {authentication}); + // Then it cannot provision or invoke either native wallet. + await expect(pending).rejects.toThrow('not supported'); + expect(mockRequest).not.toHaveBeenCalled(); + expect(mockApple).not.toHaveBeenCalled(); + expect(mockGoogle).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/sanitizeLogParamsTest.ts b/tests/unit/sanitizeLogParamsTest.ts index fc6cbe8a23a7..166a7d308768 100644 --- a/tests/unit/sanitizeLogParamsTest.ts +++ b/tests/unit/sanitizeLogParamsTest.ts @@ -1,6 +1,29 @@ import sanitizeLogParams, {sanitizeUrlForLogging} from '@libs/sanitizeLogParams'; describe('sanitizeLogParams', () => { + it('redacts wallet authentication and provisioning material from API logs', () => { + // Given signed wallet request fields and encrypted provisioning fields can pass through the API logger. + const params = { + command: 'CreateDigitalWallet', + cardID: 123, + data: { + signedChallenge: '{"signature":"secret"}', + nonce: 'nonce', + nonceSignature: 'signature', + certificates: '["certificate"]', + encryptedPassData: 'encrypted', + activationData: 'activation', + ephemeralPublicKey: 'ephemeral', + opaquePaymentCard: 'opaque', + }, + }; + // When the shared API logging boundary sanitizes these parameters. + const result = sanitizeLogParams(params); + // Then secrets are redacted without changing the actual wire input or nonsensitive identifiers. + expect(result).toEqual({command: 'CreateDigitalWallet', cardID: 123, data: Object.fromEntries(Object.keys(params.data).map((key) => [key, '']))}); + expect(params.data.signedChallenge).toBe('{"signature":"secret"}'); + }); + it('redacts known sensitive keys', () => { const result = sanitizeLogParams({authToken: 'secret', password: 'hunter2', token: 'abc'}); expect(result).toEqual({authToken: '', password: '', token: ''}); From f2aef005ac82efb40d291d67abcddac7eaa6cf66 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Thu, 15 Oct 2026 23:39:25 -0400 Subject: [PATCH 2/5] Clean up unnecessary shared MFA hardening --- ...AuthenticationComposedContextProviders.tsx | 32 +- .../MultifactorAuthenticationMainContext.tsx | 96 +--- .../Context/types.ts | 1 - ...tifactorAuthenticationMainContext.test.tsx | 417 ++++-------------- tests/unit/libs/Wallet.test.ts | 37 -- 5 files changed, 100 insertions(+), 483 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx index 9505e890a66e..bcebfbf8e4ac 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders.tsx @@ -1,8 +1,6 @@ import type {ReactNode} from 'react'; -import React, {useEffect, useReducer, useRef} from 'react'; - -import type {Action} from './types'; +import React, {useMemo, useReducer} from 'react'; import MultifactorAuthenticationActionsContext from './MultifactorAuthenticationActionsContext'; import {MultifactorAuthenticationContextProvider} from './MultifactorAuthenticationMainContext'; @@ -14,32 +12,8 @@ type MultifactorAuthenticationProviderProps = { }; function MultifactorAuthenticationStateProvider({children}: MultifactorAuthenticationProviderProps) { - const [state, reducerDispatch] = useReducer(stateReducer, DEFAULT_STATE); - const executionRef = useRef({controller: new AbortController(), started: false}); - useEffect(() => { - // StrictMode replays setup after cleanup; never reuse its aborted generation. - if (executionRef.current.controller.signal.aborted) { - executionRef.current = {controller: new AbortController(), started: false}; - } - return () => { - executionRef.current.controller.abort(); - // Also invalidate cancellation outcome callbacks, which intentionally run with an aborted signal. - executionRef.current = {...executionRef.current}; - }; - }, []); - const dispatch = (action: Action) => { - // Invalidate synchronously, including direct offline close and resets outside the main provider. - // Duplicate INIT remains the reducer's responsibility and must not abort the active attempt. - if (action.type === 'RESET' || action.type === 'REREGISTER') { - executionRef.current.controller.abort(); - executionRef.current = {controller: new AbortController(), started: false}; - } else if (action.type === 'CLOSE_MODAL') { - executionRef.current.controller.abort(); - } - reducerDispatch(action); - }; - const getActionExecution = () => executionRef.current; - const actions = {dispatch, getActionExecution}; + const [state, dispatch] = useReducer(stateReducer, DEFAULT_STATE); + const actions = useMemo(() => ({dispatch}), [dispatch]); return ( diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index c67cd146b9bb..524a18b26b1f 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -65,7 +65,8 @@ type MultifactorAuthenticationContextProviderProps = { function MultifactorAuthenticationContextProvider({children}: MultifactorAuthenticationContextProviderProps) { const state = useMultifactorAuthenticationState(); - const {dispatch, getActionExecution} = useMultifactorAuthenticationActions(); + const {dispatch} = useMultifactorAuthenticationActions(); + const actionControllerRef = useRef(undefined); const biometrics = useBiometrics(); const {accountID} = useCurrentUserPersonalDetails(); @@ -110,13 +111,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent */ const handleCallback = useCallback( async (isSuccessful: boolean) => { - const execution = getActionExecution(); - const wasAborted = execution.controller.signal.aborted; - // Cancellation still needs its failure outcome, but reset/unmount must invalidate it too. - const isCurrentExecution = () => getActionExecution() === execution && (!execution.controller.signal.aborted || (!isSuccessful && wasAborted)); const {error, scenario, scenarioResponse, payload} = state; - if (!scenario || !isCurrentExecution()) { + if (!scenario) { return; } @@ -128,9 +125,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }; const callbackResponse = await scenarioConfig.callback?.(isSuccessful, callbackInput, payload); - if (!isCurrentExecution()) { - return; - } addMFABreadcrumb('Flow completed', { isSuccessful, @@ -141,9 +135,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }); const endState = await captureCredentialsState(); - if (!isCurrentExecution()) { - return; - } trackMFAFlowOutcome({ isSuccessful, @@ -170,14 +161,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Wait out any callback-initiated transition (e.g. RHP goBack) before pushing the outcome screen. // Android raced the two animations and the RHP slide-out leaked through the outgoing screen. const outcomeScreen = isSuccessful ? SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS : SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE; - Navigation.runAfterTransition(() => { - if (!isCurrentExecution()) { - return; - } - mfaNavigate(outcomeScreen); - }); + Navigation.runAfterTransition(() => mfaNavigate(outcomeScreen)); }, - [captureCredentialsState, dispatch, getActionExecution, state], + [captureCredentialsState, dispatch, state], ); /** @@ -185,8 +171,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent * Uses if statements to determine and execute the next step in the flow. */ const process = useCallback(async () => { - // Capture before any prerequisite awaits so an old continuation cannot adopt a reset's controller. - const execution = getActionExecution(); const { error, continuableError, @@ -223,11 +207,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } addMFABreadcrumb('Flow error', error, 'error'); - await handleCallback(false); - return; - } - - if (execution.controller.signal.aborted) { + handleCallback(false); return; } @@ -246,11 +226,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } // 2b. Check if the device can actually perform the allowed authentication method - const doesDeviceSupportAuthenticationMethod = await biometrics.doesDeviceSupportAuthenticationMethod(); - if (execution.controller.signal.aborted) { - return; - } - if (!doesDeviceSupportAuthenticationMethod) { + if (!(await biometrics.doesDeviceSupportAuthenticationMethod())) { const reason = biometrics.deviceCheckFailureReason; const message = `Device check failed (deviceVerificationType: ${biometrics.deviceVerificationType})`; addMFABreadcrumb('Device check failed', {reason, deviceVerificationType: biometrics.deviceVerificationType, message}, 'warning'); @@ -260,9 +236,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // 3. Check if registration is required (local credentials not known to server yet) const isRegistrationRequired = !(await biometrics.areLocalCredentialsKnownToServer()) && !isRegistrationComplete; - if (execution.controller.signal.aborted) { - return; - } if (isRegistrationRequired) { // Need validate code before registration @@ -276,9 +249,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Request registration challenge after validateCode is set if (!registrationChallenge) { const {challenge, httpStatusCode, reason, message} = await requestRegistrationChallenge(validateCode); - if (execution.controller.signal.aborted) { - return; - } if (!isHttpSuccess(httpStatusCode) || !challenge) { const challengeError = createMFAErrorFromApiResponse(httpStatusCode, reason, message); @@ -300,9 +270,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } await biometrics.register(async (result: RegisterResult) => { - if (execution.controller.signal.aborted) { - return; - } addMFABreadcrumb('Biometric registration completed', result.success ? {success: true} : result.error, result.success ? 'info' : 'error'); if (!result.success) { @@ -313,9 +280,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const registrationResponse = await processRegistration({ keyInfo: result.keyInfo, }); - if (execution.controller.signal.aborted) { - return; - } addMFABreadcrumb( 'Backend registration completed', @@ -350,9 +314,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent // Request authorization challenge if not already fetched if (!authorizationChallenge) { const {challenge, httpStatusCode, reason, message} = await requestAuthorizationChallenge(); - if (execution.controller.signal.aborted) { - return; - } if (!isHttpSuccess(httpStatusCode) || !challenge) { const challengeError = createMFAErrorFromApiResponse(httpStatusCode, reason, message); @@ -366,17 +327,11 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } - if (execution.controller.signal.aborted || execution.started) { - return; - } await biometrics.authorize( { challenge: authorizationChallenge, }, async (result: AuthorizeResult) => { - if (execution.controller.signal.aborted || execution.started) { - return; - } addMFABreadcrumb( 'Biometric authorization completed', result.success ? {success: true, authMethod: result.authenticationMethod.code} : result.error, @@ -395,9 +350,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent ) { addMFABreadcrumb('Authorization key reset', result.error, 'warning'); await biometrics.deleteLocalKeysForAccount(); - if (execution.controller.signal.aborted) { - return; - } dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: false}); dispatch({type: 'SET_AUTHORIZATION_CHALLENGE', payload: undefined}); } else { @@ -407,7 +359,8 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } // Call backend with signed challenge - execution.started = true; + const controller = new AbortController(); + actionControllerRef.current = controller; const scenarioAPIResponse = await processScenarioAction( scenario.action, { @@ -415,11 +368,11 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent authenticationMethod: result.authenticationMethod.marqetaValue, ...payload, }, - {signal: execution.controller.signal}, + {signal: controller.signal}, ); - if (execution.controller.signal.aborted) { - return; + if (actionControllerRef.current === controller) { + actionControllerRef.current = undefined; } addMFABreadcrumb( @@ -453,8 +406,8 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } // 5. All steps completed - invoke callback to determine whether to show the outcome screen - await handleCallback(true); - }, [biometrics, dispatch, getActionExecution, handleCallback, isOffline, state, promptType]); + handleCallback(true); + }, [biometrics, dispatch, handleCallback, isOffline, state, promptType]); /** * Drives the MFA state machine forward whenever relevant state changes occur. @@ -472,11 +425,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } - const execution = getActionExecution(); process().catch((error: unknown) => { - if (execution.controller.signal.aborted) { - return; - } addMFABreadcrumb('Unhandled error', {message: getErrorMessage(error)}, 'error'); dispatch({ type: 'SET_ERROR', @@ -532,12 +481,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent return; } - const execution = getActionExecution(); - const startState = await captureCredentialsState(); - if (execution.controller.signal.aborted) { - return; - } - startStateRef.current = startState; + startStateRef.current = await captureCredentialsState(); const breadcrumbData = { scenario, @@ -562,7 +506,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent }, }); }, - [captureCredentialsState, dispatch, getActionExecution, isOffline, platform, state.scenario], + [captureCredentialsState, dispatch, isOffline, platform, state.scenario], ); /** @@ -571,8 +515,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent * close the modal directly instead to avoid getting stuck. */ const cancel = useCallback(async () => { - const execution = getActionExecution(); - execution.controller.abort(); + actionControllerRef.current?.abort(); if (!state.scenario || isOffline) { addMFABreadcrumb('Flow cancelled - closing directly', {hasScenario: !!state.scenario, isOffline}, 'warning'); dispatch({type: 'CLOSE_MODAL'}); @@ -581,9 +524,6 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent if (state.scenario.onCancel) { const error = await state.scenario.onCancel(state.payload); - if (getActionExecution() !== execution) { - return; - } addMFABreadcrumb('Flow cancelled with onCancel', error, 'warning'); dispatch({type: 'SET_ERROR', payload: error}); return; @@ -594,7 +534,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent type: 'SET_ERROR', payload: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'User cancelled the MFA flow'), }); - }, [dispatch, getActionExecution, isOffline, state.scenario, state.payload]); + }, [dispatch, isOffline, state.scenario, state.payload]); const requestCancel = () => { if (state.isFlowComplete) { diff --git a/src/components/MultifactorAuthentication/Context/types.ts b/src/components/MultifactorAuthentication/Context/types.ts index f3b46fbd2d1a..cf896b034a08 100644 --- a/src/components/MultifactorAuthentication/Context/types.ts +++ b/src/components/MultifactorAuthentication/Context/types.ts @@ -91,7 +91,6 @@ type MultifactorAuthenticationStateContextType = MultifactorAuthenticationState; /** Context value for actions - dispatch to update state */ type MultifactorAuthenticationActionsContextType = { dispatch: (action: Action) => void; - getActionExecution: () => {controller: AbortController; started: boolean}; }; export type {MultifactorAuthenticationState, Action, MultifactorAuthenticationStateContextType, MultifactorAuthenticationActionsContextType}; diff --git a/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx b/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx index 425e3a6977cb..f5368aab3d33 100644 --- a/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx @@ -1,26 +1,28 @@ -/** Composed providers exercise signing, long-running action cancellation, and reset isolation. */ +/** Composed providers exercise signing, enrollment, and explicit wallet action cancellation. */ import {act, render, waitFor} from '@testing-library/react-native'; import type {AuthorizeResult, RegisterResult} from '@components/MultifactorAuthentication/biometrics/shared/types'; -import {MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG} from '@components/MultifactorAuthentication/config'; import {useMultifactorAuthenticationActions} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext'; import MultifactorAuthenticationContextProviders from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders'; import {useMultifactorAuthentication} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext'; import {useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationStateContext'; -import {navigate as mfaNavigate} from '@components/MultifactorAuthentication/mfaNavigation'; import {getPaymentMethods} from '@libs/actions/PaymentMethods'; import {requestValidateCodeAction} from '@libs/actions/User'; +import * as API from '@libs/API'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import Navigation from '@libs/Navigation/Navigation'; import {handleAddCardToWallet} from '@libs/Wallet'; +import {handleAddCardToWallet as addAndroid} from '@libs/Wallet/index.android'; import {authorizeTransaction, denyTransaction, registerAuthenticationKey} from '@userActions/MultifactorAuthentication'; import CONST from '@src/CONST'; -import type {Card} from '@src/types/onyx'; +import type {Card, ProvisioningCardData} from '@src/types/onyx'; import type {TokenizationStatus} from '@expensify/react-native-wallet'; +import {addCardToGoogleWallet, getSecureWalletInfo} from '@expensify/react-native-wallet'; import React, {useEffect} from 'react'; import createMock from '../../../utils/createMock'; @@ -49,6 +51,8 @@ jest.mock('@components/MultifactorAuthentication/biometrics/useBiometrics', () = jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: () => ({accountID: 123})})); jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: mockIsOffline})})); jest.mock('@libs/Wallet', () => ({handleAddCardToWallet: jest.fn()})); +jest.mock('@libs/API'); +jest.mock('@expensify/react-native-wallet', () => ({addCardToGoogleWallet: jest.fn(), getSecureWalletInfo: jest.fn()})); jest.mock('@libs/actions/PaymentMethods', () => ({getPaymentMethods: jest.fn()})); jest.mock('@libs/actions/User', () => ({requestValidateCodeAction: jest.fn()})); jest.mock('@libs/getPlatform', () => ({__esModule: true, default: () => 'ios'})); @@ -84,9 +88,10 @@ function Probe() { return null; } const payload = {card: createMock({cardID: 123}), cardHolderName: 'Jane', cardDescription: 'Card'}; -async function startScenario(scenarioPayload = payload) { +const mockRequest = jest.mocked(API.makeRequestWithSideEffects); +async function startScenario() { await act(async () => { - await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, scenarioPayload); + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, payload); actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); }); } @@ -106,8 +111,6 @@ describe('Wallet MFA composed providers', () => { .mockResolvedValue({httpStatusCode: 200, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED, message: undefined}); }); - afterEach(() => jest.restoreAllMocks()); - it('does not launch a wallet until authorization signs, then completes through the scenario callback', async () => { // Given the real providers and authorization waiting for user authentication. let finishAuthorization: ((result: AuthorizeResult) => Promise) | undefined; @@ -212,13 +215,16 @@ describe('Wallet MFA composed providers', () => { expect(getPaymentMethods).not.toHaveBeenCalled(); }); - it.each(['cancel', 'offline close', 'reset'] as const)('invalidates a pending wallet action on %s and ignores its late result', async (ending) => { - // Given tokenization is still pending in the composed MFA providers. - let finishWallet: ((status: TokenizationStatus) => void) | undefined; - jest.mocked(handleAddCardToWallet).mockImplementation( + it.each(['confirmed cancel', 'offline close'] as const)('does not launch Google Wallet after %s while signed provisioning is pending', async (ending) => { + // Given the real composed providers have signed and the real Android helper is awaiting CreateDigitalWallet. + type APIResponse = NonNullable>>; + let finishRequest: ((response: APIResponse) => void) | undefined; + jest.mocked(handleAddCardToWallet).mockImplementation(addAndroid); + jest.mocked(getSecureWalletInfo).mockResolvedValue({deviceID: 'device', walletAccountID: 'wallet'}); + mockRequest.mockImplementationOnce( () => new Promise((resolve) => { - finishWallet = resolve; + finishRequest = resolve; }), ); const view = render( @@ -227,114 +233,72 @@ describe('Wallet MFA composed providers', () => { , ); await startScenario(); - await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); + expect(mockRequest).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET, + expect.objectContaining({signedChallenge: JSON.stringify(signedResult.signedChallenge), authenticationMethod: 'BIOMETRIC_FACE'}), + ); const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; - // When cancellation or reset ends that generation, before native completion. - await act(async () => { - if (ending === 'reset') { - actions.dispatch({type: 'RESET'}); - } else if (ending === 'offline close') { - mockIsOffline = true; - view.rerender( - - - , - ); - } else { - controls.confirmCancel(); - } - }); + // When back navigation cancels the flow before the already-sent server request succeeds. if (ending === 'offline close') { - await act(async () => controls.requestCancel()); + mockIsOffline = true; + view.rerender( + + + , + ); } - expect(signal?.aborted).toBe(true); - if (ending === 'reset') { - // A newer flow must not inherit the previous attempt's eventual success. - mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); - await startScenario(); + await act(async () => controls.requestCancel()); + if (ending === 'confirmed cancel') { + expect(state.isCancelConfirmVisible).toBe(true); + expect(signal?.aborted).toBe(false); + await act(async () => controls.confirmCancel()); } + expect(signal?.aborted).toBe(true); + const provisioningData: Pick = { + network: 'VISA', + opaquePaymentCard: 'opaque', + lastDigits: '1234', + userAddress: { + name: 'Jane', + address1: 'Street', + state: '', + city: 'London', + country: 'GB', + // eslint-disable-next-line @typescript-eslint/naming-convention -- Match the provisioning API response field. + postal_code: 'SW1A', + phone: '+441234', + }, + }; await act(async () => { - finishWallet?.('success'); + finishRequest?.(createMock({jsonCode: 200, ...provisioningData})); }); - // Then late tokenization cannot refresh, navigate, or mark a reset/new flow authorized. + // Then cancellation blocks native continuation and success side effects, without undoing the server request. + expect(addCardToGoogleWallet).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledTimes(1); expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); expect(state.isAuthorizationComplete).toBe(false); expect(state.scenarioResponse).toBeUndefined(); - if (ending === 'reset') { - expect(state.error).toBeUndefined(); - expect(state.isFlowComplete).toBe(false); - } if (ending === 'offline close') { expect(state.isModalOpen).toBe(false); + } else { + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED); } }); - it.each(['doesDeviceSupportAuthenticationMethod', 'areLocalCredentialsKnownToServer'] as const)( - 'does not adopt a replacement card generation after a pending %s', - async (prerequisite) => { - // Given the first card already has a challenge, but a repeated prerequisite check is pending. - mockBiometrics.authorize.mockImplementation(async () => {}); - render( - - - , - ); - await startScenario(); - await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalledTimes(1)); - let finishPrerequisite: ((value: boolean) => void) | undefined; - mockBiometrics[prerequisite].mockImplementationOnce( - () => - new Promise((resolve) => { - finishPrerequisite = resolve; - }), - ); - await act(async () => actions.dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: true})); - expect(finishPrerequisite).toBeDefined(); - const oldExecution = actions.getActionExecution(); - - // When cancellation finishes and a different card starts before the old check resolves. - await act(async () => controls.confirmCancel()); - await waitFor(() => expect(state.isFlowComplete).toBe(true)); - await act(async () => actions.dispatch({type: 'RESET'})); - const replacementPayload = {...payload, card: createMock({cardID: 456})}; - await startScenario(replacementPayload); - await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalledTimes(2)); - const finishReplacement = mockBiometrics.authorize.mock.calls.at(-1)?.[1]; - const replacementExecution = actions.getActionExecution(); - const replacementState = state; - const navigationCount = jest.mocked(mfaNavigate).mock.calls.length; - await act(async () => { - finishPrerequisite?.(true); - }); - - // Then the old continuation cannot sign, provision, or change the replacement; its own signing still works. - expect(oldExecution.controller.signal.aborted).toBe(true); - expect(mockBiometrics.authorize).toHaveBeenCalledTimes(2); - expect(handleAddCardToWallet).not.toHaveBeenCalled(); - expect(replacementExecution.started).toBe(false); - expect(state).toBe(replacementState); - expect(mfaNavigate).toHaveBeenCalledTimes(navigationCount); - await act(async () => { - await finishReplacement?.(signedResult); - }); - await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); - expect(handleAddCardToWallet).toHaveBeenCalledTimes(1); - expect(jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[0].cardID).toBe(456); - }, - ); - - it('aborts pending wallet work when the composed providers unmount', async () => { - // Given signed wallet provisioning has not completed before AuthScreens is removed. - let finishWallet: ((status: TokenizationStatus) => void) | undefined; + it.each(['canceled', 'success'] as const)('keeps explicit cancellation unsuccessful when the wallet later returns %s', async (status) => { + // Given the signed wallet action is pending and has received its cancellation signal. + let finishWallet: ((result: TokenizationStatus) => void) | undefined; jest.mocked(handleAddCardToWallet).mockImplementation( () => new Promise((resolve) => { finishWallet = resolve; }), ); - const view = render( + render( , @@ -343,101 +307,23 @@ describe('Wallet MFA composed providers', () => { await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; - // When the provider unmounts and the abandoned action eventually reports success. - view.unmount(); + // When explicit cancellation precedes a native canceled or late-success status. + await act(async () => controls.confirmCancel()); await act(async () => { - finishWallet?.('success'); + finishWallet?.(status); }); - // Then its signal is aborted and neither success refresh nor success navigation can run. + // Then the wallet scenario normalizes the aborted result without successful authorization or refresh. expect(signal?.aborted).toBe(true); + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED); + expect(state.isAuthorizationComplete).toBe(false); + expect(state.scenarioResponse).toBeUndefined(); expect(getPaymentMethods).not.toHaveBeenCalled(); expect(Navigation.navigate).not.toHaveBeenCalled(); }); - it('keeps a usable execution after StrictMode effect cleanup is replayed', async () => { - // Given provider setup and cleanup are replayed by React StrictMode. - const signals: AbortSignal[] = []; - function LifecycleProbe() { - const {getActionExecution} = useMultifactorAuthenticationActions(); - useEffect(() => { - signals.push(getActionExecution().controller.signal); - }, [getActionExecution]); - return ; - } - render( - - - - - , - {concurrentRoot: true}, - ); - expect(signals.some((signal) => signal.aborted)).toBe(true); - expect(actions.getActionExecution().controller.signal.aborted).toBe(false); - - // When a card starts after the replay. - await startScenario(); - - // Then the active generation can still sign, provision and run its success callback exactly once. - await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); - expect(handleAddCardToWallet).toHaveBeenCalledTimes(1); - expect(state.isFlowComplete).toBe(true); - }); - - it.each(['callback', 'credentials'] as const)('ignores a completed outcome after reset while its %s await is pending', async (stage) => { - // Given an existing scenario reaches its callback, with either callback or telemetry credentials still pending. - let finishOutcome: (() => void) | undefined; - const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]; - jest.spyOn(config, 'callback').mockImplementationOnce(async () => { - if (stage === 'callback') { - await new Promise((resolve) => { - finishOutcome = resolve; - }); - } else { - mockBiometrics.areLocalCredentialsKnownToServer.mockImplementationOnce( - () => - new Promise((resolve) => { - finishOutcome = () => resolve(true); - }), - ); - } - return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN; - }); - render( - - - , - ); - await act(async () => { - await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); - actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); - }); - await waitFor(() => expect(finishOutcome).toBeDefined()); - - // When a new card is waiting for enrollment before the original outcome resumes. - await act(async () => actions.dispatch({type: 'RESET'})); - mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); - await startScenario(); - await act(async () => { - finishOutcome?.(); - }); - - // Then the old callback cannot complete or navigate the new flow. - expect(state.isFlowComplete).toBe(false); - expect(state.error).toBeUndefined(); - expect(Navigation.runAfterTransition).not.toHaveBeenCalled(); - }); - - it('ignores a transaction cancellation response after reset into a different card', async () => { - // Given a scenario-specific asynchronous cancellation has not returned yet. - let finishCancel: ((response: Awaited>) => void) | undefined; - jest.mocked(denyTransaction).mockImplementationOnce( - () => - new Promise((resolve) => { - finishCancel = resolve; - }), - ); + it('preserves an existing scenario-specific cancellation outcome', async () => { + // Given an existing transaction scenario is waiting for authentication. mockBiometrics.authorize.mockImplementation(async () => {}); render( @@ -449,93 +335,18 @@ describe('Wallet MFA composed providers', () => { actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); }); await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalledTimes(1)); - await act(async () => controls.confirmCancel()); - - // When another flow starts before the previous transaction denial arrives. - await act(async () => actions.dispatch({type: 'RESET'})); - mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); - await startScenario(); - await act(async () => { - finishCancel?.({httpStatusCode: 200, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED, message: undefined}); - }); - // Then cancellation cannot fail or complete the replacement enrollment flow. - expect(state.error).toBeUndefined(); - expect(state.isFlowComplete).toBe(false); - }); - - it('invalidates a pending cancellation outcome callback on provider unmount', async () => { - // Given cancellation intentionally runs its failure callback even though the action signal is aborted. - let finishCallback: (() => void) | undefined; - const config = MULTIFACTOR_AUTHENTICATION_SCENARIO_CONFIG[CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION]; - jest.spyOn(config, 'callback').mockImplementationOnce(async () => { - await new Promise((resolve) => { - finishCallback = resolve; - }); - return CONST.MULTIFACTOR_AUTHENTICATION.CALLBACK_RESPONSE.SHOW_OUTCOME_SCREEN; - }); - mockBiometrics.authorize.mockImplementation(async () => {}); - const view = render( - - - , - ); - await act(async () => { - await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); - actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); - }); - await waitFor(() => expect(mockBiometrics.authorize).toHaveBeenCalledTimes(1)); + // When the user confirms cancellation through the existing onCancel hook. await act(async () => controls.confirmCancel()); - await waitFor(() => expect(finishCallback).toBeDefined()); - - // When AuthScreens unmounts before that failure callback completes. - view.unmount(); - await act(async () => { - finishCallback?.(); - }); - - // Then even an already-aborted cancellation generation cannot navigate after teardown. - expect(Navigation.runAfterTransition).not.toHaveBeenCalled(); - }); - - it('ignores an existing scenario action that rejects after reset into another flow', async () => { - // Given a non-wallet scenario action can reject outside the wallet action error normalization. - let rejectAction: ((error: Error) => void) | undefined; - jest.mocked(authorizeTransaction).mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectAction = reject; - }), - ); - render( - - - , - ); - await act(async () => { - await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); - actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); - }); - await waitFor(() => expect(authorizeTransaction).toHaveBeenCalledTimes(1)); - - // When reset starts a new enrollment flow before the original action rejects into the outer effect catch. - await act(async () => actions.dispatch({type: 'RESET'})); - mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); - await startScenario(); - await waitFor(() => expect(requestValidateCodeAction).toHaveBeenCalled()); - await act(async () => { - rejectAction?.(new Error('late action failure')); - }); - // Then neither a late error nor a completed failure outcome contaminates the replacement flow. - expect(state.error).toBeUndefined(); - expect(state.isFlowComplete).toBe(false); + // Then its denial request and established failure outcome are unchanged. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(denyTransaction).toHaveBeenCalledWith({transactionID: 'transaction'}); + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED); expect(state.isAuthorizationComplete).toBe(false); - expect(state.scenarioResponse).toBeUndefined(); - expect(handleAddCardToWallet).not.toHaveBeenCalled(); }); - it('re-enrolls and signs exactly once more when an existing scenario requires registration', async () => { + it('re-enrolls and signs again when an existing scenario requires registration', async () => { // Given a signed non-wallet action reports that the server no longer recognizes the credential. jest.mocked(authorizeTransaction).mockImplementationOnce(async () => { mockBiometrics.areLocalCredentialsKnownToServer.mockResolvedValue(false); @@ -546,14 +357,11 @@ describe('Wallet MFA composed providers', () => { , ); - const execution = actions.getActionExecution(); await act(async () => { await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); }); await waitFor(() => expect(requestValidateCodeAction).toHaveBeenCalled()); - expect(execution.controller.signal.aborted).toBe(true); - expect(actions.getActionExecution().started).toBe(false); // When the user completes the existing validate-code and soft-prompt enrollment steps. await act(async () => { @@ -561,7 +369,7 @@ describe('Wallet MFA composed providers', () => { actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); }); - // Then the replacement execution registers, re-signs and completes without an extra action. + // Then the existing flow registers, re-signs and completes successfully. await waitFor(() => expect(state.isFlowComplete).toBe(true)); expect(registerAuthenticationKey).toHaveBeenCalledTimes(1); expect(mockBiometrics.authorize).toHaveBeenCalledTimes(2); @@ -569,71 +377,4 @@ describe('Wallet MFA composed providers', () => { expect(state.error).toBeUndefined(); expect(state.isAuthorizationComplete).toBe(true); }); - - it('lets asynchronous transaction cancellation win over an action that ignores its signal', async () => { - // Given both the signed action and the scenario-specific cancellation request can stay pending. - let finishAction: ((response: Awaited>) => void) | undefined; - let finishCancel: ((response: Awaited>) => void) | undefined; - jest.mocked(authorizeTransaction).mockImplementationOnce( - () => - new Promise((resolve) => { - finishAction = resolve; - }), - ); - jest.mocked(denyTransaction).mockImplementationOnce( - () => - new Promise((resolve) => { - finishCancel = resolve; - }), - ); - render( - - - , - ); - await act(async () => { - await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); - actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); - }); - await waitFor(() => expect(authorizeTransaction).toHaveBeenCalledTimes(1)); - - // When the user cancels, the action returns success before the denial response arrives. - await act(async () => controls.confirmCancel()); - await act(async () => { - finishAction?.({httpStatusCode: 200, reason: undefined, message: undefined}); - }); - expect(state.isAuthorizationComplete).toBe(false); - expect(state.isFlowComplete).toBe(false); - await act(async () => { - finishCancel?.({httpStatusCode: 200, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED, message: undefined}); - }); - - // Then cancellation still reaches its intentional outcome rather than authorizing the transaction. - await waitFor(() => expect(state.isFlowComplete).toBe(true)); - expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.FLOW_OUTCOMES.TRANSACTION_DENIED); - expect(state.isAuthorizationComplete).toBe(false); - expect(state.scenarioResponse).toBeUndefined(); - }); - - it('does not execute a pending wallet action twice when provider effects rerun', async () => { - // Given wallet tokenization remains pending after signing. - jest.mocked(handleAddCardToWallet).mockImplementation(() => new Promise(() => {})); - render( - - - , - ); - await startScenario(); - await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); - const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; - - // When a relevant state change makes the process effect run again. - await act(async () => actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: false})); - await act(async () => actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true})); - await act(async () => actions.dispatch({type: 'INIT', payload: {scenario: CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, payload}})); - - // Then the action execution guard prevents a second wallet sheet/request. - expect(handleAddCardToWallet).toHaveBeenCalledTimes(1); - expect(signal?.aborted).toBe(false); - }); }); diff --git a/tests/unit/libs/Wallet.test.ts b/tests/unit/libs/Wallet.test.ts index 5f3d7999fa8c..0ed229ff1bc9 100644 --- a/tests/unit/libs/Wallet.test.ts +++ b/tests/unit/libs/Wallet.test.ts @@ -1,8 +1,3 @@ -import {render, waitFor} from '@testing-library/react-native'; - -import {useMultifactorAuthenticationActions} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationActionsContext'; -import {MultifactorAuthenticationStateProvider} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationComposedContextProviders'; - import * as API from '@libs/API'; import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; import type * as WalletModule from '@libs/Wallet'; @@ -15,7 +10,6 @@ import type {Card, ProvisioningCardData} from '@src/types/onyx'; /** Exercises the actual platform handoffs with the API and native bridge mocked. */ import {addCardToAppleWallet, addCardToGoogleWallet, getSecureWalletInfo} from '@expensify/react-native-wallet'; -import React, {useEffect} from 'react'; import pkg from '../../../package.json'; import createMock from '../../utils/createMock'; @@ -314,37 +308,6 @@ describe('Wallet platform provisioning', () => { expect(mockGoogle).not.toHaveBeenCalled(); }); - it('does not launch Google Wallet after the owning provider unmounts during signed provisioning', async () => { - // Given the real Android helper awaits its signed API response using the provider-owned signal. - let signal: AbortSignal | undefined; - function Probe() { - const {getActionExecution} = useMultifactorAuthenticationActions(); - useEffect(() => { - signal = getActionExecution().controller.signal; - }, [getActionExecution]); - return null; - } - let finishRequest: ((response: APIResponse) => void) | undefined; - mockRequest.mockImplementationOnce( - () => - new Promise((resolve) => { - finishRequest = resolve; - }), - ); - const view = render(React.createElement(MultifactorAuthenticationStateProvider, null, React.createElement(Probe))); - const pending = addAndroid(card, 'Jane', 'Card', {authentication, signal}); - await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); - - // When AuthScreens removes its provider before the response arrives. - view.unmount(); - finishRequest?.(createMock({jsonCode: 200, ...androidPayload})); - - // Then the late signed response cannot launch native Google tokenization. - await expect(pending).resolves.toBe('canceled'); - expect(signal?.aborted).toBe(true); - expect(mockGoogle).not.toHaveBeenCalled(); - }); - it('keeps the web fallback inert', async () => { // Given a non-native platform. // When a helper is called defensively with signed authentication. From cbe5a617f89d6c14f198ab1cacb9ab256b9a4dd1 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Mon, 19 Oct 2026 18:47:06 -0400 Subject: [PATCH 3/5] Abort MFA flow action on scenario change --- .../MultifactorAuthenticationMainContext.tsx | 22 ++- ...tifactorAuthenticationMainContext.test.tsx | 143 ++++++++++++++++++ 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 524a18b26b1f..ec7bceaf11d9 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -68,6 +68,14 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const {dispatch} = useMultifactorAuthenticationActions(); const actionControllerRef = useRef(undefined); + useEffect( + () => () => { + actionControllerRef.current?.abort(); + actionControllerRef.current = undefined; + }, + [state.scenario], + ); + const biometrics = useBiometrics(); const {accountID} = useCurrentUserPersonalDetails(); const {isOffline} = useNetwork(); @@ -369,11 +377,19 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent ...payload, }, {signal: controller.signal}, - ); - - if (actionControllerRef.current === controller) { + ).catch((scenarioActionError: unknown) => { + if (controller.signal.aborted || actionControllerRef.current !== controller) { + return undefined; + } actionControllerRef.current = undefined; + throw scenarioActionError; + }); + + // A canceled action can finish after the next flow has already started. + if (!scenarioAPIResponse || controller.signal.aborted || actionControllerRef.current !== controller) { + return; } + actionControllerRef.current = undefined; addMFABreadcrumb( 'Scenario action completed', diff --git a/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx b/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx index f5368aab3d33..f4eb60243a76 100644 --- a/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx @@ -322,6 +322,149 @@ describe('Wallet MFA composed providers', () => { expect(Navigation.navigate).not.toHaveBeenCalled(); }); + it.each(['cancel', 'reset'] as const)('ignores an old wallet result after %s and a replacement attempt starts', async (ending) => { + // Given the first wallet attempt is still waiting for native provisioning. + let finishFirstWallet: ((result: TokenizationStatus) => void) | undefined; + let finishSecondWallet: ((result: TokenizationStatus) => void) | undefined; + jest.mocked(handleAddCardToWallet) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstWallet = resolve; + }), + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishSecondWallet = resolve; + }), + ); + render( + + + , + ); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const firstSignal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When that flow ends and a replacement starts before the old wallet result arrives. + if (ending === 'cancel') { + await act(async () => controls.confirmCancel()); + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + } + await act(async () => actions.dispatch({type: 'RESET'})); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(2)); + const secondSignal = jest.mocked(handleAddCardToWallet).mock.calls.at(1)?.[3]?.signal; + const replacementState = state; + await act(async () => finishFirstWallet?.('success')); + + // Then the old result leaves the replacement untouched, including ownership of its cancellation signal. + expect(firstSignal?.aborted).toBe(true); + expect(state).toBe(replacementState); + expect(secondSignal?.aborted).toBe(false); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + await act(async () => controls.confirmCancel()); + expect(secondSignal?.aborted).toBe(true); + await act(async () => finishSecondWallet?.('canceled')); + }); + + it('ignores an old scenario rejection after cancellation and replacement', async () => { + // Given a transaction action can reject after its flow has already been canceled. + let rejectTransaction: ((error: Error) => void) | undefined; + let finishWallet: ((result: TokenizationStatus) => void) | undefined; + jest.mocked(authorizeTransaction).mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectTransaction = reject; + }), + ); + jest.mocked(handleAddCardToWallet).mockImplementationOnce( + () => + new Promise((resolve) => { + finishWallet = resolve; + }), + ); + render( + + + , + ); + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + await waitFor(() => expect(authorizeTransaction).toHaveBeenCalledTimes(1)); + await act(async () => controls.confirmCancel()); + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + await act(async () => actions.dispatch({type: 'RESET'})); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const replacementState = state; + + // When the old action rejects while the replacement wallet is pending. + await act(async () => rejectTransaction?.(new Error('Transaction request failed'))); + + // Then the rejection cannot fail the replacement, which can still complete successfully. + expect(state).toBe(replacementState); + await act(async () => finishWallet?.('success')); + await waitFor(() => expect(getPaymentMethods).toHaveBeenCalledTimes(1)); + expect(state.error).toBeUndefined(); + expect(state.isAuthorizationComplete).toBe(true); + }); + + it('still reports an action rejection for the current flow', async () => { + // Given an existing scenario action rejects without being canceled or replaced. + jest.mocked(authorizeTransaction).mockRejectedValueOnce(new Error('Transaction request failed')); + render( + + + , + ); + + // When the active scenario reaches its action. + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.AUTHORIZE_TRANSACTION, {transactionID: 'transaction'}); + actions.dispatch({type: 'SET_SOFT_PROMPT_APPROVED', payload: true}); + }); + + // Then normal failure handling still completes the current flow. + await waitFor(() => expect(state.isFlowComplete).toBe(true)); + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION); + expect(state.error?.message).toBe('Transaction request failed'); + expect(state.isAuthorizationComplete).toBe(false); + }); + + it('aborts a pending wallet action when the provider unmounts', async () => { + // Given native wallet work is still pending when its provider goes away. + let finishWallet: ((result: TokenizationStatus) => void) | undefined; + jest.mocked(handleAddCardToWallet).mockImplementationOnce( + () => + new Promise((resolve) => { + finishWallet = resolve; + }), + ); + const view = render( + + + , + ); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When the provider unmounts before native completion. + view.unmount(); + + // Then the helper sees cancellation and late success cannot refresh or navigate. + expect(signal?.aborted).toBe(true); + await act(async () => finishWallet?.('success')); + expect(getPaymentMethods).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + it('preserves an existing scenario-specific cancellation outcome', async () => { // Given an existing transaction scenario is waiting for authentication. mockBiometrics.authorize.mockImplementation(async () => {}); From 3c6987f30a099d2e9dbd1c00fe6ba918b34020e7 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Tue, 20 Oct 2026 08:41:12 -0400 Subject: [PATCH 4/5] Do not destructure arguments in AddToDigitalWallet MFA scenario action --- .../config/scenarios/AddToDigitalWallet.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts index e3ed008bbb00..ff30c13dec58 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts @@ -19,7 +19,9 @@ type Payload = { export default { allowedAuthenticationMethods: [CONST.MULTIFACTOR_AUTHENTICATION.TYPE.BIOMETRICS_HSM], - action: async ({card, cardHolderName, cardDescription, signedChallenge, authenticationMethod}, context) => { + // Hermes V1 does not suspend at await inside async arrow functions with destructured parameters, so destructure in the body. + action: async (params, context) => { + const {card, cardHolderName, cardDescription, signedChallenge, authenticationMethod} = params; try { const status = await handleAddCardToWallet(card, cardHolderName, cardDescription, { authentication: {signedChallenge, authenticationMethod}, From 39d2a76e0b910105b44623e6c639f34c252a66a2 Mon Sep 17 00:00:00 2001 From: Chuck Dries Date: Tue, 20 Oct 2026 10:20:00 -0400 Subject: [PATCH 5/5] Improve comments --- .../config/scenarios/AddToDigitalWallet.ts | 2 +- src/libs/Wallet/index.ios.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts index ff30c13dec58..34cb4e5c6a66 100644 --- a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts +++ b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts @@ -42,7 +42,7 @@ export default { if (error instanceof WalletProvisioningError) { return error.response; } - // Native exceptions can contain card data; the shared MFA failure UI needs only a reason. + // Native exceptions can contain card data. The shared MFA failure UI only needs a reason. return { httpStatusCode: undefined, reason: context?.signal.aborted ? CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED : CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION, diff --git a/src/libs/Wallet/index.ios.ts b/src/libs/Wallet/index.ios.ts index 7213ee712e51..56b2606aebe2 100644 --- a/src/libs/Wallet/index.ios.ts +++ b/src/libs/Wallet/index.ios.ts @@ -36,7 +36,7 @@ async function handleAddCardToWallet(card: Card, cardHolderName: string, cardDes const status = await addCardToAppleWallet(data, async (nonce, nonceSignature, certificates) => { try { // Apple's Try Again callback must not replay a consumed signed challenge. - // Wallet 0.1.22 has no public abort API; rejection cannot dismiss its sheet. + // Wallet 0.1.22 has no public abort API. Rejection cannot dismiss its sheet. if (didRequestProvisioning || signal?.aborted) { throw new Error('Wallet attempt requires fresh authentication'); }