From e060c989e3356a66255abdeecb4f485ac9eb6207 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 21 Jul 2026 17:26:40 +0200 Subject: [PATCH 01/30] feat(mfa): migrate the magic code and registration decision into the machine After the device check the machine now decides whether registration is needed: a new checkLocalCredentials actor wraps a module-level areLocalCredentialsKnownToServer, so a returning user (or a flow that already carries a code) skips the magic-code screen entirely. The request-code side effect runs only on the decision transition, never on state entry, so the invalid-code retry loop cannot resend the email. validateCode and continuableError move from the legacy reducer into the machine context: an invalid code stays on the screen as a continuable error while any other rejection ends the flow through the outcome path. ValidateCodePage talks only to the internal API now. The true INVALID_VALIDATE_CODE round-trip becomes reachable in the registration slice; this slice wires and unit-tests the machine loop with mocked events. --- ...ifactorAuthenticationInternalApiContext.ts | 6 + .../MultifactorAuthenticationMainContext.tsx | 4 + .../Context/state.ts | 9 -- .../Context/stateReducer.ts | 14 -- .../Context/types.ts | 4 - .../biometrics/operations/index.native.ts | 41 ++++- .../biometrics/operations/index.ts | 19 ++- .../machine/mfaActors.ts | 34 ++-- .../machine/mfaMachine.ts | 63 +++++++- .../machine/types.ts | 20 ++- .../shared/VALUES.ts | 4 + .../shared/readOnyxValueOnce.ts | 27 ++++ .../ValidateCodePage.tsx | 28 +--- .../biometricsOperations.test.ts | 55 ++++++- .../biometricsOperationsWeb.test.ts | 38 ++++- .../viewMatchesMachine.test.tsx | 59 ++++++- .../machine/softPromptTransition.test.ts | 20 +-- .../machine/validateCodeTransition.test.ts | 152 ++++++++++++++++++ .../unit/hooks/useSplitContextHooks.test.tsx | 11 +- tests/utils/mfa/flowActors.ts | 16 +- tests/utils/mfa/flowFixtures.ts | 3 +- tests/utils/mfa/flowPaths.ts | 29 +++- tests/utils/mfa/realUi/mocks.ts | 30 +++- 23 files changed, 579 insertions(+), 107 deletions(-) create mode 100644 src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts create mode 100644 tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts index 455810d16e2c..dbe8469cfe66 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts @@ -24,6 +24,12 @@ type MultifactorAuthenticationInternalApi = { /** Approve the soft prompt. The machine persists the acceptance and moves the flow to the outcome. */ approveSoftPrompt: () => void; + /** Submit the magic code the user entered. The machine stores it and moves the flow forward. */ + submitValidateCode: (validateCode: string) => void; + + /** Clear the inline validate-code error, called when the user starts typing again. */ + clearContinuableError: () => void; + /** Centralized back-press / backdrop entry. */ requestCancel: () => void; diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 6cba45a99ddf..65894ac2f60c 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -87,6 +87,8 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const closeModal = () => send({type: 'CLOSE_MODAL'}); const notifyModalClosed = () => send({type: 'MODAL_CLOSED'}); const approveSoftPrompt = () => send({type: 'SOFT_PROMPT_APPROVED'}); + const submitValidateCode = (validateCode: string) => send({type: 'VALIDATE_CODE_ENTERED', validateCode}); + const clearContinuableError = () => send({type: 'CLEAR_CONTINUABLE_ERROR'}); // There is no cancel-confirmation dialog yet, so every cancel path closes the modal directly. const requestCancel = () => send({type: 'CLOSE_MODAL'}); @@ -102,6 +104,8 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent closeModal, notifyModalClosed, approveSoftPrompt, + submitValidateCode, + clearContinuableError, requestCancel, hideCancelConfirm, confirmCancel, diff --git a/src/components/MultifactorAuthentication/Context/state.ts b/src/components/MultifactorAuthentication/Context/state.ts index b77a086d1329..afbd33c4e0cf 100644 --- a/src/components/MultifactorAuthentication/Context/state.ts +++ b/src/components/MultifactorAuthentication/Context/state.ts @@ -1,7 +1,6 @@ import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types'; import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; -import type {MFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; /** @@ -10,12 +9,6 @@ import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; * via `snapshotToState`. */ type MultifactorAuthenticationState = { - /** Continuable error - displayed on current screen without stopping the flow */ - continuableError: MFAError | undefined; - - /** Validate code entered by user */ - validateCode: string | undefined; - /** Challenge received from backend for registration (full object with user, rp, challenge) */ registrationChallenge: RegistrationChallenge | undefined; @@ -39,8 +32,6 @@ type MultifactorAuthenticationState = { }; const DEFAULT_STATE: MultifactorAuthenticationState = { - continuableError: undefined, - validateCode: undefined, registrationChallenge: undefined, authorizationChallenge: undefined, isRegistrationComplete: false, diff --git a/src/components/MultifactorAuthentication/Context/stateReducer.ts b/src/components/MultifactorAuthentication/Context/stateReducer.ts index 74ba2c5b0630..bf2d4fec81f5 100644 --- a/src/components/MultifactorAuthentication/Context/stateReducer.ts +++ b/src/components/MultifactorAuthentication/Context/stateReducer.ts @@ -1,5 +1,3 @@ -import CONST from '@src/CONST'; - import type {Action, MultifactorAuthenticationState} from './types'; import {DEFAULT_STATE} from './state'; @@ -9,18 +7,6 @@ import {DEFAULT_STATE} from './state'; */ function stateReducer(state: MultifactorAuthenticationState, action: Action): MultifactorAuthenticationState { switch (action.type) { - case 'SET_ERROR': { - // Only a continuable error (an invalid validate code) belongs to the reducer; a fatal error - // stops the flow and is owned by the machine, so anything else just clears the continuable one. - if (action.payload?.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE) { - return {...state, continuableError: action.payload}; - } - return {...state, continuableError: undefined}; - } - case 'CLEAR_CONTINUABLE_ERROR': - return {...state, continuableError: undefined}; - case 'SET_VALIDATE_CODE': - return {...state, validateCode: action.payload}; case 'SET_REGISTRATION_CHALLENGE': return {...state, registrationChallenge: action.payload}; case 'SET_AUTHORIZATION_CHALLENGE': diff --git a/src/components/MultifactorAuthentication/Context/types.ts b/src/components/MultifactorAuthentication/Context/types.ts index d1676bfb2386..d9740d5d7cac 100644 --- a/src/components/MultifactorAuthentication/Context/types.ts +++ b/src/components/MultifactorAuthentication/Context/types.ts @@ -1,15 +1,11 @@ import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types'; import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; -import type {MFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; import type {MultifactorAuthenticationState} from './state'; type Action = - | {type: 'SET_ERROR'; payload: MFAError | undefined} - | {type: 'CLEAR_CONTINUABLE_ERROR'} - | {type: 'SET_VALIDATE_CODE'; payload: string | undefined} | {type: 'SET_REGISTRATION_CHALLENGE'; payload: RegistrationChallenge | undefined} | {type: 'SET_AUTHORIZATION_CHALLENGE'; payload: AuthenticationChallenge | undefined} | {type: 'SET_REGISTRATION_COMPLETE'; payload: boolean} diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts index 7945e5d36ba6..7ce3a9a73e9e 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts @@ -1,10 +1,18 @@ +import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; + +import {decodeLibraryError, getKeyAlias} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; + import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import Base64URL from '@src/utils/Base64URL'; -import {isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; +import {getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; +import {mfaCredentialIDsSelector} from '@selectors/Account'; /** - * Platform-resolved biometric operations for the device check. These functions read no Onyx and no - * React state, so the MFA machine actors and other non-React callers can import them directly. + * Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions + * read no React state, so the machine actors and other non-React callers can import them directly. */ /** The authentication method this platform verifies with. Native verifies with HSM-backed biometrics. */ @@ -19,4 +27,29 @@ async function doesDeviceSupportAuthenticationMethod(): Promise { return sensorResult.isDeviceSecure; } -export {deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; +/** Resolves to the account's HSM-backed credential ID, or undefined when no key exists or the keystore read fails. */ +async function getLocalCredentialID(accountID: number): Promise { + try { + const {keys} = await getAllKeys(getKeyAlias(accountID)); + const entry = keys.at(0); + if (!entry) { + return undefined; + } + return Base64URL.base64ToBase64url(entry.publicKey); + } catch (error) { + addMFABreadcrumb('Failed to get local credential ID', decodeLibraryError(error), 'error'); + return undefined; + } +} + +/** Resolves to whether the account has a local HSM key the server also knows, meaning it can skip registration. */ +async function areLocalCredentialsKnownToServer(accountID: number): Promise { + const localCredentialID = await getLocalCredentialID(accountID); + if (!localCredentialID) { + return false; + } + const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT); + return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID); +} + +export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.ts index 754574e3c3ee..3a7a1e63da37 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.ts @@ -1,10 +1,16 @@ import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; +import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; + +import {getPasskeyOnyxKey} from '@userActions/Passkey'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import {mfaCredentialIDsSelector} from '@selectors/Account'; /** - * Platform-resolved biometric operations for the device check. These functions read no Onyx and no - * React state, so the MFA machine actors and other non-React callers can import them directly. + * Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions + * read no React state, so the machine actors and other non-React callers can import them directly. */ /** The authentication method this platform verifies with. Web verifies with passkeys. */ @@ -18,4 +24,11 @@ async function doesDeviceSupportAuthenticationMethod(): Promise { return isWebAuthnSupported(); } -export {deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; +/** Resolves to whether the account has a local passkey the server also knows, meaning it can skip registration. */ +async function areLocalCredentialsKnownToServer(accountID: number): Promise { + const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT), readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)))]); + const serverKnownCredentialIDs = new Set(mfaCredentialIDsSelector(account) ?? []); + return (localPasskeyCredentials ?? []).some((credential) => serverKnownCredentialIDs.has(credential.id)); +} + +export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/machine/mfaActors.ts b/src/components/MultifactorAuthentication/machine/mfaActors.ts index 7d1728d084b2..bac7a1b3739e 100644 --- a/src/components/MultifactorAuthentication/machine/mfaActors.ts +++ b/src/components/MultifactorAuthentication/machine/mfaActors.ts @@ -1,13 +1,14 @@ import checkDeviceEligibility from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility'; +import {areLocalCredentialsKnownToServer} from '@components/MultifactorAuthentication/biometrics/operations'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; +import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; import {getDeviceBiometricsOnyxKey} from '@userActions/MultifactorAuthentication'; -import Onyx from 'react-native-onyx'; import {fromPromise} from 'xstate'; -import type {ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from './types'; +import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from './types'; /** * A refused device resolves as a failed MFAResult, so the machine's onError transition for this @@ -19,30 +20,23 @@ const validateDevice = fromPromise(({input}) => * Reads the account's device-local soft-prompt flag once. The temporary Onyx connection is * disconnected after the first value or when XState stops the actor. */ -const readHasAcceptedSoftPrompt = fromPromise( - ({input, signal}) => - new Promise((resolve) => { - let connection: ReturnType; - const disconnect = () => Onyx.disconnect(connection); - - signal.addEventListener('abort', disconnect, {once: true}); - connection = Onyx.connectWithoutView({ - key: getDeviceBiometricsOnyxKey(input.accountID), - callback: (deviceBiometrics) => { - signal.removeEventListener('abort', disconnect); - disconnect(); - resolve(deviceBiometrics?.hasAcceptedSoftPrompt ?? false); - }, - }); - }), -); +const readHasAcceptedSoftPrompt = fromPromise(async ({input, signal}) => { + const deviceBiometrics = await readOnyxValueOnce(getDeviceBiometricsOnyxKey(input.accountID), signal); + return deviceBiometrics?.hasAcceptedSoftPrompt ?? false; +}); + +/** + * Resolves to whether the account's local credentials are known to the server. A returning user + * (true) skips the registration path entirely. + */ +const checkLocalCredentials = fromPromise(({input}) => areLocalCredentialsKnownToServer(input.accountID)); /** * Builds the side-effect actors that the machine states invoke. The machine is always created with * these working implementations, so no caller needs to provide stubs or overrides. */ function createActors() { - return {validateDevice, readHasAcceptedSoftPrompt}; + return {validateDevice, readHasAcceptedSoftPrompt, checkLocalCredentials}; } export default createActors; diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index 1ecd07d76c3c..c90f0b39f7ee 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -5,6 +5,7 @@ import {createUnhandledExceptionMFAError, getMFAFailureError} from '@libs/Multif import Navigation from '@libs/Navigation/Navigation'; import {markHasAcceptedSoftPrompt} from '@userActions/MultifactorAuthentication'; +import {requestValidateCodeAction} from '@userActions/User'; import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; @@ -21,6 +22,8 @@ const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; // sibling branch needs an id target rather than a relative one. const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const; const PROMPT_TARGET = `#${MFA_STATE.PROMPT}` as const; +const SOFT_PROMPT_CHECK_TARGET = `#${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}` as const; +const MAGIC_CODE_TARGET = `#${MFA_STATE.REQUESTING_VALIDATE_CODE}` as const; // Which prompt variant the screen renders is a device property, resolved once per platform. const PROMPT_TYPE = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[deviceVerificationType]; @@ -31,6 +34,8 @@ const DEFAULT_CONTEXT: MfaContext = { scenarioName: undefined, scenario: undefined, payload: undefined, + validateCode: undefined, + continuableError: undefined, softPromptApproved: false, isCancelConfirmVisible: false, }; @@ -82,6 +87,21 @@ const MFAMachine = setup({ navigateToPrompt: () => { Navigation.runAfterTransition(() => mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT, {promptType: PROMPT_TYPE})); }, + navigateToMagicCode: () => { + Navigation.runAfterTransition(() => mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE)); + }, + // Emails the user a magic code. Runs only on the decision transition into the magic-code + // screen, never on (re)entry, so the invalid-code retry loop cannot resend the email. + requestValidateCode: () => requestValidateCodeAction(), + // Stores the submitted code. Same narrowing pattern as initFlow: only VALIDATE_CODE_ENTERED + // is wired here, so the early return just satisfies the type checker. + submitValidateCode: assign(({event}) => { + if (event.type !== 'VALIDATE_CODE_ENTERED') { + return {}; + } + return {validateCode: event.validateCode}; + }), + clearContinuableError: assign({continuableError: undefined}), approveSoftPrompt: assign({softPromptApproved: true}), persistSoftPromptAcceptance: ({context}) => { if (context.accountID === undefined) { @@ -143,7 +163,7 @@ const MFAMachine = setup({ onDone: [ {guard: ({event}) => !event.output.success, target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})}, {guard: ({context}) => context.error !== undefined, target: OUTCOME_TARGET}, - {target: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}, + {target: MFA_STATE.DECIDING_REGISTRATION}, ], // Expected refusals travel as failed results through onDone, so a // rejection means the platform check itself threw unexpectedly. @@ -153,7 +173,31 @@ const MFAMachine = setup({ }, }, }, + [MFA_STATE.DECIDING_REGISTRATION]: { + invoke: { + id: 'checkLocalCredentials', + src: 'checkLocalCredentials', + input: ({context}) => { + if (context.accountID === undefined) { + throw new Error('MFA account must be initialized before the registration decision'); + } + return {accountID: context.accountID}; + }, + // A returning user's credentials are already registered, and a re-entered + // flow already carries a code, so only a fresh registration asks for one. + onDone: [ + {guard: ({event}) => event.output, target: SOFT_PROMPT_CHECK_TARGET}, + {guard: ({context}) => context.validateCode !== undefined, target: SOFT_PROMPT_CHECK_TARGET}, + {target: MAGIC_CODE_TARGET, actions: ['requestValidateCode', 'navigateToMagicCode']}, + ], + onError: { + target: OUTCOME_TARGET, + actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Local credentials check', event.error)}), + }, + }, + }, [MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE]: { + id: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE, invoke: { id: 'readHasAcceptedSoftPrompt', src: 'readHasAcceptedSoftPrompt', @@ -172,6 +216,23 @@ const MFAMachine = setup({ }, }, }, + // This branch shows the magic-code screen while a fresh registration waits for the + // emailed code. A rejected code either stays here as an inline, continuable error + // (invalid code, targetless so re-entry actions cannot run) or ends the flow. + [MFA_STATE.REQUESTING_VALIDATE_CODE]: { + id: MFA_STATE.REQUESTING_VALIDATE_CODE, + on: { + VALIDATE_CODE_ENTERED: {target: SOFT_PROMPT_CHECK_TARGET, actions: 'submitValidateCode'}, + VALIDATE_CODE_REJECTED: [ + { + guard: ({event}) => event.error.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, + actions: assign({continuableError: ({event}) => event.error}), + }, + {target: OUTCOME_TARGET, actions: assign({error: ({event}) => event.error})}, + ], + CLEAR_CONTINUABLE_ERROR: {actions: 'clearContinuableError'}, + }, + }, // This branch shows the soft prompt when the current account has not accepted it on this device. [MFA_STATE.PROMPT]: { id: MFA_STATE.PROMPT, diff --git a/src/components/MultifactorAuthentication/machine/types.ts b/src/components/MultifactorAuthentication/machine/types.ts index 3cefd2dd17ae..bf2b301a55fd 100644 --- a/src/components/MultifactorAuthentication/machine/types.ts +++ b/src/components/MultifactorAuthentication/machine/types.ts @@ -32,6 +32,12 @@ type MfaContext = { /** Additional parameters for the current scenario */ payload: MultifactorAuthenticationScenarioAdditionalParams | undefined; + /** Magic code the user entered on this flow's validate-code screen */ + validateCode: string | undefined; + + /** Error the validate-code screen shows inline while the flow stays on it, as opposed to `error`, which ends the flow */ + continuableError: MFAError | undefined; + /** Whether the user approved the soft prompt during this flow. The durable acceptance lives in Onyx under the device-biometrics key. */ softPromptApproved: boolean; @@ -58,7 +64,14 @@ type MultifactorAuthenticationInitEvent(key: TKey, signal?: AbortSignal): Promise> { + return new Promise((resolve) => { + let connection: Connection; + const disconnect = () => Onyx.disconnect(connection); + + signal?.addEventListener('abort', disconnect, {once: true}); + connection = Onyx.connectWithoutView({ + key, + callback: (value) => { + signal?.removeEventListener('abort', disconnect); + disconnect(); + resolve(value); + }, + }); + }); +} + +export default readOnyxValueOnce; diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index fa17989f8aeb..da0c952779f2 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -4,7 +4,6 @@ import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MagicCodeInput from '@components/MagicCodeInput'; import type {MagicCodeInputHandle} from '@components/MagicCodeInput'; -import {useMultifactorAuthenticationActions, useMultifactorAuthenticationState} from '@components/MultifactorAuthentication/Context'; import {useMultifactorAuthenticationInternal} from '@components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext'; import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import useMFACancelOnEscape from '@components/MultifactorAuthentication/useMFACancelOnEscape'; @@ -21,7 +20,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import AccountUtils from '@libs/AccountUtils'; import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; -import VALUES from '@libs/MultifactorAuthentication/VALUES'; import {isValidValidateCode} from '@libs/ValidationUtils'; import {clearAccountMessages} from '@userActions/Session'; @@ -55,11 +53,8 @@ function MultifactorAuthenticationValidateCodePage() { const [inputCode, setInputCode] = useState(''); const [formError, setFormError] = useState({}); const [canShowError, setCanShowError] = useState(false); - const {requestCancel, state} = useMultifactorAuthenticationInternal(); - - const {dispatch} = useMultifactorAuthenticationActions(); - const {continuableError} = useMultifactorAuthenticationState(); - const {isCancelConfirmVisible} = state; + const {requestCancel, submitValidateCode, clearContinuableError, state} = useMultifactorAuthenticationInternal(); + const {continuableError, isCancelConfirmVisible} = state; // Refs const inputRef = useRef(null); @@ -89,18 +84,6 @@ function MultifactorAuthenticationValidateCodePage() { return getLatestErrorMessage(account); } - // Check if this page can handle the continuable error, if not convert to regular error - useEffect(() => { - if (!continuableError) { - return; - } - - if (continuableError.reason !== VALUES.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE) { - // Cannot handle this error - convert to regular error which will stop the flow - dispatch({type: 'SET_ERROR', payload: continuableError}); - } - }, [continuableError, dispatch]); - // Auto-blur on error useEffect(() => { if (!(inputRef.current && hasError && (session?.autoAuthState === CONST.AUTO_AUTH_STATE.FAILED || account?.isLoading))) { @@ -151,7 +134,7 @@ function MultifactorAuthenticationValidateCodePage() { // Clear continuable error when user starts typing after an error if (continuableError) { - dispatch({type: 'CLEAR_CONTINUABLE_ERROR'}); + clearContinuableError(); } }; @@ -203,8 +186,7 @@ function MultifactorAuthenticationValidateCodePage() { // Clear errors before submit setFormError({}); - // Set validate code in state context - the process function will handle the rest - dispatch({type: 'SET_VALIDATE_CODE', payload: inputCode}); + submitValidateCode(inputCode); }; const interceptFocusTrapEscape = useMFACancelOnEscape(); @@ -231,6 +213,7 @@ function MultifactorAuthenticationValidateCodePage() { ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return isSensorAvailable: (...args: unknown[]) => mockIsSensorAvailable(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + getAllKeys: (...args: unknown[]) => mockGetAllKeys(...args), })); +const ACCOUNT_ID = 12345; +// The keystore returns the public key as plain base64 while the server stores base64url IDs, so the +// characters below only match after the module's base64url conversion. +const LOCAL_PUBLIC_KEY_BASE64 = 'Ab+/cd=='; +const LOCAL_CREDENTIAL_ID = 'Ab-_cd'; + describe('biometrics operations (native)', () => { beforeEach(() => { jest.clearAllMocks(); @@ -44,4 +62,39 @@ describe('biometrics operations (native)', () => { await expect(doesDeviceSupportAuthenticationMethod()).resolves.toBe(false); }); }); + + describe('areLocalCredentialsKnownToServer', () => { + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('should return true when the local HSM key is among the server-known credential IDs', async () => { + mockGetAllKeys.mockResolvedValue({keys: [{publicKey: LOCAL_PUBLIC_KEY_BASE64}]}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id', LOCAL_CREDENTIAL_ID]}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(true); + }); + + it('should return false when the server does not know the local HSM key', async () => { + mockGetAllKeys.mockResolvedValue({keys: [{publicKey: LOCAL_PUBLIC_KEY_BASE64}]}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id']}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + + it('should return false when the device holds no key for the account', async () => { + mockGetAllKeys.mockResolvedValue({keys: []}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_CREDENTIAL_ID]}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + + it('should return false when the keystore read throws', async () => { + mockGetAllKeys.mockRejectedValue(new Error('Keystore unavailable')); + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_CREDENTIAL_ID]}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + }); }); diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts index 66cb4bddf435..422a36032948 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts @@ -6,13 +6,22 @@ */ import type * as WebBiometricsOperations from '@components/MultifactorAuthentication/biometrics/operations/index'; +import {getPasskeyOnyxKey} from '@userActions/Passkey'; + import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; +import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; // jest-expo resolves the native variant by default, so load the web entry point explicitly. -const {deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} = jest.requireActual( +const {areLocalCredentialsKnownToServer, deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} = jest.requireActual( '@components/MultifactorAuthentication/biometrics/operations/index.ts', ); +const ACCOUNT_ID = 12345; +const LOCAL_PASSKEY_ID = 'local-passkey-credential-id'; + const originalPublicKeyCredentialDescriptor = Object.getOwnPropertyDescriptor(window, 'PublicKeyCredential'); function setWebAuthnSupport(isSupported: boolean) { @@ -48,4 +57,31 @@ describe('biometrics operations (web)', () => { await expect(doesDeviceSupportAuthenticationMethod()).resolves.toBe(expected); }); + + describe('areLocalCredentialsKnownToServer', () => { + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('returns true when a local passkey is among the server-known credential IDs', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id', LOCAL_PASSKEY_ID]}); + await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: LOCAL_PASSKEY_ID, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(true); + }); + + it('returns false when the server does not know the local passkey', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id']}); + await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: LOCAL_PASSKEY_ID, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + + it('returns false when the account has no local passkeys', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_PASSKEY_ID]}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + }); }); diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 73974dc6d147..3bb8e9122152 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -16,6 +16,8 @@ import type {SnapshotFrom} from 'xstate'; import Onyx from 'react-native-onyx'; import {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; import getWalkedPaths, { + CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, + CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, isAutoDrivenEvent, READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE, @@ -24,7 +26,7 @@ import getWalkedPaths, { } from 'tests/utils/mfa/flowPaths'; import {getSettleableLeafStates} from 'tests/utils/mfa/leafStates'; import renderMfaUi from 'tests/utils/mfa/realUi/harness'; -import {pendingModalClose, readHasAcceptedSoftPromptControl, resetMfaUiMocks, validateDeviceControl} from 'tests/utils/mfa/realUi/mocks'; +import {checkLocalCredentialsControl, pendingModalClose, readHasAcceptedSoftPromptControl, resetMfaUiMocks, validateDeviceControl} from 'tests/utils/mfa/realUi/mocks'; import {translateLocal} from 'tests/utils/TestHelper'; import waitForBatchedUpdatesWithAct from 'tests/utils/waitForBatchedUpdatesWithAct'; import {matchesState} from 'xstate'; @@ -54,6 +56,8 @@ jest.mock('@components/RenderHTML', () => jest.requireActual jest.requireActual('tests/utils/mfa/realUi/mocks').syncHistoryMock()); // The test renderer runs no real navigation transitions, so the mock controls when the transition callbacks fire. jest.mock('@libs/Navigation/Navigation', () => jest.requireActual('tests/utils/mfa/realUi/mocks').navigationMock()); +// The magic-code email request is a backend call outside the modal lifecycle contract. +jest.mock('@libs/actions/User', () => jest.requireActual('tests/utils/mfa/realUi/mocks').userActionsMock()); const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; @@ -72,6 +76,8 @@ type MfaActorEventExecutors = { [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: () => Promise; [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step: {event: {type: typeof READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE; output: boolean}}) => Promise; [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => Promise; + [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step: {event: {type: typeof CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE; output: boolean}}) => Promise; + [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => Promise; }; type ExecuteScenario = ReturnType['executeScenario']; @@ -80,6 +86,12 @@ function isMfaInitEvent(event: {type: string}): event is MfaInitEvent { return event.type === 'INIT' && 'accountID' in event && 'scenarioName' in event && 'scenario' in event && 'payload' in event; } +type MfaValidateCodeEnteredEvent = Extract; + +function isMfaValidateCodeEnteredEvent(event: {type: string}): event is MfaValidateCodeEnteredEvent { + return event.type === 'VALIDATE_CODE_ENTERED' && 'validateCode' in event; +} + /** * Maps every machine event to the action that produces it in the rendered app, such as a button press * or a navigator callback. The walk drives each path step through this table, and the `satisfies` @@ -124,10 +136,32 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { fireEvent.press(screen.getByTestId(TEST_ID.PROMPT_CONFIRM_BUTTON)); await waitForBatchedUpdatesWithAct(); }, + VALIDATE_CODE_ENTERED: async (step) => { + const {event} = step; + if (!isMfaValidateCodeEnteredEvent(event)) { + throw new Error('MFA VALIDATE_CODE_ENTERED executor received a path event without the code fixture payload.'); + } + fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), event.validateCode); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByTestId(TEST_ID.VALIDATE_CODE_SUBMIT_BUTTON)); + await waitForBatchedUpdatesWithAct(); + }, + // The walk filters rejection paths out (`isUiDrivablePath`), because no UI gesture produces + // the event until the registration slice wires the backend call. The executor exists only to + // keep the event table exhaustive. + VALIDATE_CODE_REJECTED: () => { + throw new Error('VALIDATE_CODE_REJECTED has no UI affordance yet, so no walked path may contain it.'); + }, + CLEAR_CONTINUABLE_ERROR: async () => { + fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), '1'); + await waitForBatchedUpdatesWithAct(); + }, [VALIDATE_DEVICE_DONE_EVENT_TYPE]: (step) => settleActor(() => validateDeviceControl.resolve(step.event.output)), [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: () => settleActor(validateDeviceControl.reject), [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step) => settleActor(() => readHasAcceptedSoftPromptControl.resolve(step.event.output)), [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => settleActor(readHasAcceptedSoftPromptControl.reject), + [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step) => settleActor(() => checkLocalCredentialsControl.resolve(step.event.output)), + [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => settleActor(checkLocalCredentialsControl.reject), } satisfies MfaEventExecutors & MfaActorEventExecutors; } /* eslint-enable @typescript-eslint/naming-convention */ @@ -144,13 +178,34 @@ const testConfig = { expect(screen.queryAllByTestId(TEST_ID.INITIAL_SCREEN)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); }, - [`${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`]: (state: SnapshotFrom) => { + [`${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.DECIDING_REGISTRATION}`]: (state: SnapshotFrom) => { expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.INITIAL_SCREEN)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); + expect(state.context.error).toBeUndefined(); + }, + [`${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`]: (state: SnapshotFrom) => { + expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); + expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); + // A stored code means the flow re-entered this check from the magic-code screen, which + // stays visible while the read runs; a first pass runs behind the transparent initial screen. + if (state.context.validateCode === undefined) { + expect(screen.queryAllByTestId(TEST_ID.INITIAL_SCREEN)).toHaveLength(1); + } else { + expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); + } expect(state.context.accountID).toBeDefined(); expect(state.context.error).toBeUndefined(); }, + [`${MFA_STATE.OPEN}.${MFA_STATE.REQUESTING_VALIDATE_CODE}`]: (state: SnapshotFrom) => { + expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); + expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); + expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); + expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT)).toBeOnTheScreen(); + expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_SUBMIT_BUTTON)).toBeOnTheScreen(); + expect(screen.getByText(translateLocal('multifactorAuthentication.letsVerifyItsYou'))).toBeOnTheScreen(); + expect(state.context.error).toBeUndefined(); + }, // The biometrics copy is expected because the jest-expo haste config resolves the operations // module to its native variant, which verifies with HSM-backed biometrics. [`${MFA_STATE.OPEN}.${MFA_STATE.PROMPT}.${MFA_STATE.AWAITING_SOFT_PROMPT}`]: (state: SnapshotFrom) => { diff --git a/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts index c4078516a113..c343495db243 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts @@ -1,5 +1,5 @@ import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type {ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; +import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; import {getDeviceBiometricsOnyxKey} from '@libs/actions/MultifactorAuthentication'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; @@ -8,7 +8,7 @@ import CONST from '@src/CONST'; import Onyx from 'react-native-onyx'; import getOnyxValue from 'tests/utils/getOnyxValue'; -import {createActorAtState, sendValidateDeviceDone} from 'tests/utils/mfa/flowActors'; +import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; import createInitEvent, {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; import {createActor, fromPromise} from 'xstate'; @@ -26,11 +26,11 @@ describe('MFA soft prompt', () => { await waitForBatchedUpdates(); }); - it('moves an eligible device to the soft prompt when the current account has not accepted it', async () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}); + it('moves a registered account to the soft prompt when the current account has not accepted it', async () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); actor.start(); - sendValidateDeviceDone(actor, {success: true}); + sendCheckLocalCredentialsDone(actor, true); await waitForBatchedUpdates(); const result = actor.getSnapshot(); @@ -42,10 +42,10 @@ describe('MFA soft prompt', () => { it('skips the soft prompt when the user has already accepted it on this device', async () => { await Onyx.merge(getDeviceBiometricsOnyxKey(MFA_TEST_ACCOUNT_ID), {hasAcceptedSoftPrompt: true}); - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); actor.start(); - sendValidateDeviceDone(actor, {success: true}); + sendCheckLocalCredentialsDone(actor, true); await waitForBatchedUpdates(); const result = actor.getSnapshot(); @@ -60,10 +60,10 @@ describe('MFA soft prompt', () => { const connection = {id: 'soft-prompt-read-test', callbackID: 'soft-prompt-read-test'}; jest.spyOn(Onyx, 'connectWithoutView').mockReturnValue(connection); const disconnectSpy = jest.spyOn(Onyx, 'disconnect').mockImplementation(); - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); actor.start(); - sendValidateDeviceDone(actor, {success: true}); + sendCheckLocalCredentialsDone(actor, true); expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); actor.send({type: 'CLOSE_MODAL'}); @@ -78,6 +78,8 @@ describe('MFA soft prompt', () => { const machine = mfaMachine.provide({ actors: { validateDevice: fromPromise(() => Promise.resolve({success: true})), + // A registered account routes the flow straight to the soft-prompt read under test. + checkLocalCredentials: fromPromise(() => Promise.resolve(true)), readHasAcceptedSoftPrompt: fromPromise(() => Promise.reject(new Error('Onyx read failed'))), }, }); diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts new file mode 100644 index 000000000000..0a66ad03e23c --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -0,0 +1,152 @@ +import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; +import type {CheckLocalCredentialsInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; + +import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; +import {createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; + +import {requestValidateCodeAction} from '@userActions/User'; +import type * as UserActions from '@userActions/User'; + +import CONST from '@src/CONST'; + +import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; +import createInitEvent, {MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; +import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; +import {createActor, fromPromise} from 'xstate'; + +// The machine fires the magic-code email request, which is a backend call this suite only observes. +jest.mock('@userActions/User', () => ({ + ...jest.requireActual('@userActions/User'), + requestValidateCodeAction: jest.fn(), +})); + +const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; +const REASON = CONST.MULTIFACTOR_AUTHENTICATION.REASON; + +const requestValidateCodeActionMock = jest.mocked(requestValidateCodeAction); + +const INVALID_CODE_ERROR = createMFAErrorFromApiResponse(400, REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, 'Invalid code for the transition spec'); +const FATAL_CODE_ERROR = createMFAErrorFromApiResponse(400, REASON.CLIENT_ERRORS.UNRECOGNIZED, 'Fatal rejection for the transition spec'); + +// The graph-traversal suites generate their expectations from the machine, so a transition pointed at +// a wrong target adjusts those expectations and still passes. This suite pins the registration +// decision and the magic-code loop by hand, including that only the decision transition may send the +// magic-code email. + +describe('MFA magic code and registration decision', () => { + beforeEach(() => { + requestValidateCodeActionMock.mockClear(); + }); + + it('requests a validate code exactly once when a fresh registration reaches the magic-code screen', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); + + actor.start(); + sendCheckLocalCredentialsDone(actor, false); + + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); + + actor.stop(); + }); + + it('skips the magic code for a returning user whose credentials the server knows', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); + + actor.start(); + sendCheckLocalCredentialsDone(actor, true); + + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); + expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); + + actor.stop(); + }); + + it('skips the magic code when the flow already carries a code', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}, {validateCode: MFA_TEST_VALIDATE_CODE}); + + actor.start(); + sendCheckLocalCredentialsDone(actor, false); + + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); + expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); + + actor.stop(); + }); + + it('stores the submitted code and continues the flow', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + + actor.start(); + actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); + expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE); + + actor.stop(); + }); + + it('stays on the magic-code screen with an inline error and no new email when the code is invalid', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + + actor.start(); + actor.send({type: 'VALIDATE_CODE_REJECTED', error: INVALID_CODE_ERROR}); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(result.context.continuableError).toBe(INVALID_CODE_ERROR); + expect(result.context.error).toBeUndefined(); + expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); + + actor.stop(); + }); + + it('ends the flow with the failure outcome when the code rejection is not continuable', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + + actor.start(); + actor.send({type: 'VALIDATE_CODE_REJECTED', error: FATAL_CODE_ERROR}); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); + expect(result.context.error).toBe(FATAL_CODE_ERROR); + expect(result.context.continuableError).toBeUndefined(); + + actor.stop(); + }); + + it('clears the inline error when the user starts typing again', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}, {continuableError: INVALID_CODE_ERROR}); + + actor.start(); + actor.send({type: 'CLEAR_CONTINUABLE_ERROR'}); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(result.context.continuableError).toBeUndefined(); + + actor.stop(); + }); + + it('ends the current flow with an error when the credentials check rejects', async () => { + const machine = mfaMachine.provide({ + actors: { + validateDevice: fromPromise(() => Promise.resolve({success: true})), + checkLocalCredentials: fromPromise(() => Promise.reject(new Error('Keystore read failed'))), + }, + }); + const actor = createActor(machine); + + actor.start(); + actor.send(createInitEvent()); + await waitForBatchedUpdates(); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); + expect(result.context.error?.reason).toBe(REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION); + expect(result.context.error?.message).toContain('Local credentials check threw: Keystore read failed'); + + actor.stop(); + }); +}); diff --git a/tests/unit/hooks/useSplitContextHooks.test.tsx b/tests/unit/hooks/useSplitContextHooks.test.tsx index 4cdff798a5eb..5995eef6990d 100644 --- a/tests/unit/hooks/useSplitContextHooks.test.tsx +++ b/tests/unit/hooks/useSplitContextHooks.test.tsx @@ -229,8 +229,7 @@ describe('Split context hooks', () => { const {result} = renderHook(() => useMultifactorAuthenticationState(), {wrapper}); expect(result.current).toEqual(DEFAULT_STATE); - expect(result.current.continuableError).toBeUndefined(); - expect(result.current.validateCode).toBeUndefined(); + expect(result.current.registrationChallenge).toBeUndefined(); expect(result.current.isFlowComplete).toBe(false); }); @@ -259,10 +258,10 @@ describe('Split context hooks', () => { ); act(() => { - result.current.actions.dispatch({type: 'SET_VALIDATE_CODE', payload: '123456'}); + result.current.actions.dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: true}); }); - expect(result.current.state.validateCode).toBe('123456'); + expect(result.current.state.isRegistrationComplete).toBe(true); }); it('dispatch handles SET_FLOW_COMPLETE', () => { @@ -299,11 +298,11 @@ describe('Split context hooks', () => { ); act(() => { - result.current.actions.dispatch({type: 'SET_VALIDATE_CODE', payload: '999'}); + result.current.actions.dispatch({type: 'SET_AUTHORIZATION_COMPLETE', payload: true}); result.current.actions.dispatch({type: 'SET_REGISTRATION_COMPLETE', payload: true}); }); - expect(result.current.state.validateCode).toBe('999'); + expect(result.current.state.isAuthorizationComplete).toBe(true); expect(result.current.state.isRegistrationComplete).toBe(true); act(() => { diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index c287fb04dbe9..6572faaab301 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -7,9 +7,10 @@ import type {OutputFrom, StateValue} from 'xstate'; import {createActor} from 'xstate'; import createInitEvent from './flowFixtures'; -import {VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; +import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; type ValidateDeviceOutput = OutputFrom['validateDevice']>; +type CheckLocalCredentialsOutput = OutputFrom['checkLocalCredentials']>; /** * Builds the context a flow carries right after INIT seeds it. Overrides express a spec's starting @@ -23,6 +24,8 @@ function createFlowContext(overrides: Partial = {}): MfaContext { scenarioName: initEvent.scenarioName, scenario: initEvent.scenario, payload: initEvent.payload, + validateCode: undefined, + continuableError: undefined, softPromptApproved: false, isCancelConfirmVisible: false, ...overrides, @@ -47,4 +50,13 @@ function sendValidateDeviceDone(actor: ReturnType, ou actor.send({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output} as unknown as MfaEvent); } -export {createActorAtState, createFlowContext, sendValidateDeviceDone}; +/** + * Completes the invoked credentials-check actor by sending its done event carrying the given output. + */ +function sendCheckLocalCredentialsDone(actor: ReturnType, output: CheckLocalCredentialsOutput) { + // Framework actor events are not part of the application's MfaEvent union. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + actor.send({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output} as unknown as MfaEvent); +} + +export {createActorAtState, createFlowContext, sendCheckLocalCredentialsDone, sendValidateDeviceDone}; diff --git a/tests/utils/mfa/flowFixtures.ts b/tests/utils/mfa/flowFixtures.ts index cf06670a85ab..1a1f34d7c925 100644 --- a/tests/utils/mfa/flowFixtures.ts +++ b/tests/utils/mfa/flowFixtures.ts @@ -5,6 +5,7 @@ import CONST from '@src/CONST'; const MFA_TEST_SCENARIO_NAME = CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST; const MFA_TEST_ACCOUNT_ID = 12345; +const MFA_TEST_VALIDATE_CODE = '123456'; /** * Builds the INIT event fixture for the test scenario. @@ -20,4 +21,4 @@ function createInitEvent(): MultifactorAuthenticationInitEvent; @@ -125,10 +142,12 @@ function isAutoDrivenEvent(eventType: string): boolean { /** * A path is UI-drivable when the walk can produce every step. A delayed transition would need real * timers, so a path containing one is not drivable. Actor completion events stay in the path so their - * executors can settle the controlled actor mocks at the correct transition. + * executors can settle the controlled actor mocks at the correct transition. A code rejection has no + * UI affordance until the registration slice wires the backend call that produces it, so paths + * containing one are covered by the machine-only suites instead. */ function isUiDrivablePath(path: {steps: PathSteps}): boolean { - return path.steps.every((step) => !step.event.type.startsWith(DELAYED_EVENT_PREFIX)); + return path.steps.every((step) => !step.event.type.startsWith(DELAYED_EVENT_PREFIX) && step.event.type !== 'VALIDATE_CODE_REJECTED'); } /** @@ -202,6 +221,8 @@ function getWalkedPaths() { export default getWalkedPaths; export { + CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, + CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, getDrivingJourneyPaths, getMfaShortestPaths, isAutoDrivenEvent, diff --git a/tests/utils/mfa/realUi/mocks.ts b/tests/utils/mfa/realUi/mocks.ts index 3aea4789d1c1..73931c04bb80 100644 --- a/tests/utils/mfa/realUi/mocks.ts +++ b/tests/utils/mfa/realUi/mocks.ts @@ -1,6 +1,6 @@ import type {UseBiometricsReturn} from '@components/MultifactorAuthentication/biometrics/shared/types'; import type createActors from '@components/MultifactorAuthentication/machine/mfaActors'; -import type {ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; +import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; import type Navigation from '@libs/Navigation/Navigation'; @@ -84,11 +84,13 @@ function createControlledActor(actorID: string) { const validateDeviceControl = createControlledActor('validateDevice'); const readHasAcceptedSoftPromptControl = createControlledActor('readHasAcceptedSoftPrompt'); +const checkLocalCredentialsControl = createControlledActor('checkLocalCredentials'); function resetMfaUiMocks() { pendingModalClose.clear(); validateDeviceControl.reset(); readHasAcceptedSoftPromptControl.reset(); + checkLocalCredentialsControl.reset(); } /** Replaces the machine's side-effect actors with controlled test implementations. */ @@ -96,6 +98,7 @@ function mfaActorsMock() { const actors = { validateDevice: validateDeviceControl.actor, readHasAcceptedSoftPrompt: readHasAcceptedSoftPromptControl.actor, + checkLocalCredentials: checkLocalCredentialsControl.actor, } satisfies ReturnType; return { @@ -111,6 +114,17 @@ function biometricsHookMock() { }; } +/** + * Stubs only the magic-code email request. It is a backend call outside the modal lifecycle + * contract, and the machine fires it when the walk enters the magic-code screen. + */ +function userActionsMock() { + return { + ...jest.requireActual>('@libs/actions/User'), + requestValidateCodeAction: jest.fn(), + }; +} + function renderHtmlMock() { return { __esModule: true, @@ -153,4 +167,16 @@ function navigationMock() { }; } -export {pendingModalClose, validateDeviceControl, readHasAcceptedSoftPromptControl, resetMfaUiMocks, mfaActorsMock, biometricsHookMock, renderHtmlMock, syncHistoryMock, navigationMock}; +export { + pendingModalClose, + validateDeviceControl, + readHasAcceptedSoftPromptControl, + checkLocalCredentialsControl, + resetMfaUiMocks, + mfaActorsMock, + userActionsMock, + biometricsHookMock, + renderHtmlMock, + syncHistoryMock, + navigationMock, +}; From 3ca407f779ee55e6e4ba1f577daf3737e242c901 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 24 Jul 2026 15:41:51 +0200 Subject: [PATCH 02/30] fix(mfa): clear the inline error when a rejected code is resubmitted The page clears the continuable error only when the user edits the code, so resubmitting the unchanged one carried the stale invalid-code error past the magic-code state, and the screen, which stays visible through the soft-prompt read, kept showing it while the flow had moved on. The submit transition now drops the error alongside storing the code, and a reject-then-resubmit spec pins the loop the walk cannot reach. --- .../machine/mfaMachine.ts | 2 +- .../machine/validateCodeTransition.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index c90f0b39f7ee..aea5150600c1 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -222,7 +222,7 @@ const MFAMachine = setup({ [MFA_STATE.REQUESTING_VALIDATE_CODE]: { id: MFA_STATE.REQUESTING_VALIDATE_CODE, on: { - VALIDATE_CODE_ENTERED: {target: SOFT_PROMPT_CHECK_TARGET, actions: 'submitValidateCode'}, + VALIDATE_CODE_ENTERED: {target: SOFT_PROMPT_CHECK_TARGET, actions: ['clearContinuableError', 'submitValidateCode']}, VALIDATE_CODE_REJECTED: [ { guard: ({event}) => event.error.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index 0a66ad03e23c..f28e3aed5bd7 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -102,6 +102,21 @@ describe('MFA magic code and registration decision', () => { actor.stop(); }); + it('clears the inline error when the rejected code is submitted again without editing', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + + actor.start(); + actor.send({type: 'VALIDATE_CODE_REJECTED', error: INVALID_CODE_ERROR}); + actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); + expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE); + expect(result.context.continuableError).toBeUndefined(); + + actor.stop(); + }); + it('ends the flow with the failure outcome when the code rejection is not continuable', () => { const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); From 414d974dc42fbbf48749d2cfd49f27d7131a2085 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 24 Jul 2026 16:02:52 +0200 Subject: [PATCH 03/30] refactor(mfa): drop the unreachable carried-code guard from the registration decision validateCode is assigned only in the magic-code state, which never routes back to the decision, and every entry there goes through INIT, which resets the context. The guard could therefore never pass in a running flow; the spec exercising it only passed by seeding the code into the context by hand. The slice that loops registration back into the decision can reintroduce the guard together with the path that makes it reachable. --- .../MultifactorAuthentication/machine/mfaMachine.ts | 4 +--- .../machine/validateCodeTransition.test.ts | 12 ------------ 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index aea5150600c1..d9c383a13dba 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -183,11 +183,9 @@ const MFAMachine = setup({ } return {accountID: context.accountID}; }, - // A returning user's credentials are already registered, and a re-entered - // flow already carries a code, so only a fresh registration asks for one. + // A returning user's credentials are already registered, so only a fresh registration asks for a code. onDone: [ {guard: ({event}) => event.output, target: SOFT_PROMPT_CHECK_TARGET}, - {guard: ({context}) => context.validateCode !== undefined, target: SOFT_PROMPT_CHECK_TARGET}, {target: MAGIC_CODE_TARGET, actions: ['requestValidateCode', 'navigateToMagicCode']}, ], onError: { diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index f28e3aed5bd7..464cf47938e2 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -62,18 +62,6 @@ describe('MFA magic code and registration decision', () => { actor.stop(); }); - it('skips the magic code when the flow already carries a code', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}, {validateCode: MFA_TEST_VALIDATE_CODE}); - - actor.start(); - sendCheckLocalCredentialsDone(actor, false); - - expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); - expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); - - actor.stop(); - }); - it('stores the submitted code and continues the flow', () => { const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); From 10a49a884bee04e372ce93aa0a70591dcff10139 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Fri, 24 Jul 2026 17:24:38 +0200 Subject: [PATCH 04/30] feat(mfa): exchange the magic code for a registration challenge The submitted code now drives a real backend round-trip: a new requestingRegistrationChallenge state invokes an actor that wraps requestRegistrationChallenge, and the machine routes on the normalized result. A valid challenge lands in the machine context and the flow continues; an invalid code returns to the magic-code screen as the inline, continuable error; anything else ends the flow through the outcome path. This retires the mocked VALIDATE_CODE_REJECTED event and its walk exclusions, so the graph walk now drives the invalid-code retry loop through the real UI, including a dedicated journey. The legacy reducer's registrationChallenge field moves into the machine context with no remaining legacy consumers. The response's publicKeys stay unused until the registration slice reconciles local credentials. --- .../Context/state.ts | 6 +- .../Context/stateReducer.ts | 2 - .../Context/types.ts | 3 +- .../machine/mfaActors.ts | 20 ++- .../machine/mfaMachine.ts | 44 +++++-- .../machine/types.ts | 25 +++- .../shared/VALUES.ts | 1 + .../viewMatchesMachine.test.tsx | 42 +++++-- .../machine/validateCodeTransition.test.ts | 116 +++++++++++++++--- .../unit/hooks/useSplitContextHooks.test.tsx | 1 - tests/utils/mfa/flowActors.ts | 13 +- tests/utils/mfa/flowFixtures.ts | 18 ++- tests/utils/mfa/flowPaths.ts | 74 ++++++----- tests/utils/mfa/realUi/mocks.ts | 12 +- 14 files changed, 296 insertions(+), 81 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/state.ts b/src/components/MultifactorAuthentication/Context/state.ts index afbd33c4e0cf..6fb139d4e086 100644 --- a/src/components/MultifactorAuthentication/Context/state.ts +++ b/src/components/MultifactorAuthentication/Context/state.ts @@ -1,6 +1,6 @@ import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types'; -import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; /** @@ -9,9 +9,6 @@ import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; * via `snapshotToState`. */ type MultifactorAuthenticationState = { - /** Challenge received from backend for registration (full object with user, rp, challenge) */ - registrationChallenge: RegistrationChallenge | undefined; - /** Challenge received from backend for authorization (full object with allowCredentials, rpId, challenge) */ authorizationChallenge: AuthenticationChallenge | undefined; @@ -32,7 +29,6 @@ type MultifactorAuthenticationState = { }; const DEFAULT_STATE: MultifactorAuthenticationState = { - registrationChallenge: undefined, authorizationChallenge: undefined, isRegistrationComplete: false, isAuthorizationComplete: false, diff --git a/src/components/MultifactorAuthentication/Context/stateReducer.ts b/src/components/MultifactorAuthentication/Context/stateReducer.ts index bf2d4fec81f5..94b4fe0b0d80 100644 --- a/src/components/MultifactorAuthentication/Context/stateReducer.ts +++ b/src/components/MultifactorAuthentication/Context/stateReducer.ts @@ -7,8 +7,6 @@ import {DEFAULT_STATE} from './state'; */ function stateReducer(state: MultifactorAuthenticationState, action: Action): MultifactorAuthenticationState { switch (action.type) { - case 'SET_REGISTRATION_CHALLENGE': - return {...state, registrationChallenge: action.payload}; case 'SET_AUTHORIZATION_CHALLENGE': return {...state, authorizationChallenge: action.payload}; case 'SET_REGISTRATION_COMPLETE': diff --git a/src/components/MultifactorAuthentication/Context/types.ts b/src/components/MultifactorAuthentication/Context/types.ts index d9740d5d7cac..3578fc70cb05 100644 --- a/src/components/MultifactorAuthentication/Context/types.ts +++ b/src/components/MultifactorAuthentication/Context/types.ts @@ -1,12 +1,11 @@ import type {MultifactorAuthenticationScenarioResponse} from '@components/MultifactorAuthentication/config/types'; -import type {AuthenticationChallenge, RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import type {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; import type {MultifactorAuthenticationState} from './state'; type Action = - | {type: 'SET_REGISTRATION_CHALLENGE'; payload: RegistrationChallenge | undefined} | {type: 'SET_AUTHORIZATION_CHALLENGE'; payload: AuthenticationChallenge | undefined} | {type: 'SET_REGISTRATION_COMPLETE'; payload: boolean} | {type: 'SET_AUTHORIZATION_COMPLETE'; payload: boolean} diff --git a/src/components/MultifactorAuthentication/machine/mfaActors.ts b/src/components/MultifactorAuthentication/machine/mfaActors.ts index bac7a1b3739e..4b975fdf64ec 100644 --- a/src/components/MultifactorAuthentication/machine/mfaActors.ts +++ b/src/components/MultifactorAuthentication/machine/mfaActors.ts @@ -1,14 +1,16 @@ import checkDeviceEligibility from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility'; import {areLocalCredentialsKnownToServer} from '@components/MultifactorAuthentication/biometrics/operations'; +import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; +import {createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; -import {getDeviceBiometricsOnyxKey} from '@userActions/MultifactorAuthentication'; +import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; import {fromPromise} from 'xstate'; -import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from './types'; +import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, ValidateDeviceInput} from './types'; /** * A refused device resolves as a failed MFAResult, so the machine's onError transition for this @@ -31,12 +33,24 @@ const readHasAcceptedSoftPrompt = fromPromise(({input}) => areLocalCredentialsKnownToServer(input.accountID)); +/** + * Exchanges the submitted magic code for a validated registration challenge. The action normalizes + * backend failures into a reason; the actor exposes them as failed MFA results for machine routing. + */ +const requestRegistrationChallengeActor = fromPromise(async ({input}) => { + const {challenge, httpStatusCode, reason, message} = await requestRegistrationChallenge(input.validateCode); + if (!isHttpSuccess(httpStatusCode) || !challenge) { + return {success: false, error: createMFAErrorFromApiResponse(httpStatusCode, reason, message)}; + } + return {success: true, challenge}; +}); + /** * Builds the side-effect actors that the machine states invoke. The machine is always created with * these working implementations, so no caller needs to provide stubs or overrides. */ function createActors() { - return {validateDevice, readHasAcceptedSoftPrompt, checkLocalCredentials}; + return {validateDevice, readHasAcceptedSoftPrompt, checkLocalCredentials, requestRegistrationChallenge: requestRegistrationChallengeActor}; } export default createActors; diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index d9c383a13dba..44852e6f072a 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -24,6 +24,7 @@ const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const; const PROMPT_TARGET = `#${MFA_STATE.PROMPT}` as const; const SOFT_PROMPT_CHECK_TARGET = `#${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}` as const; const MAGIC_CODE_TARGET = `#${MFA_STATE.REQUESTING_VALIDATE_CODE}` as const; +const REGISTRATION_CHALLENGE_TARGET = `#${MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}` as const; // Which prompt variant the screen renders is a device property, resolved once per platform. const PROMPT_TYPE = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[deviceVerificationType]; @@ -36,6 +37,7 @@ const DEFAULT_CONTEXT: MfaContext = { payload: undefined, validateCode: undefined, continuableError: undefined, + registrationChallenge: undefined, softPromptApproved: false, isCancelConfirmVisible: false, }; @@ -215,20 +217,46 @@ const MFAMachine = setup({ }, }, // This branch shows the magic-code screen while a fresh registration waits for the - // emailed code. A rejected code either stays here as an inline, continuable error - // (invalid code, targetless so re-entry actions cannot run) or ends the flow. + // emailed code. Submitting stores the code and starts the backend challenge request. [MFA_STATE.REQUESTING_VALIDATE_CODE]: { id: MFA_STATE.REQUESTING_VALIDATE_CODE, on: { - VALIDATE_CODE_ENTERED: {target: SOFT_PROMPT_CHECK_TARGET, actions: ['clearContinuableError', 'submitValidateCode']}, - VALIDATE_CODE_REJECTED: [ + VALIDATE_CODE_ENTERED: {target: REGISTRATION_CHALLENGE_TARGET, actions: ['clearContinuableError', 'submitValidateCode']}, + CLEAR_CONTINUABLE_ERROR: {actions: 'clearContinuableError'}, + }, + }, + // The magic-code screen stays mounted while the backend exchanges the code for a + // registration challenge. Only a real challenge advances the flow; an invalid code + // returns to the same screen with an inline error. + [MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE]: { + id: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE, + invoke: { + id: 'requestRegistrationChallenge', + src: 'requestRegistrationChallenge', + input: ({context}) => { + if (context.validateCode === undefined) { + throw new Error('MFA validate code must be stored before requesting a registration challenge'); + } + return {validateCode: context.validateCode}; + }, + onDone: [ { - guard: ({event}) => event.error.reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, - actions: assign({continuableError: ({event}) => event.error}), + guard: ({event}) => event.output.success, + target: SOFT_PROMPT_CHECK_TARGET, + actions: assign({registrationChallenge: ({event}) => (event.output.success ? event.output.challenge : undefined)}), }, - {target: OUTCOME_TARGET, actions: assign({error: ({event}) => event.error})}, + { + guard: ({event}) => + !event.output.success && getMFAFailureError(event.output).reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, + target: MAGIC_CODE_TARGET, + actions: assign({continuableError: ({event}) => getMFAFailureError(event.output)}), + }, + {target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})}, ], - CLEAR_CONTINUABLE_ERROR: {actions: 'clearContinuableError'}, + onError: { + target: OUTCOME_TARGET, + actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Registration challenge request', event.error)}), + }, }, }, // This branch shows the soft prompt when the current account has not accepted it on this device. diff --git a/src/components/MultifactorAuthentication/machine/types.ts b/src/components/MultifactorAuthentication/machine/types.ts index bf2b301a55fd..be860f9fc6ed 100644 --- a/src/components/MultifactorAuthentication/machine/types.ts +++ b/src/components/MultifactorAuthentication/machine/types.ts @@ -6,7 +6,8 @@ import type { MultifactorAuthenticationScenarioParams, } from '@components/MultifactorAuthentication/config/types'; -import type {MFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; +import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import type {MFAError, MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; import type CONST from '@src/CONST'; @@ -38,6 +39,9 @@ type MfaContext = { /** Error the validate-code screen shows inline while the flow stays on it, as opposed to `error`, which ends the flow */ continuableError: MFAError | undefined; + /** Registration challenge returned after the backend accepts the magic code */ + registrationChallenge: RegistrationChallenge | undefined; + /** Whether the user approved the soft prompt during this flow. The durable acceptance lives in Onyx under the device-biometrics key. */ softPromptApproved: boolean; @@ -70,7 +74,6 @@ type MfaEvent = | {type: 'MODAL_CLOSED'} | {type: 'SOFT_PROMPT_APPROVED'} | {type: 'VALIDATE_CODE_ENTERED'; validateCode: string} - | {type: 'VALIDATE_CODE_REJECTED'; error: MFAError} | {type: 'CLEAR_CONTINUABLE_ERROR'}; /** Describes the input the machine passes to the device-check actor. */ @@ -82,4 +85,20 @@ type ReadHasAcceptedSoftPromptInput = {accountID: number}; /** Identifies the account whose local credentials the registration-decision actor checks. */ type CheckLocalCredentialsInput = {accountID: number}; -export type {CheckLocalCredentialsInput, MfaContext, MfaEvent, MfaModalState, MultifactorAuthenticationInitEvent, ReadHasAcceptedSoftPromptInput, ValidateDeviceInput}; +/** Magic code sent to the backend to obtain a registration challenge. */ +type RequestRegistrationChallengeInput = {validateCode: string}; + +/** A successful response must carry the validated registration challenge. */ +type RequestRegistrationChallengeOutput = MFAResult<{challenge: RegistrationChallenge}>; + +export type { + CheckLocalCredentialsInput, + MfaContext, + MfaEvent, + MfaModalState, + MultifactorAuthenticationInitEvent, + ReadHasAcceptedSoftPromptInput, + RequestRegistrationChallengeInput, + RequestRegistrationChallengeOutput, + ValidateDeviceInput, +}; diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 815f51306e3c..771cde58dd63 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -224,6 +224,7 @@ const MFA_STATE = { DECIDING_REGISTRATION: 'decidingRegistration', CHECKING_SOFT_PROMPT_ACCEPTANCE: 'checkingSoftPromptAcceptance', REQUESTING_VALIDATE_CODE: 'requestingValidateCode', + REQUESTING_REGISTRATION_CHALLENGE: 'requestingRegistrationChallenge', PROMPT: 'prompt', AWAITING_SOFT_PROMPT: 'awaitingSoftPrompt', OUTCOME: 'outcome', diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 3bb8e9122152..bd503ebf672f 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -1,7 +1,7 @@ import {act, fireEvent, screen} from '@testing-library/react-native'; import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type {MfaEvent} from '@components/MultifactorAuthentication/machine/types'; +import type {MfaEvent, RequestRegistrationChallengeOutput} from '@components/MultifactorAuthentication/machine/types'; import {mfaNavigationRef} from '@components/MultifactorAuthentication/mfaNavigation'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; @@ -21,12 +21,21 @@ import getWalkedPaths, { isAutoDrivenEvent, READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE, + REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, + REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE, VALIDATE_DEVICE_ERROR_EVENT_TYPE, } from 'tests/utils/mfa/flowPaths'; import {getSettleableLeafStates} from 'tests/utils/mfa/leafStates'; import renderMfaUi from 'tests/utils/mfa/realUi/harness'; -import {checkLocalCredentialsControl, pendingModalClose, readHasAcceptedSoftPromptControl, resetMfaUiMocks, validateDeviceControl} from 'tests/utils/mfa/realUi/mocks'; +import { + checkLocalCredentialsControl, + pendingModalClose, + readHasAcceptedSoftPromptControl, + requestRegistrationChallengeControl, + resetMfaUiMocks, + validateDeviceControl, +} from 'tests/utils/mfa/realUi/mocks'; import {translateLocal} from 'tests/utils/TestHelper'; import waitForBatchedUpdatesWithAct from 'tests/utils/waitForBatchedUpdatesWithAct'; import {matchesState} from 'xstate'; @@ -78,6 +87,10 @@ type MfaActorEventExecutors = { [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => Promise; [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step: {event: {type: typeof CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE; output: boolean}}) => Promise; [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => Promise; + [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step: { + event: {type: typeof REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE; output: RequestRegistrationChallengeOutput}; + }) => Promise; + [REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE]: () => Promise; }; type ExecuteScenario = ReturnType['executeScenario']; @@ -146,12 +159,6 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { fireEvent.press(screen.getByTestId(TEST_ID.VALIDATE_CODE_SUBMIT_BUTTON)); await waitForBatchedUpdatesWithAct(); }, - // The walk filters rejection paths out (`isUiDrivablePath`), because no UI gesture produces - // the event until the registration slice wires the backend call. The executor exists only to - // keep the event table exhaustive. - VALIDATE_CODE_REJECTED: () => { - throw new Error('VALIDATE_CODE_REJECTED has no UI affordance yet, so no walked path may contain it.'); - }, CLEAR_CONTINUABLE_ERROR: async () => { fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), '1'); await waitForBatchedUpdatesWithAct(); @@ -162,6 +169,8 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => settleActor(readHasAcceptedSoftPromptControl.reject), [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step) => settleActor(() => checkLocalCredentialsControl.resolve(step.event.output)), [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => settleActor(checkLocalCredentialsControl.reject), + [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step) => settleActor(() => requestRegistrationChallengeControl.resolve(step.event.output)), + [REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE]: () => settleActor(requestRegistrationChallengeControl.reject), } satisfies MfaEventExecutors & MfaActorEventExecutors; } /* eslint-enable @typescript-eslint/naming-convention */ @@ -191,8 +200,10 @@ const testConfig = { // stays visible while the read runs; a first pass runs behind the transparent initial screen. if (state.context.validateCode === undefined) { expect(screen.queryAllByTestId(TEST_ID.INITIAL_SCREEN)).toHaveLength(1); + expect(state.context.registrationChallenge).toBeUndefined(); } else { expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); + expect(state.context.registrationChallenge).toBeDefined(); } expect(state.context.accountID).toBeDefined(); expect(state.context.error).toBeUndefined(); @@ -205,6 +216,21 @@ const testConfig = { expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_SUBMIT_BUTTON)).toBeOnTheScreen(); expect(screen.getByText(translateLocal('multifactorAuthentication.letsVerifyItsYou'))).toBeOnTheScreen(); expect(state.context.error).toBeUndefined(); + const inlineError = translateLocal('validateCodeForm.error.incorrectMagicCode'); + if (state.context.continuableError) { + expect(screen.getByText(inlineError)).toBeOnTheScreen(); + } else { + expect(screen.queryByText(inlineError)).not.toBeOnTheScreen(); + } + }, + [`${MFA_STATE.OPEN}.${MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}`]: (state: SnapshotFrom) => { + expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); + expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); + expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); + expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT)).toBeOnTheScreen(); + expect(state.context.validateCode).toBeDefined(); + expect(state.context.registrationChallenge).toBeUndefined(); + expect(state.context.error).toBeUndefined(); }, // The biometrics copy is expected because the jest-expo haste config resolves the operations // module to its native variant, which verifies with HSM-backed biometrics. diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index 464cf47938e2..fc94d3755d30 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -2,15 +2,16 @@ import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine import type {CheckLocalCredentialsInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; -import {createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; +import {requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; +import type * as MultifactorAuthenticationActions from '@userActions/MultifactorAuthentication'; import {requestValidateCodeAction} from '@userActions/User'; import type * as UserActions from '@userActions/User'; import CONST from '@src/CONST'; import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; -import createInitEvent, {MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; +import createInitEvent, {MFA_TEST_INVALID_CODE_ERROR, MFA_TEST_REGISTRATION_CHALLENGE, MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; import {createActor, fromPromise} from 'xstate'; @@ -19,14 +20,45 @@ jest.mock('@userActions/User', () => ({ ...jest.requireActual('@userActions/User'), requestValidateCodeAction: jest.fn(), })); +jest.mock('@userActions/MultifactorAuthentication', () => ({ + ...jest.requireActual('@userActions/MultifactorAuthentication'), + requestRegistrationChallenge: jest.fn(), +})); const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; const REASON = CONST.MULTIFACTOR_AUTHENTICATION.REASON; const requestValidateCodeActionMock = jest.mocked(requestValidateCodeAction); - -const INVALID_CODE_ERROR = createMFAErrorFromApiResponse(400, REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, 'Invalid code for the transition spec'); -const FATAL_CODE_ERROR = createMFAErrorFromApiResponse(400, REASON.CLIENT_ERRORS.UNRECOGNIZED, 'Fatal rejection for the transition spec'); +const requestRegistrationChallengeMock = jest.mocked(requestRegistrationChallenge); +type RegistrationChallengeResponse = Awaited>; +const VALID_REGISTRATION_CHALLENGE_RESPONSE = { + httpStatusCode: 200, + reason: undefined, + message: undefined, + challenge: MFA_TEST_REGISTRATION_CHALLENGE, + publicKeys: [], +} satisfies RegistrationChallengeResponse; +const INVALID_CODE_RESPONSE = { + httpStatusCode: 400, + reason: REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, + message: 'Invalid code for the transition spec', + challenge: undefined, + publicKeys: undefined, +} satisfies RegistrationChallengeResponse; +const MISSING_REGISTRATION_CHALLENGE_RESPONSE = { + httpStatusCode: 200, + reason: undefined, + message: undefined, + challenge: undefined, + publicKeys: [], +} satisfies RegistrationChallengeResponse; +const FATAL_REGISTRATION_CHALLENGE_RESPONSE = { + httpStatusCode: 500, + reason: REASON.SERVER_ERRORS.UNRECOGNIZED, + message: 'Fatal registration challenge rejection', + challenge: undefined, + publicKeys: undefined, +} satisfies RegistrationChallengeResponse; // The graph-traversal suites generate their expectations from the machine, so a transition pointed at // a wrong target adjusts those expectations and still passes. This suite pins the registration @@ -36,6 +68,13 @@ const FATAL_CODE_ERROR = createMFAErrorFromApiResponse(400, REASON.CLIENT_ERRORS describe('MFA magic code and registration decision', () => { beforeEach(() => { requestValidateCodeActionMock.mockClear(); + requestRegistrationChallengeMock.mockReset(); + requestRegistrationChallengeMock.mockImplementation( + () => + new Promise(() => { + // Keep the actor pending so the test can assert the challenge-request gate. + }), + ); }); it('requests a validate code exactly once when a fresh registration reaches the magic-code screen', () => { @@ -62,65 +101,108 @@ describe('MFA magic code and registration decision', () => { actor.stop(); }); - it('stores the submitted code and continues the flow', () => { + it('stores the submitted code and waits for a registration challenge before continuing', () => { const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); actor.start(); actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); + expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE})).toBe(true); expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE); + expect(result.context.registrationChallenge).toBeUndefined(); + expect(requestRegistrationChallengeMock).toHaveBeenCalledWith(MFA_TEST_VALIDATE_CODE); actor.stop(); }); - it('stays on the magic-code screen with an inline error and no new email when the code is invalid', () => { + it('stores a valid registration challenge before continuing the flow', async () => { const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + requestRegistrationChallengeMock.mockResolvedValue(VALID_REGISTRATION_CHALLENGE_RESPONSE); actor.start(); - actor.send({type: 'VALIDATE_CODE_REJECTED', error: INVALID_CODE_ERROR}); + actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + await waitForBatchedUpdates(); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE})).toBe(false); + expect(result.context.registrationChallenge).toBe(MFA_TEST_REGISTRATION_CHALLENGE); + expect(result.context.error).toBeUndefined(); + + actor.stop(); + }); + + it('stays on the magic-code screen with an inline error and no new email when the code is invalid', async () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + requestRegistrationChallengeMock.mockResolvedValue(INVALID_CODE_RESPONSE); + + actor.start(); + actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + await waitForBatchedUpdates(); const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); - expect(result.context.continuableError).toBe(INVALID_CODE_ERROR); + expect(result.context.continuableError?.reason).toBe(REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE); + expect(result.context.registrationChallenge).toBeUndefined(); expect(result.context.error).toBeUndefined(); expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); actor.stop(); }); - it('clears the inline error when the rejected code is submitted again without editing', () => { + it('clears the inline error when the rejected code is submitted again without editing', async () => { const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + requestRegistrationChallengeMock.mockResolvedValueOnce(INVALID_CODE_RESPONSE).mockResolvedValueOnce(VALID_REGISTRATION_CHALLENGE_RESPONSE); actor.start(); - actor.send({type: 'VALIDATE_CODE_REJECTED', error: INVALID_CODE_ERROR}); actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + await waitForBatchedUpdates(); + actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + await waitForBatchedUpdates(); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); + expect(result.context.registrationChallenge).toBe(MFA_TEST_REGISTRATION_CHALLENGE); expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE); expect(result.context.continuableError).toBeUndefined(); actor.stop(); }); - it('ends the flow with the failure outcome when the code rejection is not continuable', () => { + it('ends the flow with the failure outcome when the challenge request fails fatally', async () => { const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + requestRegistrationChallengeMock.mockResolvedValue(FATAL_REGISTRATION_CHALLENGE_RESPONSE); actor.start(); - actor.send({type: 'VALIDATE_CODE_REJECTED', error: FATAL_CODE_ERROR}); + actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + await waitForBatchedUpdates(); const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); - expect(result.context.error).toBe(FATAL_CODE_ERROR); + expect(result.context.error?.reason).toBe(REASON.SERVER_ERRORS.UNRECOGNIZED); + expect(result.context.registrationChallenge).toBeUndefined(); expect(result.context.continuableError).toBeUndefined(); actor.stop(); }); + it('does not continue when a successful response has no valid registration challenge', async () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + requestRegistrationChallengeMock.mockResolvedValue(MISSING_REGISTRATION_CHALLENGE_RESPONSE); + + actor.start(); + actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + await waitForBatchedUpdates(); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); + expect(result.context.error?.reason).toBe(REASON.LOCAL_ERRORS.UNHANDLED_API_RESPONSE); + expect(result.context.registrationChallenge).toBeUndefined(); + + actor.stop(); + }); + it('clears the inline error when the user starts typing again', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}, {continuableError: INVALID_CODE_ERROR}); + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}, {continuableError: MFA_TEST_INVALID_CODE_ERROR}); actor.start(); actor.send({type: 'CLEAR_CONTINUABLE_ERROR'}); diff --git a/tests/unit/hooks/useSplitContextHooks.test.tsx b/tests/unit/hooks/useSplitContextHooks.test.tsx index 5995eef6990d..bf6c689db879 100644 --- a/tests/unit/hooks/useSplitContextHooks.test.tsx +++ b/tests/unit/hooks/useSplitContextHooks.test.tsx @@ -229,7 +229,6 @@ describe('Split context hooks', () => { const {result} = renderHook(() => useMultifactorAuthenticationState(), {wrapper}); expect(result.current).toEqual(DEFAULT_STATE); - expect(result.current.registrationChallenge).toBeUndefined(); expect(result.current.isFlowComplete).toBe(false); }); diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index 6572faaab301..88825104f30a 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -7,10 +7,11 @@ import type {OutputFrom, StateValue} from 'xstate'; import {createActor} from 'xstate'; import createInitEvent from './flowFixtures'; -import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; +import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; type ValidateDeviceOutput = OutputFrom['validateDevice']>; type CheckLocalCredentialsOutput = OutputFrom['checkLocalCredentials']>; +type RequestRegistrationChallengeOutput = OutputFrom['requestRegistrationChallenge']>; /** * Builds the context a flow carries right after INIT seeds it. Overrides express a spec's starting @@ -26,6 +27,7 @@ function createFlowContext(overrides: Partial = {}): MfaContext { payload: initEvent.payload, validateCode: undefined, continuableError: undefined, + registrationChallenge: undefined, softPromptApproved: false, isCancelConfirmVisible: false, ...overrides, @@ -59,4 +61,11 @@ function sendCheckLocalCredentialsDone(actor: ReturnType, output: RequestRegistrationChallengeOutput) { + // Framework actor events are not part of the application's MfaEvent union. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + actor.send({type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, output} as unknown as MfaEvent); +} + +export {createActorAtState, createFlowContext, sendCheckLocalCredentialsDone, sendRequestRegistrationChallengeDone, sendValidateDeviceDone}; diff --git a/tests/utils/mfa/flowFixtures.ts b/tests/utils/mfa/flowFixtures.ts index 1a1f34d7c925..ca69dc12b842 100644 --- a/tests/utils/mfa/flowFixtures.ts +++ b/tests/utils/mfa/flowFixtures.ts @@ -1,11 +1,27 @@ import {getScenarioConfig} from '@components/MultifactorAuthentication/config'; import type {MultifactorAuthenticationInitEvent} from '@components/MultifactorAuthentication/machine/types'; +import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; +import {createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; + import CONST from '@src/CONST'; const MFA_TEST_SCENARIO_NAME = CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST; const MFA_TEST_ACCOUNT_ID = 12345; const MFA_TEST_VALIDATE_CODE = '123456'; +const MFA_TEST_REGISTRATION_CHALLENGE: RegistrationChallenge = { + challenge: 'registration-challenge', + rp: {id: 'expensify.com'}, + user: {id: 'mfa-test-user', displayName: 'MFA Test User'}, + pubKeyCredParams: [{type: 'public-key', alg: -7}], + timeout: 60000, +}; +const MFA_TEST_INVALID_CODE_ERROR = createMFAErrorFromApiResponse(400, CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, 'Graph-traversal invalid code'); +const MFA_TEST_FATAL_REGISTRATION_CHALLENGE_ERROR = createMFAErrorFromApiResponse( + 400, + CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.UNRECOGNIZED, + 'Graph-traversal fatal registration challenge rejection', +); /** * Builds the INIT event fixture for the test scenario. @@ -21,4 +37,4 @@ function createInitEvent(): MultifactorAuthenticationInitEvent; type MfaSnapshot = SnapshotFrom; @@ -142,12 +160,10 @@ function isAutoDrivenEvent(eventType: string): boolean { /** * A path is UI-drivable when the walk can produce every step. A delayed transition would need real * timers, so a path containing one is not drivable. Actor completion events stay in the path so their - * executors can settle the controlled actor mocks at the correct transition. A code rejection has no - * UI affordance until the registration slice wires the backend call that produces it, so paths - * containing one are covered by the machine-only suites instead. + * executors can settle the controlled actor mocks at the correct transition. */ function isUiDrivablePath(path: {steps: PathSteps}): boolean { - return path.steps.every((step) => !step.event.type.startsWith(DELAYED_EVENT_PREFIX) && step.event.type !== 'VALIDATE_CODE_REJECTED'); + return path.steps.every((step) => !step.event.type.startsWith(DELAYED_EVENT_PREFIX)); } /** @@ -228,6 +244,8 @@ export { isAutoDrivenEvent, READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE, + REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, + REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE, VALIDATE_DEVICE_ERROR_EVENT_TYPE, }; diff --git a/tests/utils/mfa/realUi/mocks.ts b/tests/utils/mfa/realUi/mocks.ts index 73931c04bb80..becf847a0494 100644 --- a/tests/utils/mfa/realUi/mocks.ts +++ b/tests/utils/mfa/realUi/mocks.ts @@ -1,6 +1,12 @@ import type {UseBiometricsReturn} from '@components/MultifactorAuthentication/biometrics/shared/types'; import type createActors from '@components/MultifactorAuthentication/machine/mfaActors'; -import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; +import type { + CheckLocalCredentialsInput, + ReadHasAcceptedSoftPromptInput, + RequestRegistrationChallengeInput, + RequestRegistrationChallengeOutput, + ValidateDeviceInput, +} from '@components/MultifactorAuthentication/machine/types'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; import type Navigation from '@libs/Navigation/Navigation'; @@ -85,12 +91,14 @@ function createControlledActor(actorID: string) { const validateDeviceControl = createControlledActor('validateDevice'); const readHasAcceptedSoftPromptControl = createControlledActor('readHasAcceptedSoftPrompt'); const checkLocalCredentialsControl = createControlledActor('checkLocalCredentials'); +const requestRegistrationChallengeControl = createControlledActor('requestRegistrationChallenge'); function resetMfaUiMocks() { pendingModalClose.clear(); validateDeviceControl.reset(); readHasAcceptedSoftPromptControl.reset(); checkLocalCredentialsControl.reset(); + requestRegistrationChallengeControl.reset(); } /** Replaces the machine's side-effect actors with controlled test implementations. */ @@ -99,6 +107,7 @@ function mfaActorsMock() { validateDevice: validateDeviceControl.actor, readHasAcceptedSoftPrompt: readHasAcceptedSoftPromptControl.actor, checkLocalCredentials: checkLocalCredentialsControl.actor, + requestRegistrationChallenge: requestRegistrationChallengeControl.actor, } satisfies ReturnType; return { @@ -172,6 +181,7 @@ export { validateDeviceControl, readHasAcceptedSoftPromptControl, checkLocalCredentialsControl, + requestRegistrationChallengeControl, resetMfaUiMocks, mfaActorsMock, userActionsMock, From 3016ec031b940aa9f55b6dd48fc2334e31cf47e1 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 27 Jul 2026 11:43:46 +0200 Subject: [PATCH 05/30] refactor(mfa): decide registration from the credentials snapshot captured at flow start The registration decision needed the same credentials check the Provider already runs for start telemetry, and the machine actor duplicated the hook logic in the operations modules to get it, costing a second native keystore read per flow start. INIT now carries the captured localCredentialsKnownToServer flag, the decision becomes an eventless transition on it, and the operations copies and their suites go away, leaving the hooks as the single implementation. A keystore read failure now routes to registration instead of a fatal outcome, because the hooks resolve the check to false instead of rejecting; re-registration recovers such an account anyway. The UI walk grows stronger: the INIT executor seeds the biometrics hook mock, so the flag flows through the real Provider wiring, and both decision branches traverse via INIT fixture variants. --- .../MultifactorAuthenticationMainContext.tsx | 9 ++- .../biometrics/operations/index.native.ts | 41 ++------------ .../biometrics/operations/index.ts | 19 +------ .../machine/mfaActors.ts | 11 +--- .../machine/mfaMachine.ts | 27 +++------ .../machine/types.ts | 8 +-- .../biometricsOperations.test.ts | 55 +------------------ .../biometricsOperationsWeb.test.ts | 38 +------------ .../viewMatchesMachine.test.tsx | 17 +----- .../machine/softPromptTransition.test.ts | 20 +++---- .../machine/validateCodeTransition.test.ts | 39 ++----------- tests/utils/mfa/flowActors.ts | 15 +---- tests/utils/mfa/flowFixtures.ts | 3 +- tests/utils/mfa/flowPaths.ts | 8 +-- tests/utils/mfa/realUi/mocks.ts | 23 +++++--- 15 files changed, 70 insertions(+), 263 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 65894ac2f60c..483ea2995044 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -81,7 +81,14 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const scenario = getScenarioConfig(scenarioName); - send({type: 'INIT', accountID, scenarioName, scenario, payload: params && Object.keys(params).length > 0 ? params : undefined}); + send({ + type: 'INIT', + accountID, + scenarioName, + scenario, + payload: params && Object.keys(params).length > 0 ? params : undefined, + localCredentialsKnownToServer: startCredentialsState.hasLocalCredentials, + }); }; const closeModal = () => send({type: 'CLOSE_MODAL'}); diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts index 7ce3a9a73e9e..7945e5d36ba6 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts @@ -1,18 +1,10 @@ -import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; - -import {decodeLibraryError, getKeyAlias} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; -import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; - import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import Base64URL from '@src/utils/Base64URL'; -import {getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; -import {mfaCredentialIDsSelector} from '@selectors/Account'; +import {isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; /** - * Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions - * read no React state, so the machine actors and other non-React callers can import them directly. + * Platform-resolved biometric operations for the device check. These functions read no Onyx and no + * React state, so the MFA machine actors and other non-React callers can import them directly. */ /** The authentication method this platform verifies with. Native verifies with HSM-backed biometrics. */ @@ -27,29 +19,4 @@ async function doesDeviceSupportAuthenticationMethod(): Promise { return sensorResult.isDeviceSecure; } -/** Resolves to the account's HSM-backed credential ID, or undefined when no key exists or the keystore read fails. */ -async function getLocalCredentialID(accountID: number): Promise { - try { - const {keys} = await getAllKeys(getKeyAlias(accountID)); - const entry = keys.at(0); - if (!entry) { - return undefined; - } - return Base64URL.base64ToBase64url(entry.publicKey); - } catch (error) { - addMFABreadcrumb('Failed to get local credential ID', decodeLibraryError(error), 'error'); - return undefined; - } -} - -/** Resolves to whether the account has a local HSM key the server also knows, meaning it can skip registration. */ -async function areLocalCredentialsKnownToServer(accountID: number): Promise { - const localCredentialID = await getLocalCredentialID(accountID); - if (!localCredentialID) { - return false; - } - const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT); - return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID); -} - -export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; +export {deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.ts index 3a7a1e63da37..754574e3c3ee 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.ts @@ -1,16 +1,10 @@ import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; -import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; - -import {getPasskeyOnyxKey} from '@userActions/Passkey'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import {mfaCredentialIDsSelector} from '@selectors/Account'; /** - * Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions - * read no React state, so the machine actors and other non-React callers can import them directly. + * Platform-resolved biometric operations for the device check. These functions read no Onyx and no + * React state, so the MFA machine actors and other non-React callers can import them directly. */ /** The authentication method this platform verifies with. Web verifies with passkeys. */ @@ -24,11 +18,4 @@ async function doesDeviceSupportAuthenticationMethod(): Promise { return isWebAuthnSupported(); } -/** Resolves to whether the account has a local passkey the server also knows, meaning it can skip registration. */ -async function areLocalCredentialsKnownToServer(accountID: number): Promise { - const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT), readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)))]); - const serverKnownCredentialIDs = new Set(mfaCredentialIDsSelector(account) ?? []); - return (localPasskeyCredentials ?? []).some((credential) => serverKnownCredentialIDs.has(credential.id)); -} - -export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; +export {deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/machine/mfaActors.ts b/src/components/MultifactorAuthentication/machine/mfaActors.ts index 4b975fdf64ec..bc5cf0e037f5 100644 --- a/src/components/MultifactorAuthentication/machine/mfaActors.ts +++ b/src/components/MultifactorAuthentication/machine/mfaActors.ts @@ -1,5 +1,4 @@ import checkDeviceEligibility from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility'; -import {areLocalCredentialsKnownToServer} from '@components/MultifactorAuthentication/biometrics/operations'; import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; @@ -10,7 +9,7 @@ import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userAct import {fromPromise} from 'xstate'; -import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, ValidateDeviceInput} from './types'; +import type {ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, ValidateDeviceInput} from './types'; /** * A refused device resolves as a failed MFAResult, so the machine's onError transition for this @@ -27,12 +26,6 @@ const readHasAcceptedSoftPrompt = fromPromise(({input}) => areLocalCredentialsKnownToServer(input.accountID)); - /** * Exchanges the submitted magic code for a validated registration challenge. The action normalizes * backend failures into a reason; the actor exposes them as failed MFA results for machine routing. @@ -50,7 +43,7 @@ const requestRegistrationChallengeActor = fromPromise { - if (context.accountID === undefined) { - throw new Error('MFA account must be initialized before the registration decision'); - } - return {accountID: context.accountID}; - }, - // A returning user's credentials are already registered, so only a fresh registration asks for a code. - onDone: [ - {guard: ({event}) => event.output, target: SOFT_PROMPT_CHECK_TARGET}, - {target: MAGIC_CODE_TARGET, actions: ['requestValidateCode', 'navigateToMagicCode']}, - ], - onError: { - target: OUTCOME_TARGET, - actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Local credentials check', event.error)}), - }, - }, + // The Provider captures this value once for start telemetry and INIT. Reusing that + // snapshot here avoids a second native keystore read before the first screen appears. + always: [ + {guard: ({context}) => context.localCredentialsKnownToServer, target: SOFT_PROMPT_CHECK_TARGET}, + {target: MAGIC_CODE_TARGET, actions: ['requestValidateCode', 'navigateToMagicCode']}, + ], }, [MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE]: { id: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE, diff --git a/src/components/MultifactorAuthentication/machine/types.ts b/src/components/MultifactorAuthentication/machine/types.ts index be860f9fc6ed..3de1f230bd78 100644 --- a/src/components/MultifactorAuthentication/machine/types.ts +++ b/src/components/MultifactorAuthentication/machine/types.ts @@ -33,6 +33,9 @@ type MfaContext = { /** Additional parameters for the current scenario */ payload: MultifactorAuthenticationScenarioAdditionalParams | undefined; + /** Whether the local credential captured at flow start is among the server-known credential IDs */ + localCredentialsKnownToServer: boolean; + /** Magic code the user entered on this flow's validate-code screen */ validateCode: string | undefined; @@ -65,6 +68,7 @@ type MultifactorAuthenticationInitEvent; payload: MultifactorAuthenticationScenarioParams | undefined; + localCredentialsKnownToServer: boolean; }; /** Events handled by the MFA state machine. */ @@ -82,9 +86,6 @@ type ValidateDeviceInput = {allowedAuthenticationMethods: AllowedAuthenticationM /** Identifies the per-account Onyx member read by the soft-prompt actor. */ type ReadHasAcceptedSoftPromptInput = {accountID: number}; -/** Identifies the account whose local credentials the registration-decision actor checks. */ -type CheckLocalCredentialsInput = {accountID: number}; - /** Magic code sent to the backend to obtain a registration challenge. */ type RequestRegistrationChallengeInput = {validateCode: string}; @@ -92,7 +93,6 @@ type RequestRegistrationChallengeInput = {validateCode: string}; type RequestRegistrationChallengeOutput = MFAResult<{challenge: RegistrationChallenge}>; export type { - CheckLocalCredentialsInput, MfaContext, MfaEvent, MfaModalState, diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts index 8a1b20b00aec..89ee57d5ce5b 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts @@ -1,36 +1,18 @@ // jest-expo defaults to the ios platform, so this import resolves the native operations module // (operations/index.native.ts), which checks the HSM biometric sensor. -import { - areLocalCredentialsKnownToServer, - deviceCheckFailureReason, - deviceVerificationType, - doesDeviceSupportAuthenticationMethod, -} from '@components/MultifactorAuthentication/biometrics/operations'; +import {deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} from '@components/MultifactorAuthentication/biometrics/operations'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import Onyx from 'react-native-onyx'; -import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; const mockIsSensorAvailable = jest.fn(); -const mockGetAllKeys = jest.fn(); jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return isSensorAvailable: (...args: unknown[]) => mockIsSensorAvailable(...args), - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - getAllKeys: (...args: unknown[]) => mockGetAllKeys(...args), })); -const ACCOUNT_ID = 12345; -// The keystore returns the public key as plain base64 while the server stores base64url IDs, so the -// characters below only match after the module's base64url conversion. -const LOCAL_PUBLIC_KEY_BASE64 = 'Ab+/cd=='; -const LOCAL_CREDENTIAL_ID = 'Ab-_cd'; - describe('biometrics operations (native)', () => { beforeEach(() => { jest.clearAllMocks(); @@ -62,39 +44,4 @@ describe('biometrics operations (native)', () => { await expect(doesDeviceSupportAuthenticationMethod()).resolves.toBe(false); }); }); - - describe('areLocalCredentialsKnownToServer', () => { - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdates(); - }); - - it('should return true when the local HSM key is among the server-known credential IDs', async () => { - mockGetAllKeys.mockResolvedValue({keys: [{publicKey: LOCAL_PUBLIC_KEY_BASE64}]}); - await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id', LOCAL_CREDENTIAL_ID]}); - - await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(true); - }); - - it('should return false when the server does not know the local HSM key', async () => { - mockGetAllKeys.mockResolvedValue({keys: [{publicKey: LOCAL_PUBLIC_KEY_BASE64}]}); - await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id']}); - - await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); - }); - - it('should return false when the device holds no key for the account', async () => { - mockGetAllKeys.mockResolvedValue({keys: []}); - await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_CREDENTIAL_ID]}); - - await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); - }); - - it('should return false when the keystore read throws', async () => { - mockGetAllKeys.mockRejectedValue(new Error('Keystore unavailable')); - await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_CREDENTIAL_ID]}); - - await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); - }); - }); }); diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts index 422a36032948..66cb4bddf435 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts @@ -6,22 +6,13 @@ */ import type * as WebBiometricsOperations from '@components/MultifactorAuthentication/biometrics/operations/index'; -import {getPasskeyOnyxKey} from '@userActions/Passkey'; - import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import Onyx from 'react-native-onyx'; -import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; // jest-expo resolves the native variant by default, so load the web entry point explicitly. -const {areLocalCredentialsKnownToServer, deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} = jest.requireActual( +const {deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} = jest.requireActual( '@components/MultifactorAuthentication/biometrics/operations/index.ts', ); -const ACCOUNT_ID = 12345; -const LOCAL_PASSKEY_ID = 'local-passkey-credential-id'; - const originalPublicKeyCredentialDescriptor = Object.getOwnPropertyDescriptor(window, 'PublicKeyCredential'); function setWebAuthnSupport(isSupported: boolean) { @@ -57,31 +48,4 @@ describe('biometrics operations (web)', () => { await expect(doesDeviceSupportAuthenticationMethod()).resolves.toBe(expected); }); - - describe('areLocalCredentialsKnownToServer', () => { - afterEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdates(); - }); - - it('returns true when a local passkey is among the server-known credential IDs', async () => { - await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id', LOCAL_PASSKEY_ID]}); - await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: LOCAL_PASSKEY_ID, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); - - await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(true); - }); - - it('returns false when the server does not know the local passkey', async () => { - await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id']}); - await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: LOCAL_PASSKEY_ID, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); - - await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); - }); - - it('returns false when the account has no local passkeys', async () => { - await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_PASSKEY_ID]}); - - await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); - }); - }); }); diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index bd503ebf672f..6923dc4f4ce1 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -16,8 +16,6 @@ import type {SnapshotFrom} from 'xstate'; import Onyx from 'react-native-onyx'; import {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; import getWalkedPaths, { - CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, - CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, isAutoDrivenEvent, READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE, @@ -29,7 +27,7 @@ import getWalkedPaths, { import {getSettleableLeafStates} from 'tests/utils/mfa/leafStates'; import renderMfaUi from 'tests/utils/mfa/realUi/harness'; import { - checkLocalCredentialsControl, + localCredentialsKnownToServerControl, pendingModalClose, readHasAcceptedSoftPromptControl, requestRegistrationChallengeControl, @@ -85,8 +83,6 @@ type MfaActorEventExecutors = { [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: () => Promise; [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step: {event: {type: typeof READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE; output: boolean}}) => Promise; [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => Promise; - [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step: {event: {type: typeof CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE; output: boolean}}) => Promise; - [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => Promise; [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step: { event: {type: typeof REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE; output: RequestRegistrationChallengeOutput}; }) => Promise; @@ -96,7 +92,7 @@ type MfaActorEventExecutors = { type ExecuteScenario = ReturnType['executeScenario']; function isMfaInitEvent(event: {type: string}): event is MfaInitEvent { - return event.type === 'INIT' && 'accountID' in event && 'scenarioName' in event && 'scenario' in event && 'payload' in event; + return event.type === 'INIT' && 'accountID' in event && 'scenarioName' in event && 'scenario' in event && 'payload' in event && 'localCredentialsKnownToServer' in event; } type MfaValidateCodeEnteredEvent = Extract; @@ -123,6 +119,7 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { if (!isMfaInitEvent(event)) { throw new Error('MFA INIT executor received a path event without the scenario fixture payload.'); } + localCredentialsKnownToServerControl.set(event.localCredentialsKnownToServer); await act(async () => { await executeScenario(event.scenarioName, event.payload); }); @@ -167,8 +164,6 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: () => settleActor(validateDeviceControl.reject), [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step) => settleActor(() => readHasAcceptedSoftPromptControl.resolve(step.event.output)), [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => settleActor(readHasAcceptedSoftPromptControl.reject), - [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step) => settleActor(() => checkLocalCredentialsControl.resolve(step.event.output)), - [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => settleActor(checkLocalCredentialsControl.reject), [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step) => settleActor(() => requestRegistrationChallengeControl.resolve(step.event.output)), [REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE]: () => settleActor(requestRegistrationChallengeControl.reject), } satisfies MfaEventExecutors & MfaActorEventExecutors; @@ -187,12 +182,6 @@ const testConfig = { expect(screen.queryAllByTestId(TEST_ID.INITIAL_SCREEN)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); }, - [`${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.DECIDING_REGISTRATION}`]: (state: SnapshotFrom) => { - expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); - expect(screen.queryAllByTestId(TEST_ID.INITIAL_SCREEN)).toHaveLength(1); - expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); - expect(state.context.error).toBeUndefined(); - }, [`${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`]: (state: SnapshotFrom) => { expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); diff --git a/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts index c343495db243..b65e321e5b76 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts @@ -1,5 +1,5 @@ import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; +import type {ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; import {getDeviceBiometricsOnyxKey} from '@libs/actions/MultifactorAuthentication'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; @@ -8,7 +8,7 @@ import CONST from '@src/CONST'; import Onyx from 'react-native-onyx'; import getOnyxValue from 'tests/utils/getOnyxValue'; -import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; +import {createActorAtState, sendValidateDeviceDone} from 'tests/utils/mfa/flowActors'; import createInitEvent, {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; import {createActor, fromPromise} from 'xstate'; @@ -27,10 +27,10 @@ describe('MFA soft prompt', () => { }); it('moves a registered account to the soft prompt when the current account has not accepted it', async () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}, {localCredentialsKnownToServer: true}); actor.start(); - sendCheckLocalCredentialsDone(actor, true); + sendValidateDeviceDone(actor, {success: true}); await waitForBatchedUpdates(); const result = actor.getSnapshot(); @@ -42,10 +42,10 @@ describe('MFA soft prompt', () => { it('skips the soft prompt when the user has already accepted it on this device', async () => { await Onyx.merge(getDeviceBiometricsOnyxKey(MFA_TEST_ACCOUNT_ID), {hasAcceptedSoftPrompt: true}); - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}, {localCredentialsKnownToServer: true}); actor.start(); - sendCheckLocalCredentialsDone(actor, true); + sendValidateDeviceDone(actor, {success: true}); await waitForBatchedUpdates(); const result = actor.getSnapshot(); @@ -60,10 +60,10 @@ describe('MFA soft prompt', () => { const connection = {id: 'soft-prompt-read-test', callbackID: 'soft-prompt-read-test'}; jest.spyOn(Onyx, 'connectWithoutView').mockReturnValue(connection); const disconnectSpy = jest.spyOn(Onyx, 'disconnect').mockImplementation(); - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}, {localCredentialsKnownToServer: true}); actor.start(); - sendCheckLocalCredentialsDone(actor, true); + sendValidateDeviceDone(actor, {success: true}); expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); actor.send({type: 'CLOSE_MODAL'}); @@ -78,15 +78,13 @@ describe('MFA soft prompt', () => { const machine = mfaMachine.provide({ actors: { validateDevice: fromPromise(() => Promise.resolve({success: true})), - // A registered account routes the flow straight to the soft-prompt read under test. - checkLocalCredentials: fromPromise(() => Promise.resolve(true)), readHasAcceptedSoftPrompt: fromPromise(() => Promise.reject(new Error('Onyx read failed'))), }, }); const actor = createActor(machine); actor.start(); - actor.send(createInitEvent()); + actor.send(createInitEvent(true)); await waitForBatchedUpdates(); const result = actor.getSnapshot(); diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index fc94d3755d30..b67a8f0c877c 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -1,8 +1,3 @@ -import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type {CheckLocalCredentialsInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; - -import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; - import {requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; import type * as MultifactorAuthenticationActions from '@userActions/MultifactorAuthentication'; import {requestValidateCodeAction} from '@userActions/User'; @@ -10,10 +5,9 @@ import type * as UserActions from '@userActions/User'; import CONST from '@src/CONST'; -import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; -import createInitEvent, {MFA_TEST_INVALID_CODE_ERROR, MFA_TEST_REGISTRATION_CHALLENGE, MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; +import {createActorAtState, sendValidateDeviceDone} from 'tests/utils/mfa/flowActors'; +import {MFA_TEST_INVALID_CODE_ERROR, MFA_TEST_REGISTRATION_CHALLENGE, MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; -import {createActor, fromPromise} from 'xstate'; // The machine fires the magic-code email request, which is a backend call this suite only observes. jest.mock('@userActions/User', () => ({ @@ -78,10 +72,10 @@ describe('MFA magic code and registration decision', () => { }); it('requests a validate code exactly once when a fresh registration reaches the magic-code screen', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}); actor.start(); - sendCheckLocalCredentialsDone(actor, false); + sendValidateDeviceDone(actor, {success: true}); expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); @@ -90,10 +84,10 @@ describe('MFA magic code and registration decision', () => { }); it('skips the magic code for a returning user whose credentials the server knows', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}, {localCredentialsKnownToServer: true}); actor.start(); - sendCheckLocalCredentialsDone(actor, true); + sendValidateDeviceDone(actor, {success: true}); expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); @@ -213,25 +207,4 @@ describe('MFA magic code and registration decision', () => { actor.stop(); }); - - it('ends the current flow with an error when the credentials check rejects', async () => { - const machine = mfaMachine.provide({ - actors: { - validateDevice: fromPromise(() => Promise.resolve({success: true})), - checkLocalCredentials: fromPromise(() => Promise.reject(new Error('Keystore read failed'))), - }, - }); - const actor = createActor(machine); - - actor.start(); - actor.send(createInitEvent()); - await waitForBatchedUpdates(); - - const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); - expect(result.context.error?.reason).toBe(REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION); - expect(result.context.error?.message).toContain('Local credentials check threw: Keystore read failed'); - - actor.stop(); - }); }); diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index 88825104f30a..f3894ec74d7a 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -7,10 +7,9 @@ import type {OutputFrom, StateValue} from 'xstate'; import {createActor} from 'xstate'; import createInitEvent from './flowFixtures'; -import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; +import {REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; type ValidateDeviceOutput = OutputFrom['validateDevice']>; -type CheckLocalCredentialsOutput = OutputFrom['checkLocalCredentials']>; type RequestRegistrationChallengeOutput = OutputFrom['requestRegistrationChallenge']>; /** @@ -25,6 +24,7 @@ function createFlowContext(overrides: Partial = {}): MfaContext { scenarioName: initEvent.scenarioName, scenario: initEvent.scenario, payload: initEvent.payload, + localCredentialsKnownToServer: initEvent.localCredentialsKnownToServer, validateCode: undefined, continuableError: undefined, registrationChallenge: undefined, @@ -52,15 +52,6 @@ function sendValidateDeviceDone(actor: ReturnType, ou actor.send({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output} as unknown as MfaEvent); } -/** - * Completes the invoked credentials-check actor by sending its done event carrying the given output. - */ -function sendCheckLocalCredentialsDone(actor: ReturnType, output: CheckLocalCredentialsOutput) { - // Framework actor events are not part of the application's MfaEvent union. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - actor.send({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output} as unknown as MfaEvent); -} - /** Completes the registration-challenge request with the supplied backend-shaped result. */ function sendRequestRegistrationChallengeDone(actor: ReturnType, output: RequestRegistrationChallengeOutput) { // Framework actor events are not part of the application's MfaEvent union. @@ -68,4 +59,4 @@ function sendRequestRegistrationChallengeDone(actor: ReturnType { +function createInitEvent(localCredentialsKnownToServer = false): MultifactorAuthenticationInitEvent { return { type: 'INIT', accountID: MFA_TEST_ACCOUNT_ID, scenarioName: MFA_TEST_SCENARIO_NAME, scenario: getScenarioConfig(MFA_TEST_SCENARIO_NAME), payload: undefined, + localCredentialsKnownToServer, }; } diff --git a/tests/utils/mfa/flowPaths.ts b/tests/utils/mfa/flowPaths.ts index e246813002a8..9742a3996d43 100644 --- a/tests/utils/mfa/flowPaths.ts +++ b/tests/utils/mfa/flowPaths.ts @@ -22,8 +22,6 @@ const VALIDATE_DEVICE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}validateDevic const VALIDATE_DEVICE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}validateDevice`; const READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}readHasAcceptedSoftPrompt`; const READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}readHasAcceptedSoftPrompt`; -const CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}checkLocalCredentials`; -const CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}checkLocalCredentials`; const REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}requestRegistrationChallenge`; const REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}requestRegistrationChallenge`; @@ -74,7 +72,6 @@ const DRIVING_JOURNEYS: DrivingJourney[] = [ events: [ createInitEvent(), createActorDoneEvent(VALIDATE_DEVICE_DONE_EVENT_TYPE, {success: true}), - createActorDoneEvent(CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, false), {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, createActorDoneEvent(REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, {success: false, error: MFA_TEST_INVALID_CODE_ERROR}), {type: 'CLEAR_CONTINUABLE_ERROR'}, @@ -95,7 +92,7 @@ type MfaEventFixtures = { * `{type}` and potentially bypass event-dependent behavior. */ const MFA_GRAPH_EVENT_FIXTURES = { - INIT: [createInitEvent()], + INIT: [createInitEvent(), createInitEvent(true)], CLOSE_MODAL: [{type: 'CLOSE_MODAL'}], MODAL_CLOSED: [{type: 'MODAL_CLOSED'}], SOFT_PROMPT_APPROVED: [{type: 'SOFT_PROMPT_APPROVED'}], @@ -132,7 +129,6 @@ const MFA_ACTOR_DONE_OUTPUT_FIXTURES = { }, ], readHasAcceptedSoftPrompt: [false, true], - checkLocalCredentials: [false, true], requestRegistrationChallenge: [ {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, {success: false, error: MFA_TEST_INVALID_CODE_ERROR}, @@ -237,8 +233,6 @@ function getWalkedPaths() { export default getWalkedPaths; export { - CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, - CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, getDrivingJourneyPaths, getMfaShortestPaths, isAutoDrivenEvent, diff --git a/tests/utils/mfa/realUi/mocks.ts b/tests/utils/mfa/realUi/mocks.ts index becf847a0494..db568279e1cc 100644 --- a/tests/utils/mfa/realUi/mocks.ts +++ b/tests/utils/mfa/realUi/mocks.ts @@ -1,7 +1,6 @@ import type {UseBiometricsReturn} from '@components/MultifactorAuthentication/biometrics/shared/types'; import type createActors from '@components/MultifactorAuthentication/machine/mfaActors'; import type { - CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, @@ -24,6 +23,7 @@ type PendingCall = { }; let pendingCloseCallback: CapturedCallback | undefined; +let localCredentialsKnownToServer = false; /** * Captures the callback scheduled by the navigator through `runAfterUpcomingTransition` @@ -48,12 +48,21 @@ const pendingModalClose = { }; /** - * Provides only the biometric values captured for telemetry while preparing `INIT`. They do not - * currently affect machine transitions. The `Pick` makes renamed hook fields fail type checking. + * Provides the biometric values captured while preparing `INIT`. The `Pick` makes renamed hook + * fields fail type checking. */ const biometricsMock: Pick = { serverKnownCredentialIDs: [], - areLocalCredentialsKnownToServer: () => Promise.resolve(false), + areLocalCredentialsKnownToServer: () => Promise.resolve(localCredentialsKnownToServer), +}; + +const localCredentialsKnownToServerControl = { + set: (value: boolean) => { + localCredentialsKnownToServer = value; + }, + reset: () => { + localCredentialsKnownToServer = false; + }, }; /** @@ -90,14 +99,13 @@ function createControlledActor(actorID: string) { const validateDeviceControl = createControlledActor('validateDevice'); const readHasAcceptedSoftPromptControl = createControlledActor('readHasAcceptedSoftPrompt'); -const checkLocalCredentialsControl = createControlledActor('checkLocalCredentials'); const requestRegistrationChallengeControl = createControlledActor('requestRegistrationChallenge'); function resetMfaUiMocks() { pendingModalClose.clear(); + localCredentialsKnownToServerControl.reset(); validateDeviceControl.reset(); readHasAcceptedSoftPromptControl.reset(); - checkLocalCredentialsControl.reset(); requestRegistrationChallengeControl.reset(); } @@ -106,7 +114,6 @@ function mfaActorsMock() { const actors = { validateDevice: validateDeviceControl.actor, readHasAcceptedSoftPrompt: readHasAcceptedSoftPromptControl.actor, - checkLocalCredentials: checkLocalCredentialsControl.actor, requestRegistrationChallenge: requestRegistrationChallengeControl.actor, } satisfies ReturnType; @@ -178,9 +185,9 @@ function navigationMock() { export { pendingModalClose, + localCredentialsKnownToServerControl, validateDeviceControl, readHasAcceptedSoftPromptControl, - checkLocalCredentialsControl, requestRegistrationChallengeControl, resetMfaUiMocks, mfaActorsMock, From b0973f487023d489057b245fd03d12c8791b679e Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 27 Jul 2026 12:46:07 +0200 Subject: [PATCH 06/30] Revert "refactor(mfa): decide registration from the credentials snapshot captured at flow start" This reverts commit 550345f5dff3fa358029f8a7d0835ff526645fea. --- .../MultifactorAuthenticationMainContext.tsx | 9 +-- .../biometrics/operations/index.native.ts | 41 ++++++++++++-- .../biometrics/operations/index.ts | 19 ++++++- .../machine/mfaActors.ts | 11 +++- .../machine/mfaMachine.ts | 27 ++++++--- .../machine/types.ts | 8 +-- .../biometricsOperations.test.ts | 55 ++++++++++++++++++- .../biometricsOperationsWeb.test.ts | 38 ++++++++++++- .../viewMatchesMachine.test.tsx | 17 +++++- .../machine/softPromptTransition.test.ts | 20 ++++--- .../machine/validateCodeTransition.test.ts | 39 +++++++++++-- tests/utils/mfa/flowActors.ts | 15 ++++- tests/utils/mfa/flowFixtures.ts | 3 +- tests/utils/mfa/flowPaths.ts | 8 ++- tests/utils/mfa/realUi/mocks.ts | 23 +++----- 15 files changed, 263 insertions(+), 70 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 483ea2995044..65894ac2f60c 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -81,14 +81,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const scenario = getScenarioConfig(scenarioName); - send({ - type: 'INIT', - accountID, - scenarioName, - scenario, - payload: params && Object.keys(params).length > 0 ? params : undefined, - localCredentialsKnownToServer: startCredentialsState.hasLocalCredentials, - }); + send({type: 'INIT', accountID, scenarioName, scenario, payload: params && Object.keys(params).length > 0 ? params : undefined}); }; const closeModal = () => send({type: 'CLOSE_MODAL'}); diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts index 7945e5d36ba6..7ce3a9a73e9e 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts @@ -1,10 +1,18 @@ +import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; + +import {decodeLibraryError, getKeyAlias} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; + import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import Base64URL from '@src/utils/Base64URL'; -import {isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; +import {getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; +import {mfaCredentialIDsSelector} from '@selectors/Account'; /** - * Platform-resolved biometric operations for the device check. These functions read no Onyx and no - * React state, so the MFA machine actors and other non-React callers can import them directly. + * Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions + * read no React state, so the machine actors and other non-React callers can import them directly. */ /** The authentication method this platform verifies with. Native verifies with HSM-backed biometrics. */ @@ -19,4 +27,29 @@ async function doesDeviceSupportAuthenticationMethod(): Promise { return sensorResult.isDeviceSecure; } -export {deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; +/** Resolves to the account's HSM-backed credential ID, or undefined when no key exists or the keystore read fails. */ +async function getLocalCredentialID(accountID: number): Promise { + try { + const {keys} = await getAllKeys(getKeyAlias(accountID)); + const entry = keys.at(0); + if (!entry) { + return undefined; + } + return Base64URL.base64ToBase64url(entry.publicKey); + } catch (error) { + addMFABreadcrumb('Failed to get local credential ID', decodeLibraryError(error), 'error'); + return undefined; + } +} + +/** Resolves to whether the account has a local HSM key the server also knows, meaning it can skip registration. */ +async function areLocalCredentialsKnownToServer(accountID: number): Promise { + const localCredentialID = await getLocalCredentialID(accountID); + if (!localCredentialID) { + return false; + } + const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT); + return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID); +} + +export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.ts index 754574e3c3ee..3a7a1e63da37 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.ts @@ -1,10 +1,16 @@ import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; +import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; + +import {getPasskeyOnyxKey} from '@userActions/Passkey'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import {mfaCredentialIDsSelector} from '@selectors/Account'; /** - * Platform-resolved biometric operations for the device check. These functions read no Onyx and no - * React state, so the MFA machine actors and other non-React callers can import them directly. + * Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions + * read no React state, so the machine actors and other non-React callers can import them directly. */ /** The authentication method this platform verifies with. Web verifies with passkeys. */ @@ -18,4 +24,11 @@ async function doesDeviceSupportAuthenticationMethod(): Promise { return isWebAuthnSupported(); } -export {deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; +/** Resolves to whether the account has a local passkey the server also knows, meaning it can skip registration. */ +async function areLocalCredentialsKnownToServer(accountID: number): Promise { + const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT), readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)))]); + const serverKnownCredentialIDs = new Set(mfaCredentialIDsSelector(account) ?? []); + return (localPasskeyCredentials ?? []).some((credential) => serverKnownCredentialIDs.has(credential.id)); +} + +export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/machine/mfaActors.ts b/src/components/MultifactorAuthentication/machine/mfaActors.ts index bc5cf0e037f5..4b975fdf64ec 100644 --- a/src/components/MultifactorAuthentication/machine/mfaActors.ts +++ b/src/components/MultifactorAuthentication/machine/mfaActors.ts @@ -1,4 +1,5 @@ import checkDeviceEligibility from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility'; +import {areLocalCredentialsKnownToServer} from '@components/MultifactorAuthentication/biometrics/operations'; import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; @@ -9,7 +10,7 @@ import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userAct import {fromPromise} from 'xstate'; -import type {ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, ValidateDeviceInput} from './types'; +import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, ValidateDeviceInput} from './types'; /** * A refused device resolves as a failed MFAResult, so the machine's onError transition for this @@ -26,6 +27,12 @@ const readHasAcceptedSoftPrompt = fromPromise(({input}) => areLocalCredentialsKnownToServer(input.accountID)); + /** * Exchanges the submitted magic code for a validated registration challenge. The action normalizes * backend failures into a reason; the actor exposes them as failed MFA results for machine routing. @@ -43,7 +50,7 @@ const requestRegistrationChallengeActor = fromPromise context.localCredentialsKnownToServer, target: SOFT_PROMPT_CHECK_TARGET}, - {target: MAGIC_CODE_TARGET, actions: ['requestValidateCode', 'navigateToMagicCode']}, - ], + invoke: { + id: 'checkLocalCredentials', + src: 'checkLocalCredentials', + input: ({context}) => { + if (context.accountID === undefined) { + throw new Error('MFA account must be initialized before the registration decision'); + } + return {accountID: context.accountID}; + }, + // A returning user's credentials are already registered, so only a fresh registration asks for a code. + onDone: [ + {guard: ({event}) => event.output, target: SOFT_PROMPT_CHECK_TARGET}, + {target: MAGIC_CODE_TARGET, actions: ['requestValidateCode', 'navigateToMagicCode']}, + ], + onError: { + target: OUTCOME_TARGET, + actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Local credentials check', event.error)}), + }, + }, }, [MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE]: { id: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE, diff --git a/src/components/MultifactorAuthentication/machine/types.ts b/src/components/MultifactorAuthentication/machine/types.ts index 3de1f230bd78..be860f9fc6ed 100644 --- a/src/components/MultifactorAuthentication/machine/types.ts +++ b/src/components/MultifactorAuthentication/machine/types.ts @@ -33,9 +33,6 @@ type MfaContext = { /** Additional parameters for the current scenario */ payload: MultifactorAuthenticationScenarioAdditionalParams | undefined; - /** Whether the local credential captured at flow start is among the server-known credential IDs */ - localCredentialsKnownToServer: boolean; - /** Magic code the user entered on this flow's validate-code screen */ validateCode: string | undefined; @@ -68,7 +65,6 @@ type MultifactorAuthenticationInitEvent; payload: MultifactorAuthenticationScenarioParams | undefined; - localCredentialsKnownToServer: boolean; }; /** Events handled by the MFA state machine. */ @@ -86,6 +82,9 @@ type ValidateDeviceInput = {allowedAuthenticationMethods: AllowedAuthenticationM /** Identifies the per-account Onyx member read by the soft-prompt actor. */ type ReadHasAcceptedSoftPromptInput = {accountID: number}; +/** Identifies the account whose local credentials the registration-decision actor checks. */ +type CheckLocalCredentialsInput = {accountID: number}; + /** Magic code sent to the backend to obtain a registration challenge. */ type RequestRegistrationChallengeInput = {validateCode: string}; @@ -93,6 +92,7 @@ type RequestRegistrationChallengeInput = {validateCode: string}; type RequestRegistrationChallengeOutput = MFAResult<{challenge: RegistrationChallenge}>; export type { + CheckLocalCredentialsInput, MfaContext, MfaEvent, MfaModalState, diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts index 89ee57d5ce5b..8a1b20b00aec 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts @@ -1,18 +1,36 @@ // jest-expo defaults to the ios platform, so this import resolves the native operations module // (operations/index.native.ts), which checks the HSM biometric sensor. -import {deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} from '@components/MultifactorAuthentication/biometrics/operations'; +import { + areLocalCredentialsKnownToServer, + deviceCheckFailureReason, + deviceVerificationType, + doesDeviceSupportAuthenticationMethod, +} from '@components/MultifactorAuthentication/biometrics/operations'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; +import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; const mockIsSensorAvailable = jest.fn(); +const mockGetAllKeys = jest.fn(); jest.mock('@sbaiahmed1/react-native-biometrics', () => ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-return isSensorAvailable: (...args: unknown[]) => mockIsSensorAvailable(...args), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + getAllKeys: (...args: unknown[]) => mockGetAllKeys(...args), })); +const ACCOUNT_ID = 12345; +// The keystore returns the public key as plain base64 while the server stores base64url IDs, so the +// characters below only match after the module's base64url conversion. +const LOCAL_PUBLIC_KEY_BASE64 = 'Ab+/cd=='; +const LOCAL_CREDENTIAL_ID = 'Ab-_cd'; + describe('biometrics operations (native)', () => { beforeEach(() => { jest.clearAllMocks(); @@ -44,4 +62,39 @@ describe('biometrics operations (native)', () => { await expect(doesDeviceSupportAuthenticationMethod()).resolves.toBe(false); }); }); + + describe('areLocalCredentialsKnownToServer', () => { + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('should return true when the local HSM key is among the server-known credential IDs', async () => { + mockGetAllKeys.mockResolvedValue({keys: [{publicKey: LOCAL_PUBLIC_KEY_BASE64}]}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id', LOCAL_CREDENTIAL_ID]}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(true); + }); + + it('should return false when the server does not know the local HSM key', async () => { + mockGetAllKeys.mockResolvedValue({keys: [{publicKey: LOCAL_PUBLIC_KEY_BASE64}]}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id']}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + + it('should return false when the device holds no key for the account', async () => { + mockGetAllKeys.mockResolvedValue({keys: []}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_CREDENTIAL_ID]}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + + it('should return false when the keystore read throws', async () => { + mockGetAllKeys.mockRejectedValue(new Error('Keystore unavailable')); + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_CREDENTIAL_ID]}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + }); }); diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts index 66cb4bddf435..422a36032948 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts @@ -6,13 +6,22 @@ */ import type * as WebBiometricsOperations from '@components/MultifactorAuthentication/biometrics/operations/index'; +import {getPasskeyOnyxKey} from '@userActions/Passkey'; + import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; +import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; // jest-expo resolves the native variant by default, so load the web entry point explicitly. -const {deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} = jest.requireActual( +const {areLocalCredentialsKnownToServer, deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} = jest.requireActual( '@components/MultifactorAuthentication/biometrics/operations/index.ts', ); +const ACCOUNT_ID = 12345; +const LOCAL_PASSKEY_ID = 'local-passkey-credential-id'; + const originalPublicKeyCredentialDescriptor = Object.getOwnPropertyDescriptor(window, 'PublicKeyCredential'); function setWebAuthnSupport(isSupported: boolean) { @@ -48,4 +57,31 @@ describe('biometrics operations (web)', () => { await expect(doesDeviceSupportAuthenticationMethod()).resolves.toBe(expected); }); + + describe('areLocalCredentialsKnownToServer', () => { + afterEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('returns true when a local passkey is among the server-known credential IDs', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id', LOCAL_PASSKEY_ID]}); + await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: LOCAL_PASSKEY_ID, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(true); + }); + + it('returns false when the server does not know the local passkey', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: ['other-credential-id']}); + await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: LOCAL_PASSKEY_ID, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + + it('returns false when the account has no local passkeys', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_PASSKEY_ID]}); + + await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); + }); + }); }); diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 6923dc4f4ce1..bd503ebf672f 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -16,6 +16,8 @@ import type {SnapshotFrom} from 'xstate'; import Onyx from 'react-native-onyx'; import {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; import getWalkedPaths, { + CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, + CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, isAutoDrivenEvent, READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE, @@ -27,7 +29,7 @@ import getWalkedPaths, { import {getSettleableLeafStates} from 'tests/utils/mfa/leafStates'; import renderMfaUi from 'tests/utils/mfa/realUi/harness'; import { - localCredentialsKnownToServerControl, + checkLocalCredentialsControl, pendingModalClose, readHasAcceptedSoftPromptControl, requestRegistrationChallengeControl, @@ -83,6 +85,8 @@ type MfaActorEventExecutors = { [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: () => Promise; [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step: {event: {type: typeof READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE; output: boolean}}) => Promise; [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => Promise; + [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step: {event: {type: typeof CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE; output: boolean}}) => Promise; + [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => Promise; [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step: { event: {type: typeof REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE; output: RequestRegistrationChallengeOutput}; }) => Promise; @@ -92,7 +96,7 @@ type MfaActorEventExecutors = { type ExecuteScenario = ReturnType['executeScenario']; function isMfaInitEvent(event: {type: string}): event is MfaInitEvent { - return event.type === 'INIT' && 'accountID' in event && 'scenarioName' in event && 'scenario' in event && 'payload' in event && 'localCredentialsKnownToServer' in event; + return event.type === 'INIT' && 'accountID' in event && 'scenarioName' in event && 'scenario' in event && 'payload' in event; } type MfaValidateCodeEnteredEvent = Extract; @@ -119,7 +123,6 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { if (!isMfaInitEvent(event)) { throw new Error('MFA INIT executor received a path event without the scenario fixture payload.'); } - localCredentialsKnownToServerControl.set(event.localCredentialsKnownToServer); await act(async () => { await executeScenario(event.scenarioName, event.payload); }); @@ -164,6 +167,8 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: () => settleActor(validateDeviceControl.reject), [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step) => settleActor(() => readHasAcceptedSoftPromptControl.resolve(step.event.output)), [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => settleActor(readHasAcceptedSoftPromptControl.reject), + [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step) => settleActor(() => checkLocalCredentialsControl.resolve(step.event.output)), + [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => settleActor(checkLocalCredentialsControl.reject), [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step) => settleActor(() => requestRegistrationChallengeControl.resolve(step.event.output)), [REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE]: () => settleActor(requestRegistrationChallengeControl.reject), } satisfies MfaEventExecutors & MfaActorEventExecutors; @@ -182,6 +187,12 @@ const testConfig = { expect(screen.queryAllByTestId(TEST_ID.INITIAL_SCREEN)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); }, + [`${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.DECIDING_REGISTRATION}`]: (state: SnapshotFrom) => { + expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); + expect(screen.queryAllByTestId(TEST_ID.INITIAL_SCREEN)).toHaveLength(1); + expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); + expect(state.context.error).toBeUndefined(); + }, [`${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`]: (state: SnapshotFrom) => { expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); diff --git a/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts index b65e321e5b76..c343495db243 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/softPromptTransition.test.ts @@ -1,5 +1,5 @@ import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type {ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; +import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; import {getDeviceBiometricsOnyxKey} from '@libs/actions/MultifactorAuthentication'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; @@ -8,7 +8,7 @@ import CONST from '@src/CONST'; import Onyx from 'react-native-onyx'; import getOnyxValue from 'tests/utils/getOnyxValue'; -import {createActorAtState, sendValidateDeviceDone} from 'tests/utils/mfa/flowActors'; +import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; import createInitEvent, {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; import {createActor, fromPromise} from 'xstate'; @@ -27,10 +27,10 @@ describe('MFA soft prompt', () => { }); it('moves a registered account to the soft prompt when the current account has not accepted it', async () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}, {localCredentialsKnownToServer: true}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); actor.start(); - sendValidateDeviceDone(actor, {success: true}); + sendCheckLocalCredentialsDone(actor, true); await waitForBatchedUpdates(); const result = actor.getSnapshot(); @@ -42,10 +42,10 @@ describe('MFA soft prompt', () => { it('skips the soft prompt when the user has already accepted it on this device', async () => { await Onyx.merge(getDeviceBiometricsOnyxKey(MFA_TEST_ACCOUNT_ID), {hasAcceptedSoftPrompt: true}); - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}, {localCredentialsKnownToServer: true}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); actor.start(); - sendValidateDeviceDone(actor, {success: true}); + sendCheckLocalCredentialsDone(actor, true); await waitForBatchedUpdates(); const result = actor.getSnapshot(); @@ -60,10 +60,10 @@ describe('MFA soft prompt', () => { const connection = {id: 'soft-prompt-read-test', callbackID: 'soft-prompt-read-test'}; jest.spyOn(Onyx, 'connectWithoutView').mockReturnValue(connection); const disconnectSpy = jest.spyOn(Onyx, 'disconnect').mockImplementation(); - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}, {localCredentialsKnownToServer: true}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); actor.start(); - sendValidateDeviceDone(actor, {success: true}); + sendCheckLocalCredentialsDone(actor, true); expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); actor.send({type: 'CLOSE_MODAL'}); @@ -78,13 +78,15 @@ describe('MFA soft prompt', () => { const machine = mfaMachine.provide({ actors: { validateDevice: fromPromise(() => Promise.resolve({success: true})), + // A registered account routes the flow straight to the soft-prompt read under test. + checkLocalCredentials: fromPromise(() => Promise.resolve(true)), readHasAcceptedSoftPrompt: fromPromise(() => Promise.reject(new Error('Onyx read failed'))), }, }); const actor = createActor(machine); actor.start(); - actor.send(createInitEvent(true)); + actor.send(createInitEvent()); await waitForBatchedUpdates(); const result = actor.getSnapshot(); diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index b67a8f0c877c..fc94d3755d30 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -1,3 +1,8 @@ +import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; +import type {CheckLocalCredentialsInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; + +import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; + import {requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; import type * as MultifactorAuthenticationActions from '@userActions/MultifactorAuthentication'; import {requestValidateCodeAction} from '@userActions/User'; @@ -5,9 +10,10 @@ import type * as UserActions from '@userActions/User'; import CONST from '@src/CONST'; -import {createActorAtState, sendValidateDeviceDone} from 'tests/utils/mfa/flowActors'; -import {MFA_TEST_INVALID_CODE_ERROR, MFA_TEST_REGISTRATION_CHALLENGE, MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; +import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; +import createInitEvent, {MFA_TEST_INVALID_CODE_ERROR, MFA_TEST_REGISTRATION_CHALLENGE, MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; +import {createActor, fromPromise} from 'xstate'; // The machine fires the magic-code email request, which is a backend call this suite only observes. jest.mock('@userActions/User', () => ({ @@ -72,10 +78,10 @@ describe('MFA magic code and registration decision', () => { }); it('requests a validate code exactly once when a fresh registration reaches the magic-code screen', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); actor.start(); - sendValidateDeviceDone(actor, {success: true}); + sendCheckLocalCredentialsDone(actor, false); expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); @@ -84,10 +90,10 @@ describe('MFA magic code and registration decision', () => { }); it('skips the magic code for a returning user whose credentials the server knows', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.VALIDATING_DEVICE}}, {localCredentialsKnownToServer: true}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.DECIDING_REGISTRATION}}); actor.start(); - sendValidateDeviceDone(actor, {success: true}); + sendCheckLocalCredentialsDone(actor, true); expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}})).toBe(true); expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); @@ -207,4 +213,25 @@ describe('MFA magic code and registration decision', () => { actor.stop(); }); + + it('ends the current flow with an error when the credentials check rejects', async () => { + const machine = mfaMachine.provide({ + actors: { + validateDevice: fromPromise(() => Promise.resolve({success: true})), + checkLocalCredentials: fromPromise(() => Promise.reject(new Error('Keystore read failed'))), + }, + }); + const actor = createActor(machine); + + actor.start(); + actor.send(createInitEvent()); + await waitForBatchedUpdates(); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); + expect(result.context.error?.reason).toBe(REASON.LOCAL_ERRORS.UNHANDLED_EXCEPTION); + expect(result.context.error?.message).toContain('Local credentials check threw: Keystore read failed'); + + actor.stop(); + }); }); diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index f3894ec74d7a..88825104f30a 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -7,9 +7,10 @@ import type {OutputFrom, StateValue} from 'xstate'; import {createActor} from 'xstate'; import createInitEvent from './flowFixtures'; -import {REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; +import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; type ValidateDeviceOutput = OutputFrom['validateDevice']>; +type CheckLocalCredentialsOutput = OutputFrom['checkLocalCredentials']>; type RequestRegistrationChallengeOutput = OutputFrom['requestRegistrationChallenge']>; /** @@ -24,7 +25,6 @@ function createFlowContext(overrides: Partial = {}): MfaContext { scenarioName: initEvent.scenarioName, scenario: initEvent.scenario, payload: initEvent.payload, - localCredentialsKnownToServer: initEvent.localCredentialsKnownToServer, validateCode: undefined, continuableError: undefined, registrationChallenge: undefined, @@ -52,6 +52,15 @@ function sendValidateDeviceDone(actor: ReturnType, ou actor.send({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output} as unknown as MfaEvent); } +/** + * Completes the invoked credentials-check actor by sending its done event carrying the given output. + */ +function sendCheckLocalCredentialsDone(actor: ReturnType, output: CheckLocalCredentialsOutput) { + // Framework actor events are not part of the application's MfaEvent union. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + actor.send({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output} as unknown as MfaEvent); +} + /** Completes the registration-challenge request with the supplied backend-shaped result. */ function sendRequestRegistrationChallengeDone(actor: ReturnType, output: RequestRegistrationChallengeOutput) { // Framework actor events are not part of the application's MfaEvent union. @@ -59,4 +68,4 @@ function sendRequestRegistrationChallengeDone(actor: ReturnType { +function createInitEvent(): MultifactorAuthenticationInitEvent { return { type: 'INIT', accountID: MFA_TEST_ACCOUNT_ID, scenarioName: MFA_TEST_SCENARIO_NAME, scenario: getScenarioConfig(MFA_TEST_SCENARIO_NAME), payload: undefined, - localCredentialsKnownToServer, }; } diff --git a/tests/utils/mfa/flowPaths.ts b/tests/utils/mfa/flowPaths.ts index 9742a3996d43..e246813002a8 100644 --- a/tests/utils/mfa/flowPaths.ts +++ b/tests/utils/mfa/flowPaths.ts @@ -22,6 +22,8 @@ const VALIDATE_DEVICE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}validateDevic const VALIDATE_DEVICE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}validateDevice`; const READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}readHasAcceptedSoftPrompt`; const READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}readHasAcceptedSoftPrompt`; +const CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}checkLocalCredentials`; +const CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}checkLocalCredentials`; const REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}requestRegistrationChallenge`; const REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}requestRegistrationChallenge`; @@ -72,6 +74,7 @@ const DRIVING_JOURNEYS: DrivingJourney[] = [ events: [ createInitEvent(), createActorDoneEvent(VALIDATE_DEVICE_DONE_EVENT_TYPE, {success: true}), + createActorDoneEvent(CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, false), {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, createActorDoneEvent(REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, {success: false, error: MFA_TEST_INVALID_CODE_ERROR}), {type: 'CLEAR_CONTINUABLE_ERROR'}, @@ -92,7 +95,7 @@ type MfaEventFixtures = { * `{type}` and potentially bypass event-dependent behavior. */ const MFA_GRAPH_EVENT_FIXTURES = { - INIT: [createInitEvent(), createInitEvent(true)], + INIT: [createInitEvent()], CLOSE_MODAL: [{type: 'CLOSE_MODAL'}], MODAL_CLOSED: [{type: 'MODAL_CLOSED'}], SOFT_PROMPT_APPROVED: [{type: 'SOFT_PROMPT_APPROVED'}], @@ -129,6 +132,7 @@ const MFA_ACTOR_DONE_OUTPUT_FIXTURES = { }, ], readHasAcceptedSoftPrompt: [false, true], + checkLocalCredentials: [false, true], requestRegistrationChallenge: [ {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, {success: false, error: MFA_TEST_INVALID_CODE_ERROR}, @@ -233,6 +237,8 @@ function getWalkedPaths() { export default getWalkedPaths; export { + CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, + CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, getDrivingJourneyPaths, getMfaShortestPaths, isAutoDrivenEvent, diff --git a/tests/utils/mfa/realUi/mocks.ts b/tests/utils/mfa/realUi/mocks.ts index db568279e1cc..becf847a0494 100644 --- a/tests/utils/mfa/realUi/mocks.ts +++ b/tests/utils/mfa/realUi/mocks.ts @@ -1,6 +1,7 @@ import type {UseBiometricsReturn} from '@components/MultifactorAuthentication/biometrics/shared/types'; import type createActors from '@components/MultifactorAuthentication/machine/mfaActors'; import type { + CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, @@ -23,7 +24,6 @@ type PendingCall = { }; let pendingCloseCallback: CapturedCallback | undefined; -let localCredentialsKnownToServer = false; /** * Captures the callback scheduled by the navigator through `runAfterUpcomingTransition` @@ -48,21 +48,12 @@ const pendingModalClose = { }; /** - * Provides the biometric values captured while preparing `INIT`. The `Pick` makes renamed hook - * fields fail type checking. + * Provides only the biometric values captured for telemetry while preparing `INIT`. They do not + * currently affect machine transitions. The `Pick` makes renamed hook fields fail type checking. */ const biometricsMock: Pick = { serverKnownCredentialIDs: [], - areLocalCredentialsKnownToServer: () => Promise.resolve(localCredentialsKnownToServer), -}; - -const localCredentialsKnownToServerControl = { - set: (value: boolean) => { - localCredentialsKnownToServer = value; - }, - reset: () => { - localCredentialsKnownToServer = false; - }, + areLocalCredentialsKnownToServer: () => Promise.resolve(false), }; /** @@ -99,13 +90,14 @@ function createControlledActor(actorID: string) { const validateDeviceControl = createControlledActor('validateDevice'); const readHasAcceptedSoftPromptControl = createControlledActor('readHasAcceptedSoftPrompt'); +const checkLocalCredentialsControl = createControlledActor('checkLocalCredentials'); const requestRegistrationChallengeControl = createControlledActor('requestRegistrationChallenge'); function resetMfaUiMocks() { pendingModalClose.clear(); - localCredentialsKnownToServerControl.reset(); validateDeviceControl.reset(); readHasAcceptedSoftPromptControl.reset(); + checkLocalCredentialsControl.reset(); requestRegistrationChallengeControl.reset(); } @@ -114,6 +106,7 @@ function mfaActorsMock() { const actors = { validateDevice: validateDeviceControl.actor, readHasAcceptedSoftPrompt: readHasAcceptedSoftPromptControl.actor, + checkLocalCredentials: checkLocalCredentialsControl.actor, requestRegistrationChallenge: requestRegistrationChallengeControl.actor, } satisfies ReturnType; @@ -185,9 +178,9 @@ function navigationMock() { export { pendingModalClose, - localCredentialsKnownToServerControl, validateDeviceControl, readHasAcceptedSoftPromptControl, + checkLocalCredentialsControl, requestRegistrationChallengeControl, resetMfaUiMocks, mfaActorsMock, From 1a0978684931534ab9a775380a70ea373d77ace8 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 27 Jul 2026 13:33:15 +0200 Subject: [PATCH 07/30] feat(mfa): route the magic-code resend through the state machine The resend button called requestValidateCodeAction directly from the view, bypassing the machine that owns every other send of the magic-code email. A new RESEND_VALIDATE_CODE event, accepted only while the magic-code screen waits for a code, makes the machine the single sender: a resend fired while the registration challenge request is in flight is dropped, and a resend also clears the stale inline invalid-code error. The view keeps only its UI-local cleanup and now disables the resend button on the request that actually loads during a resend (the `??` in the disable condition never reached its right-hand side). --- ...ifactorAuthenticationInternalApiContext.ts | 3 ++ .../MultifactorAuthenticationMainContext.tsx | 2 + .../ValidateCodeResendButton.tsx | 1 + .../machine/mfaMachine.ts | 6 ++- .../machine/types.ts | 1 + .../shared/VALUES.ts | 1 + .../ValidateCodePage.tsx | 9 ++-- .../viewMatchesMachine.test.tsx | 6 +++ .../machine/validateCodeTransition.test.ts | 43 ++++++++++++++++++- tests/utils/mfa/flowPaths.ts | 15 +++++++ tests/utils/mfa/realUi/mocks.ts | 19 ++++++++ 11 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts index dbe8469cfe66..47bf6b3f9d1b 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts @@ -27,6 +27,9 @@ type MultifactorAuthenticationInternalApi = { /** Submit the magic code the user entered. The machine stores it and moves the flow forward. */ submitValidateCode: (validateCode: string) => void; + /** Request a fresh magic-code email. The machine sends it only while the magic-code screen waits for a code. */ + resendValidateCode: () => void; + /** Clear the inline validate-code error, called when the user starts typing again. */ clearContinuableError: () => void; diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 65894ac2f60c..15e49e5aea6a 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -88,6 +88,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const notifyModalClosed = () => send({type: 'MODAL_CLOSED'}); const approveSoftPrompt = () => send({type: 'SOFT_PROMPT_APPROVED'}); const submitValidateCode = (validateCode: string) => send({type: 'VALIDATE_CODE_ENTERED', validateCode}); + const resendValidateCode = () => send({type: 'RESEND_VALIDATE_CODE'}); const clearContinuableError = () => send({type: 'CLEAR_CONTINUABLE_ERROR'}); // There is no cancel-confirmation dialog yet, so every cancel path closes the modal directly. @@ -105,6 +106,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent notifyModalClosed, approveSoftPrompt, submitValidateCode, + resendValidateCode, clearContinuableError, requestCancel, hideCancelConfirm, diff --git a/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx b/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx index 2fe4d804892c..992d22b25c18 100644 --- a/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx +++ b/src/components/MultifactorAuthentication/ValidateCodeResendButton.tsx @@ -64,6 +64,7 @@ function MultifactorAuthenticationValidateCodeResendButton({ ) : ( mfaNavigate(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE)); }, // Emails the user a magic code. Runs only on the decision transition into the magic-code - // screen, never on (re)entry, so the invalid-code retry loop cannot resend the email. + // screen and on an explicit resend request, never on (re)entry, so the invalid-code retry + // loop cannot resend the email. requestValidateCode: () => requestValidateCodeAction(), // Stores the submitted code. Same narrowing pattern as initFlow: only VALIDATE_CODE_ENTERED // is wired here, so the early return just satisfies the type checker. @@ -218,10 +219,13 @@ const MFAMachine = setup({ }, // This branch shows the magic-code screen while a fresh registration waits for the // emailed code. Submitting stores the code and starts the backend challenge request. + // A resend is accepted only here, so one fired while the challenge request is in + // flight is dropped instead of emailing a code the pending submission ignores. [MFA_STATE.REQUESTING_VALIDATE_CODE]: { id: MFA_STATE.REQUESTING_VALIDATE_CODE, on: { VALIDATE_CODE_ENTERED: {target: REGISTRATION_CHALLENGE_TARGET, actions: ['clearContinuableError', 'submitValidateCode']}, + RESEND_VALIDATE_CODE: {actions: ['clearContinuableError', 'requestValidateCode']}, CLEAR_CONTINUABLE_ERROR: {actions: 'clearContinuableError'}, }, }, diff --git a/src/components/MultifactorAuthentication/machine/types.ts b/src/components/MultifactorAuthentication/machine/types.ts index be860f9fc6ed..4816231ed213 100644 --- a/src/components/MultifactorAuthentication/machine/types.ts +++ b/src/components/MultifactorAuthentication/machine/types.ts @@ -74,6 +74,7 @@ type MfaEvent = | {type: 'MODAL_CLOSED'} | {type: 'SOFT_PROMPT_APPROVED'} | {type: 'VALIDATE_CODE_ENTERED'; validateCode: string} + | {type: 'RESEND_VALIDATE_CODE'} | {type: 'CLEAR_CONTINUABLE_ERROR'}; /** Describes the input the machine passes to the device-check actor. */ diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 771cde58dd63..5717090785dd 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -328,6 +328,7 @@ const SHARED_VALUES = { PROMPT_CONFIRM_BUTTON: 'MultifactorAuthenticationPromptConfirmButton', VALIDATE_CODE_INPUT: 'MultifactorAuthenticationValidateCodeInput', VALIDATE_CODE_SUBMIT_BUTTON: 'MultifactorAuthenticationValidateCodeSubmitButton', + VALIDATE_CODE_RESEND_BUTTON: 'MultifactorAuthenticationValidateCodeResendButton', }, } as const; diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index da0c952779f2..48e9fc9bc031 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -23,14 +23,13 @@ import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; import {isValidValidateCode} from '@libs/ValidationUtils'; import {clearAccountMessages} from '@userActions/Session'; -import {clearValidateCodeActionError, requestValidateCodeAction} from '@userActions/User'; +import {clearValidateCodeActionError} from '@userActions/User'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {CONST as COMMON_CONST} from 'expensify-common'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -53,7 +52,7 @@ function MultifactorAuthenticationValidateCodePage() { const [inputCode, setInputCode] = useState(''); const [formError, setFormError] = useState({}); const [canShowError, setCanShowError] = useState(false); - const {requestCancel, submitValidateCode, clearContinuableError, state} = useMultifactorAuthenticationInternal(); + const {requestCancel, submitValidateCode, resendValidateCode, clearContinuableError, state} = useMultifactorAuthenticationInternal(); const {continuableError, isCancelConfirmVisible} = state; // Refs @@ -65,7 +64,7 @@ function MultifactorAuthenticationValidateCodePage() { const hasAccountError = !!account && !isEmptyObject(account?.errors); const hasContinuableError = !!continuableError; const isValidateCodeFormSubmitting = AccountUtils.isValidateCodeFormSubmitting(account); - const shouldDisableResendCode = isOffline ?? account?.isLoading; + const shouldDisableResendCode = isOffline || !!validateActionCode?.isLoading; const validateCodeActionError = getLatestErrorField(validateActionCode, 'actionVerified'); const hasValidateCodeActionError = !isEmptyObject(validateCodeActionError); const hasError = hasAccountError || hasContinuableError || hasValidateCodeActionError; @@ -143,7 +142,7 @@ function MultifactorAuthenticationValidateCodePage() { clearValidateCodeActionError('actionVerified'); } addMFABreadcrumb('Validate code resend requested'); - requestValidateCodeAction({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.REGISTER_AUTHENTICATION_KEY}); + resendValidateCode(); inputRef.current?.clear(); setInputCode(''); setFormError({}); diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index bd503ebf672f..2574b47b25ee 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -61,6 +61,8 @@ jest.mock('@components/MultifactorAuthentication/machine/mfaActors', () => jest. jest.mock('@components/MultifactorAuthentication/biometrics/useBiometrics', () => jest.requireActual('tests/utils/mfa/realUi/mocks').biometricsHookMock()); // RenderHTML requires an ambient provider that this lifecycle test does not mount. jest.mock('@components/RenderHTML', () => jest.requireActual('tests/utils/mfa/realUi/mocks').renderHtmlMock()); +// The resend countdown is a real-time presentational timer; finishing it immediately keeps the resend button pressable for the walk. +jest.mock('@components/ValidateCodeCountdown', () => jest.requireActual('tests/utils/mfa/realUi/mocks').validateCodeCountdownMock()); // Browser and Android history synchronization is outside the contract between the machine and UI. jest.mock('@components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory', () => jest.requireActual('tests/utils/mfa/realUi/mocks').syncHistoryMock()); // The test renderer runs no real navigation transitions, so the mock controls when the transition callbacks fire. @@ -159,6 +161,10 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { fireEvent.press(screen.getByTestId(TEST_ID.VALIDATE_CODE_SUBMIT_BUTTON)); await waitForBatchedUpdatesWithAct(); }, + RESEND_VALIDATE_CODE: async () => { + fireEvent.press(screen.getByTestId(TEST_ID.VALIDATE_CODE_RESEND_BUTTON)); + await waitForBatchedUpdatesWithAct(); + }, CLEAR_CONTINUABLE_ERROR: async () => { fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), '1'); await waitForBatchedUpdatesWithAct(); diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index fc94d3755d30..fce40adf0e86 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -62,8 +62,8 @@ const FATAL_REGISTRATION_CHALLENGE_RESPONSE = { // The graph-traversal suites generate their expectations from the machine, so a transition pointed at // a wrong target adjusts those expectations and still passes. This suite pins the registration -// decision and the magic-code loop by hand, including that only the decision transition may send the -// magic-code email. +// decision and the magic-code loop by hand, including that only the decision transition and an +// explicit resend request may send the magic-code email. describe('MFA magic code and registration decision', () => { beforeEach(() => { @@ -101,6 +101,45 @@ describe('MFA magic code and registration decision', () => { actor.stop(); }); + it('sends a fresh magic-code email and stays on the screen when the user requests a resend', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + + actor.start(); + actor.send({type: 'RESEND_VALIDATE_CODE'}); + + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); + + actor.stop(); + }); + + it('clears the inline error when the user requests a resend after a rejected code', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}, {continuableError: MFA_TEST_INVALID_CODE_ERROR}); + + actor.start(); + actor.send({type: 'RESEND_VALIDATE_CODE'}); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(result.context.continuableError).toBeUndefined(); + expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); + + actor.stop(); + }); + + it('drops a resend request while the registration challenge request is in flight', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + + actor.start(); + actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + actor.send({type: 'RESEND_VALIDATE_CODE'}); + + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE})).toBe(true); + expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); + + actor.stop(); + }); + it('stores the submitted code and waits for a registration challenge before continuing', () => { const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); diff --git a/tests/utils/mfa/flowPaths.ts b/tests/utils/mfa/flowPaths.ts index e246813002a8..dd8163ec8d04 100644 --- a/tests/utils/mfa/flowPaths.ts +++ b/tests/utils/mfa/flowPaths.ts @@ -69,6 +69,20 @@ const DRIVING_JOURNEYS: DrivingJourney[] = [ events: [createInitEvent(), {type: 'CLOSE_MODAL'}, {type: 'MODAL_CLOSED'}, createInitEvent()], endState: `${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.VALIDATING_DEVICE}`, }, + // A resend is a self-transition, and a self-transition never lies on a shortest path, so only + // this journey drives the resend gesture through the real UI. + { + description: 'the resend journey requests a fresh code and still accepts the emailed code', + events: [ + createInitEvent(), + createActorDoneEvent(VALIDATE_DEVICE_DONE_EVENT_TYPE, {success: true}), + createActorDoneEvent(CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, false), + {type: 'RESEND_VALIDATE_CODE'}, + {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, + createActorDoneEvent(REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}), + ], + endState: `${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`, + }, { description: 'the invalid-code journey clears the inline error and accepts a corrected code', events: [ @@ -100,6 +114,7 @@ const MFA_GRAPH_EVENT_FIXTURES = { MODAL_CLOSED: [{type: 'MODAL_CLOSED'}], SOFT_PROMPT_APPROVED: [{type: 'SOFT_PROMPT_APPROVED'}], VALIDATE_CODE_ENTERED: [{type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}], + RESEND_VALIDATE_CODE: [{type: 'RESEND_VALIDATE_CODE'}], CLEAR_CONTINUABLE_ERROR: [{type: 'CLEAR_CONTINUABLE_ERROR'}], } satisfies MfaEventFixtures; diff --git a/tests/utils/mfa/realUi/mocks.ts b/tests/utils/mfa/realUi/mocks.ts index becf847a0494..1f84526d08fe 100644 --- a/tests/utils/mfa/realUi/mocks.ts +++ b/tests/utils/mfa/realUi/mocks.ts @@ -11,6 +11,7 @@ import type { import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; import type Navigation from '@libs/Navigation/Navigation'; +import {useEffect} from 'react'; import {fromPromise} from 'xstate'; // This module keeps mutable mock state and factory bodies outside the test so the test stays focused on @@ -141,6 +142,23 @@ function renderHtmlMock() { }; } +/** + * Replaces the resend countdown, a real-time presentational timer outside the modal lifecycle + * contract. Finishing it immediately keeps the resend button pressable for the walk. + */ +function validateCodeCountdownMock() { + function ImmediatelyFinishedCountdown({onCountdownFinish}: {onCountdownFinish: () => void}) { + useEffect(() => { + onCountdownFinish(); + }, [onCountdownFinish]); + return null; + } + return { + __esModule: true, + default: ImmediatelyFinishedCountdown, + }; +} + function syncHistoryMock() { return { __esModule: true, @@ -187,6 +205,7 @@ export { userActionsMock, biometricsHookMock, renderHtmlMock, + validateCodeCountdownMock, syncHistoryMock, navigationMock, }; From fd24563107ceec8f4f9ff3d8dd5ccff13dc27c26 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 27 Jul 2026 13:48:27 +0200 Subject: [PATCH 08/30] fix(mfa): disable the resend button while the challenge request is in flight The machine drops a resend sent during the challenge request, but the button stayed pressable once the countdown expired, so a press cleared the input and restarted the countdown without a new email coming. The view now reads a flag derived from the machine snapshot, which cannot lag behind the state that decides whether the event is accepted, unlike the account loading state delivered through Onyx. --- .../machine/snapshotToState.ts | 13 +++++++++++-- .../MultifactorAuthentication/ValidateCodePage.tsx | 4 ++-- .../graphTraversal/viewMatchesMachine.test.tsx | 4 ++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/MultifactorAuthentication/machine/snapshotToState.ts b/src/components/MultifactorAuthentication/machine/snapshotToState.ts index e087f353c3e2..0813275548e8 100644 --- a/src/components/MultifactorAuthentication/machine/snapshotToState.ts +++ b/src/components/MultifactorAuthentication/machine/snapshotToState.ts @@ -10,7 +10,12 @@ const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; type MfaSnapshot = SnapshotFrom; /** The machine-derived state consumers read: the wired context subset plus the modal lifecycle state. */ -type MfaState = MfaContext & {modalState: MfaModalState}; +type MfaState = MfaContext & { + modalState: MfaModalState; + + /** Whether the entered code is being exchanged for a registration challenge. While true the machine drops further magic-code events. */ + isSubmittingValidateCode: boolean; +}; function getModalState(snapshot: MfaSnapshot): MfaModalState { if (snapshot.matches(MFA_STATE.OPEN)) { @@ -29,7 +34,11 @@ function getModalState(snapshot: MfaSnapshot): MfaModalState { * `@xstate/react` (a dedicated later PR), which retires this function and the `MfaState` bridge shape. */ function snapshotToState(snapshot: MfaSnapshot): MfaState { - return {...snapshot.context, modalState: getModalState(snapshot)}; + return { + ...snapshot.context, + modalState: getModalState(snapshot), + isSubmittingValidateCode: snapshot.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}), + }; } export default snapshotToState; diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index 48e9fc9bc031..38dc788022a1 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -53,7 +53,7 @@ function MultifactorAuthenticationValidateCodePage() { const [formError, setFormError] = useState({}); const [canShowError, setCanShowError] = useState(false); const {requestCancel, submitValidateCode, resendValidateCode, clearContinuableError, state} = useMultifactorAuthenticationInternal(); - const {continuableError, isCancelConfirmVisible} = state; + const {continuableError, isCancelConfirmVisible, isSubmittingValidateCode} = state; // Refs const inputRef = useRef(null); @@ -64,7 +64,7 @@ function MultifactorAuthenticationValidateCodePage() { const hasAccountError = !!account && !isEmptyObject(account?.errors); const hasContinuableError = !!continuableError; const isValidateCodeFormSubmitting = AccountUtils.isValidateCodeFormSubmitting(account); - const shouldDisableResendCode = isOffline || !!validateActionCode?.isLoading; + const shouldDisableResendCode = isOffline || isSubmittingValidateCode || !!validateActionCode?.isLoading; const validateCodeActionError = getLatestErrorField(validateActionCode, 'actionVerified'); const hasValidateCodeActionError = !isEmptyObject(validateCodeActionError); const hasError = hasAccountError || hasContinuableError || hasValidateCodeActionError; diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 2574b47b25ee..74e6f32c9e4d 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -220,6 +220,8 @@ const testConfig = { expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT)).toBeOnTheScreen(); expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_SUBMIT_BUTTON)).toBeOnTheScreen(); + // The countdown mock finishes immediately, so the resend button is rendered and must be pressable while the screen waits for a code. + expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_RESEND_BUTTON)).toBeEnabled(); expect(screen.getByText(translateLocal('multifactorAuthentication.letsVerifyItsYou'))).toBeOnTheScreen(); expect(state.context.error).toBeUndefined(); const inlineError = translateLocal('validateCodeForm.error.incorrectMagicCode'); @@ -234,6 +236,8 @@ const testConfig = { expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT)).toBeOnTheScreen(); + // The machine drops a resend while the challenge request is in flight, so the button must not offer one. + expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_RESEND_BUTTON)).toBeDisabled(); expect(state.context.validateCode).toBeDefined(); expect(state.context.registrationChallenge).toBeUndefined(); expect(state.context.error).toBeUndefined(); From d1b00be602c60afac6ea6018b7c38656623773dd Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 27 Jul 2026 17:12:10 +0200 Subject: [PATCH 09/30] fix(mfa): send the registration reason code with the magic code request Upstream 6e87a8cc5a9 started passing REGISTER_AUTHENTICATION_KEY on both sends of the magic-code email, and this slice moved both of them behind the machine's requestValidateCode action, so the action has to carry the reason code or the backend loses the context it was just given. --- src/components/MultifactorAuthentication/machine/mfaMachine.ts | 3 ++- .../machine/validateCodeTransition.test.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index 87ca6d8f5c69..c0b89259daaa 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -10,6 +10,7 @@ import {requestValidateCodeAction} from '@userActions/User'; import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; +import {CONST as COMMON_CONST} from 'expensify-common'; import {assign, setup} from 'xstate'; import type {MfaContext, MfaEvent} from './types'; @@ -95,7 +96,7 @@ const MFAMachine = setup({ // Emails the user a magic code. Runs only on the decision transition into the magic-code // screen and on an explicit resend request, never on (re)entry, so the invalid-code retry // loop cannot resend the email. - requestValidateCode: () => requestValidateCodeAction(), + requestValidateCode: () => requestValidateCodeAction({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.REGISTER_AUTHENTICATION_KEY}), // Stores the submitted code. Same narrowing pattern as initFlow: only VALIDATE_CODE_ENTERED // is wired here, so the early return just satisfies the type checker. submitValidateCode: assign(({event}) => { diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index fce40adf0e86..b62670554421 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -10,6 +10,7 @@ import type * as UserActions from '@userActions/User'; import CONST from '@src/CONST'; +import {CONST as COMMON_CONST} from 'expensify-common'; import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; import createInitEvent, {MFA_TEST_INVALID_CODE_ERROR, MFA_TEST_REGISTRATION_CHALLENGE, MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; @@ -85,6 +86,7 @@ describe('MFA magic code and registration decision', () => { expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); + expect(requestValidateCodeActionMock).toHaveBeenCalledWith({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.REGISTER_AUTHENTICATION_KEY}); actor.stop(); }); From cda053eb1170548b3dc167967a7ba909fb66b0c1 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 27 Jul 2026 17:12:11 +0200 Subject: [PATCH 10/30] test(mfa): follow main's security-code translation rename Upstream 2f096a52c27 renamed the user-facing magic-code keys to security-code. The page picked the new key up through the sync, the walk assertion still read the removed one and threw on every path that renders the inline error. --- .../machine/graphTraversal/viewMatchesMachine.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 74e6f32c9e4d..370cc1db9cbd 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -224,7 +224,7 @@ const testConfig = { expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_RESEND_BUTTON)).toBeEnabled(); expect(screen.getByText(translateLocal('multifactorAuthentication.letsVerifyItsYou'))).toBeOnTheScreen(); expect(state.context.error).toBeUndefined(); - const inlineError = translateLocal('validateCodeForm.error.incorrectMagicCode'); + const inlineError = translateLocal('validateCodeForm.error.incorrectSecurityCode'); if (state.context.continuableError) { expect(screen.getByText(inlineError)).toBeOnTheScreen(); } else { From 9e20ab42c2024d1b6eebfe23ba88be5271d50da0 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 27 Jul 2026 17:12:12 +0200 Subject: [PATCH 11/30] fix(mfa): type the one-shot Onyx read with OnyxValue The Onyx bump that came with the sync widened the connect callback to a collection-aware conditional type, which no longer matches OnyxEntry. OnyxValue is the type Onyx resolves the callback to, and it is what tests/utils/getOnyxValue already uses. --- .../MultifactorAuthentication/shared/readOnyxValueOnce.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts b/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts index 843fe88dffea..7a5afe7ef62e 100644 --- a/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts +++ b/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts @@ -1,4 +1,4 @@ -import type {Connection, KeyValueMapping, OnyxEntry, OnyxKey} from 'react-native-onyx'; +import type {Connection, OnyxKey, OnyxValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -7,7 +7,7 @@ import Onyx from 'react-native-onyx'; * disconnected after the first value or when the optional abort signal fires; an aborted read never * resolves, which lets XState actors drop it silently when they are stopped. */ -function readOnyxValueOnce(key: TKey, signal?: AbortSignal): Promise> { +function readOnyxValueOnce(key: TKey, signal?: AbortSignal): Promise> { return new Promise((resolve) => { let connection: Connection; const disconnect = () => Onyx.disconnect(connection); From e8b1182f0cf6e8adc2449b0e4fafafbf8af5416d Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 27 Jul 2026 17:31:12 +0200 Subject: [PATCH 12/30] test(mfa): align compiler handling for countdown mock --- tests/utils/mfa/realUi/mocks.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/utils/mfa/realUi/mocks.ts b/tests/utils/mfa/realUi/mocks.ts index 1f84526d08fe..d695c09cfa30 100644 --- a/tests/utils/mfa/realUi/mocks.ts +++ b/tests/utils/mfa/realUi/mocks.ts @@ -148,6 +148,9 @@ function renderHtmlMock() { */ function validateCodeCountdownMock() { function ImmediatelyFinishedCountdown({onCountdownFinish}: {onCountdownFinish: () => void}) { + // Babel memoizes this nested mock component while OXC does not detect it. Memoization is unnecessary here, so opt out to keep both compilers aligned. + 'use no memo'; + useEffect(() => { onCountdownFinish(); }, [onCountdownFinish]); From fdee1652cf3b417bd05245e2440e3fc73ab18401 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 28 Jul 2026 09:44:38 +0200 Subject: [PATCH 13/30] fix(mfa): derive resend availability from machine --- .../MultifactorAuthentication/machine/snapshotToState.ts | 6 +++--- src/pages/MultifactorAuthentication/ValidateCodePage.tsx | 4 ++-- .../machine/graphTraversal/viewMatchesMachine.test.tsx | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/MultifactorAuthentication/machine/snapshotToState.ts b/src/components/MultifactorAuthentication/machine/snapshotToState.ts index 0813275548e8..9f9ae1567a08 100644 --- a/src/components/MultifactorAuthentication/machine/snapshotToState.ts +++ b/src/components/MultifactorAuthentication/machine/snapshotToState.ts @@ -13,8 +13,8 @@ type MfaSnapshot = SnapshotFrom; type MfaState = MfaContext & { modalState: MfaModalState; - /** Whether the entered code is being exchanged for a registration challenge. While true the machine drops further magic-code events. */ - isSubmittingValidateCode: boolean; + /** Whether the machine currently accepts a request for a fresh magic-code email. */ + canResendValidateCode: boolean; }; function getModalState(snapshot: MfaSnapshot): MfaModalState { @@ -37,7 +37,7 @@ function snapshotToState(snapshot: MfaSnapshot): MfaState { return { ...snapshot.context, modalState: getModalState(snapshot), - isSubmittingValidateCode: snapshot.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}), + canResendValidateCode: snapshot.can({type: 'RESEND_VALIDATE_CODE'}), }; } diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index 38dc788022a1..6a79270bb629 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -53,7 +53,7 @@ function MultifactorAuthenticationValidateCodePage() { const [formError, setFormError] = useState({}); const [canShowError, setCanShowError] = useState(false); const {requestCancel, submitValidateCode, resendValidateCode, clearContinuableError, state} = useMultifactorAuthenticationInternal(); - const {continuableError, isCancelConfirmVisible, isSubmittingValidateCode} = state; + const {continuableError, isCancelConfirmVisible, canResendValidateCode} = state; // Refs const inputRef = useRef(null); @@ -64,7 +64,7 @@ function MultifactorAuthenticationValidateCodePage() { const hasAccountError = !!account && !isEmptyObject(account?.errors); const hasContinuableError = !!continuableError; const isValidateCodeFormSubmitting = AccountUtils.isValidateCodeFormSubmitting(account); - const shouldDisableResendCode = isOffline || isSubmittingValidateCode || !!validateActionCode?.isLoading; + const shouldDisableResendCode = isOffline || !canResendValidateCode || !!validateActionCode?.isLoading; const validateCodeActionError = getLatestErrorField(validateActionCode, 'actionVerified'); const hasValidateCodeActionError = !isEmptyObject(validateCodeActionError); const hasError = hasAccountError || hasContinuableError || hasValidateCodeActionError; diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 370cc1db9cbd..efa41bd6ed74 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -210,6 +210,9 @@ const testConfig = { } else { expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); expect(state.context.registrationChallenge).toBeDefined(); + // The magic-code screen stays visible during this read, but the machine no longer + // accepts resend requests after a valid code has advanced the flow. + expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_RESEND_BUTTON)).toBeDisabled(); } expect(state.context.accountID).toBeDefined(); expect(state.context.error).toBeUndefined(); From 80c66a7a740cb479f96b57c8b604fc5bcc016257 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 28 Jul 2026 10:16:42 +0200 Subject: [PATCH 14/30] fix(mfa): show submit spinner for accounts with 2FA --- .../ValidateCodePage.tsx | 4 +- .../viewMatchesMachine.test.tsx | 49 ++++++++++++++++++- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index 6a79270bb629..dfc544d8526f 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -18,7 +18,6 @@ import useOnyx from '@hooks/useOnyx'; import usePrimaryContactMethod from '@hooks/usePrimaryContactMethod'; import useThemeStyles from '@hooks/useThemeStyles'; -import AccountUtils from '@libs/AccountUtils'; import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; import {isValidValidateCode} from '@libs/ValidationUtils'; @@ -63,7 +62,8 @@ function MultifactorAuthenticationValidateCodePage() { // Derived state const hasAccountError = !!account && !isEmptyObject(account?.errors); const hasContinuableError = !!continuableError; - const isValidateCodeFormSubmitting = AccountUtils.isValidateCodeFormSubmitting(account); + // The MFA registration challenge always uses VALIDATE_CODE_FORM, even when the account has 2FA enabled. + const isValidateCodeFormSubmitting = !!account?.isLoading && account.loadingForm === CONST.FORMS.VALIDATE_CODE_FORM; const shouldDisableResendCode = isOffline || !canResendValidateCode || !!validateActionCode?.isLoading; const validateCodeActionError = getLatestErrorField(validateActionCode, 'actionVerified'); const hasValidateCodeActionError = !isEmptyObject(validateCodeActionError); diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index efa41bd6ed74..10d8d7eb1198 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -14,7 +14,7 @@ import type * as MfaRealUiMocks from 'tests/utils/mfa/realUi/mocks'; import type {SnapshotFrom} from 'xstate'; import Onyx from 'react-native-onyx'; -import {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; +import createInitEvent, {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; import getWalkedPaths, { CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, @@ -334,6 +334,53 @@ describe('the real MFA modal matches the machine at every step of every generate }); }); +describe('MFA validate-code loading state', () => { + beforeEach(async () => { + resetMfaUiMocks(); + await act(async () => { + await Onyx.clear(); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: MFA_TEST_ACCOUNT_ID}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {requiresTwoFactorAuth: true}); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('shows the submit spinner for the registration challenge request when the account has 2FA enabled', async () => { + const {executeScenario} = renderMfaUi(); + await waitForBatchedUpdatesWithAct(); + + const initEvent = createInitEvent(); + await act(async () => executeScenario(initEvent.scenarioName, initEvent.payload)); + await waitForBatchedUpdatesWithAct(); + fireEvent(screen.getByTestId(TEST_ID.INITIAL_SCREEN), 'layout', { + nativeEvent: {layout: {width: 1, height: 1, x: 0, y: 0}}, + }); + await waitForBatchedUpdatesWithAct(); + + await act(async () => validateDeviceControl.resolve({success: true})); + await waitForBatchedUpdatesWithAct(); + await act(async () => checkLocalCredentialsControl.resolve(false)); + await waitForBatchedUpdatesWithAct(); + + const submitButtonText = screen.getByText(translateLocal('common.verify')); + expect(submitButtonText).toBeVisible(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isLoading: true, + loadingForm: CONST.FORMS.VALIDATE_CODE_FORM, + }); + }); + await waitForBatchedUpdatesWithAct(); + + expect(submitButtonText).not.toBeVisible(); + }); +}); + // Every settleable leaf must occur in a path that the walk above drives. `everyStateReachable.test.ts` // checks the unfiltered graph, so only this guard catches a state whose every route needs a step the // walk cannot drive, such as a delayed transition. Paths removed as prefixes of longer paths do not From e0c72475ed2b465b129788e8b3da31258c744af3 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 28 Jul 2026 10:43:15 +0200 Subject: [PATCH 15/30] Remove unused MFA test helper --- tests/utils/mfa/flowActors.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index 88825104f30a..2e4c6797c262 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -7,11 +7,10 @@ import type {OutputFrom, StateValue} from 'xstate'; import {createActor} from 'xstate'; import createInitEvent from './flowFixtures'; -import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; +import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; type ValidateDeviceOutput = OutputFrom['validateDevice']>; type CheckLocalCredentialsOutput = OutputFrom['checkLocalCredentials']>; -type RequestRegistrationChallengeOutput = OutputFrom['requestRegistrationChallenge']>; /** * Builds the context a flow carries right after INIT seeds it. Overrides express a spec's starting @@ -61,11 +60,4 @@ function sendCheckLocalCredentialsDone(actor: ReturnType, output: RequestRegistrationChallengeOutput) { - // Framework actor events are not part of the application's MfaEvent union. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - actor.send({type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, output} as unknown as MfaEvent); -} - -export {createActorAtState, createFlowContext, sendCheckLocalCredentialsDone, sendRequestRegistrationChallengeDone, sendValidateDeviceDone}; +export {createActorAtState, createFlowContext, sendCheckLocalCredentialsDone, sendValidateDeviceDone}; From d32b67e020962e9b5c1777c61b1901229e3d72e4 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 28 Jul 2026 10:46:36 +0200 Subject: [PATCH 16/30] Reuse MFA actor done event helper --- tests/utils/mfa/flowActors.ts | 12 ++++-------- tests/utils/mfa/flowPaths.ts | 1 + 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index 2e4c6797c262..70c67d609446 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -1,13 +1,13 @@ import type createActors from '@components/MultifactorAuthentication/machine/mfaActors'; import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type {MfaContext, MfaEvent} from '@components/MultifactorAuthentication/machine/types'; +import type {MfaContext} from '@components/MultifactorAuthentication/machine/types'; import type {OutputFrom, StateValue} from 'xstate'; import {createActor} from 'xstate'; import createInitEvent from './flowFixtures'; -import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; +import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, createActorDoneEvent, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; type ValidateDeviceOutput = OutputFrom['validateDevice']>; type CheckLocalCredentialsOutput = OutputFrom['checkLocalCredentials']>; @@ -46,18 +46,14 @@ function createActorAtState(value: StateValue, contextOverrides?: Partial, output: ValidateDeviceOutput) { - // Framework actor events are not part of the application's MfaEvent union. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - actor.send({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output} as unknown as MfaEvent); + actor.send(createActorDoneEvent(VALIDATE_DEVICE_DONE_EVENT_TYPE, output)); } /** * Completes the invoked credentials-check actor by sending its done event carrying the given output. */ function sendCheckLocalCredentialsDone(actor: ReturnType, output: CheckLocalCredentialsOutput) { - // Framework actor events are not part of the application's MfaEvent union. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - actor.send({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output} as unknown as MfaEvent); + actor.send(createActorDoneEvent(CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output)); } export {createActorAtState, createFlowContext, sendCheckLocalCredentialsDone, sendValidateDeviceDone}; diff --git a/tests/utils/mfa/flowPaths.ts b/tests/utils/mfa/flowPaths.ts index dd8163ec8d04..563445a0b493 100644 --- a/tests/utils/mfa/flowPaths.ts +++ b/tests/utils/mfa/flowPaths.ts @@ -254,6 +254,7 @@ export default getWalkedPaths; export { CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, + createActorDoneEvent, getDrivingJourneyPaths, getMfaShortestPaths, isAutoDrivenEvent, From 76d32be5066779e755a7a6936ca1eaf2426a324f Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 28 Jul 2026 11:28:49 +0200 Subject: [PATCH 17/30] refactor(mfa): nest magic-code request states --- .../machine/mfaMachine.ts | 89 ++++++++++--------- .../shared/VALUES.ts | 3 +- .../viewMatchesMachine.test.tsx | 4 +- .../machine/validateCodeTransition.test.ts | 36 ++++---- 4 files changed, 67 insertions(+), 65 deletions(-) diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index c0b89259daaa..dfda035cfebc 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -24,8 +24,7 @@ const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const; const PROMPT_TARGET = `#${MFA_STATE.PROMPT}` as const; const SOFT_PROMPT_CHECK_TARGET = `#${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}` as const; -const MAGIC_CODE_TARGET = `#${MFA_STATE.REQUESTING_VALIDATE_CODE}` as const; -const REGISTRATION_CHALLENGE_TARGET = `#${MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}` as const; +const MAGIC_CODE_TARGET = `#${MFA_STATE.MAGIC_CODE}` as const; // Which prompt variant the screen renders is a device property, resolved once per platform. const PROMPT_TYPE = CONST.MULTIFACTOR_AUTHENTICATION.PROMPT_TYPE_MAP[deviceVerificationType]; @@ -190,7 +189,7 @@ const MFAMachine = setup({ // A returning user's credentials are already registered, so only a fresh registration asks for a code. onDone: [ {guard: ({event}) => event.output, target: SOFT_PROMPT_CHECK_TARGET}, - {target: MAGIC_CODE_TARGET, actions: ['requestValidateCode', 'navigateToMagicCode']}, + {target: MAGIC_CODE_TARGET, actions: 'requestValidateCode'}, ], onError: { target: OUTCOME_TARGET, @@ -218,49 +217,51 @@ const MFAMachine = setup({ }, }, }, - // This branch shows the magic-code screen while a fresh registration waits for the - // emailed code. Submitting stores the code and starts the backend challenge request. - // A resend is accepted only here, so one fired while the challenge request is in - // flight is dropped instead of emailing a code the pending submission ignores. - [MFA_STATE.REQUESTING_VALIDATE_CODE]: { - id: MFA_STATE.REQUESTING_VALIDATE_CODE, - on: { - VALIDATE_CODE_ENTERED: {target: REGISTRATION_CHALLENGE_TARGET, actions: ['clearContinuableError', 'submitValidateCode']}, - RESEND_VALIDATE_CODE: {actions: ['clearContinuableError', 'requestValidateCode']}, - CLEAR_CONTINUABLE_ERROR: {actions: 'clearContinuableError'}, - }, - }, - // The magic-code screen stays mounted while the backend exchanges the code for a - // registration challenge. Only a real challenge advances the flow; an invalid code - // returns to the same screen with an inline error. - [MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE]: { - id: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE, - invoke: { - id: 'requestRegistrationChallenge', - src: 'requestRegistrationChallenge', - input: ({context}) => { - if (context.validateCode === undefined) { - throw new Error('MFA validate code must be stored before requesting a registration challenge'); - } - return {validateCode: context.validateCode}; - }, - onDone: [ - { - guard: ({event}) => event.output.success, - target: SOFT_PROMPT_CHECK_TARGET, - actions: assign({registrationChallenge: ({event}) => (event.output.success ? event.output.challenge : undefined)}), + [MFA_STATE.MAGIC_CODE]: { + id: MFA_STATE.MAGIC_CODE, + entry: 'navigateToMagicCode', + initial: MFA_STATE.AWAITING_VALIDATE_CODE, + states: { + // Waits for the emailed code. A resend is accepted only here, so one fired + // while the challenge request is in flight is dropped instead of emailing a + // code the pending submission ignores. + [MFA_STATE.AWAITING_VALIDATE_CODE]: { + on: { + VALIDATE_CODE_ENTERED: {target: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE, actions: 'submitValidateCode'}, + RESEND_VALIDATE_CODE: {actions: ['clearContinuableError', 'requestValidateCode']}, + CLEAR_CONTINUABLE_ERROR: {actions: 'clearContinuableError'}, }, - { - guard: ({event}) => - !event.output.success && getMFAFailureError(event.output).reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, - target: MAGIC_CODE_TARGET, - actions: assign({continuableError: ({event}) => getMFAFailureError(event.output)}), + }, + [MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE]: { + entry: 'clearContinuableError', + invoke: { + id: 'requestRegistrationChallenge', + src: 'requestRegistrationChallenge', + input: ({context}) => { + if (context.validateCode === undefined) { + throw new Error('MFA validate code must be stored before requesting a registration challenge'); + } + return {validateCode: context.validateCode}; + }, + onDone: [ + { + guard: ({event}) => event.output.success, + target: SOFT_PROMPT_CHECK_TARGET, + actions: assign({registrationChallenge: ({event}) => (event.output.success ? event.output.challenge : undefined)}), + }, + { + guard: ({event}) => + !event.output.success && getMFAFailureError(event.output).reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, + target: MFA_STATE.AWAITING_VALIDATE_CODE, + actions: assign({continuableError: ({event}) => getMFAFailureError(event.output)}), + }, + {target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})}, + ], + onError: { + target: OUTCOME_TARGET, + actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Registration challenge request', event.error)}), + }, }, - {target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})}, - ], - onError: { - target: OUTCOME_TARGET, - actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Registration challenge request', event.error)}), }, }, }, diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 5717090785dd..e875138d60dd 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -223,7 +223,8 @@ const MFA_STATE = { VALIDATING_DEVICE: 'validatingDevice', DECIDING_REGISTRATION: 'decidingRegistration', CHECKING_SOFT_PROMPT_ACCEPTANCE: 'checkingSoftPromptAcceptance', - REQUESTING_VALIDATE_CODE: 'requestingValidateCode', + MAGIC_CODE: 'magicCode', + AWAITING_VALIDATE_CODE: 'awaitingValidateCode', REQUESTING_REGISTRATION_CHALLENGE: 'requestingRegistrationChallenge', PROMPT: 'prompt', AWAITING_SOFT_PROMPT: 'awaitingSoftPrompt', diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 10d8d7eb1198..d9df9b6e8a5f 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -217,7 +217,7 @@ const testConfig = { expect(state.context.accountID).toBeDefined(); expect(state.context.error).toBeUndefined(); }, - [`${MFA_STATE.OPEN}.${MFA_STATE.REQUESTING_VALIDATE_CODE}`]: (state: SnapshotFrom) => { + [`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.AWAITING_VALIDATE_CODE}`]: (state: SnapshotFrom) => { expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); @@ -234,7 +234,7 @@ const testConfig = { expect(screen.queryByText(inlineError)).not.toBeOnTheScreen(); } }, - [`${MFA_STATE.OPEN}.${MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}`]: (state: SnapshotFrom) => { + [`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}`]: (state: SnapshotFrom) => { expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index b62670554421..28e9f8f95a8f 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -84,7 +84,7 @@ describe('MFA magic code and registration decision', () => { actor.start(); sendCheckLocalCredentialsDone(actor, false); - expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true); expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); expect(requestValidateCodeActionMock).toHaveBeenCalledWith({reasonCode: COMMON_CONST.VALIDATE_CODE_REASONS.REGISTER_AUTHENTICATION_KEY}); @@ -104,25 +104,25 @@ describe('MFA magic code and registration decision', () => { }); it('sends a fresh magic-code email and stays on the screen when the user requests a resend', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); actor.start(); actor.send({type: 'RESEND_VALIDATE_CODE'}); - expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true); expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); actor.stop(); }); it('clears the inline error when the user requests a resend after a rejected code', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}, {continuableError: MFA_TEST_INVALID_CODE_ERROR}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}, {continuableError: MFA_TEST_INVALID_CODE_ERROR}); actor.start(); actor.send({type: 'RESEND_VALIDATE_CODE'}); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true); expect(result.context.continuableError).toBeUndefined(); expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); @@ -130,26 +130,26 @@ describe('MFA magic code and registration decision', () => { }); it('drops a resend request while the registration challenge request is in flight', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); actor.start(); actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); actor.send({type: 'RESEND_VALIDATE_CODE'}); - expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE})).toBe(true); + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}})).toBe(true); expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); actor.stop(); }); it('stores the submitted code and waits for a registration challenge before continuing', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); actor.start(); actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE})).toBe(true); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}})).toBe(true); expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE); expect(result.context.registrationChallenge).toBeUndefined(); expect(requestRegistrationChallengeMock).toHaveBeenCalledWith(MFA_TEST_VALIDATE_CODE); @@ -158,7 +158,7 @@ describe('MFA magic code and registration decision', () => { }); it('stores a valid registration challenge before continuing the flow', async () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); requestRegistrationChallengeMock.mockResolvedValue(VALID_REGISTRATION_CHALLENGE_RESPONSE); actor.start(); @@ -166,7 +166,7 @@ describe('MFA magic code and registration decision', () => { await waitForBatchedUpdates(); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE})).toBe(false); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}})).toBe(false); expect(result.context.registrationChallenge).toBe(MFA_TEST_REGISTRATION_CHALLENGE); expect(result.context.error).toBeUndefined(); @@ -174,7 +174,7 @@ describe('MFA magic code and registration decision', () => { }); it('stays on the magic-code screen with an inline error and no new email when the code is invalid', async () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); requestRegistrationChallengeMock.mockResolvedValue(INVALID_CODE_RESPONSE); actor.start(); @@ -182,7 +182,7 @@ describe('MFA magic code and registration decision', () => { await waitForBatchedUpdates(); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true); expect(result.context.continuableError?.reason).toBe(REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE); expect(result.context.registrationChallenge).toBeUndefined(); expect(result.context.error).toBeUndefined(); @@ -192,7 +192,7 @@ describe('MFA magic code and registration decision', () => { }); it('clears the inline error when the rejected code is submitted again without editing', async () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); requestRegistrationChallengeMock.mockResolvedValueOnce(INVALID_CODE_RESPONSE).mockResolvedValueOnce(VALID_REGISTRATION_CHALLENGE_RESPONSE); actor.start(); @@ -210,7 +210,7 @@ describe('MFA magic code and registration decision', () => { }); it('ends the flow with the failure outcome when the challenge request fails fatally', async () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); requestRegistrationChallengeMock.mockResolvedValue(FATAL_REGISTRATION_CHALLENGE_RESPONSE); actor.start(); @@ -227,7 +227,7 @@ describe('MFA magic code and registration decision', () => { }); it('does not continue when a successful response has no valid registration challenge', async () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); requestRegistrationChallengeMock.mockResolvedValue(MISSING_REGISTRATION_CHALLENGE_RESPONSE); actor.start(); @@ -243,13 +243,13 @@ describe('MFA magic code and registration decision', () => { }); it('clears the inline error when the user starts typing again', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE}, {continuableError: MFA_TEST_INVALID_CODE_ERROR}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}, {continuableError: MFA_TEST_INVALID_CODE_ERROR}); actor.start(); actor.send({type: 'CLEAR_CONTINUABLE_ERROR'}); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.REQUESTING_VALIDATE_CODE})).toBe(true); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true); expect(result.context.continuableError).toBeUndefined(); actor.stop(); From c63162e2ba2b9f3ae0d27b1c88758c1b5e5186c3 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Tue, 28 Jul 2026 16:43:48 +0200 Subject: [PATCH 18/30] refactor(mfa): model the inline invalid-code error as a state Replace the continuableError context field and the CLEAR_CONTINUABLE_ERROR command event with an invalidCode substate of awaitingValidateCode. Every way out of the substate (typing, a resend, a new submission) clears the inline error by construction, so the three manual clear sites disappear. The VALIDATE_CODE_CHANGED event states what happened instead of commanding a context write, and the view reads the showsInvalidCodeError tag through snapshotToState. The stored MFAError payload had no consumers, so nothing replaces it. --- ...ifactorAuthenticationInternalApiContext.ts | 4 +-- .../MultifactorAuthenticationMainContext.tsx | 4 +-- .../machine/mfaMachine.ts | 26 +++++++++++++------ .../machine/snapshotToState.ts | 4 +++ .../machine/types.ts | 13 +++++++--- .../shared/VALUES.ts | 2 ++ .../ValidateCodePage.tsx | 15 +++++------ .../viewMatchesMachine.test.tsx | 14 +++++----- .../machine/validateCodeTransition.test.ts | 24 ++++++++--------- tests/utils/mfa/flowActors.ts | 1 - tests/utils/mfa/flowPaths.ts | 4 +-- 11 files changed, 65 insertions(+), 46 deletions(-) diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts index 47bf6b3f9d1b..978071137fbc 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts @@ -30,8 +30,8 @@ type MultifactorAuthenticationInternalApi = { /** Request a fresh magic-code email. The machine sends it only while the magic-code screen waits for a code. */ resendValidateCode: () => void; - /** Clear the inline validate-code error, called when the user starts typing again. */ - clearContinuableError: () => void; + /** Notify the machine that the user edited the entered code; the machine then drops the inline invalid-code error. */ + notifyValidateCodeChanged: () => void; /** Centralized back-press / backdrop entry. */ requestCancel: () => void; diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx index 15e49e5aea6a..11740400e320 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -89,7 +89,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent const approveSoftPrompt = () => send({type: 'SOFT_PROMPT_APPROVED'}); const submitValidateCode = (validateCode: string) => send({type: 'VALIDATE_CODE_ENTERED', validateCode}); const resendValidateCode = () => send({type: 'RESEND_VALIDATE_CODE'}); - const clearContinuableError = () => send({type: 'CLEAR_CONTINUABLE_ERROR'}); + const notifyValidateCodeChanged = () => send({type: 'VALIDATE_CODE_CHANGED'}); // There is no cancel-confirmation dialog yet, so every cancel path closes the modal directly. const requestCancel = () => send({type: 'CLOSE_MODAL'}); @@ -107,7 +107,7 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent approveSoftPrompt, submitValidateCode, resendValidateCode, - clearContinuableError, + notifyValidateCodeChanged, requestCancel, hideCancelConfirm, confirmCancel, diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index dfda035cfebc..ad9d8b8735c2 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -13,7 +13,7 @@ import SCREENS from '@src/SCREENS'; import {CONST as COMMON_CONST} from 'expensify-common'; import {assign, setup} from 'xstate'; -import type {MfaContext, MfaEvent} from './types'; +import type {MfaContext, MfaEvent, MfaTag} from './types'; import createActors from './mfaActors'; @@ -36,7 +36,6 @@ const DEFAULT_CONTEXT: MfaContext = { scenario: undefined, payload: undefined, validateCode: undefined, - continuableError: undefined, registrationChallenge: undefined, softPromptApproved: false, isCancelConfirmVisible: false, @@ -56,6 +55,7 @@ const MFAMachine = setup({ types: { context: {} as MfaContext, events: {} as MfaEvent, + tags: {} as MfaTag, }, /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ actors: createActors(), @@ -104,7 +104,6 @@ const MFAMachine = setup({ } return {validateCode: event.validateCode}; }), - clearContinuableError: assign({continuableError: undefined}), approveSoftPrompt: assign({softPromptApproved: true}), persistSoftPromptAcceptance: ({context}) => { if (context.accountID === undefined) { @@ -226,14 +225,26 @@ const MFAMachine = setup({ // while the challenge request is in flight is dropped instead of emailing a // code the pending submission ignores. [MFA_STATE.AWAITING_VALIDATE_CODE]: { + initial: MFA_STATE.IDLE, on: { VALIDATE_CODE_ENTERED: {target: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE, actions: 'submitValidateCode'}, - RESEND_VALIDATE_CODE: {actions: ['clearContinuableError', 'requestValidateCode']}, - CLEAR_CONTINUABLE_ERROR: {actions: 'clearContinuableError'}, + RESEND_VALIDATE_CODE: {target: `.${MFA_STATE.IDLE}`, actions: 'requestValidateCode'}, + }, + states: { + [MFA_STATE.IDLE]: {}, + // The backend rejected the submitted code. The screen shows the + // inline error exactly while this state is active, so every way out + // (typing, a resend, a new submission) drops the error by + // construction and nothing stale can outlive the screen. + [MFA_STATE.INVALID_CODE]: { + tags: 'showsInvalidCodeError', + on: { + VALIDATE_CODE_CHANGED: MFA_STATE.IDLE, + }, + }, }, }, [MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE]: { - entry: 'clearContinuableError', invoke: { id: 'requestRegistrationChallenge', src: 'requestRegistrationChallenge', @@ -252,8 +263,7 @@ const MFAMachine = setup({ { guard: ({event}) => !event.output.success && getMFAFailureError(event.output).reason === CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE, - target: MFA_STATE.AWAITING_VALIDATE_CODE, - actions: assign({continuableError: ({event}) => getMFAFailureError(event.output)}), + target: `${MFA_STATE.AWAITING_VALIDATE_CODE}.${MFA_STATE.INVALID_CODE}`, }, {target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})}, ], diff --git a/src/components/MultifactorAuthentication/machine/snapshotToState.ts b/src/components/MultifactorAuthentication/machine/snapshotToState.ts index 9f9ae1567a08..fb8cb0945022 100644 --- a/src/components/MultifactorAuthentication/machine/snapshotToState.ts +++ b/src/components/MultifactorAuthentication/machine/snapshotToState.ts @@ -15,6 +15,9 @@ type MfaState = MfaContext & { /** Whether the machine currently accepts a request for a fresh magic-code email. */ canResendValidateCode: boolean; + + /** Whether the magic-code screen currently shows the inline invalid-code error. */ + showsInvalidCodeError: boolean; }; function getModalState(snapshot: MfaSnapshot): MfaModalState { @@ -38,6 +41,7 @@ function snapshotToState(snapshot: MfaSnapshot): MfaState { ...snapshot.context, modalState: getModalState(snapshot), canResendValidateCode: snapshot.can({type: 'RESEND_VALIDATE_CODE'}), + showsInvalidCodeError: snapshot.hasTag('showsInvalidCodeError'), }; } diff --git a/src/components/MultifactorAuthentication/machine/types.ts b/src/components/MultifactorAuthentication/machine/types.ts index 4816231ed213..4747d968c686 100644 --- a/src/components/MultifactorAuthentication/machine/types.ts +++ b/src/components/MultifactorAuthentication/machine/types.ts @@ -36,9 +36,6 @@ type MfaContext = { /** Magic code the user entered on this flow's validate-code screen */ validateCode: string | undefined; - /** Error the validate-code screen shows inline while the flow stays on it, as opposed to `error`, which ends the flow */ - continuableError: MFAError | undefined; - /** Registration challenge returned after the backend accepts the magic code */ registrationChallenge: RegistrationChallenge | undefined; @@ -75,7 +72,14 @@ type MfaEvent = | {type: 'SOFT_PROMPT_APPROVED'} | {type: 'VALIDATE_CODE_ENTERED'; validateCode: string} | {type: 'RESEND_VALIDATE_CODE'} - | {type: 'CLEAR_CONTINUABLE_ERROR'}; + | {type: 'VALIDATE_CODE_CHANGED'}; + +/** + * Tags the chart marks UI-facing conditions with. The view bridge reads them through `hasTag` + * instead of matching a concrete state path, so a chart restructuring that moves the tagged state + * does not break the bridge. + */ +type MfaTag = 'showsInvalidCodeError'; /** Describes the input the machine passes to the device-check actor. */ type ValidateDeviceInput = {allowedAuthenticationMethods: AllowedAuthenticationMethods}; @@ -97,6 +101,7 @@ export type { MfaContext, MfaEvent, MfaModalState, + MfaTag, MultifactorAuthenticationInitEvent, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index e875138d60dd..bcd256e9d2bd 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -225,6 +225,8 @@ const MFA_STATE = { CHECKING_SOFT_PROMPT_ACCEPTANCE: 'checkingSoftPromptAcceptance', MAGIC_CODE: 'magicCode', AWAITING_VALIDATE_CODE: 'awaitingValidateCode', + IDLE: 'idle', + INVALID_CODE: 'invalidCode', REQUESTING_REGISTRATION_CHALLENGE: 'requestingRegistrationChallenge', PROMPT: 'prompt', AWAITING_SOFT_PROMPT: 'awaitingSoftPrompt', diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index dfc544d8526f..38c197331e18 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -51,8 +51,8 @@ function MultifactorAuthenticationValidateCodePage() { const [inputCode, setInputCode] = useState(''); const [formError, setFormError] = useState({}); const [canShowError, setCanShowError] = useState(false); - const {requestCancel, submitValidateCode, resendValidateCode, clearContinuableError, state} = useMultifactorAuthenticationInternal(); - const {continuableError, isCancelConfirmVisible, canResendValidateCode} = state; + const {requestCancel, submitValidateCode, resendValidateCode, notifyValidateCodeChanged, state} = useMultifactorAuthenticationInternal(); + const {showsInvalidCodeError, isCancelConfirmVisible, canResendValidateCode} = state; // Refs const inputRef = useRef(null); @@ -61,13 +61,12 @@ function MultifactorAuthenticationValidateCodePage() { // Derived state const hasAccountError = !!account && !isEmptyObject(account?.errors); - const hasContinuableError = !!continuableError; // The MFA registration challenge always uses VALIDATE_CODE_FORM, even when the account has 2FA enabled. const isValidateCodeFormSubmitting = !!account?.isLoading && account.loadingForm === CONST.FORMS.VALIDATE_CODE_FORM; const shouldDisableResendCode = isOffline || !canResendValidateCode || !!validateActionCode?.isLoading; const validateCodeActionError = getLatestErrorField(validateActionCode, 'actionVerified'); const hasValidateCodeActionError = !isEmptyObject(validateCodeActionError); - const hasError = hasAccountError || hasContinuableError || hasValidateCodeActionError; + const hasError = hasAccountError || showsInvalidCodeError || hasValidateCodeActionError; const errorMessage = getErrorMessage(); function getErrorMessage() { @@ -76,7 +75,7 @@ function MultifactorAuthenticationValidateCodePage() { return Object.values(validateCodeActionError).at(0); } // Invalid validate code submitted by the user - if (hasContinuableError) { + if (showsInvalidCodeError) { return translate('validateCodeForm.error.incorrectSecurityCode'); } // Generic account/session error (e.g. stale errors from a previous flow) @@ -131,9 +130,9 @@ function MultifactorAuthenticationValidateCodePage() { clearAccountMessages(); } - // Clear continuable error when user starts typing after an error - if (continuableError) { - clearContinuableError(); + // The machine drops the inline invalid-code error once it learns the code changed + if (showsInvalidCodeError) { + notifyValidateCodeChanged(); } }; diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index d9df9b6e8a5f..1e7fcb2157b5 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -165,7 +165,7 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { fireEvent.press(screen.getByTestId(TEST_ID.VALIDATE_CODE_RESEND_BUTTON)); await waitForBatchedUpdatesWithAct(); }, - CLEAR_CONTINUABLE_ERROR: async () => { + VALIDATE_CODE_CHANGED: async () => { fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), '1'); await waitForBatchedUpdatesWithAct(); }, @@ -227,12 +227,12 @@ const testConfig = { expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_RESEND_BUTTON)).toBeEnabled(); expect(screen.getByText(translateLocal('multifactorAuthentication.letsVerifyItsYou'))).toBeOnTheScreen(); expect(state.context.error).toBeUndefined(); - const inlineError = translateLocal('validateCodeForm.error.incorrectSecurityCode'); - if (state.context.continuableError) { - expect(screen.getByText(inlineError)).toBeOnTheScreen(); - } else { - expect(screen.queryByText(inlineError)).not.toBeOnTheScreen(); - } + }, + [`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.AWAITING_VALIDATE_CODE}.${MFA_STATE.IDLE}`]: () => { + expect(screen.queryByText(translateLocal('validateCodeForm.error.incorrectSecurityCode'))).not.toBeOnTheScreen(); + }, + [`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.AWAITING_VALIDATE_CODE}.${MFA_STATE.INVALID_CODE}`]: () => { + expect(screen.getByText(translateLocal('validateCodeForm.error.incorrectSecurityCode'))).toBeOnTheScreen(); }, [`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}`]: (state: SnapshotFrom) => { expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index 28e9f8f95a8f..a5214f243d86 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import {CONST as COMMON_CONST} from 'expensify-common'; import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; -import createInitEvent, {MFA_TEST_INVALID_CODE_ERROR, MFA_TEST_REGISTRATION_CHALLENGE, MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; +import createInitEvent, {MFA_TEST_REGISTRATION_CHALLENGE, MFA_TEST_VALIDATE_CODE} from 'tests/utils/mfa/flowFixtures'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; import {createActor, fromPromise} from 'xstate'; @@ -116,14 +116,14 @@ describe('MFA magic code and registration decision', () => { }); it('clears the inline error when the user requests a resend after a rejected code', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}, {continuableError: MFA_TEST_INVALID_CODE_ERROR}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.INVALID_CODE}}}); actor.start(); actor.send({type: 'RESEND_VALIDATE_CODE'}); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true); - expect(result.context.continuableError).toBeUndefined(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.IDLE}}})).toBe(true); + expect(result.hasTag('showsInvalidCodeError')).toBe(false); expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); actor.stop(); @@ -182,8 +182,8 @@ describe('MFA magic code and registration decision', () => { await waitForBatchedUpdates(); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true); - expect(result.context.continuableError?.reason).toBe(REASON.CLIENT_ERRORS.INVALID_VALIDATE_CODE); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.INVALID_CODE}}})).toBe(true); + expect(result.hasTag('showsInvalidCodeError')).toBe(true); expect(result.context.registrationChallenge).toBeUndefined(); expect(result.context.error).toBeUndefined(); expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); @@ -198,13 +198,14 @@ describe('MFA magic code and registration decision', () => { actor.start(); actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); await waitForBatchedUpdates(); + expect(actor.getSnapshot().hasTag('showsInvalidCodeError')).toBe(true); actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); await waitForBatchedUpdates(); const result = actor.getSnapshot(); expect(result.context.registrationChallenge).toBe(MFA_TEST_REGISTRATION_CHALLENGE); expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE); - expect(result.context.continuableError).toBeUndefined(); + expect(result.hasTag('showsInvalidCodeError')).toBe(false); actor.stop(); }); @@ -221,7 +222,6 @@ describe('MFA magic code and registration decision', () => { expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); expect(result.context.error?.reason).toBe(REASON.SERVER_ERRORS.UNRECOGNIZED); expect(result.context.registrationChallenge).toBeUndefined(); - expect(result.context.continuableError).toBeUndefined(); actor.stop(); }); @@ -243,14 +243,14 @@ describe('MFA magic code and registration decision', () => { }); it('clears the inline error when the user starts typing again', () => { - const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}, {continuableError: MFA_TEST_INVALID_CODE_ERROR}); + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.INVALID_CODE}}}); actor.start(); - actor.send({type: 'CLEAR_CONTINUABLE_ERROR'}); + actor.send({type: 'VALIDATE_CODE_CHANGED'}); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}})).toBe(true); - expect(result.context.continuableError).toBeUndefined(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.IDLE}}})).toBe(true); + expect(result.hasTag('showsInvalidCodeError')).toBe(false); actor.stop(); }); diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index 70c67d609446..8f41bac3566a 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -25,7 +25,6 @@ function createFlowContext(overrides: Partial = {}): MfaContext { scenario: initEvent.scenario, payload: initEvent.payload, validateCode: undefined, - continuableError: undefined, registrationChallenge: undefined, softPromptApproved: false, isCancelConfirmVisible: false, diff --git a/tests/utils/mfa/flowPaths.ts b/tests/utils/mfa/flowPaths.ts index 563445a0b493..e04beafebb16 100644 --- a/tests/utils/mfa/flowPaths.ts +++ b/tests/utils/mfa/flowPaths.ts @@ -91,7 +91,7 @@ const DRIVING_JOURNEYS: DrivingJourney[] = [ createActorDoneEvent(CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, false), {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, createActorDoneEvent(REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, {success: false, error: MFA_TEST_INVALID_CODE_ERROR}), - {type: 'CLEAR_CONTINUABLE_ERROR'}, + {type: 'VALIDATE_CODE_CHANGED'}, {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, createActorDoneEvent(REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}), ], @@ -115,7 +115,7 @@ const MFA_GRAPH_EVENT_FIXTURES = { SOFT_PROMPT_APPROVED: [{type: 'SOFT_PROMPT_APPROVED'}], VALIDATE_CODE_ENTERED: [{type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}], RESEND_VALIDATE_CODE: [{type: 'RESEND_VALIDATE_CODE'}], - CLEAR_CONTINUABLE_ERROR: [{type: 'CLEAR_CONTINUABLE_ERROR'}], + VALIDATE_CODE_CHANGED: [{type: 'VALIDATE_CODE_CHANGED'}], } satisfies MfaEventFixtures; function hasMfaEventFixtures(type: string): type is MfaEvent['type'] { From 8b434874b156f76509d34ba128d4d292a6c18740 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 29 Jul 2026 11:10:56 +0200 Subject: [PATCH 19/30] fix(mfa): avoid stale loading state blocking resend --- src/pages/MultifactorAuthentication/ValidateCodePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index 38c197331e18..bbce7b9779cd 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -63,7 +63,7 @@ function MultifactorAuthenticationValidateCodePage() { const hasAccountError = !!account && !isEmptyObject(account?.errors); // The MFA registration challenge always uses VALIDATE_CODE_FORM, even when the account has 2FA enabled. const isValidateCodeFormSubmitting = !!account?.isLoading && account.loadingForm === CONST.FORMS.VALIDATE_CODE_FORM; - const shouldDisableResendCode = isOffline || !canResendValidateCode || !!validateActionCode?.isLoading; + const shouldDisableResendCode = isOffline || !canResendValidateCode; const validateCodeActionError = getLatestErrorField(validateActionCode, 'actionVerified'); const hasValidateCodeActionError = !isEmptyObject(validateCodeActionError); const hasError = hasAccountError || showsInvalidCodeError || hasValidateCodeActionError; From d88f31d808f743d71795ae87d03edad567cf2a50 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 29 Jul 2026 12:08:04 +0200 Subject: [PATCH 20/30] test(mfa): cover registration challenge loading data --- .../actions/MultifactorAuthenticationTest.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/actions/MultifactorAuthenticationTest.ts diff --git a/tests/actions/MultifactorAuthenticationTest.ts b/tests/actions/MultifactorAuthenticationTest.ts new file mode 100644 index 000000000000..553d01ea420b --- /dev/null +++ b/tests/actions/MultifactorAuthenticationTest.ts @@ -0,0 +1,54 @@ +import {requestRegistrationChallenge} from '@libs/actions/MultifactorAuthentication'; +import {makeRequestWithSideEffects} from '@libs/API'; +import {SIDE_EFFECT_REQUEST_COMMANDS} from '@libs/API/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +jest.mock('@libs/API'); + +const mockMakeRequestWithSideEffects = jest.mocked(makeRequestWithSideEffects); +const VALIDATE_CODE = '123456'; + +describe('actions/MultifactorAuthentication', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMakeRequestWithSideEffects.mockResolvedValue(undefined); + }); + + it('marks the validate-code form as loading while requesting a registration challenge', async () => { + await requestRegistrationChallenge(VALIDATE_CODE); + + expect(mockMakeRequestWithSideEffects).toHaveBeenCalledWith( + SIDE_EFFECT_REQUEST_COMMANDS.REQUEST_AUTHENTICATION_CHALLENGE, + { + challengeType: 'registration', + validateCode: VALIDATE_CODE, + }, + expect.objectContaining({ + optimisticData: expect.arrayContaining([ + { + key: ONYXKEYS.ACCOUNT, + onyxMethod: Onyx.METHOD.MERGE, + value: { + isLoading: true, + loadingForm: CONST.FORMS.VALIDATE_CODE_FORM, + }, + }, + ]), + finallyData: expect.arrayContaining([ + { + key: ONYXKEYS.ACCOUNT, + onyxMethod: Onyx.METHOD.MERGE, + value: { + isLoading: false, + loadingForm: undefined, + }, + }, + ]), + }), + ); + }); +}); From 20792aa674b81787ab07c5d9696067382cecdb3f Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 29 Jul 2026 12:29:54 +0200 Subject: [PATCH 21/30] test(mfa): move validate-code loading test --- .../ValidateCodePage.test.tsx | 76 +++++++++++++++++++ .../viewMatchesMachine.test.tsx | 49 +----------- 2 files changed, 77 insertions(+), 48 deletions(-) create mode 100644 tests/unit/components/MultifactorAuthentication/ValidateCodePage.test.tsx diff --git a/tests/unit/components/MultifactorAuthentication/ValidateCodePage.test.tsx b/tests/unit/components/MultifactorAuthentication/ValidateCodePage.test.tsx new file mode 100644 index 000000000000..c2a58176b40e --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/ValidateCodePage.test.tsx @@ -0,0 +1,76 @@ +import {act, fireEvent, screen, within} from '@testing-library/react-native'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as MfaRealUiMocks from 'tests/utils/mfa/realUi/mocks'; + +import Onyx from 'react-native-onyx'; +import createInitEvent, {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; +import renderMfaUi from 'tests/utils/mfa/realUi/harness'; +import {checkLocalCredentialsControl, resetMfaUiMocks, validateDeviceControl} from 'tests/utils/mfa/realUi/mocks'; +import {translateLocal} from 'tests/utils/TestHelper'; +import waitForBatchedUpdatesWithAct from 'tests/utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@hooks/useResponsiveLayout'); +jest.mock('@libs/XStateInspector', () => ({__esModule: true, default: {inspect: undefined}})); + +jest.mock('@components/MultifactorAuthentication/machine/mfaActors', () => jest.requireActual('tests/utils/mfa/realUi/mocks').mfaActorsMock()); +jest.mock('@components/MultifactorAuthentication/biometrics/useBiometrics', () => jest.requireActual('tests/utils/mfa/realUi/mocks').biometricsHookMock()); +jest.mock('@components/RenderHTML', () => jest.requireActual('tests/utils/mfa/realUi/mocks').renderHtmlMock()); +jest.mock('@components/ValidateCodeCountdown', () => jest.requireActual('tests/utils/mfa/realUi/mocks').validateCodeCountdownMock()); +jest.mock('@components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory', () => jest.requireActual('tests/utils/mfa/realUi/mocks').syncHistoryMock()); +jest.mock('@libs/Navigation/Navigation', () => jest.requireActual('tests/utils/mfa/realUi/mocks').navigationMock()); +jest.mock('@libs/actions/User', () => jest.requireActual('tests/utils/mfa/realUi/mocks').userActionsMock()); + +const TEST_ID = CONST.MULTIFACTOR_AUTHENTICATION.TEST_ID; + +describe('MultifactorAuthenticationValidateCodePage', () => { + beforeEach(async () => { + resetMfaUiMocks(); + await act(async () => { + await Onyx.clear(); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: MFA_TEST_ACCOUNT_ID}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {requiresTwoFactorAuth: true}); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('puts the submit button in a loading state for the registration challenge request when the account has 2FA enabled', async () => { + const {executeScenario} = renderMfaUi(); + await waitForBatchedUpdatesWithAct(); + + const initEvent = createInitEvent(); + await act(async () => executeScenario(initEvent.scenarioName, initEvent.payload)); + await waitForBatchedUpdatesWithAct(); + fireEvent(screen.getByTestId(TEST_ID.INITIAL_SCREEN), 'layout', { + nativeEvent: {layout: {width: 1, height: 1, x: 0, y: 0}}, + }); + await waitForBatchedUpdatesWithAct(); + + await act(async () => validateDeviceControl.resolve({success: true})); + await waitForBatchedUpdatesWithAct(); + await act(async () => checkLocalCredentialsControl.resolve(false)); + await waitForBatchedUpdatesWithAct(); + + const submitButton = screen.getByTestId(TEST_ID.VALIDATE_CODE_SUBMIT_BUTTON); + const submitButtonText = within(submitButton).getByText(translateLocal('common.verify')); + expect(submitButton).toBeEnabled(); + expect(submitButtonText).toBeVisible(); + + await act(async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isLoading: true, + loadingForm: CONST.FORMS.VALIDATE_CODE_FORM, + }); + }); + await waitForBatchedUpdatesWithAct(); + + expect(submitButton).toBeDisabled(); + expect(submitButtonText).not.toBeVisible(); + }); +}); diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 1e7fcb2157b5..cc4d95b55266 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -14,7 +14,7 @@ import type * as MfaRealUiMocks from 'tests/utils/mfa/realUi/mocks'; import type {SnapshotFrom} from 'xstate'; import Onyx from 'react-native-onyx'; -import createInitEvent, {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; +import {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; import getWalkedPaths, { CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, @@ -334,53 +334,6 @@ describe('the real MFA modal matches the machine at every step of every generate }); }); -describe('MFA validate-code loading state', () => { - beforeEach(async () => { - resetMfaUiMocks(); - await act(async () => { - await Onyx.clear(); - await Onyx.merge(ONYXKEYS.SESSION, {accountID: MFA_TEST_ACCOUNT_ID}); - await Onyx.merge(ONYXKEYS.ACCOUNT, {requiresTwoFactorAuth: true}); - }); - await waitForBatchedUpdatesWithAct(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('shows the submit spinner for the registration challenge request when the account has 2FA enabled', async () => { - const {executeScenario} = renderMfaUi(); - await waitForBatchedUpdatesWithAct(); - - const initEvent = createInitEvent(); - await act(async () => executeScenario(initEvent.scenarioName, initEvent.payload)); - await waitForBatchedUpdatesWithAct(); - fireEvent(screen.getByTestId(TEST_ID.INITIAL_SCREEN), 'layout', { - nativeEvent: {layout: {width: 1, height: 1, x: 0, y: 0}}, - }); - await waitForBatchedUpdatesWithAct(); - - await act(async () => validateDeviceControl.resolve({success: true})); - await waitForBatchedUpdatesWithAct(); - await act(async () => checkLocalCredentialsControl.resolve(false)); - await waitForBatchedUpdatesWithAct(); - - const submitButtonText = screen.getByText(translateLocal('common.verify')); - expect(submitButtonText).toBeVisible(); - - await act(async () => { - await Onyx.merge(ONYXKEYS.ACCOUNT, { - isLoading: true, - loadingForm: CONST.FORMS.VALIDATE_CODE_FORM, - }); - }); - await waitForBatchedUpdatesWithAct(); - - expect(submitButtonText).not.toBeVisible(); - }); -}); - // Every settleable leaf must occur in a path that the walk above drives. `everyStateReachable.test.ts` // checks the unfiltered graph, so only this guard catches a state whose every route needs a step the // walk cannot drive, such as a delayed transition. Paths removed as prefixes of longer paths do not From 2bade7d0f7a744534e91079ccc046fb0a21066aa Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 29 Jul 2026 12:35:16 +0200 Subject: [PATCH 22/30] fix(mfa): cancel local credential reads --- .../biometrics/operations/index.native.ts | 4 ++-- .../MultifactorAuthentication/biometrics/operations/index.ts | 4 ++-- src/components/MultifactorAuthentication/machine/mfaActors.ts | 2 +- .../MultifactorAuthentication/shared/readOnyxValueOnce.ts | 4 ++++ 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts index 7ce3a9a73e9e..2b633f6f2d50 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts @@ -43,12 +43,12 @@ async function getLocalCredentialID(accountID: number): Promise { +async function areLocalCredentialsKnownToServer(accountID: number, signal?: AbortSignal): Promise { const localCredentialID = await getLocalCredentialID(accountID); if (!localCredentialID) { return false; } - const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT); + const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal); return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID); } diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.ts index 3a7a1e63da37..4d8432919173 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.ts @@ -25,8 +25,8 @@ async function doesDeviceSupportAuthenticationMethod(): Promise { } /** Resolves to whether the account has a local passkey the server also knows, meaning it can skip registration. */ -async function areLocalCredentialsKnownToServer(accountID: number): Promise { - const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT), readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)))]); +async function areLocalCredentialsKnownToServer(accountID: number, signal?: AbortSignal): Promise { + const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal), readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)), signal)]); const serverKnownCredentialIDs = new Set(mfaCredentialIDsSelector(account) ?? []); return (localPasskeyCredentials ?? []).some((credential) => serverKnownCredentialIDs.has(credential.id)); } diff --git a/src/components/MultifactorAuthentication/machine/mfaActors.ts b/src/components/MultifactorAuthentication/machine/mfaActors.ts index 4b975fdf64ec..f7b73d97dd16 100644 --- a/src/components/MultifactorAuthentication/machine/mfaActors.ts +++ b/src/components/MultifactorAuthentication/machine/mfaActors.ts @@ -31,7 +31,7 @@ const readHasAcceptedSoftPrompt = fromPromise(({input}) => areLocalCredentialsKnownToServer(input.accountID)); +const checkLocalCredentials = fromPromise(({input, signal}) => areLocalCredentialsKnownToServer(input.accountID, signal)); /** * Exchanges the submitted magic code for a validated registration challenge. The action normalizes diff --git a/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts b/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts index 7a5afe7ef62e..d00c8b3dfbb4 100644 --- a/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts +++ b/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts @@ -9,6 +9,10 @@ import Onyx from 'react-native-onyx'; */ function readOnyxValueOnce(key: TKey, signal?: AbortSignal): Promise> { return new Promise((resolve) => { + if (signal?.aborted) { + return; + } + let connection: Connection; const disconnect = () => Onyx.disconnect(connection); From 73cbb2f2a67cd9ab5a8d150e26f71e7406b90c1e Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 29 Jul 2026 12:49:45 +0200 Subject: [PATCH 23/30] docs(mfa): clarify credential check ownership --- .../biometrics/operations/index.native.ts | 8 +++++++- .../biometrics/operations/index.ts | 8 +++++++- .../biometrics/useNativeBiometricsHSM.ts | 5 +++++ .../MultifactorAuthentication/biometrics/usePasskeys.ts | 5 +++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts index 2b633f6f2d50..1def1e724867 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts @@ -42,7 +42,13 @@ async function getLocalCredentialID(accountID: number): Promise { const localCredentialID = await getLocalCredentialID(accountID); if (!localCredentialID) { diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.ts index 4d8432919173..161c57b21409 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.ts @@ -24,7 +24,13 @@ async function doesDeviceSupportAuthenticationMethod(): Promise { return isWebAuthnSupported(); } -/** Resolves to whether the account has a local passkey the server also knows, meaning it can skip registration. */ +/** + * Resolves to whether the account has a local passkey the server also knows, meaning it can skip registration. + * + * This is the canonical non-React implementation. The legacy `usePasskeys` hook intentionally + * performs the same comparison using its reactive Onyx values. Keep both implementations aligned + * until the hook is removed. + */ async function areLocalCredentialsKnownToServer(accountID: number, signal?: AbortSignal): Promise { const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal), readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)), signal)]); const serverKnownCredentialIDs = new Set(mfaCredentialIDsSelector(account) ?? []); diff --git a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts index a67df30a580c..6be12df6b846 100644 --- a/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts +++ b/src/components/MultifactorAuthentication/biometrics/useNativeBiometricsHSM.ts @@ -60,6 +60,11 @@ function useNativeBiometricsHSM(): UseBiometricsReturn { } }; + /** + * Legacy compatibility path. The MFA machine uses the platform-resolved biometrics operation, + * while this hook keeps using reactive Onyx values for existing React consumers. Keep this + * comparison aligned with `operations/index.native.ts` until the hook is removed. + */ const areLocalCredentialsKnownToServer = async () => { const key = await getLocalCredentialID(); return !!key && serverKnownCredentialIDs.includes(key); diff --git a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts index a0924484833c..f49aec8d1871 100644 --- a/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts +++ b/src/components/MultifactorAuthentication/biometrics/usePasskeys.ts @@ -37,6 +37,11 @@ function usePasskeys(): UseBiometricsReturn { const hasLocalCredentials = async () => (localPasskeyCredentials?.length ?? 0) > 0; + /** + * Legacy compatibility path. The MFA machine uses the platform-resolved biometrics operation, + * while this hook keeps using reactive Onyx values for existing React consumers. Keep this + * comparison aligned with `operations/index.ts` until the hook is removed. + */ const areLocalCredentialsKnownToServer = async () => { const serverSet = new Set(serverKnownCredentialIDs); return (localPasskeyCredentials ?? []).some((c) => serverSet.has(c.id)); From a7bea77c12b8a52bc41bc0f946e893143907ca8b Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 29 Jul 2026 13:07:17 +0200 Subject: [PATCH 24/30] Improve MFA actor event type safety --- .../machine/mfaMachine.ts | 33 ++- .../viewMatchesMachine.test.tsx | 33 ++- tests/utils/mfa/flowActors.ts | 4 +- tests/utils/mfa/flowPaths.ts | 193 ++++++++++++------ 4 files changed, 173 insertions(+), 90 deletions(-) diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index ad9d8b8735c2..fbc3595f5d22 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -10,6 +10,8 @@ import {requestValidateCodeAction} from '@userActions/User'; import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; +import type {OutputFrom} from 'xstate'; + import {CONST as COMMON_CONST} from 'expensify-common'; import {assign, setup} from 'xstate'; @@ -19,6 +21,30 @@ import createActors from './mfaActors'; const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; +type MfaActors = ReturnType; +type MfaActorId = Extract; +type MfaActorDoneEventType = `xstate.done.actor.${MfaActorId}`; +type MfaActorErrorEventType = `xstate.error.actor.${MfaActorId}`; +type MfaActorDoneOutputByType = { + [Id in MfaActorId as `xstate.done.actor.${Id}`]: OutputFrom; +}; +type MfaActorDoneEventFor = { + type: Type; + output: MfaActorDoneOutputByType[Type]; +}; +type MfaActorErrorEventFor = { + type: Type; + error: unknown; +}; +type MfaActorDoneEvent = { + [Type in MfaActorDoneEventType]: MfaActorDoneEventFor; +}[MfaActorDoneEventType]; +type MfaActorErrorEvent = { + [Type in MfaActorErrorEventType]: MfaActorErrorEventFor; +}[MfaActorErrorEventType]; +type MfaDelayedEventType = `xstate.after${string}`; +type MfaMachineEvent = MfaEvent | MfaActorDoneEvent | MfaActorErrorEvent | {type: MfaDelayedEventType} | {type: 'xstate.init'}; + // Absolute targets for the screen branches. The device check runs under `preparing`, so reaching a // sibling branch needs an id target rather than a relative one. const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const; @@ -54,7 +80,7 @@ const MFAMachine = setup({ /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ types: { context: {} as MfaContext, - events: {} as MfaEvent, + events: {} as MfaMachineEvent, tags: {} as MfaTag, }, /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ @@ -64,8 +90,8 @@ const MFAMachine = setup({ }, actions: { // Seeds the flow's context from the INIT event. A named action's event is typed as the full - // MfaEvent union, so the guard narrows it to INIT to read the scenario fields; INIT is the only - // transition wired here, so that early return is unreachable (it just satisfies the type checker). + // machine-event union, so the guard narrows it to INIT to read the scenario fields; INIT is the + // only transition wired here, so that early return is unreachable (it just satisfies the type checker). initFlow: assign(({event}) => { if (event.type !== 'INIT') { return {}; @@ -321,3 +347,4 @@ const MFAMachine = setup({ }); export default MFAMachine; +export type {MfaActorDoneEvent, MfaActorDoneEventFor, MfaActorDoneEventType, MfaActorDoneOutputByType, MfaActorErrorEventFor, MfaActorErrorEventType, MfaDelayedEventType, MfaMachineEvent}; diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index cc4d95b55266..7ac0d9afe73c 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -1,11 +1,10 @@ import {act, fireEvent, screen} from '@testing-library/react-native'; import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type {MfaEvent, RequestRegistrationChallengeOutput} from '@components/MultifactorAuthentication/machine/types'; +import type {MfaActorDoneEventFor, MfaActorDoneEventType, MfaActorDoneOutputByType, MfaActorErrorEventType} from '@components/MultifactorAuthentication/machine/mfaMachine'; +import type {MfaEvent} from '@components/MultifactorAuthentication/machine/types'; import {mfaNavigationRef} from '@components/MultifactorAuthentication/mfaNavigation'; -import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -83,17 +82,8 @@ type MfaEventExecutors = { [Type in MfaEventType]: (step: MfaEventExecutorStep) => Promise; }; type MfaActorEventExecutors = { - [VALIDATE_DEVICE_DONE_EVENT_TYPE]: (step: {event: {type: typeof VALIDATE_DEVICE_DONE_EVENT_TYPE; output: MFAResult}}) => Promise; - [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: () => Promise; - [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step: {event: {type: typeof READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE; output: boolean}}) => Promise; - [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => Promise; - [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step: {event: {type: typeof CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE; output: boolean}}) => Promise; - [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => Promise; - [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step: { - event: {type: typeof REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE; output: RequestRegistrationChallengeOutput}; - }) => Promise; - [REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE]: () => Promise; -}; + [Type in MfaActorDoneEventType]: (step: {event: {type: Type} | MfaActorDoneEventFor}) => Promise; +} & Record Promise>; type ExecuteScenario = ReturnType['executeScenario']; @@ -107,6 +97,13 @@ function isMfaValidateCodeEnteredEvent(event: {type: string}): event is MfaValid return event.type === 'VALIDATE_CODE_ENTERED' && 'validateCode' in event; } +function getActorDoneOutput(step: {event: {type: Type} | MfaActorDoneEventFor}): MfaActorDoneOutputByType[Type] { + if (!('output' in step.event)) { + throw new Error(`Actor done executor received event "${step.event.type}" without output.`); + } + return step.event.output; +} + /** * Maps every machine event to the action that produces it in the rendered app, such as a button press * or a navigator callback. The walk drives each path step through this table, and the `satisfies` @@ -169,13 +166,13 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), '1'); await waitForBatchedUpdatesWithAct(); }, - [VALIDATE_DEVICE_DONE_EVENT_TYPE]: (step) => settleActor(() => validateDeviceControl.resolve(step.event.output)), + [VALIDATE_DEVICE_DONE_EVENT_TYPE]: (step) => settleActor(() => validateDeviceControl.resolve(getActorDoneOutput(step))), [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: () => settleActor(validateDeviceControl.reject), - [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step) => settleActor(() => readHasAcceptedSoftPromptControl.resolve(step.event.output)), + [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step) => settleActor(() => readHasAcceptedSoftPromptControl.resolve(getActorDoneOutput(step))), [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => settleActor(readHasAcceptedSoftPromptControl.reject), - [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step) => settleActor(() => checkLocalCredentialsControl.resolve(step.event.output)), + [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step) => settleActor(() => checkLocalCredentialsControl.resolve(getActorDoneOutput(step))), [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => settleActor(checkLocalCredentialsControl.reject), - [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step) => settleActor(() => requestRegistrationChallengeControl.resolve(step.event.output)), + [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step) => settleActor(() => requestRegistrationChallengeControl.resolve(getActorDoneOutput(step))), [REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE]: () => settleActor(requestRegistrationChallengeControl.reject), } satisfies MfaEventExecutors & MfaActorEventExecutors; } diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index 8f41bac3566a..8315d313b793 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -45,14 +45,14 @@ function createActorAtState(value: StateValue, contextOverrides?: Partial, output: ValidateDeviceOutput) { - actor.send(createActorDoneEvent(VALIDATE_DEVICE_DONE_EVENT_TYPE, output)); + actor.send(createActorDoneEvent({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output})); } /** * Completes the invoked credentials-check actor by sending its done event carrying the given output. */ function sendCheckLocalCredentialsDone(actor: ReturnType, output: CheckLocalCredentialsOutput) { - actor.send(createActorDoneEvent(CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output)); + actor.send(createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output})); } export {createActorAtState, createFlowContext, sendCheckLocalCredentialsDone, sendValidateDeviceDone}; diff --git a/tests/utils/mfa/flowPaths.ts b/tests/utils/mfa/flowPaths.ts index e04beafebb16..ab306655aff1 100644 --- a/tests/utils/mfa/flowPaths.ts +++ b/tests/utils/mfa/flowPaths.ts @@ -1,12 +1,20 @@ -import type createActors from '@components/MultifactorAuthentication/machine/mfaActors'; import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; +import type { + MfaActorDoneEvent, + MfaActorDoneEventFor, + MfaActorDoneEventType, + MfaActorErrorEventFor, + MfaActorErrorEventType, + MfaDelayedEventType, + MfaMachineEvent, +} from '@components/MultifactorAuthentication/machine/mfaMachine'; import type {MfaEvent} from '@components/MultifactorAuthentication/machine/types'; import {createLocalMFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; import CONST from '@src/CONST'; -import type {OutputFrom, SnapshotFrom} from 'xstate'; +import type {SnapshotFrom} from 'xstate'; import {matchesState} from 'xstate'; import {getShortestPaths, TestModel} from 'xstate/graph'; @@ -18,29 +26,31 @@ const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; const DELAYED_EVENT_PREFIX = 'xstate.after'; const ACTOR_DONE_EVENT_PREFIX = 'xstate.done.actor.'; const ACTOR_ERROR_EVENT_PREFIX = 'xstate.error.actor.'; -const VALIDATE_DEVICE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}validateDevice`; -const VALIDATE_DEVICE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}validateDevice`; -const READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}readHasAcceptedSoftPrompt`; -const READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}readHasAcceptedSoftPrompt`; -const CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}checkLocalCredentials`; -const CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}checkLocalCredentials`; -const REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}requestRegistrationChallenge`; -const REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}requestRegistrationChallenge`; +const VALIDATE_DEVICE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}validateDevice` satisfies MfaActorDoneEventType; +const VALIDATE_DEVICE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}validateDevice` satisfies MfaActorErrorEventType; +const READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}readHasAcceptedSoftPrompt` satisfies MfaActorDoneEventType; +const READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}readHasAcceptedSoftPrompt` satisfies MfaActorErrorEventType; +const CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}checkLocalCredentials` satisfies MfaActorDoneEventType; +const CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}checkLocalCredentials` satisfies MfaActorErrorEventType; +const REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}requestRegistrationChallenge` satisfies MfaActorDoneEventType; +const REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}requestRegistrationChallenge` satisfies MfaActorErrorEventType; /** - * Framework actor events are not part of the application's event union, but TestModel accepts them - * in explicit journeys and forwards their output to the invoked actor transition. + * Builds an actor completion event while keeping its event type tied to that actor's output. */ -function createActorDoneEvent(type: string, output: unknown): MfaEvent { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return {type, output} as MfaEvent; +function createActorDoneEvent(event: Event): Event { + return event; +} + +function createActorErrorEvent(type: Type): MfaActorErrorEventFor { + return {type, error: new Error(`Graph-traversal rejection for actor event "${type}"`)}; } type DrivingJourney = { /** Names the journey in test titles. */ description: string; /** The event sequence the walk drives, in order. */ - events: MfaEvent[]; + events: MfaMachineEvent[]; /** Dot-path state value the journey must end in, compared with `matchesState`. */ endState: string; }; @@ -75,11 +85,14 @@ const DRIVING_JOURNEYS: DrivingJourney[] = [ description: 'the resend journey requests a fresh code and still accepts the emailed code', events: [ createInitEvent(), - createActorDoneEvent(VALIDATE_DEVICE_DONE_EVENT_TYPE, {success: true}), - createActorDoneEvent(CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, false), + createActorDoneEvent({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output: {success: true}}), + createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output: false}), {type: 'RESEND_VALIDATE_CODE'}, {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, - createActorDoneEvent(REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}), + createActorDoneEvent({ + type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, + output: {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, + }), ], endState: `${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`, }, @@ -87,13 +100,19 @@ const DRIVING_JOURNEYS: DrivingJourney[] = [ description: 'the invalid-code journey clears the inline error and accepts a corrected code', events: [ createInitEvent(), - createActorDoneEvent(VALIDATE_DEVICE_DONE_EVENT_TYPE, {success: true}), - createActorDoneEvent(CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, false), + createActorDoneEvent({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output: {success: true}}), + createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output: false}), {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, - createActorDoneEvent(REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, {success: false, error: MFA_TEST_INVALID_CODE_ERROR}), + createActorDoneEvent({ + type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, + output: {success: false, error: MFA_TEST_INVALID_CODE_ERROR}, + }), {type: 'VALIDATE_CODE_CHANGED'}, {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, - createActorDoneEvent(REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}), + createActorDoneEvent({ + type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, + output: {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, + }), ], endState: `${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`, }, @@ -122,41 +141,77 @@ function hasMfaEventFixtures(type: string): type is MfaEvent['type'] { return Object.hasOwn(MFA_GRAPH_EVENT_FIXTURES, type); } -type MfaActors = ReturnType; - -type MfaActorDoneOutputFixtures = { - readonly [Id in keyof MfaActors]: readonly [OutputFrom, ...Array>]; +type MfaActorDoneEventFixtures = { + readonly [Type in MfaActorDoneEventType]: readonly [MfaActorDoneEventFor, ...Array>]; }; /** - * Holds the output variants for each invoked actor's done event, keyed by invoke id. The machine - * routes a done event through guards on the actor's output, so the traversal must offer every output - * shape a branch depends on. XState's bare `{type}` synthesis would make those guards read an - * undefined output. The exhaustive keyed type makes a new actor fail compilation until its output - * variants are added. + * Holds the done-event variants for each invoked actor. The machine routes these events through + * guards on the actor's output, so the traversal must offer every output shape a branch depends on. + * XState's bare `{type}` synthesis would make those guards read an undefined output. The exhaustive + * keyed type makes a new actor fail compilation until its event variants are added. */ -const MFA_ACTOR_DONE_OUTPUT_FIXTURES = { +const MFA_ACTOR_DONE_EVENT_FIXTURES = { // The refusal variants mirror the actor's gates. Each reason maps to its own failure screen, so // every variant needs a graph branch for the walk to reach that screen. - validateDevice: [ - {success: true}, - {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.AUTHENTICATION_TYPE_NOT_SUPPORTED, 'Graph-traversal device-check refusal')}, - { - success: false, - error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.NO_AUTHENTICATION_METHODS_ENROLLED, 'Graph-traversal device-check enrollment refusal'), - }, + [VALIDATE_DEVICE_DONE_EVENT_TYPE]: [ + createActorDoneEvent({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output: {success: true}}), + createActorDoneEvent({ + type: VALIDATE_DEVICE_DONE_EVENT_TYPE, + output: { + success: false, + error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.AUTHENTICATION_TYPE_NOT_SUPPORTED, 'Graph-traversal device-check refusal'), + }, + }), + createActorDoneEvent({ + type: VALIDATE_DEVICE_DONE_EVENT_TYPE, + output: { + success: false, + error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.NO_AUTHENTICATION_METHODS_ENROLLED, 'Graph-traversal device-check enrollment refusal'), + }, + }), + ], + [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: [ + createActorDoneEvent({type: READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, output: false}), + createActorDoneEvent({type: READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, output: true}), + ], + [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: [ + createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output: false}), + createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output: true}), ], - readHasAcceptedSoftPrompt: [false, true], - checkLocalCredentials: [false, true], - requestRegistrationChallenge: [ - {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, - {success: false, error: MFA_TEST_INVALID_CODE_ERROR}, - {success: false, error: MFA_TEST_FATAL_REGISTRATION_CHALLENGE_ERROR}, + [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: [ + createActorDoneEvent({ + type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, + output: {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, + }), + createActorDoneEvent({ + type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, + output: {success: false, error: MFA_TEST_INVALID_CODE_ERROR}, + }), + createActorDoneEvent({ + type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, + output: {success: false, error: MFA_TEST_FATAL_REGISTRATION_CHALLENGE_ERROR}, + }), ], -} satisfies MfaActorDoneOutputFixtures; +} satisfies MfaActorDoneEventFixtures; + +const MFA_ACTOR_ERROR_EVENT_FIXTURES = { + [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: createActorErrorEvent(VALIDATE_DEVICE_ERROR_EVENT_TYPE), + [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: createActorErrorEvent(READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE), + [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: createActorErrorEvent(CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE), + [REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE]: createActorErrorEvent(REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE), +} satisfies {[Type in MfaActorErrorEventType]: MfaActorErrorEventFor}; + +function hasActorDoneEventFixtures(type: string): type is keyof typeof MFA_ACTOR_DONE_EVENT_FIXTURES { + return Object.hasOwn(MFA_ACTOR_DONE_EVENT_FIXTURES, type); +} + +function hasActorErrorEventFixtures(type: string): type is keyof typeof MFA_ACTOR_ERROR_EVENT_FIXTURES { + return Object.hasOwn(MFA_ACTOR_ERROR_EVENT_FIXTURES, type); +} -function hasActorDoneOutputFixtures(actorId: string): actorId is keyof typeof MFA_ACTOR_DONE_OUTPUT_FIXTURES { - return Object.hasOwn(MFA_ACTOR_DONE_OUTPUT_FIXTURES, actorId); +function isDelayedEventType(type: string): type is MfaDelayedEventType { + return type.startsWith(DELAYED_EVENT_PREFIX); } type PathSteps = ReadonlyArray<{event: {type: string}}>; @@ -183,39 +238,43 @@ function isUiDrivablePath(path: {steps: PathSteps}): boolean { /** * Supplies explicit fixtures for application events declared by the current state. A custom `events` - * function replaces XState's default traversal events entirely, so this also mirrors the default bare - * `{type}` synthesis for the framework event descriptors (delayed transitions and actor completion) - * that the machine's transitions depend on. + * function replaces XState's default traversal events entirely, so this also supplies typed framework + * event fixtures for the delayed and invoked-actor transitions that the machine depends on. */ -function getTraversalEvents(snapshot: MfaSnapshot): MfaEvent[] { +function getTraversalEvents(snapshot: MfaSnapshot): MfaMachineEvent[] { // `_nodes` is part of the snapshot's public type. XState exports an equivalent helper only as // `__unsafe_getAllOwnEventDescriptors`, whose `any[]` return type would weaken the typing, so this // reads `_nodes` directly. // eslint-disable-next-line no-underscore-dangle const declaredEventTypes: string[] = [...new Set(snapshot._nodes.flatMap((node) => node.ownEvents))]; - const events: MfaEvent[] = []; + const events: MfaMachineEvent[] = []; for (const type of declaredEventTypes) { if (hasMfaEventFixtures(type)) { events.push(...MFA_GRAPH_EVENT_FIXTURES[type]); continue; } - if (!type.startsWith('xstate.')) { - throw new Error(`Missing MFA graph event fixture for application event "${type}"`); + if (hasActorDoneEventFixtures(type)) { + events.push(...MFA_ACTOR_DONE_EVENT_FIXTURES[type]); + continue; } if (type.startsWith(ACTOR_DONE_EVENT_PREFIX)) { - const actorId = type.slice(ACTOR_DONE_EVENT_PREFIX.length); - if (!hasActorDoneOutputFixtures(actorId)) { - throw new Error(`Missing MFA actor done-output fixtures for invoked actor "${actorId}"`); - } - // XState types `events` as the machine's event union, which cannot name framework events, so - // this widens the synthesized done events. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - events.push(...MFA_ACTOR_DONE_OUTPUT_FIXTURES[actorId].map((output) => ({type, output}) as MfaEvent)); + throw new Error(`Missing MFA actor done-event fixtures for "${type}"`); + } + if (hasActorErrorEventFixtures(type)) { + events.push(MFA_ACTOR_ERROR_EVENT_FIXTURES[type]); + continue; + } + if (type.startsWith(ACTOR_ERROR_EVENT_PREFIX)) { + throw new Error(`Missing MFA actor error-event fixture for "${type}"`); + } + if (isDelayedEventType(type) || type === 'xstate.init') { + events.push({type}); continue; } - // This widens the remaining framework events for the same reason as the done events above. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - events.push({type} as MfaEvent); + if (!type.startsWith('xstate.')) { + throw new Error(`Missing MFA graph event fixture for application event "${type}"`); + } + throw new Error(`Unsupported MFA framework event "${type}"`); } return events; } From 33e37cffd0314dace2e34a43df320f442696656b Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 29 Jul 2026 14:09:55 +0200 Subject: [PATCH 25/30] refactor(mfa): derive framework event types from invoked actors Replace the hand-rolled done and error event shapes with XState's DoneActorEvent and ErrorActorEvent, keyed by actor id instead of by event type. The derived union lives in machine/machineEvents.ts so the machine module stays focused on the chart. Graph-traversal fixtures now hold one entry per actor, built by createActorEvents. Its non-empty return type carries the "at least one output variant" guarantee into the fixture table, and the keyed type pins each slot to that actor's own events. getTraversalEvents filters a single fixture list and keeps a separate branch for framework events that cannot be given a fixture at all. Also fixes flowActors.ts, which still called the previous createActorDoneEvent signature and did not compile. --- .../machine/machineEvents.ts | 27 ++ .../machine/mfaMachine.ts | 30 +-- .../viewMatchesMachine.test.tsx | 83 +++--- tests/utils/mfa/flowActors.ts | 17 +- tests/utils/mfa/flowPaths.ts | 239 +++++++----------- 5 files changed, 163 insertions(+), 233 deletions(-) create mode 100644 src/components/MultifactorAuthentication/machine/machineEvents.ts diff --git a/src/components/MultifactorAuthentication/machine/machineEvents.ts b/src/components/MultifactorAuthentication/machine/machineEvents.ts new file mode 100644 index 000000000000..949def9111f7 --- /dev/null +++ b/src/components/MultifactorAuthentication/machine/machineEvents.ts @@ -0,0 +1,27 @@ +import type {DoneActorEvent, ErrorActorEvent, OutputFrom} from 'xstate'; + +import type createActors from './mfaActors'; +import type {MfaEvent} from './types'; + +type MfaActors = ReturnType; +type MfaActorId = keyof MfaActors; +type MfaActorOutput = OutputFrom; + +/** The event XState raises when an invoked actor resolves, carrying that actor's own output type. */ +type MfaActorDoneEvent = Id extends MfaActorId ? DoneActorEvent, Id> : never; + +/** The event XState raises when an invoked actor rejects. */ +type MfaActorErrorEvent = Id extends MfaActorId ? ErrorActorEvent : never; + +/** The events XState raises on its own without a payload, which are the initial event and the delayed-transition timers. */ +type MfaInternalEvent = {type: 'xstate.init'} | {type: `xstate.after${string}`}; + +/** + * Everything the machine receives. XState leaves its own events out of a declared event union, which + * is enough for the machine itself because `invoke` types its `onDone` and `onError` transitions from + * the actor. The graph traversal has to drive those events explicitly, so declaring them here keeps + * its fixtures assignable without an assertion. + */ +type MfaMachineEvent = MfaEvent | MfaActorDoneEvent | MfaActorErrorEvent | MfaInternalEvent; + +export type {MfaActorDoneEvent, MfaActorErrorEvent, MfaActorId, MfaActorOutput, MfaInternalEvent, MfaMachineEvent}; diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index fbc3595f5d22..4a51af7ec624 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -10,41 +10,16 @@ import {requestValidateCodeAction} from '@userActions/User'; import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; -import type {OutputFrom} from 'xstate'; - import {CONST as COMMON_CONST} from 'expensify-common'; import {assign, setup} from 'xstate'; -import type {MfaContext, MfaEvent, MfaTag} from './types'; +import type {MfaMachineEvent} from './machineEvents'; +import type {MfaContext, MfaTag} from './types'; import createActors from './mfaActors'; const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; -type MfaActors = ReturnType; -type MfaActorId = Extract; -type MfaActorDoneEventType = `xstate.done.actor.${MfaActorId}`; -type MfaActorErrorEventType = `xstate.error.actor.${MfaActorId}`; -type MfaActorDoneOutputByType = { - [Id in MfaActorId as `xstate.done.actor.${Id}`]: OutputFrom; -}; -type MfaActorDoneEventFor = { - type: Type; - output: MfaActorDoneOutputByType[Type]; -}; -type MfaActorErrorEventFor = { - type: Type; - error: unknown; -}; -type MfaActorDoneEvent = { - [Type in MfaActorDoneEventType]: MfaActorDoneEventFor; -}[MfaActorDoneEventType]; -type MfaActorErrorEvent = { - [Type in MfaActorErrorEventType]: MfaActorErrorEventFor; -}[MfaActorErrorEventType]; -type MfaDelayedEventType = `xstate.after${string}`; -type MfaMachineEvent = MfaEvent | MfaActorDoneEvent | MfaActorErrorEvent | {type: MfaDelayedEventType} | {type: 'xstate.init'}; - // Absolute targets for the screen branches. The device check runs under `preparing`, so reaching a // sibling branch needs an id target rather than a relative one. const OUTCOME_TARGET = `#${MFA_STATE.OUTCOME}` as const; @@ -347,4 +322,3 @@ const MFAMachine = setup({ }); export default MFAMachine; -export type {MfaActorDoneEvent, MfaActorDoneEventFor, MfaActorDoneEventType, MfaActorDoneOutputByType, MfaActorErrorEventFor, MfaActorErrorEventType, MfaDelayedEventType, MfaMachineEvent}; diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 7ac0d9afe73c..dc1b472b4e14 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -1,8 +1,7 @@ import {act, fireEvent, screen} from '@testing-library/react-native'; +import type {MfaActorDoneEvent, MfaInternalEvent, MfaMachineEvent} from '@components/MultifactorAuthentication/machine/machineEvents'; import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type {MfaActorDoneEventFor, MfaActorDoneEventType, MfaActorDoneOutputByType, MfaActorErrorEventType} from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type {MfaEvent} from '@components/MultifactorAuthentication/machine/types'; import {mfaNavigationRef} from '@components/MultifactorAuthentication/mfaNavigation'; import CONST from '@src/CONST'; @@ -14,17 +13,7 @@ import type {SnapshotFrom} from 'xstate'; import Onyx from 'react-native-onyx'; import {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; -import getWalkedPaths, { - CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, - CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, - isAutoDrivenEvent, - READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, - READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE, - REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, - REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE, - VALIDATE_DEVICE_DONE_EVENT_TYPE, - VALIDATE_DEVICE_ERROR_EVENT_TYPE, -} from 'tests/utils/mfa/flowPaths'; +import getWalkedPaths, {actorDoneEventType, actorErrorEventType, isAutoDrivenEvent} from 'tests/utils/mfa/flowPaths'; import {getSettleableLeafStates} from 'tests/utils/mfa/leafStates'; import renderMfaUi from 'tests/utils/mfa/realUi/harness'; import { @@ -74,30 +63,41 @@ const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; // These UI markers distinguish the closed, closing, and outcome states. The backdrop exists only while the MFA navigator is mounted. const TEST_ID = CONST.MULTIFACTOR_AUTHENTICATION.TEST_ID; -type MfaEventType = MfaEvent['type']; +/** Every event the walk drives as a gesture, which is all of them except the ones XState raises on its own. */ +type MfaDrivableEventType = Exclude; + +/** + * The step an executor receives. `TestParam` types its event as the bare `{type}`, while the walk + * passes the fixture it actually drove. Accepting both keeps each executor assignable to what + * `path.test` expects and still lets it narrow back to the payload. + */ +type MfaExecutorStep = {event: {type: Type} | Extract}; -type MfaInitEvent = Extract; -type MfaEventExecutorStep = {event: {type: Type}}; type MfaEventExecutors = { - [Type in MfaEventType]: (step: MfaEventExecutorStep) => Promise; + [Type in MfaDrivableEventType]: (step: MfaExecutorStep) => Promise; }; -type MfaActorEventExecutors = { - [Type in MfaActorDoneEventType]: (step: {event: {type: Type} | MfaActorDoneEventFor}) => Promise; -} & Record Promise>; type ExecuteScenario = ReturnType['executeScenario']; -function isMfaInitEvent(event: {type: string}): event is MfaInitEvent { - return event.type === 'INIT' && 'accountID' in event && 'scenarioName' in event && 'scenario' in event && 'payload' in event; +function getInitEvent(step: MfaExecutorStep<'INIT'>) { + if (!('scenario' in step.event)) { + throw new Error('MFA INIT executor received a path event without the scenario fixture payload.'); + } + return step.event; } -type MfaValidateCodeEnteredEvent = Extract; - -function isMfaValidateCodeEnteredEvent(event: {type: string}): event is MfaValidateCodeEnteredEvent { - return event.type === 'VALIDATE_CODE_ENTERED' && 'validateCode' in event; +function getValidateCode(step: MfaExecutorStep<'VALIDATE_CODE_ENTERED'>) { + if (!('validateCode' in step.event)) { + throw new Error('MFA VALIDATE_CODE_ENTERED executor received a path event without the code fixture payload.'); + } + return step.event.validateCode; } -function getActorDoneOutput(step: {event: {type: Type} | MfaActorDoneEventFor}): MfaActorDoneOutputByType[Type] { +/** + * Reads the output an actor resolved with. The generic recovers it from the step's own fixture member, + * because a mapped executor type cannot infer the actor id back out of an `Extract`. + */ +function getActorDoneOutput(step: {event: {type: MfaActorDoneEvent['type']} | {type: MfaActorDoneEvent['type']; output: TOutput}}): TOutput { if (!('output' in step.event)) { throw new Error(`Actor done executor received event "${step.event.type}" without output.`); } @@ -118,10 +118,7 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { return { INIT: async (step) => { - const {event} = step; - if (!isMfaInitEvent(event)) { - throw new Error('MFA INIT executor received a path event without the scenario fixture payload.'); - } + const event = getInitEvent(step); await act(async () => { await executeScenario(event.scenarioName, event.payload); }); @@ -149,11 +146,7 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { await waitForBatchedUpdatesWithAct(); }, VALIDATE_CODE_ENTERED: async (step) => { - const {event} = step; - if (!isMfaValidateCodeEnteredEvent(event)) { - throw new Error('MFA VALIDATE_CODE_ENTERED executor received a path event without the code fixture payload.'); - } - fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), event.validateCode); + fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), getValidateCode(step)); await waitForBatchedUpdatesWithAct(); fireEvent.press(screen.getByTestId(TEST_ID.VALIDATE_CODE_SUBMIT_BUTTON)); await waitForBatchedUpdatesWithAct(); @@ -166,15 +159,15 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), '1'); await waitForBatchedUpdatesWithAct(); }, - [VALIDATE_DEVICE_DONE_EVENT_TYPE]: (step) => settleActor(() => validateDeviceControl.resolve(getActorDoneOutput(step))), - [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: () => settleActor(validateDeviceControl.reject), - [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: (step) => settleActor(() => readHasAcceptedSoftPromptControl.resolve(getActorDoneOutput(step))), - [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: () => settleActor(readHasAcceptedSoftPromptControl.reject), - [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: (step) => settleActor(() => checkLocalCredentialsControl.resolve(getActorDoneOutput(step))), - [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: () => settleActor(checkLocalCredentialsControl.reject), - [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: (step) => settleActor(() => requestRegistrationChallengeControl.resolve(getActorDoneOutput(step))), - [REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE]: () => settleActor(requestRegistrationChallengeControl.reject), - } satisfies MfaEventExecutors & MfaActorEventExecutors; + [actorDoneEventType('validateDevice')]: (step) => settleActor(() => validateDeviceControl.resolve(getActorDoneOutput(step))), + [actorErrorEventType('validateDevice')]: () => settleActor(validateDeviceControl.reject), + [actorDoneEventType('readHasAcceptedSoftPrompt')]: (step) => settleActor(() => readHasAcceptedSoftPromptControl.resolve(getActorDoneOutput(step))), + [actorErrorEventType('readHasAcceptedSoftPrompt')]: () => settleActor(readHasAcceptedSoftPromptControl.reject), + [actorDoneEventType('checkLocalCredentials')]: (step) => settleActor(() => checkLocalCredentialsControl.resolve(getActorDoneOutput(step))), + [actorErrorEventType('checkLocalCredentials')]: () => settleActor(checkLocalCredentialsControl.reject), + [actorDoneEventType('requestRegistrationChallenge')]: (step) => settleActor(() => requestRegistrationChallengeControl.resolve(getActorDoneOutput(step))), + [actorErrorEventType('requestRegistrationChallenge')]: () => settleActor(requestRegistrationChallengeControl.reject), + } satisfies MfaEventExecutors; } /* eslint-enable @typescript-eslint/naming-convention */ diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index 8315d313b793..2eea802ca3f4 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -1,16 +1,13 @@ -import type createActors from '@components/MultifactorAuthentication/machine/mfaActors'; +import type {MfaActorOutput} from '@components/MultifactorAuthentication/machine/machineEvents'; import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; import type {MfaContext} from '@components/MultifactorAuthentication/machine/types'; -import type {OutputFrom, StateValue} from 'xstate'; +import type {StateValue} from 'xstate'; import {createActor} from 'xstate'; import createInitEvent from './flowFixtures'; -import {CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, createActorDoneEvent, VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; - -type ValidateDeviceOutput = OutputFrom['validateDevice']>; -type CheckLocalCredentialsOutput = OutputFrom['checkLocalCredentials']>; +import {createActorDoneEvent} from './flowPaths'; /** * Builds the context a flow carries right after INIT seeds it. Overrides express a spec's starting @@ -44,15 +41,15 @@ function createActorAtState(value: StateValue, contextOverrides?: Partial, output: ValidateDeviceOutput) { - actor.send(createActorDoneEvent({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output})); +function sendValidateDeviceDone(actor: ReturnType, output: MfaActorOutput<'validateDevice'>) { + actor.send(createActorDoneEvent('validateDevice', output)); } /** * Completes the invoked credentials-check actor by sending its done event carrying the given output. */ -function sendCheckLocalCredentialsDone(actor: ReturnType, output: CheckLocalCredentialsOutput) { - actor.send(createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output})); +function sendCheckLocalCredentialsDone(actor: ReturnType, output: MfaActorOutput<'checkLocalCredentials'>) { + actor.send(createActorDoneEvent('checkLocalCredentials', output)); } export {createActorAtState, createFlowContext, sendCheckLocalCredentialsDone, sendValidateDeviceDone}; diff --git a/tests/utils/mfa/flowPaths.ts b/tests/utils/mfa/flowPaths.ts index ab306655aff1..214d78ccbe6d 100644 --- a/tests/utils/mfa/flowPaths.ts +++ b/tests/utils/mfa/flowPaths.ts @@ -1,20 +1,12 @@ +import type {MfaActorId, MfaActorOutput, MfaInternalEvent, MfaMachineEvent} from '@components/MultifactorAuthentication/machine/machineEvents'; import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; -import type { - MfaActorDoneEvent, - MfaActorDoneEventFor, - MfaActorDoneEventType, - MfaActorErrorEventFor, - MfaActorErrorEventType, - MfaDelayedEventType, - MfaMachineEvent, -} from '@components/MultifactorAuthentication/machine/mfaMachine'; import type {MfaEvent} from '@components/MultifactorAuthentication/machine/types'; import {createLocalMFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; import CONST from '@src/CONST'; -import type {SnapshotFrom} from 'xstate'; +import type {DoneActorEvent, ErrorActorEvent, SnapshotFrom} from 'xstate'; import {matchesState} from 'xstate'; import {getShortestPaths, TestModel} from 'xstate/graph'; @@ -23,27 +15,43 @@ import createInitEvent, {MFA_TEST_FATAL_REGISTRATION_CHALLENGE_ERROR, MFA_TEST_I const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; -const DELAYED_EVENT_PREFIX = 'xstate.after'; -const ACTOR_DONE_EVENT_PREFIX = 'xstate.done.actor.'; -const ACTOR_ERROR_EVENT_PREFIX = 'xstate.error.actor.'; -const VALIDATE_DEVICE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}validateDevice` satisfies MfaActorDoneEventType; -const VALIDATE_DEVICE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}validateDevice` satisfies MfaActorErrorEventType; -const READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}readHasAcceptedSoftPrompt` satisfies MfaActorDoneEventType; -const READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}readHasAcceptedSoftPrompt` satisfies MfaActorErrorEventType; -const CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}checkLocalCredentials` satisfies MfaActorDoneEventType; -const CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}checkLocalCredentials` satisfies MfaActorErrorEventType; -const REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE = `${ACTOR_DONE_EVENT_PREFIX}requestRegistrationChallenge` satisfies MfaActorDoneEventType; -const REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE = `${ACTOR_ERROR_EVENT_PREFIX}requestRegistrationChallenge` satisfies MfaActorErrorEventType; +const FRAMEWORK_EVENT_PREFIX = 'xstate.'; +const DELAYED_EVENT_PREFIX = `${FRAMEWORK_EVENT_PREFIX}after`; + +/** Names the event XState raises when the given actor resolves. */ +function actorDoneEventType(actorId: Id) { + return `xstate.done.actor.${actorId}` as const; +} + +/** Names the event XState raises when the given actor rejects. */ +function actorErrorEventType(actorId: Id) { + return `xstate.error.actor.${actorId}` as const; +} /** - * Builds an actor completion event while keeping its event type tied to that actor's output. + * Builds the completion event of one invoked actor, keeping its event type tied to that actor's output. */ -function createActorDoneEvent(event: Event): Event { - return event; +function createActorDoneEvent(actorId: Id, output: NoInfer>): DoneActorEvent, Id> { + return {type: actorDoneEventType(actorId), output, actorId}; } -function createActorErrorEvent(type: Type): MfaActorErrorEventFor { - return {type, error: new Error(`Graph-traversal rejection for actor event "${type}"`)}; +/** Everything XState raises for one invoked actor, which is a done event per output plus its rejection. */ +type MfaActorEvent = DoneActorEvent, Id> | ErrorActorEvent; + +/** + * Builds every traversal event of one invoked actor. The non-empty return type carries the "at least + * one output variant" guarantee of the parameter list through to the fixture table. + */ +function createActorEvents( + actorId: Id, + ...outputs: [NoInfer>, ...Array>>] +): [MfaActorEvent, ...Array>] { + const [firstOutput, ...otherOutputs] = outputs; + return [ + createActorDoneEvent(actorId, firstOutput), + ...otherOutputs.map((output) => createActorDoneEvent(actorId, output)), + {type: actorErrorEventType(actorId), actorId, error: new Error(`Graph-traversal rejection for actor "${actorId}"`)}, + ]; } type DrivingJourney = { @@ -85,14 +93,11 @@ const DRIVING_JOURNEYS: DrivingJourney[] = [ description: 'the resend journey requests a fresh code and still accepts the emailed code', events: [ createInitEvent(), - createActorDoneEvent({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output: {success: true}}), - createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output: false}), + createActorDoneEvent('validateDevice', {success: true}), + createActorDoneEvent('checkLocalCredentials', false), {type: 'RESEND_VALIDATE_CODE'}, {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, - createActorDoneEvent({ - type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, - output: {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, - }), + createActorDoneEvent('requestRegistrationChallenge', {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}), ], endState: `${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`, }, @@ -100,19 +105,13 @@ const DRIVING_JOURNEYS: DrivingJourney[] = [ description: 'the invalid-code journey clears the inline error and accepts a corrected code', events: [ createInitEvent(), - createActorDoneEvent({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output: {success: true}}), - createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output: false}), + createActorDoneEvent('validateDevice', {success: true}), + createActorDoneEvent('checkLocalCredentials', false), {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, - createActorDoneEvent({ - type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, - output: {success: false, error: MFA_TEST_INVALID_CODE_ERROR}, - }), + createActorDoneEvent('requestRegistrationChallenge', {success: false, error: MFA_TEST_INVALID_CODE_ERROR}), {type: 'VALIDATE_CODE_CHANGED'}, {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, - createActorDoneEvent({ - type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, - output: {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, - }), + createActorDoneEvent('requestRegistrationChallenge', {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}), ], endState: `${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`, }, @@ -122,6 +121,11 @@ type MfaEventFixtures = { readonly [Type in MfaEvent['type']]: readonly [Extract, ...Array>]; }; +/** Pins each slot to the events of that one actor, so a fixture cannot drift to another actor's key. */ +type MfaActorEventFixtures = { + readonly [Id in MfaActorId]: readonly [MfaActorEvent, ...Array>]; +}; + /** * Concrete graph-traversal fixtures for every application event. The exhaustive keyed type makes a * new event fail compilation until its real fixture is added instead of letting XState substitute @@ -137,81 +141,40 @@ const MFA_GRAPH_EVENT_FIXTURES = { VALIDATE_CODE_CHANGED: [{type: 'VALIDATE_CODE_CHANGED'}], } satisfies MfaEventFixtures; -function hasMfaEventFixtures(type: string): type is MfaEvent['type'] { - return Object.hasOwn(MFA_GRAPH_EVENT_FIXTURES, type); -} - -type MfaActorDoneEventFixtures = { - readonly [Type in MfaActorDoneEventType]: readonly [MfaActorDoneEventFor, ...Array>]; -}; - /** - * Holds the done-event variants for each invoked actor. The machine routes these events through - * guards on the actor's output, so the traversal must offer every output shape a branch depends on. - * XState's bare `{type}` synthesis would make those guards read an undefined output. The exhaustive - * keyed type makes a new actor fail compilation until its event variants are added. + * Holds the traversal events of every invoked actor. The machine routes done events through guards + * on the actor's output, so the traversal must offer every output shape a branch depends on. XState's + * bare `{type}` synthesis would make those guards read an undefined output. The exhaustive keyed type + * makes a new actor fail compilation until its variants are added. */ -const MFA_ACTOR_DONE_EVENT_FIXTURES = { +const MFA_ACTOR_EVENT_FIXTURES = { // The refusal variants mirror the actor's gates. Each reason maps to its own failure screen, so // every variant needs a graph branch for the walk to reach that screen. - [VALIDATE_DEVICE_DONE_EVENT_TYPE]: [ - createActorDoneEvent({type: VALIDATE_DEVICE_DONE_EVENT_TYPE, output: {success: true}}), - createActorDoneEvent({ - type: VALIDATE_DEVICE_DONE_EVENT_TYPE, - output: { - success: false, - error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.AUTHENTICATION_TYPE_NOT_SUPPORTED, 'Graph-traversal device-check refusal'), - }, - }), - createActorDoneEvent({ - type: VALIDATE_DEVICE_DONE_EVENT_TYPE, - output: { - success: false, - error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.NO_AUTHENTICATION_METHODS_ENROLLED, 'Graph-traversal device-check enrollment refusal'), - }, - }), - ], - [READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE]: [ - createActorDoneEvent({type: READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, output: false}), - createActorDoneEvent({type: READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, output: true}), - ], - [CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE]: [ - createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output: false}), - createActorDoneEvent({type: CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, output: true}), - ], - [REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE]: [ - createActorDoneEvent({ - type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, - output: {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, - }), - createActorDoneEvent({ - type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, - output: {success: false, error: MFA_TEST_INVALID_CODE_ERROR}, - }), - createActorDoneEvent({ - type: REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, - output: {success: false, error: MFA_TEST_FATAL_REGISTRATION_CHALLENGE_ERROR}, - }), - ], -} satisfies MfaActorDoneEventFixtures; - -const MFA_ACTOR_ERROR_EVENT_FIXTURES = { - [VALIDATE_DEVICE_ERROR_EVENT_TYPE]: createActorErrorEvent(VALIDATE_DEVICE_ERROR_EVENT_TYPE), - [READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE]: createActorErrorEvent(READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE), - [CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE]: createActorErrorEvent(CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE), - [REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE]: createActorErrorEvent(REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE), -} satisfies {[Type in MfaActorErrorEventType]: MfaActorErrorEventFor}; - -function hasActorDoneEventFixtures(type: string): type is keyof typeof MFA_ACTOR_DONE_EVENT_FIXTURES { - return Object.hasOwn(MFA_ACTOR_DONE_EVENT_FIXTURES, type); -} + validateDevice: createActorEvents( + 'validateDevice', + {success: true}, + {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.AUTHENTICATION_TYPE_NOT_SUPPORTED, 'Graph-traversal device-check refusal')}, + { + success: false, + error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.NO_AUTHENTICATION_METHODS_ENROLLED, 'Graph-traversal device-check enrollment refusal'), + }, + ), + readHasAcceptedSoftPrompt: createActorEvents('readHasAcceptedSoftPrompt', false, true), + checkLocalCredentials: createActorEvents('checkLocalCredentials', false, true), + requestRegistrationChallenge: createActorEvents( + 'requestRegistrationChallenge', + {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}, + {success: false, error: MFA_TEST_INVALID_CODE_ERROR}, + {success: false, error: MFA_TEST_FATAL_REGISTRATION_CHALLENGE_ERROR}, + ), +} satisfies MfaActorEventFixtures; -function hasActorErrorEventFixtures(type: string): type is keyof typeof MFA_ACTOR_ERROR_EVENT_FIXTURES { - return Object.hasOwn(MFA_ACTOR_ERROR_EVENT_FIXTURES, type); -} +/** Every concrete event the traversal can offer, in the order its fixtures declare them. */ +const MFA_TRAVERSAL_EVENT_FIXTURES: readonly MfaMachineEvent[] = [...Object.values(MFA_GRAPH_EVENT_FIXTURES).flat(), ...Object.values(MFA_ACTOR_EVENT_FIXTURES).flat()]; -function isDelayedEventType(type: string): type is MfaDelayedEventType { - return type.startsWith(DELAYED_EVENT_PREFIX); +/** Tells whether XState raises this event on its own, in which case its bare `{type}` is the whole event. */ +function isMfaInternalEventType(type: string): type is MfaInternalEvent['type'] { + return type === 'xstate.init' || type.startsWith(DELAYED_EVENT_PREFIX); } type PathSteps = ReadonlyArray<{event: {type: string}}>; @@ -224,7 +187,7 @@ type MfaSnapshot = SnapshotFrom; * of them corresponds to a gesture, so the prefix check matches exactly the framework events. */ function isAutoDrivenEvent(eventType: string): boolean { - return eventType.startsWith('xstate.'); + return eventType.startsWith(FRAMEWORK_EVENT_PREFIX); } /** @@ -237,44 +200,33 @@ function isUiDrivablePath(path: {steps: PathSteps}): boolean { } /** - * Supplies explicit fixtures for application events declared by the current state. A custom `events` - * function replaces XState's default traversal events entirely, so this also supplies typed framework - * event fixtures for the delayed and invoked-actor transitions that the machine depends on. + * Supplies the fixtures of every event the current state declares. A custom `events` function + * replaces XState's default traversal events entirely, so the events XState raises on its own are + * supplied here as well. */ function getTraversalEvents(snapshot: MfaSnapshot): MfaMachineEvent[] { // `_nodes` is part of the snapshot's public type. XState exports an equivalent helper only as // `__unsafe_getAllOwnEventDescriptors`, whose `any[]` return type would weaken the typing, so this // reads `_nodes` directly. // eslint-disable-next-line no-underscore-dangle - const declaredEventTypes: string[] = [...new Set(snapshot._nodes.flatMap((node) => node.ownEvents))]; + const declaredEventTypes = new Set(snapshot._nodes.flatMap((node) => node.ownEvents)); const events: MfaMachineEvent[] = []; for (const type of declaredEventTypes) { - if (hasMfaEventFixtures(type)) { - events.push(...MFA_GRAPH_EVENT_FIXTURES[type]); - continue; - } - if (hasActorDoneEventFixtures(type)) { - events.push(...MFA_ACTOR_DONE_EVENT_FIXTURES[type]); - continue; - } - if (type.startsWith(ACTOR_DONE_EVENT_PREFIX)) { - throw new Error(`Missing MFA actor done-event fixtures for "${type}"`); - } - if (hasActorErrorEventFixtures(type)) { - events.push(MFA_ACTOR_ERROR_EVENT_FIXTURES[type]); + const fixtures = MFA_TRAVERSAL_EVENT_FIXTURES.filter((event) => event.type === type); + if (fixtures.length > 0) { + events.push(...fixtures); continue; } - if (type.startsWith(ACTOR_ERROR_EVENT_PREFIX)) { - throw new Error(`Missing MFA actor error-event fixture for "${type}"`); - } - if (isDelayedEventType(type) || type === 'xstate.init') { + if (isMfaInternalEventType(type)) { events.push({type}); continue; } - if (!type.startsWith('xstate.')) { - throw new Error(`Missing MFA graph event fixture for application event "${type}"`); + // A framework event outside `MfaMachineEvent` cannot be given a fixture, so it needs the union + // widened first rather than a fixture added. + if (type.startsWith(FRAMEWORK_EVENT_PREFIX)) { + throw new Error(`Unsupported MFA framework event "${type}". Declare it in MfaMachineEvent before the traversal can drive it.`); } - throw new Error(`Unsupported MFA framework event "${type}"`); + throw new Error(`Missing MFA graph event fixture for "${type}"`); } return events; } @@ -310,17 +262,4 @@ function getWalkedPaths() { } export default getWalkedPaths; -export { - CHECK_LOCAL_CREDENTIALS_DONE_EVENT_TYPE, - CHECK_LOCAL_CREDENTIALS_ERROR_EVENT_TYPE, - createActorDoneEvent, - getDrivingJourneyPaths, - getMfaShortestPaths, - isAutoDrivenEvent, - READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, - READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE, - REQUEST_REGISTRATION_CHALLENGE_DONE_EVENT_TYPE, - REQUEST_REGISTRATION_CHALLENGE_ERROR_EVENT_TYPE, - VALIDATE_DEVICE_DONE_EVENT_TYPE, - VALIDATE_DEVICE_ERROR_EVENT_TYPE, -}; +export {actorDoneEventType, actorErrorEventType, createActorDoneEvent, getDrivingJourneyPaths, getMfaShortestPaths, isAutoDrivenEvent}; From 884788af61c8d7161c66e63b54c8f467776b27f4 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 29 Jul 2026 14:12:54 +0200 Subject: [PATCH 26/30] refactor(mfa): rename idle input state --- .../MultifactorAuthentication/machine/mfaMachine.ts | 8 ++++---- src/libs/MultifactorAuthentication/shared/VALUES.ts | 2 +- .../machine/graphTraversal/viewMatchesMachine.test.tsx | 2 +- .../machine/validateCodeTransition.test.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index 4a51af7ec624..d7cba5f27fc0 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -226,13 +226,13 @@ const MFAMachine = setup({ // while the challenge request is in flight is dropped instead of emailing a // code the pending submission ignores. [MFA_STATE.AWAITING_VALIDATE_CODE]: { - initial: MFA_STATE.IDLE, + initial: MFA_STATE.AWAITING_INPUT, on: { VALIDATE_CODE_ENTERED: {target: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE, actions: 'submitValidateCode'}, - RESEND_VALIDATE_CODE: {target: `.${MFA_STATE.IDLE}`, actions: 'requestValidateCode'}, + RESEND_VALIDATE_CODE: {target: `.${MFA_STATE.AWAITING_INPUT}`, actions: 'requestValidateCode'}, }, states: { - [MFA_STATE.IDLE]: {}, + [MFA_STATE.AWAITING_INPUT]: {}, // The backend rejected the submitted code. The screen shows the // inline error exactly while this state is active, so every way out // (typing, a resend, a new submission) drops the error by @@ -240,7 +240,7 @@ const MFAMachine = setup({ [MFA_STATE.INVALID_CODE]: { tags: 'showsInvalidCodeError', on: { - VALIDATE_CODE_CHANGED: MFA_STATE.IDLE, + VALIDATE_CODE_CHANGED: MFA_STATE.AWAITING_INPUT, }, }, }, diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index bcd256e9d2bd..18b1fd260e34 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -225,7 +225,7 @@ const MFA_STATE = { CHECKING_SOFT_PROMPT_ACCEPTANCE: 'checkingSoftPromptAcceptance', MAGIC_CODE: 'magicCode', AWAITING_VALIDATE_CODE: 'awaitingValidateCode', - IDLE: 'idle', + AWAITING_INPUT: 'awaitingInput', INVALID_CODE: 'invalidCode', REQUESTING_REGISTRATION_CHALLENGE: 'requestingRegistrationChallenge', PROMPT: 'prompt', diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index dc1b472b4e14..d54fb4454fd0 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -218,7 +218,7 @@ const testConfig = { expect(screen.getByText(translateLocal('multifactorAuthentication.letsVerifyItsYou'))).toBeOnTheScreen(); expect(state.context.error).toBeUndefined(); }, - [`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.AWAITING_VALIDATE_CODE}.${MFA_STATE.IDLE}`]: () => { + [`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.AWAITING_VALIDATE_CODE}.${MFA_STATE.AWAITING_INPUT}`]: () => { expect(screen.queryByText(translateLocal('validateCodeForm.error.incorrectSecurityCode'))).not.toBeOnTheScreen(); }, [`${MFA_STATE.OPEN}.${MFA_STATE.MAGIC_CODE}.${MFA_STATE.AWAITING_VALIDATE_CODE}.${MFA_STATE.INVALID_CODE}`]: () => { diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index a5214f243d86..20349943df2f 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -122,7 +122,7 @@ describe('MFA magic code and registration decision', () => { actor.send({type: 'RESEND_VALIDATE_CODE'}); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.IDLE}}})).toBe(true); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.AWAITING_INPUT}}})).toBe(true); expect(result.hasTag('showsInvalidCodeError')).toBe(false); expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); @@ -249,7 +249,7 @@ describe('MFA magic code and registration decision', () => { actor.send({type: 'VALIDATE_CODE_CHANGED'}); const result = actor.getSnapshot(); - expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.IDLE}}})).toBe(true); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.AWAITING_INPUT}}})).toBe(true); expect(result.hasTag('showsInvalidCodeError')).toBe(false); actor.stop(); From c8819452bd49e3f59ce8f5997063fd18f6fe3bda Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Wed, 29 Jul 2026 15:16:40 +0200 Subject: [PATCH 27/30] refactor(mfa): derive invalid-code error from state --- .../MultifactorAuthentication/machine/mfaMachine.ts | 4 +--- .../machine/snapshotToState.ts | 8 +++++++- .../MultifactorAuthentication/machine/types.ts | 8 -------- .../machine/validateCodeTransition.test.ts | 11 ++++++----- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index d7cba5f27fc0..da8764fe5448 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -14,7 +14,7 @@ import {CONST as COMMON_CONST} from 'expensify-common'; import {assign, setup} from 'xstate'; import type {MfaMachineEvent} from './machineEvents'; -import type {MfaContext, MfaTag} from './types'; +import type {MfaContext} from './types'; import createActors from './mfaActors'; @@ -56,7 +56,6 @@ const MFAMachine = setup({ types: { context: {} as MfaContext, events: {} as MfaMachineEvent, - tags: {} as MfaTag, }, /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ actors: createActors(), @@ -238,7 +237,6 @@ const MFAMachine = setup({ // (typing, a resend, a new submission) drops the error by // construction and nothing stale can outlive the screen. [MFA_STATE.INVALID_CODE]: { - tags: 'showsInvalidCodeError', on: { VALIDATE_CODE_CHANGED: MFA_STATE.AWAITING_INPUT, }, diff --git a/src/components/MultifactorAuthentication/machine/snapshotToState.ts b/src/components/MultifactorAuthentication/machine/snapshotToState.ts index fb8cb0945022..a818e1bd914d 100644 --- a/src/components/MultifactorAuthentication/machine/snapshotToState.ts +++ b/src/components/MultifactorAuthentication/machine/snapshotToState.ts @@ -41,7 +41,13 @@ function snapshotToState(snapshot: MfaSnapshot): MfaState { ...snapshot.context, modalState: getModalState(snapshot), canResendValidateCode: snapshot.can({type: 'RESEND_VALIDATE_CODE'}), - showsInvalidCodeError: snapshot.hasTag('showsInvalidCodeError'), + showsInvalidCodeError: snapshot.matches({ + [MFA_STATE.OPEN]: { + [MFA_STATE.MAGIC_CODE]: { + [MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.INVALID_CODE, + }, + }, + }), }; } diff --git a/src/components/MultifactorAuthentication/machine/types.ts b/src/components/MultifactorAuthentication/machine/types.ts index 4747d968c686..095bd4f399da 100644 --- a/src/components/MultifactorAuthentication/machine/types.ts +++ b/src/components/MultifactorAuthentication/machine/types.ts @@ -74,13 +74,6 @@ type MfaEvent = | {type: 'RESEND_VALIDATE_CODE'} | {type: 'VALIDATE_CODE_CHANGED'}; -/** - * Tags the chart marks UI-facing conditions with. The view bridge reads them through `hasTag` - * instead of matching a concrete state path, so a chart restructuring that moves the tagged state - * does not break the bridge. - */ -type MfaTag = 'showsInvalidCodeError'; - /** Describes the input the machine passes to the device-check actor. */ type ValidateDeviceInput = {allowedAuthenticationMethods: AllowedAuthenticationMethods}; @@ -101,7 +94,6 @@ export type { MfaContext, MfaEvent, MfaModalState, - MfaTag, MultifactorAuthenticationInitEvent, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index 20349943df2f..5b8e63b19c74 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -1,4 +1,5 @@ import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; +import snapshotToState from '@components/MultifactorAuthentication/machine/snapshotToState'; import type {CheckLocalCredentialsInput, ValidateDeviceInput} from '@components/MultifactorAuthentication/machine/types'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; @@ -123,7 +124,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.AWAITING_INPUT}}})).toBe(true); - expect(result.hasTag('showsInvalidCodeError')).toBe(false); + expect(snapshotToState(result).showsInvalidCodeError).toBe(false); expect(requestValidateCodeActionMock).toHaveBeenCalledTimes(1); actor.stop(); @@ -183,7 +184,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.INVALID_CODE}}})).toBe(true); - expect(result.hasTag('showsInvalidCodeError')).toBe(true); + expect(snapshotToState(result).showsInvalidCodeError).toBe(true); expect(result.context.registrationChallenge).toBeUndefined(); expect(result.context.error).toBeUndefined(); expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); @@ -198,14 +199,14 @@ describe('MFA magic code and registration decision', () => { actor.start(); actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); await waitForBatchedUpdates(); - expect(actor.getSnapshot().hasTag('showsInvalidCodeError')).toBe(true); + expect(snapshotToState(actor.getSnapshot()).showsInvalidCodeError).toBe(true); actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); await waitForBatchedUpdates(); const result = actor.getSnapshot(); expect(result.context.registrationChallenge).toBe(MFA_TEST_REGISTRATION_CHALLENGE); expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE); - expect(result.hasTag('showsInvalidCodeError')).toBe(false); + expect(snapshotToState(result).showsInvalidCodeError).toBe(false); actor.stop(); }); @@ -250,7 +251,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.AWAITING_INPUT}}})).toBe(true); - expect(result.hasTag('showsInvalidCodeError')).toBe(false); + expect(snapshotToState(result).showsInvalidCodeError).toBe(false); actor.stop(); }); From cfc1273501c7b379d1eacf6a2ae76f964029ff38 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 3 Aug 2026 17:09:45 +0200 Subject: [PATCH 28/30] fix(mfa): wait for account data before deciding on registration Gate areLocalCredentialsKnownToServer on HAS_LOADED_APP and IS_LOADING_APP so the check does not read ACCOUNT before OpenApp data arrives and start registration unnecessarily. Generalize readOnyxValueOnce into a predicate-based waitForOnyxValue and rename the module accordingly. On web, return early when there are no local passkeys so the gate never delays an already determined answer. The gate guarantees hydrated data, not fresh data. Reconciling credentials revoked while the app was closed stays with the recovery flow. --- .../biometrics/operations/index.native.ts | 4 +- .../biometrics/operations/index.ts | 12 ++++-- .../machine/mfaActors.ts | 2 +- .../shared/waitForAccountDataReady.ts | 24 +++++++++++ ...adOnyxValueOnce.ts => waitForOnyxValue.ts} | 24 ++++++++--- .../biometricsOperations.test.ts | 41 +++++++++++++++++++ .../biometricsOperationsWeb.test.ts | 41 +++++++++++++++++++ 7 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 src/libs/MultifactorAuthentication/shared/waitForAccountDataReady.ts rename src/libs/MultifactorAuthentication/shared/{readOnyxValueOnce.ts => waitForOnyxValue.ts} (58%) diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts index 1def1e724867..b76d0b3c4e66 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts @@ -1,7 +1,8 @@ import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import {decodeLibraryError, getKeyAlias} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; -import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; +import waitForAccountDataReady from '@libs/MultifactorAuthentication/shared/waitForAccountDataReady'; +import {readOnyxValueOnce} from '@libs/MultifactorAuthentication/shared/waitForOnyxValue'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -54,6 +55,7 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor if (!localCredentialID) { return false; } + await waitForAccountDataReady(signal); const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal); return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID); } diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.ts index 161c57b21409..652f208f6c39 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.ts @@ -1,5 +1,6 @@ import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; -import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; +import waitForAccountDataReady from '@libs/MultifactorAuthentication/shared/waitForAccountDataReady'; +import {readOnyxValueOnce} from '@libs/MultifactorAuthentication/shared/waitForOnyxValue'; import {getPasskeyOnyxKey} from '@userActions/Passkey'; @@ -32,9 +33,14 @@ async function doesDeviceSupportAuthenticationMethod(): Promise { * until the hook is removed. */ async function areLocalCredentialsKnownToServer(accountID: number, signal?: AbortSignal): Promise { - const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal), readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)), signal)]); + const localPasskeyCredentials = await readOnyxValueOnce(getPasskeyOnyxKey(String(accountID)), signal); + if (!localPasskeyCredentials?.length) { + return false; + } + await waitForAccountDataReady(signal); + const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal); const serverKnownCredentialIDs = new Set(mfaCredentialIDsSelector(account) ?? []); - return (localPasskeyCredentials ?? []).some((credential) => serverKnownCredentialIDs.has(credential.id)); + return localPasskeyCredentials.some((credential) => serverKnownCredentialIDs.has(credential.id)); } export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/machine/mfaActors.ts b/src/components/MultifactorAuthentication/machine/mfaActors.ts index f7b73d97dd16..94edfaa6b3c9 100644 --- a/src/components/MultifactorAuthentication/machine/mfaActors.ts +++ b/src/components/MultifactorAuthentication/machine/mfaActors.ts @@ -4,7 +4,7 @@ import {areLocalCredentialsKnownToServer} from '@components/MultifactorAuthentic import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; import {createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; -import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; +import {readOnyxValueOnce} from '@libs/MultifactorAuthentication/shared/waitForOnyxValue'; import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; diff --git a/src/libs/MultifactorAuthentication/shared/waitForAccountDataReady.ts b/src/libs/MultifactorAuthentication/shared/waitForAccountDataReady.ts new file mode 100644 index 000000000000..3c4e7d37f66a --- /dev/null +++ b/src/libs/MultifactorAuthentication/shared/waitForAccountDataReady.ts @@ -0,0 +1,24 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import waitForOnyxValue from './waitForOnyxValue'; + +/** + * Waits until the account data delivered by OpenApp is authoritative. HAS_LOADED_APP covers the + * first load after sign-in, while IS_LOADING_APP covers later account switches that preserve + * HAS_LOADED_APP. The final microtask lets the complete OpenApp Onyx batch settle because callback + * order within the batch is not guaranteed. + * + * HAS_LOADED_APP persists across restarts, so after a relaunch this resolves with the persisted + * account data while ReconnectApp may still be in flight. The wait guarantees hydrated data, not + * fresh data. Reconciling credentials revoked while the app was closed belongs to the recovery + * flow. + */ +async function waitForAccountDataReady(signal?: AbortSignal): Promise { + await Promise.all([ + waitForOnyxValue(ONYXKEYS.HAS_LOADED_APP, (hasLoadedApp) => hasLoadedApp === true, signal), + waitForOnyxValue(ONYXKEYS.IS_LOADING_APP, (isLoadingApp) => isLoadingApp === false, signal), + ]); + await Promise.resolve(); +} + +export default waitForAccountDataReady; diff --git a/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts b/src/libs/MultifactorAuthentication/shared/waitForOnyxValue.ts similarity index 58% rename from src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts rename to src/libs/MultifactorAuthentication/shared/waitForOnyxValue.ts index d00c8b3dfbb4..b95091e198b4 100644 --- a/src/libs/MultifactorAuthentication/shared/readOnyxValueOnce.ts +++ b/src/libs/MultifactorAuthentication/shared/waitForOnyxValue.ts @@ -3,11 +3,12 @@ import type {Connection, OnyxKey, OnyxValue} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; /** - * Reads the current value of an Onyx key once through a temporary connection. The connection is - * disconnected after the first value or when the optional abort signal fires; an aborted read never - * resolves, which lets XState actors drop it silently when they are stopped. + * Resolves with the first value of an Onyx key that satisfies the predicate. The temporary + * connection stays active while values do not match and is disconnected when the optional abort + * signal fires. An aborted wait never resolves, which lets XState actors drop it silently when + * they are stopped. */ -function readOnyxValueOnce(key: TKey, signal?: AbortSignal): Promise> { +function waitForOnyxValue(key: TKey, predicate: (value: OnyxValue) => boolean, signal?: AbortSignal): Promise> { return new Promise((resolve) => { if (signal?.aborted) { return; @@ -20,6 +21,9 @@ function readOnyxValueOnce(key: TKey, signal?: AbortSignal connection = Onyx.connectWithoutView({ key, callback: (value) => { + if (!predicate(value)) { + return; + } signal?.removeEventListener('abort', disconnect); disconnect(); resolve(value); @@ -28,4 +32,14 @@ function readOnyxValueOnce(key: TKey, signal?: AbortSignal }); } -export default readOnyxValueOnce; +/** + * Reads the current value of an Onyx key once through a temporary connection. The connection is + * disconnected after the first value or when the optional abort signal fires. An aborted read never + * resolves, which lets XState actors drop it silently when they are stopped. + */ +function readOnyxValueOnce(key: TKey, signal?: AbortSignal): Promise> { + return waitForOnyxValue(key, () => true, signal); +} + +export {readOnyxValueOnce}; +export default waitForOnyxValue; diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts index 8a1b20b00aec..98e3417633a0 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts @@ -64,6 +64,14 @@ describe('biometrics operations (native)', () => { }); describe('areLocalCredentialsKnownToServer', () => { + beforeEach(async () => { + await Onyx.multiSet({ + [ONYXKEYS.HAS_LOADED_APP]: true, + [ONYXKEYS.IS_LOADING_APP]: false, + }); + await waitForBatchedUpdates(); + }); + afterEach(async () => { await Onyx.clear(); await waitForBatchedUpdates(); @@ -96,5 +104,38 @@ describe('biometrics operations (native)', () => { await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); }); + + it('should wait for the initial account data before deciding that registration is required', async () => { + mockGetAllKeys.mockResolvedValue({keys: [{publicKey: LOCAL_PUBLIC_KEY_BASE64}]}); + await Onyx.multiSet({ + [ONYXKEYS.HAS_LOADED_APP]: false, + [ONYXKEYS.IS_LOADING_APP]: true, + }); + + const credentialsCheck = areLocalCredentialsKnownToServer(ACCOUNT_ID); + await waitForBatchedUpdates(); + + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_CREDENTIAL_ID]}); + await Onyx.multiSet({ + [ONYXKEYS.HAS_LOADED_APP]: true, + [ONYXKEYS.IS_LOADING_APP]: false, + }); + + await expect(credentialsCheck).resolves.toBe(true); + }); + + it('should not trust stale server credentials while new account data is loading', async () => { + mockGetAllKeys.mockResolvedValue({keys: [{publicKey: LOCAL_PUBLIC_KEY_BASE64}]}); + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_CREDENTIAL_ID]}); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, true); + + const credentialsCheck = areLocalCredentialsKnownToServer(ACCOUNT_ID); + await waitForBatchedUpdates(); + + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: []}); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + + await expect(credentialsCheck).resolves.toBe(false); + }); }); }); diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts index 422a36032948..316dbf7f5078 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts @@ -59,6 +59,14 @@ describe('biometrics operations (web)', () => { }); describe('areLocalCredentialsKnownToServer', () => { + beforeEach(async () => { + await Onyx.multiSet({ + [ONYXKEYS.HAS_LOADED_APP]: true, + [ONYXKEYS.IS_LOADING_APP]: false, + }); + await waitForBatchedUpdates(); + }); + afterEach(async () => { await Onyx.clear(); await waitForBatchedUpdates(); @@ -83,5 +91,38 @@ describe('biometrics operations (web)', () => { await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); }); + + it('waits for the initial account data before deciding that registration is required', async () => { + await Onyx.multiSet({ + [ONYXKEYS.HAS_LOADED_APP]: false, + [ONYXKEYS.IS_LOADING_APP]: true, + }); + await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: LOCAL_PASSKEY_ID, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); + + const credentialsCheck = areLocalCredentialsKnownToServer(ACCOUNT_ID); + await waitForBatchedUpdates(); + + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_PASSKEY_ID]}); + await Onyx.multiSet({ + [ONYXKEYS.HAS_LOADED_APP]: true, + [ONYXKEYS.IS_LOADING_APP]: false, + }); + + await expect(credentialsCheck).resolves.toBe(true); + }); + + it('does not trust stale server credentials while new account data is loading', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [LOCAL_PASSKEY_ID]}); + await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: LOCAL_PASSKEY_ID, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, true); + + const credentialsCheck = areLocalCredentialsKnownToServer(ACCOUNT_ID); + await waitForBatchedUpdates(); + + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: []}); + await Onyx.set(ONYXKEYS.IS_LOADING_APP, false); + + await expect(credentialsCheck).resolves.toBe(false); + }); }); }); From 7cf44c66ab61a8c509e2061e4c4452719f710f9c Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 3 Aug 2026 19:03:12 +0200 Subject: [PATCH 29/30] refactor(mfa): derive validate code submitting state from machine --- .../MultifactorAuthentication/machine/snapshotToState.ts | 8 ++++++++ src/pages/MultifactorAuthentication/ValidateCodePage.tsx | 6 ++---- .../machine/validateCodeTransition.test.ts | 2 ++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/MultifactorAuthentication/machine/snapshotToState.ts b/src/components/MultifactorAuthentication/machine/snapshotToState.ts index a818e1bd914d..239e6c39ac45 100644 --- a/src/components/MultifactorAuthentication/machine/snapshotToState.ts +++ b/src/components/MultifactorAuthentication/machine/snapshotToState.ts @@ -16,6 +16,9 @@ type MfaState = MfaContext & { /** Whether the machine currently accepts a request for a fresh magic-code email. */ canResendValidateCode: boolean; + /** Whether the submitted magic code is currently being validated. */ + isValidateCodeFormSubmitting: boolean; + /** Whether the magic-code screen currently shows the inline invalid-code error. */ showsInvalidCodeError: boolean; }; @@ -41,6 +44,11 @@ function snapshotToState(snapshot: MfaSnapshot): MfaState { ...snapshot.context, modalState: getModalState(snapshot), canResendValidateCode: snapshot.can({type: 'RESEND_VALIDATE_CODE'}), + isValidateCodeFormSubmitting: snapshot.matches({ + [MFA_STATE.OPEN]: { + [MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE, + }, + }), showsInvalidCodeError: snapshot.matches({ [MFA_STATE.OPEN]: { [MFA_STATE.MAGIC_CODE]: { diff --git a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index bbce7b9779cd..48d6530b3a08 100644 --- a/src/pages/MultifactorAuthentication/ValidateCodePage.tsx +++ b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx @@ -52,7 +52,7 @@ function MultifactorAuthenticationValidateCodePage() { const [formError, setFormError] = useState({}); const [canShowError, setCanShowError] = useState(false); const {requestCancel, submitValidateCode, resendValidateCode, notifyValidateCodeChanged, state} = useMultifactorAuthenticationInternal(); - const {showsInvalidCodeError, isCancelConfirmVisible, canResendValidateCode} = state; + const {showsInvalidCodeError, isCancelConfirmVisible, canResendValidateCode, isValidateCodeFormSubmitting} = state; // Refs const inputRef = useRef(null); @@ -61,8 +61,6 @@ function MultifactorAuthenticationValidateCodePage() { // Derived state const hasAccountError = !!account && !isEmptyObject(account?.errors); - // The MFA registration challenge always uses VALIDATE_CODE_FORM, even when the account has 2FA enabled. - const isValidateCodeFormSubmitting = !!account?.isLoading && account.loadingForm === CONST.FORMS.VALIDATE_CODE_FORM; const shouldDisableResendCode = isOffline || !canResendValidateCode; const validateCodeActionError = getLatestErrorField(validateActionCode, 'actionVerified'); const hasValidateCodeActionError = !isEmptyObject(validateCodeActionError); @@ -154,7 +152,7 @@ function MultifactorAuthenticationValidateCodePage() { */ const validateAndSubmitForm = () => { // Check if already loading - if (account?.isLoading) { + if (isValidateCodeFormSubmitting) { return; } diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index 5b8e63b19c74..8162c8918e7a 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -151,6 +151,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}})).toBe(true); + expect(snapshotToState(result).isValidateCodeFormSubmitting).toBe(true); expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE); expect(result.context.registrationChallenge).toBeUndefined(); expect(requestRegistrationChallengeMock).toHaveBeenCalledWith(MFA_TEST_VALIDATE_CODE); @@ -168,6 +169,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}})).toBe(false); + expect(snapshotToState(result).isValidateCodeFormSubmitting).toBe(false); expect(result.context.registrationChallenge).toBe(MFA_TEST_REGISTRATION_CHALLENGE); expect(result.context.error).toBeUndefined(); From c8ac8923282ef4b49a89695652c0388878cdae04 Mon Sep 17 00:00:00 2001 From: Dariusz Biela Date: Mon, 3 Aug 2026 19:13:40 +0200 Subject: [PATCH 30/30] fix(mfa): clear validate code after challenge request --- .../MultifactorAuthentication/machine/mfaMachine.ts | 4 ++++ .../machine/graphTraversal/viewMatchesMachine.test.tsx | 10 +++++----- .../machine/validateCodeTransition.test.ts | 6 +++++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index da8764fe5448..35abb34c0ead 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -104,6 +104,7 @@ const MFAMachine = setup({ } return {validateCode: event.validateCode}; }), + clearValidateCode: assign({validateCode: undefined}), approveSoftPrompt: assign({softPromptApproved: true}), persistSoftPromptAcceptance: ({context}) => { if (context.accountID === undefined) { @@ -244,6 +245,9 @@ const MFAMachine = setup({ }, }, [MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE]: { + // The submitted code is needed only while this actor starts and runs. Clear it on + // every way out so the one-time code cannot outlive the request that consumes it. + exit: 'clearValidateCode', invoke: { id: 'requestRegistrationChallenge', src: 'requestRegistrationChallenge', diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index d54fb4454fd0..ec3e188f69e8 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -192,14 +192,14 @@ const testConfig = { [`${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`]: (state: SnapshotFrom) => { expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); - // A stored code means the flow re-entered this check from the magic-code screen, which - // stays visible while the read runs; a first pass runs behind the transparent initial screen. - if (state.context.validateCode === undefined) { + expect(state.context.validateCode).toBeUndefined(); + // A registration challenge means the flow re-entered this check from the magic-code + // screen, which stays visible while the read runs; a first pass has no challenge and + // runs behind the transparent initial screen. + if (state.context.registrationChallenge === undefined) { expect(screen.queryAllByTestId(TEST_ID.INITIAL_SCREEN)).toHaveLength(1); - expect(state.context.registrationChallenge).toBeUndefined(); } else { expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); - expect(state.context.registrationChallenge).toBeDefined(); // The magic-code screen stays visible during this read, but the machine no longer // accepts resend requests after a valid code has advanced the flow. expect(screen.getByTestId(TEST_ID.VALIDATE_CODE_RESEND_BUTTON)).toBeDisabled(); diff --git a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts index 8162c8918e7a..0889b8390fb6 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -170,6 +170,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE}})).toBe(false); expect(snapshotToState(result).isValidateCodeFormSubmitting).toBe(false); + expect(result.context.validateCode).toBeUndefined(); expect(result.context.registrationChallenge).toBe(MFA_TEST_REGISTRATION_CHALLENGE); expect(result.context.error).toBeUndefined(); @@ -187,6 +188,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.INVALID_CODE}}})).toBe(true); expect(snapshotToState(result).showsInvalidCodeError).toBe(true); + expect(result.context.validateCode).toBeUndefined(); expect(result.context.registrationChallenge).toBeUndefined(); expect(result.context.error).toBeUndefined(); expect(requestValidateCodeActionMock).not.toHaveBeenCalled(); @@ -207,7 +209,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.context.registrationChallenge).toBe(MFA_TEST_REGISTRATION_CHALLENGE); - expect(result.context.validateCode).toBe(MFA_TEST_VALIDATE_CODE); + expect(result.context.validateCode).toBeUndefined(); expect(snapshotToState(result).showsInvalidCodeError).toBe(false); actor.stop(); @@ -223,6 +225,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); + expect(result.context.validateCode).toBeUndefined(); expect(result.context.error?.reason).toBe(REASON.SERVER_ERRORS.UNRECOGNIZED); expect(result.context.registrationChallenge).toBeUndefined(); @@ -239,6 +242,7 @@ describe('MFA magic code and registration decision', () => { const result = actor.getSnapshot(); expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); + expect(result.context.validateCode).toBeUndefined(); expect(result.context.error?.reason).toBe(REASON.LOCAL_ERRORS.UNHANDLED_API_RESPONSE); expect(result.context.registrationChallenge).toBeUndefined();