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/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 8b0453a4538f..ec7bceaf11d9 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -66,6 +66,15 @@ type MultifactorAuthenticationContextProviderProps = { function MultifactorAuthenticationContextProvider({children}: MultifactorAuthenticationContextProviderProps) { const state = useMultifactorAuthenticationState(); const {dispatch} = useMultifactorAuthenticationActions(); + const actionControllerRef = useRef(undefined); + + useEffect( + () => () => { + actionControllerRef.current?.abort(); + actionControllerRef.current = undefined; + }, + [state.scenario], + ); const biometrics = useBiometrics(); const {accountID} = useCurrentUserPersonalDetails(); @@ -358,12 +367,30 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent } // Call backend with signed challenge - const scenarioAPIResponse = await processScenarioAction(scenario.action, { - signedChallenge: result.signedChallenge, - authenticationMethod: result.authenticationMethod.marqetaValue, - ...payload, + const controller = new AbortController(); + actionControllerRef.current = controller; + const scenarioAPIResponse = await processScenarioAction( + scenario.action, + { + signedChallenge: result.signedChallenge, + authenticationMethod: result.authenticationMethod.marqetaValue, + ...payload, + }, + {signal: controller.signal}, + ).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', scenarioAPIResponse.success @@ -504,6 +531,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent * close the modal directly instead to avoid getting stuck. */ const cancel = useCallback(async () => { + actionControllerRef.current?.abort(); if (!state.scenario || isOffline) { addMFABreadcrumb('Flow cancelled - closing directly', {hasScenario: !!state.scenario, isOffline}, 'warning'); dispatch({type: 'CLOSE_MODAL'}); diff --git a/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts new file mode 100644 index 000000000000..34cb4e5c6a66 --- /dev/null +++ b/src/components/MultifactorAuthentication/config/scenarios/AddToDigitalWallet.ts @@ -0,0 +1,65 @@ +/** 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], + // 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}, + 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 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, + 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..56b2606aebe2 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..f4eb60243a76 --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/MultifactorAuthenticationMainContext.test.tsx @@ -0,0 +1,523 @@ +/** 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 {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 {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, 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'; + +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/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'})); +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'}; +const mockRequest = jest.mocked(API.makeRequestWithSideEffects); +async function startScenario() { + await act(async () => { + await controls.executeScenario(CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.ADD_TO_DIGITAL_WALLET, payload); + 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}); + }); + + 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(['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) => { + finishRequest = resolve; + }), + ); + const view = render( + + + , + ); + await startScenario(); + 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 back navigation cancels the flow before the already-sent server request succeeds. + if (ending === 'offline close') { + mockIsOffline = true; + view.rerender( + + + , + ); + } + 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 () => { + finishRequest?.(createMock({jsonCode: 200, ...provisioningData})); + }); + + // 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 === 'offline close') { + expect(state.isModalOpen).toBe(false); + } else { + expect(state.error?.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED); + } + }); + + 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; + }), + ); + render( + + + , + ); + await startScenario(); + await waitFor(() => expect(handleAddCardToWallet).toHaveBeenCalledTimes(1)); + const signal = jest.mocked(handleAddCardToWallet).mock.calls.at(0)?.[3]?.signal; + + // When explicit cancellation precedes a native canceled or late-success status. + await act(async () => controls.confirmCancel()); + await act(async () => { + finishWallet?.(status); + }); + + // 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.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 () => {}); + 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()); + + // 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); + }); + + 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); + return {httpStatusCode: 400, reason: CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.REGISTRATION_REQUIRED, message: undefined}; + }); + 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(requestValidateCodeAction).toHaveBeenCalled()); + + // 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 existing flow registers, re-signs and completes successfully. + 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); + }); +}); 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..0ed229ff1bc9 --- /dev/null +++ b/tests/unit/libs/Wallet.test.ts @@ -0,0 +1,321 @@ +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 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('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: ''});