diff --git a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts index 455810d16e2c..978071137fbc 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationInternalApiContext.ts @@ -24,6 +24,15 @@ 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; + + /** Request a fresh magic-code email. The machine sends it only while the magic-code screen waits for a code. */ + resendValidateCode: () => 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 6cba45a99ddf..11740400e320 100644 --- a/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx +++ b/src/components/MultifactorAuthentication/Context/MultifactorAuthenticationMainContext.tsx @@ -87,6 +87,9 @@ 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 resendValidateCode = () => send({type: 'RESEND_VALIDATE_CODE'}); + 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'}); @@ -102,6 +105,9 @@ function MultifactorAuthenticationContextProvider({children}: MultifactorAuthent closeModal, notifyModalClosed, approveSoftPrompt, + submitValidateCode, + resendValidateCode, + notifyValidateCodeChanged, requestCancel, hideCancelConfirm, confirmCancel, diff --git a/src/components/MultifactorAuthentication/Context/state.ts b/src/components/MultifactorAuthentication/Context/state.ts index b77a086d1329..6fb139d4e086 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 {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import type {AuthTypeInfo} from '@libs/MultifactorAuthentication/shared/types'; /** @@ -10,15 +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; - /** Challenge received from backend for authorization (full object with allowCredentials, rpId, challenge) */ authorizationChallenge: AuthenticationChallenge | undefined; @@ -39,9 +29,6 @@ type MultifactorAuthenticationState = { }; const DEFAULT_STATE: MultifactorAuthenticationState = { - continuableError: undefined, - validateCode: undefined, - 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 74ba2c5b0630..94b4fe0b0d80 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,20 +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': 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 d1676bfb2386..3578fc70cb05 100644 --- a/src/components/MultifactorAuthentication/Context/types.ts +++ b/src/components/MultifactorAuthentication/Context/types.ts @@ -1,16 +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 {AuthenticationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; 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} | {type: 'SET_AUTHORIZATION_COMPLETE'; payload: boolean} 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({ ) : ( { 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. + * + * This is the canonical non-React implementation. The legacy `useNativeBiometricsHSM` 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 localCredentialID = await getLocalCredentialID(accountID); + if (!localCredentialID) { + return false; + } + await waitForAccountDataReady(signal); + const account = await readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal); + 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..652f208f6c39 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.ts @@ -1,10 +1,17 @@ import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; +import waitForAccountDataReady from '@libs/MultifactorAuthentication/shared/waitForAccountDataReady'; +import {readOnyxValueOnce} from '@libs/MultifactorAuthentication/shared/waitForOnyxValue'; + +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 +25,22 @@ 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. + * + * 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 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)); +} + +export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; 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)); 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/mfaActors.ts b/src/components/MultifactorAuthentication/machine/mfaActors.ts index 7d1728d084b2..94edfaa6b3c9 100644 --- a/src/components/MultifactorAuthentication/machine/mfaActors.ts +++ b/src/components/MultifactorAuthentication/machine/mfaActors.ts @@ -1,13 +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/waitForOnyxValue'; -import {getDeviceBiometricsOnyxKey} from '@userActions/MultifactorAuthentication'; +import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; -import Onyx from 'react-native-onyx'; import {fromPromise} from 'xstate'; -import type {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 @@ -19,30 +22,35 @@ 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, signal}) => areLocalCredentialsKnownToServer(input.accountID, signal)); + +/** + * 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}; + 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 1ecd07d76c3c..35abb34c0ead 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -5,13 +5,16 @@ 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'; +import {CONST as COMMON_CONST} from 'expensify-common'; import {assign, setup} from 'xstate'; -import type {MfaContext, MfaEvent} from './types'; +import type {MfaMachineEvent} from './machineEvents'; +import type {MfaContext} from './types'; import createActors from './mfaActors'; @@ -21,6 +24,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.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]; @@ -31,6 +36,8 @@ const DEFAULT_CONTEXT: MfaContext = { scenarioName: undefined, scenario: undefined, payload: undefined, + validateCode: undefined, + registrationChallenge: undefined, softPromptApproved: false, isCancelConfirmVisible: false, }; @@ -48,7 +55,7 @@ const MFAMachine = setup({ /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ types: { context: {} as MfaContext, - events: {} as MfaEvent, + events: {} as MfaMachineEvent, }, /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ actors: createActors(), @@ -57,8 +64,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 {}; @@ -82,6 +89,22 @@ 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 and on an explicit resend request, never on (re)entry, so the invalid-code retry + // loop cannot resend the email. + 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}) => { + if (event.type !== 'VALIDATE_CODE_ENTERED') { + return {}; + } + return {validateCode: event.validateCode}; + }), + clearValidateCode: assign({validateCode: undefined}), approveSoftPrompt: assign({softPromptApproved: true}), persistSoftPromptAcceptance: ({context}) => { if (context.accountID === undefined) { @@ -143,7 +166,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 +176,29 @@ 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, 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'}, + ], + 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 +217,67 @@ const MFAMachine = setup({ }, }, }, + [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]: { + initial: MFA_STATE.AWAITING_INPUT, + on: { + VALIDATE_CODE_ENTERED: {target: MFA_STATE.REQUESTING_REGISTRATION_CHALLENGE, actions: 'submitValidateCode'}, + RESEND_VALIDATE_CODE: {target: `.${MFA_STATE.AWAITING_INPUT}`, actions: 'requestValidateCode'}, + }, + states: { + [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 + // construction and nothing stale can outlive the screen. + [MFA_STATE.INVALID_CODE]: { + on: { + VALIDATE_CODE_CHANGED: MFA_STATE.AWAITING_INPUT, + }, + }, + }, + }, + [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', + 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}.${MFA_STATE.INVALID_CODE}`, + }, + {target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})}, + ], + 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. [MFA_STATE.PROMPT]: { id: MFA_STATE.PROMPT, diff --git a/src/components/MultifactorAuthentication/machine/snapshotToState.ts b/src/components/MultifactorAuthentication/machine/snapshotToState.ts index e087f353c3e2..239e6c39ac45 100644 --- a/src/components/MultifactorAuthentication/machine/snapshotToState.ts +++ b/src/components/MultifactorAuthentication/machine/snapshotToState.ts @@ -10,7 +10,18 @@ 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 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; +}; function getModalState(snapshot: MfaSnapshot): MfaModalState { if (snapshot.matches(MFA_STATE.OPEN)) { @@ -29,7 +40,23 @@ 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), + 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]: { + [MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.INVALID_CODE, + }, + }, + }), + }; } export default snapshotToState; diff --git a/src/components/MultifactorAuthentication/machine/types.ts b/src/components/MultifactorAuthentication/machine/types.ts index 3cefd2dd17ae..095bd4f399da 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'; @@ -32,6 +33,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; + + /** 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; @@ -58,7 +65,14 @@ type MultifactorAuthenticationInitEvent; + +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 04a5a8a6f6da..18b1fd260e34 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -221,7 +221,13 @@ const MFA_STATE = { CLOSING: 'closing', PREPARING: 'preparing', VALIDATING_DEVICE: 'validatingDevice', + DECIDING_REGISTRATION: 'decidingRegistration', CHECKING_SOFT_PROMPT_ACCEPTANCE: 'checkingSoftPromptAcceptance', + MAGIC_CODE: 'magicCode', + AWAITING_VALIDATE_CODE: 'awaitingValidateCode', + AWAITING_INPUT: 'awaitingInput', + INVALID_CODE: 'invalidCode', + REQUESTING_REGISTRATION_CHALLENGE: 'requestingRegistrationChallenge', PROMPT: 'prompt', AWAITING_SOFT_PROMPT: 'awaitingSoftPrompt', OUTCOME: 'outcome', @@ -323,6 +329,9 @@ const SHARED_VALUES = { OUTCOME_SCREEN: 'MultifactorAuthenticationOutcomeScreen', OUTCOME_CONFIRM_BUTTON: 'MultifactorAuthenticationOutcomeConfirmButton', PROMPT_CONFIRM_BUTTON: 'MultifactorAuthenticationPromptConfirmButton', + VALIDATE_CODE_INPUT: 'MultifactorAuthenticationValidateCodeInput', + VALIDATE_CODE_SUBMIT_BUTTON: 'MultifactorAuthenticationValidateCodeSubmitButton', + VALIDATE_CODE_RESEND_BUTTON: 'MultifactorAuthenticationValidateCodeResendButton', }, } as const; 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/waitForOnyxValue.ts b/src/libs/MultifactorAuthentication/shared/waitForOnyxValue.ts new file mode 100644 index 000000000000..b95091e198b4 --- /dev/null +++ b/src/libs/MultifactorAuthentication/shared/waitForOnyxValue.ts @@ -0,0 +1,45 @@ +import type {Connection, OnyxKey, OnyxValue} from 'react-native-onyx'; + +import Onyx from 'react-native-onyx'; + +/** + * 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 waitForOnyxValue(key: TKey, predicate: (value: OnyxValue) => boolean, signal?: AbortSignal): Promise> { + return new Promise((resolve) => { + if (signal?.aborted) { + return; + } + + let connection: Connection; + const disconnect = () => Onyx.disconnect(connection); + + signal?.addEventListener('abort', disconnect, {once: true}); + connection = Onyx.connectWithoutView({ + key, + callback: (value) => { + if (!predicate(value)) { + return; + } + signal?.removeEventListener('abort', disconnect); + disconnect(); + resolve(value); + }, + }); + }); +} + +/** + * 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/src/pages/MultifactorAuthentication/ValidateCodePage.tsx b/src/pages/MultifactorAuthentication/ValidateCodePage.tsx index fa17989f8aeb..48d6530b3a08 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'; @@ -19,20 +18,17 @@ 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 VALUES from '@libs/MultifactorAuthentication/VALUES'; 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'; @@ -55,11 +51,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, resendValidateCode, notifyValidateCodeChanged, state} = useMultifactorAuthenticationInternal(); + const {showsInvalidCodeError, isCancelConfirmVisible, canResendValidateCode, isValidateCodeFormSubmitting} = state; // Refs const inputRef = useRef(null); @@ -68,12 +61,10 @@ function MultifactorAuthenticationValidateCodePage() { // Derived state const hasAccountError = !!account && !isEmptyObject(account?.errors); - const hasContinuableError = !!continuableError; - const isValidateCodeFormSubmitting = AccountUtils.isValidateCodeFormSubmitting(account); - const shouldDisableResendCode = isOffline ?? account?.isLoading; + const shouldDisableResendCode = isOffline || !canResendValidateCode; const validateCodeActionError = getLatestErrorField(validateActionCode, 'actionVerified'); const hasValidateCodeActionError = !isEmptyObject(validateCodeActionError); - const hasError = hasAccountError || hasContinuableError || hasValidateCodeActionError; + const hasError = hasAccountError || showsInvalidCodeError || hasValidateCodeActionError; const errorMessage = getErrorMessage(); function getErrorMessage() { @@ -82,25 +73,13 @@ 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) 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))) { @@ -149,9 +128,9 @@ function MultifactorAuthenticationValidateCodePage() { clearAccountMessages(); } - // Clear continuable error when user starts typing after an error - if (continuableError) { - dispatch({type: 'CLEAR_CONTINUABLE_ERROR'}); + // The machine drops the inline invalid-code error once it learns the code changed + if (showsInvalidCodeError) { + notifyValidateCodeChanged(); } }; @@ -160,7 +139,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({}); @@ -173,7 +152,7 @@ function MultifactorAuthenticationValidateCodePage() { */ const validateAndSubmitForm = () => { // Check if already loading - if (account?.isLoading) { + if (isValidateCodeFormSubmitting) { return; } @@ -203,8 +182,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 +209,7 @@ function MultifactorAuthenticationValidateCodePage() { { + 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, + }, + }, + ]), + }), + ); + }); +}); 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/biometricsOperations.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts index 89ee57d5ce5b..98e3417633a0 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,80 @@ describe('biometrics operations (native)', () => { await expect(doesDeviceSupportAuthenticationMethod()).resolves.toBe(false); }); }); + + 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(); + }); + + 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); + }); + + 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 66cb4bddf435..316dbf7f5078 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,72 @@ describe('biometrics operations (web)', () => { await expect(doesDeviceSupportAuthenticationMethod()).resolves.toBe(expected); }); + + 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(); + }); + + 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); + }); + + 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); + }); + }); }); diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index 73974dc6d147..ec3e188f69e8 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -1,11 +1,9 @@ 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 {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'; @@ -15,16 +13,17 @@ import type {SnapshotFrom} from 'xstate'; import Onyx from 'react-native-onyx'; import {MFA_TEST_ACCOUNT_ID} from 'tests/utils/mfa/flowFixtures'; -import getWalkedPaths, { - isAutoDrivenEvent, - READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, - READ_HAS_ACCEPTED_SOFT_PROMPT_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 {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'; @@ -50,34 +49,59 @@ 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. 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; // 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 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; + [Type in MfaDrivableEventType]: (step: MfaExecutorStep) => 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; +} + +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; +} + +/** + * 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.`); + } + return step.event.output; } /** @@ -94,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); }); @@ -124,11 +145,29 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { fireEvent.press(screen.getByTestId(TEST_ID.PROMPT_CONFIRM_BUTTON)); 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), - } satisfies MfaEventExecutors & MfaActorEventExecutors; + VALIDATE_CODE_ENTERED: async (step) => { + 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(); + }, + RESEND_VALIDATE_CODE: async () => { + fireEvent.press(screen.getByTestId(TEST_ID.VALIDATE_CODE_RESEND_BUTTON)); + await waitForBatchedUpdatesWithAct(); + }, + VALIDATE_CODE_CHANGED: async () => { + fireEvent.changeText(screen.getByTestId(TEST_ID.VALIDATE_CODE_INPUT), '1'); + await waitForBatchedUpdatesWithAct(); + }, + [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 */ @@ -144,13 +183,58 @@ 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); + 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); + } else { + expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); + // 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(); }, + [`${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); + 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(); + }, + [`${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}`]: () => { + 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); + 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(); + }, // 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..0889b8390fb6 --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/machine/validateCodeTransition.test.ts @@ -0,0 +1,285 @@ +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'; + +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 {CONST as COMMON_CONST} from 'expensify-common'; +import {createActorAtState, sendCheckLocalCredentialsDone} from 'tests/utils/mfa/flowActors'; +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'; + +// 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(), +})); +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 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 +// 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(() => { + 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', () => { + 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.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}); + + 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('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.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); + + actor.start(); + actor.send({type: 'RESEND_VALIDATE_CODE'}); + + 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.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]: MFA_STATE.AWAITING_INPUT}}})).toBe(true); + expect(snapshotToState(result).showsInvalidCodeError).toBe(false); + 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.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.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.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.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); + + actor.stop(); + }); + + it('stores a valid registration challenge before continuing the flow', async () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); + requestRegistrationChallengeMock.mockResolvedValue(VALID_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.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(); + + 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.MAGIC_CODE]: MFA_STATE.AWAITING_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.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(); + + actor.stop(); + }); + + it('clears the inline error when the rejected code is submitted again without editing', async () => { + 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(); + actor.send({type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}); + await waitForBatchedUpdates(); + 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).toBeUndefined(); + expect(snapshotToState(result).showsInvalidCodeError).toBe(false); + + actor.stop(); + }); + + it('ends the flow with the failure outcome when the challenge request fails fatally', async () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.MAGIC_CODE]: MFA_STATE.AWAITING_VALIDATE_CODE}}); + requestRegistrationChallengeMock.mockResolvedValue(FATAL_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.validateCode).toBeUndefined(); + expect(result.context.error?.reason).toBe(REASON.SERVER_ERRORS.UNRECOGNIZED); + expect(result.context.registrationChallenge).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.MAGIC_CODE]: MFA_STATE.AWAITING_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.validateCode).toBeUndefined(); + 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.MAGIC_CODE]: {[MFA_STATE.AWAITING_VALIDATE_CODE]: MFA_STATE.INVALID_CODE}}}); + + actor.start(); + 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.AWAITING_INPUT}}})).toBe(true); + expect(snapshotToState(result).showsInvalidCodeError).toBe(false); + + 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..bf6c689db879 100644 --- a/tests/unit/hooks/useSplitContextHooks.test.tsx +++ b/tests/unit/hooks/useSplitContextHooks.test.tsx @@ -229,8 +229,6 @@ 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.isFlowComplete).toBe(false); }); @@ -259,10 +257,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 +297,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..2eea802ca3f4 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -1,15 +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, MfaEvent} from '@components/MultifactorAuthentication/machine/types'; +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 {VALIDATE_DEVICE_DONE_EVENT_TYPE} from './flowPaths'; - -type ValidateDeviceOutput = OutputFrom['validateDevice']>; +import {createActorDoneEvent} from './flowPaths'; /** * Builds the context a flow carries right after INIT seeds it. Overrides express a spec's starting @@ -23,6 +21,8 @@ function createFlowContext(overrides: Partial = {}): MfaContext { scenarioName: initEvent.scenarioName, scenario: initEvent.scenario, payload: initEvent.payload, + validateCode: undefined, + registrationChallenge: undefined, softPromptApproved: false, isCancelConfirmVisible: false, ...overrides, @@ -41,10 +41,15 @@ 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); +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: MfaActorOutput<'checkLocalCredentials'>) { + actor.send(createActorDoneEvent('checkLocalCredentials', output)); } -export {createActorAtState, createFlowContext, sendValidateDeviceDone}; +export {createActorAtState, createFlowContext, sendCheckLocalCredentialsDone, sendValidateDeviceDone}; diff --git a/tests/utils/mfa/flowFixtures.ts b/tests/utils/mfa/flowFixtures.ts index cf06670a85ab..ca69dc12b842 100644 --- a/tests/utils/mfa/flowFixtures.ts +++ b/tests/utils/mfa/flowFixtures.ts @@ -1,10 +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. @@ -20,4 +37,4 @@ function createInitEvent(): MultifactorAuthenticationInitEvent(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 the completion event of one invoked actor, keeping its event type tied to that actor's output. + */ +function createActorDoneEvent(actorId: Id, output: NoInfer>): DoneActorEvent, Id> { + return {type: actorDoneEventType(actorId), output, actorId}; +} + +/** 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 = { /** 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; }; @@ -48,12 +87,45 @@ 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('validateDevice', {success: true}), + createActorDoneEvent('checkLocalCredentials', false), + {type: 'RESEND_VALIDATE_CODE'}, + {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, + createActorDoneEvent('requestRegistrationChallenge', {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: [ + createInitEvent(), + createActorDoneEvent('validateDevice', {success: true}), + createActorDoneEvent('checkLocalCredentials', false), + {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, + createActorDoneEvent('requestRegistrationChallenge', {success: false, error: MFA_TEST_INVALID_CODE_ERROR}), + {type: 'VALIDATE_CODE_CHANGED'}, + {type: 'VALIDATE_CODE_ENTERED', validateCode: MFA_TEST_VALIDATE_CODE}, + createActorDoneEvent('requestRegistrationChallenge', {success: true, challenge: MFA_TEST_REGISTRATION_CHALLENGE}), + ], + endState: `${MFA_STATE.OPEN}.${MFA_STATE.PREPARING}.${MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}`, + }, ]; 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 @@ -64,50 +136,46 @@ const MFA_GRAPH_EVENT_FIXTURES = { CLOSE_MODAL: [{type: 'CLOSE_MODAL'}], 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'}], + 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 MfaActors = ReturnType; - -type MfaActorDoneOutputFixtures = { - readonly [Id in keyof MfaActors]: readonly [OutputFrom, ...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 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_OUTPUT_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. - validateDevice: [ + 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: [false, true], -} satisfies MfaActorDoneOutputFixtures; + ), + 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 hasActorDoneOutputFixtures(actorId: string): actorId is keyof typeof MFA_ACTOR_DONE_OUTPUT_FIXTURES { - return Object.hasOwn(MFA_ACTOR_DONE_OUTPUT_FIXTURES, actorId); -} +/** 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()]; -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`; +/** 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}}>; @@ -119,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); } /** @@ -132,40 +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 mirrors the default bare - * `{type}` synthesis for the framework event descriptors (delayed transitions and actor completion) - * that the machine's transitions depend 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): 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 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]); + const fixtures = MFA_TRAVERSAL_EVENT_FIXTURES.filter((event) => event.type === type); + if (fixtures.length > 0) { + events.push(...fixtures); continue; } - if (!type.startsWith('xstate.')) { - throw new Error(`Missing MFA graph event fixture for application event "${type}"`); - } - 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)); + if (isMfaInternalEventType(type)) { + 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); + // 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(`Missing MFA graph event fixture for "${type}"`); } return events; } @@ -201,12 +262,4 @@ function getWalkedPaths() { } export default getWalkedPaths; -export { - getDrivingJourneyPaths, - getMfaShortestPaths, - isAutoDrivenEvent, - READ_HAS_ACCEPTED_SOFT_PROMPT_DONE_EVENT_TYPE, - READ_HAS_ACCEPTED_SOFT_PROMPT_ERROR_EVENT_TYPE, - VALIDATE_DEVICE_DONE_EVENT_TYPE, - VALIDATE_DEVICE_ERROR_EVENT_TYPE, -}; +export {actorDoneEventType, actorErrorEventType, createActorDoneEvent, getDrivingJourneyPaths, getMfaShortestPaths, isAutoDrivenEvent}; diff --git a/tests/utils/mfa/realUi/mocks.ts b/tests/utils/mfa/realUi/mocks.ts index 3aea4789d1c1..d695c09cfa30 100644 --- a/tests/utils/mfa/realUi/mocks.ts +++ b/tests/utils/mfa/realUi/mocks.ts @@ -1,10 +1,17 @@ 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, + RequestRegistrationChallengeInput, + RequestRegistrationChallengeOutput, + ValidateDeviceInput, +} from '@components/MultifactorAuthentication/machine/types'; 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 @@ -84,11 +91,15 @@ 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. */ @@ -96,6 +107,8 @@ function mfaActorsMock() { const actors = { validateDevice: validateDeviceControl.actor, readHasAcceptedSoftPrompt: readHasAcceptedSoftPromptControl.actor, + checkLocalCredentials: checkLocalCredentialsControl.actor, + requestRegistrationChallenge: requestRegistrationChallengeControl.actor, } satisfies ReturnType; return { @@ -111,6 +124,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, @@ -118,6 +142,26 @@ 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}) { + // 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]); + return null; + } + return { + __esModule: true, + default: ImmediatelyFinishedCountdown, + }; +} + function syncHistoryMock() { return { __esModule: true, @@ -153,4 +197,18 @@ function navigationMock() { }; } -export {pendingModalClose, validateDeviceControl, readHasAcceptedSoftPromptControl, resetMfaUiMocks, mfaActorsMock, biometricsHookMock, renderHtmlMock, syncHistoryMock, navigationMock}; +export { + pendingModalClose, + validateDeviceControl, + readHasAcceptedSoftPromptControl, + checkLocalCredentialsControl, + requestRegistrationChallengeControl, + resetMfaUiMocks, + mfaActorsMock, + userActionsMock, + biometricsHookMock, + renderHtmlMock, + validateCodeCountdownMock, + syncHistoryMock, + navigationMock, +};