From 2b785d38453882543f4800465add294735952bba Mon Sep 17 00:00:00 2001 From: jakubstec Date: Mon, 3 Aug 2026 11:34:36 +0200 Subject: [PATCH 1/2] feat: prepare credential creation skeleton and its tests --- .../biometrics/operations/index.native.ts | 13 +- .../biometrics/operations/index.ts | 14 +- .../biometrics/shared/types.ts | 16 +- .../machine/mfaActors.ts | 26 ++- .../machine/types.ts | 9 + .../shared/VALUES.ts | 1 + .../biometricsOperations.test.ts | 58 ++++++ .../biometricsOperationsWeb.test.ts | 188 +++++++++++++++++- .../machine/createCredentialActor.test.ts | 92 +++++++++ .../credentialCreationTransition.test.ts | 150 ++++++++++++++ .../viewMatchesMachine.test.tsx | 17 ++ tests/utils/mfa/flowActors.ts | 16 +- tests/utils/mfa/flowFixtures.ts | 13 +- tests/utils/mfa/flowPaths.ts | 37 +++- tests/utils/mfa/realUi/mocks.ts | 6 + 15 files changed, 639 insertions(+), 17 deletions(-) create mode 100644 tests/unit/components/MultifactorAuthentication/machine/createCredentialActor.test.ts create mode 100644 tests/unit/components/MultifactorAuthentication/machine/credentialCreationTransition.test.ts diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts index 1def1e724867..9ab4e9b540e8 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts @@ -1,3 +1,4 @@ +import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types'; import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import {decodeLibraryError, getKeyAlias} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; @@ -11,8 +12,9 @@ import {getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics import {mfaCredentialIDsSelector} from '@selectors/Account'; /** - * Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions - * read no React state, so the machine actors and other non-React callers can import them directly. + * Platform-resolved biometric operations for the MFA machine's pre-screen checks and credential + * creation ceremony. These functions read no React state, so the machine actors and other + * non-React callers can import them directly. */ /** The authentication method this platform verifies with. Native verifies with HSM-backed biometrics. */ @@ -58,4 +60,9 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor return (mfaCredentialIDsSelector(account) ?? []).includes(localCredentialID); } -export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; +/** Runs the platform HSM key-creation ceremony. */ +async function createCredential(params: CreateCredentialParams): Promise { + throw new Error('Not implemented'); +} + +export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.ts index 161c57b21409..dc719bcbf9ce 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.ts @@ -1,3 +1,5 @@ +import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types'; + import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; @@ -9,8 +11,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {mfaCredentialIDsSelector} from '@selectors/Account'; /** - * Platform-resolved biometric operations for the MFA machine's pre-screen checks. These functions - * read no React state, so the machine actors and other non-React callers can import them directly. + * Platform-resolved biometric operations for the MFA machine's pre-screen checks and credential + * creation ceremony. 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. */ @@ -37,4 +40,9 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor return (localPasskeyCredentials ?? []).some((credential) => serverKnownCredentialIDs.has(credential.id)); } -export {areLocalCredentialsKnownToServer, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; +/** Runs the platform passkey ceremony and persists the resulting credential locally. */ +async function createCredential(params: CreateCredentialParams): Promise { + throw new Error('Not implemented'); +} + +export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/biometrics/shared/types.ts b/src/components/MultifactorAuthentication/biometrics/shared/types.ts index 3076552080fb..21d86da8e424 100644 --- a/src/components/MultifactorAuthentication/biometrics/shared/types.ts +++ b/src/components/MultifactorAuthentication/biometrics/shared/types.ts @@ -1,5 +1,5 @@ import type {AuthenticationChallenge, RegistrationChallenge, SignedChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; -import type {MFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; +import type {MFAError, MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; import type {AuthTypeInfo, RegistrationKeyInfo} from '@libs/MultifactorAuthentication/shared/types'; type BaseRegisterResult = { @@ -15,6 +15,18 @@ type RegisterResult = error: MFAError; } & Partial); +/** + * Params for the platform-resolved credential-creation ceremony. A params object (not positional + * args) keeps both platform signatures identical while native simply ignores `signal`. + */ +type CreateCredentialParams = { + accountID: number; + registrationChallenge: RegistrationChallenge; + signal?: AbortSignal; +}; + +type CreateCredentialResult = MFAResult<{keyInfo: RegistrationKeyInfo}>; + type AuthorizeParams = { challenge: AuthenticationChallenge; }; @@ -58,4 +70,4 @@ type UseBiometricsReturn = { deleteLocalKeysForAccount: () => Promise; }; -export type {RegisterResult, AuthorizeParams, AuthorizeResult, UseBiometricsReturn}; +export type {RegisterResult, AuthorizeParams, AuthorizeResult, UseBiometricsReturn, CreateCredentialParams, CreateCredentialResult}; diff --git a/src/components/MultifactorAuthentication/machine/mfaActors.ts b/src/components/MultifactorAuthentication/machine/mfaActors.ts index f7b73d97dd16..b8bff26a173b 100644 --- a/src/components/MultifactorAuthentication/machine/mfaActors.ts +++ b/src/components/MultifactorAuthentication/machine/mfaActors.ts @@ -10,7 +10,15 @@ import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userAct import {fromPromise} from 'xstate'; -import type {CheckLocalCredentialsInput, ReadHasAcceptedSoftPromptInput, RequestRegistrationChallengeInput, RequestRegistrationChallengeOutput, ValidateDeviceInput} from './types'; +import type { + CheckLocalCredentialsInput, + CreateCredentialInput, + CreateCredentialOutput, + ReadHasAcceptedSoftPromptInput, + RequestRegistrationChallengeInput, + RequestRegistrationChallengeOutput, + ValidateDeviceInput, +} from './types'; /** * A refused device resolves as a failed MFAResult, so the machine's onError transition for this @@ -45,12 +53,26 @@ const requestRegistrationChallengeActor = fromPromise(async () => { + throw new Error('Not implemented'); +}); + /** * Builds the side-effect actors that the machine states invoke. The machine is always created with * these working implementations, so no caller needs to provide stubs or overrides. */ function createActors() { - return {validateDevice, readHasAcceptedSoftPrompt, checkLocalCredentials, requestRegistrationChallenge: requestRegistrationChallengeActor}; + return { + validateDevice, + readHasAcceptedSoftPrompt, + checkLocalCredentials, + requestRegistrationChallenge: requestRegistrationChallengeActor, + createCredential: createCredentialActor, + }; } export default createActors; diff --git a/src/components/MultifactorAuthentication/machine/types.ts b/src/components/MultifactorAuthentication/machine/types.ts index 095bd4f399da..94eb15731731 100644 --- a/src/components/MultifactorAuthentication/machine/types.ts +++ b/src/components/MultifactorAuthentication/machine/types.ts @@ -1,4 +1,5 @@ import type {AllowedAuthenticationMethods} from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility'; +import type {CreateCredentialParams} from '@components/MultifactorAuthentication/biometrics/shared/types'; import type {MultifactorAuthenticationScenarioConfigFor} from '@components/MultifactorAuthentication/config'; import type { MultifactorAuthenticationScenario, @@ -89,8 +90,16 @@ type RequestRegistrationChallengeInput = {validateCode: string}; /** A successful response must carry the validated registration challenge. */ type RequestRegistrationChallengeOutput = MFAResult<{challenge: RegistrationChallenge}>; +/** Input the machine passes to the credential-creation actor: everything `CreateCredentialParams` needs except the abort signal, which the actor supplies itself. */ +type CreateCredentialInput = Omit; + +/** The credential-creation actor's result. `keyInfo` never leaves the actor, so a success carries no additional data. */ +type CreateCredentialOutput = MFAResult; + export type { CheckLocalCredentialsInput, + CreateCredentialInput, + CreateCredentialOutput, MfaContext, MfaEvent, MfaModalState, diff --git a/src/libs/MultifactorAuthentication/shared/VALUES.ts b/src/libs/MultifactorAuthentication/shared/VALUES.ts index 18b1fd260e34..47136bc12094 100644 --- a/src/libs/MultifactorAuthentication/shared/VALUES.ts +++ b/src/libs/MultifactorAuthentication/shared/VALUES.ts @@ -230,6 +230,7 @@ const MFA_STATE = { REQUESTING_REGISTRATION_CHALLENGE: 'requestingRegistrationChallenge', PROMPT: 'prompt', AWAITING_SOFT_PROMPT: 'awaitingSoftPrompt', + CREATING_CREDENTIAL: 'creatingCredential', OUTCOME: 'outcome', RESOLVING_OUTCOME: 'resolvingOutcome', SUCCESS: 'success', diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts index 8a1b20b00aec..93c2aacede20 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperations.test.ts @@ -2,27 +2,33 @@ // (operations/index.native.ts), which checks the HSM biometric sensor. import { areLocalCredentialsKnownToServer, + createCredential, deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod, } from '@components/MultifactorAuthentication/biometrics/operations'; +import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; import VALUES from '@libs/MultifactorAuthentication/VALUES'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import Base64URL from '@src/utils/Base64URL'; import Onyx from 'react-native-onyx'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; const mockIsSensorAvailable = jest.fn(); const mockGetAllKeys = jest.fn(); +const mockCreateKeys = 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), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + createKeys: (...args: unknown[]) => mockCreateKeys(...args), })); const ACCOUNT_ID = 12345; @@ -30,6 +36,13 @@ const ACCOUNT_ID = 12345; // characters below only match after the module's base64url conversion. const LOCAL_PUBLIC_KEY_BASE64 = 'Ab+/cd=='; const LOCAL_CREDENTIAL_ID = 'Ab-_cd'; +const REGISTRATION_CHALLENGE: RegistrationChallenge = { + challenge: 'native-registration-challenge', + rp: {id: 'expensify.com'}, + user: {id: 'native-test-user', displayName: 'Native Test User'}, + pubKeyCredParams: [{type: 'public-key', alg: -7}], + timeout: 60000, +}; describe('biometrics operations (native)', () => { beforeEach(() => { @@ -97,4 +110,49 @@ describe('biometrics operations (native)', () => { await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); }); }); + + // Mirrors the `register` cases in useNativeBiometricsHSM.test.ts, which move over here when that + // hook is deleted. + describe('createCredential', () => { + beforeEach(() => { + mockCreateKeys.mockResolvedValue({publicKey: LOCAL_PUBLIC_KEY_BASE64}); + }); + + it('creates the HSM key with the account-specific alias', async () => { + await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE}); + + expect(mockCreateKeys).toHaveBeenCalledWith('12345_HSM_KEY', 'ec256', undefined, true, false); + }); + + it('returns the exact NativeBiometricsHSMKeyInfo shape on success', async () => { + const result = await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE}); + + expect(result).toEqual({ + success: true, + keyInfo: { + rawId: LOCAL_CREDENTIAL_ID, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_TYPE, + response: { + clientDataJSON: Base64URL.encode(JSON.stringify({challenge: REGISTRATION_CHALLENGE.challenge})), + biometric: { + publicKey: LOCAL_CREDENTIAL_ID, + algorithm: CONST.COSE_ALGORITHM.ES256, + }, + }, + }, + }); + }); + + it('returns a failed result with the mapped reason when the library throws', async () => { + mockCreateKeys.mockRejectedValue(Object.assign(new Error('Key creation failed'), {code: 'CREATE_KEYS_ERROR'})); + + const result = await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE}); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected credential creation to fail'); + } + expect(result.error.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.HSM.KEY_CREATION_FAILED); + }); + }); }); diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts index 422a36032948..5a82b96e7053 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts @@ -4,24 +4,98 @@ * * @jest-environment jsdom */ +/* eslint-disable max-classes-per-file -- two fake DOM globals (`FakeAuthenticatorAttestationResponse`, `PublicKeyCredential`) are each simplest as a class expression. */ import type * as WebBiometricsOperations from '@components/MultifactorAuthentication/biometrics/operations/index'; +import type * as WebAuthnModule from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; +import {arrayBufferToBase64URL, extractAAGUID} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; +import type {RegistrationChallenge} from '@libs/MultifactorAuthentication/shared/challengeTypes'; + import {getPasskeyOnyxKey} from '@userActions/Passkey'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import Onyx from 'react-native-onyx'; +import getOnyxValue from 'tests/utils/getOnyxValue'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; +const mockCreatePasskeyCredential = jest.fn, [PublicKeyCredentialCreationOptions]>(); + +// The navigator boundary is the only thing mocked here; the real option-building, extraction, and +// error-decoding helpers stay under test, matching checkDeviceEligibility.test.ts's partial-mock shape. +jest.mock('@libs/MultifactorAuthentication/Passkeys/WebAuthn', () => ({ + ...jest.requireActual('@libs/MultifactorAuthentication/Passkeys/WebAuthn'), + createPasskeyCredential: (options: PublicKeyCredentialCreationOptions) => mockCreatePasskeyCredential(options), +})); + // jest-expo resolves the native variant by default, so load the web entry point explicitly. -const {areLocalCredentialsKnownToServer, deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} = jest.requireActual( - '@components/MultifactorAuthentication/biometrics/operations/index.ts', -); +const {areLocalCredentialsKnownToServer, createCredential, deviceCheckFailureReason, deviceVerificationType, doesDeviceSupportAuthenticationMethod} = jest.requireActual< + typeof WebBiometricsOperations +>('@components/MultifactorAuthentication/biometrics/operations/index.ts'); const ACCOUNT_ID = 12345; const LOCAL_PASSKEY_ID = 'local-passkey-credential-id'; +/** + * jsdom has no `AuthenticatorAttestationResponse` global. `window === globalThis` in jsdom, so + * assigning it here lets the operation's bare `instanceof AuthenticatorAttestationResponse` check + * resolve against this fake class. + */ +class FakeAuthenticatorAttestationResponse { + clientDataJSON: ArrayBuffer; + + attestationObject: ArrayBuffer; + + private transports: string[]; + + private authenticatorData: ArrayBuffer; + + constructor(clientDataJSON: ArrayBuffer, attestationObject: ArrayBuffer, transports: string[], authenticatorData: ArrayBuffer) { + this.clientDataJSON = clientDataJSON; + this.attestationObject = attestationObject; + this.transports = transports; + this.authenticatorData = authenticatorData; + } + + getTransports(): string[] { + return this.transports; + } + + getAuthenticatorData(): ArrayBuffer { + return this.authenticatorData; + } +} + +function bytesToArrayBuffer(bytes: number[]): ArrayBuffer { + return new Uint8Array(bytes).buffer; +} + +/** 55 bytes so the aaguid slice (bytes 37-52) is populated and meaningful. */ +const FAKE_AUTHENTICATOR_DATA = bytesToArrayBuffer(Array.from({length: 55}, (_, index) => index)); +const EXPECTED_AAGUID = extractAAGUID(FAKE_AUTHENTICATOR_DATA); + +function buildFakeAttestationCredential(rawId: ArrayBuffer, response: unknown) { + // The operation only reads `rawId` and `response` off the WebAuthn credential, so a minimal fake + // stands in for the full `PublicKeyCredential` shape jsdom cannot produce. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see comment above. + return {rawId, response} as unknown as PublicKeyCredential; +} + +function buildFakeAttestationResponse(transports: string[] = [CONST.PASSKEY_TRANSPORT.INTERNAL, CONST.PASSKEY_TRANSPORT.HYBRID]) { + return new FakeAuthenticatorAttestationResponse(bytesToArrayBuffer([1, 2, 3]), bytesToArrayBuffer([4, 5, 6]), transports, FAKE_AUTHENTICATOR_DATA); +} + +const REGISTRATION_CHALLENGE: RegistrationChallenge = { + challenge: 'web-registration-challenge', + rp: {id: 'expensify.com'}, + user: {id: 'web-test-user', displayName: 'Web Test User'}, + pubKeyCredParams: [{type: 'public-key', alg: -7}], + timeout: 60000, +}; + +const originalAuthenticatorAttestationResponseDescriptor = Object.getOwnPropertyDescriptor(window, 'AuthenticatorAttestationResponse'); + const originalPublicKeyCredentialDescriptor = Object.getOwnPropertyDescriptor(window, 'PublicKeyCredential'); function setWebAuthnSupport(isSupported: boolean) { @@ -84,4 +158,112 @@ describe('biometrics operations (web)', () => { await expect(areLocalCredentialsKnownToServer(ACCOUNT_ID)).resolves.toBe(false); }); }); + + // No coverage exists yet for `usePasskeys.register()`'s ceremony; this pins it at the operation + // level ahead of the hook being deleted. + describe('createCredential', () => { + const KNOWN_CREDENTIAL_ID = 'known-cred-id'; + + beforeEach(() => { + mockCreatePasskeyCredential.mockReset(); + Object.defineProperty(window, 'AuthenticatorAttestationResponse', {configurable: true, value: FakeAuthenticatorAttestationResponse}); + }); + + afterEach(async () => { + if (originalAuthenticatorAttestationResponseDescriptor) { + Object.defineProperty(window, 'AuthenticatorAttestationResponse', originalAuthenticatorAttestationResponseDescriptor); + } else { + Reflect.deleteProperty(window, 'AuthenticatorAttestationResponse'); + } + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('creates the passkey, persists it locally, and returns the exact keyInfo shape', async () => { + await Onyx.merge(ONYXKEYS.ACCOUNT, {multifactorAuthenticationPublicKeyIDs: [KNOWN_CREDENTIAL_ID, 'server-only-id']}); + await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: KNOWN_CREDENTIAL_ID, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); + + const rawId = bytesToArrayBuffer([10, 20, 30, 40]); + const expectedCredentialId = arrayBufferToBase64URL(rawId); + mockCreatePasskeyCredential.mockResolvedValue(buildFakeAttestationCredential(rawId, buildFakeAttestationResponse())); + + const result = await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE}); + + expect(result).toEqual({ + success: true, + keyInfo: { + rawId: expectedCredentialId, + type: CONST.PASSKEY_CREDENTIAL_TYPE, + transports: [CONST.PASSKEY_TRANSPORT.INTERNAL, CONST.PASSKEY_TRANSPORT.HYBRID], + aaguid: EXPECTED_AAGUID, + response: { + clientDataJSON: arrayBufferToBase64URL(bytesToArrayBuffer([1, 2, 3])), + attestationObject: arrayBufferToBase64URL(bytesToArrayBuffer([4, 5, 6])), + }, + }, + }); + + // The reconciled (server-known) local credentials, not the raw local list, are excluded. + // `buildAllowedCredentialDescriptors` (the real, unmocked helper) always builds `id` as a + // plain ArrayBuffer, even though the DOM lib widens `PublicKeyCredentialDescriptor.id` to + // `BufferSource`. + const optionsPassedToCeremony = mockCreatePasskeyCredential.mock.calls.at(0)?.[0]; + const excludedCredentialId = optionsPassedToCeremony?.excludeCredentials?.at(0)?.id; + expect(optionsPassedToCeremony?.excludeCredentials).toHaveLength(1); + expect(excludedCredentialId).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see comment above. + expect(arrayBufferToBase64URL((excludedCredentialId ?? new ArrayBuffer(0)) as ArrayBuffer)).toBe(KNOWN_CREDENTIAL_ID); + + await waitForBatchedUpdates(); + const storedCredentials = await getOnyxValue(getPasskeyOnyxKey(String(ACCOUNT_ID))); + expect(storedCredentials?.map((credential) => credential.id)).toEqual(expect.arrayContaining([KNOWN_CREDENTIAL_ID, expectedCredentialId])); + }); + + it('maps a WebAuthn DOMException to the corresponding local error reason', async () => { + mockCreatePasskeyCredential.mockRejectedValue(new DOMException('The operation was not allowed', 'NotAllowedError')); + + const result = await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE}); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected credential creation to fail'); + } + expect(result.error.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.WEBAUTHN.NOT_ALLOWED); + }); + + it('rejects an unexpected response type', async () => { + const rawId = bytesToArrayBuffer([50, 60, 70]); + mockCreatePasskeyCredential.mockResolvedValue(buildFakeAttestationCredential(rawId, {})); + + const result = await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE}); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected credential creation to fail'); + } + expect(result.error.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.WEBAUTHN.UNEXPECTED_RESPONSE); + }); + + it('still persists the credential when reconciliation has already wiped the duplicate id', async () => { + // The backend doesn't know this id, so reconciliation removes it from Onyx before the + // ceremony runs. The ceremony then resolves with the same rawId bytes, so the new + // credential's id collides with the entry reconciliation just wiped. A duplicate check + // against the stale pre-reconciliation list would spuriously throw here and, if swallowed, + // would leave the credential registered on the backend but absent from local storage -- + // the next launch would find no local credential and force re-registration. Success alone + // doesn't catch that regression, so this also asserts the credential is actually stored. + const rawId = bytesToArrayBuffer([70, 80, 90]); + const duplicateCredentialId = arrayBufferToBase64URL(rawId); + await Onyx.set(getPasskeyOnyxKey(String(ACCOUNT_ID)), [{id: duplicateCredentialId, type: CONST.PASSKEY_CREDENTIAL_TYPE}]); + mockCreatePasskeyCredential.mockResolvedValue(buildFakeAttestationCredential(rawId, buildFakeAttestationResponse())); + + const result = await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE}); + + expect(result.success).toBe(true); + + await waitForBatchedUpdates(); + const storedCredentials = await getOnyxValue(getPasskeyOnyxKey(String(ACCOUNT_ID))); + expect(storedCredentials?.map((credential) => credential.id)).toEqual([duplicateCredentialId]); + }); + }); }); diff --git a/tests/unit/components/MultifactorAuthentication/machine/createCredentialActor.test.ts b/tests/unit/components/MultifactorAuthentication/machine/createCredentialActor.test.ts new file mode 100644 index 000000000000..5f799039ba7e --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/machine/createCredentialActor.test.ts @@ -0,0 +1,92 @@ +import type * as BiometricsOperations from '@components/MultifactorAuthentication/biometrics/operations'; +import createActors from '@components/MultifactorAuthentication/machine/mfaActors'; +import type {CreateCredentialInput} from '@components/MultifactorAuthentication/machine/types'; + +import {createLocalMFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; +import type {RegistrationKeyInfo} from '@libs/MultifactorAuthentication/shared/types'; + +import {processRegistration} from '@userActions/MultifactorAuthentication/processing'; +import type * as ProcessingActions from '@userActions/MultifactorAuthentication/processing'; + +import CONST from '@src/CONST'; + +import {MFA_TEST_REGISTRATION_CHALLENGE} from 'tests/utils/mfa/flowFixtures'; +import {createActor, waitFor} from 'xstate'; + +const REASON = CONST.MULTIFACTOR_AUTHENTICATION.REASON; + +const mockCreateCredential = jest.fn(); + +// The actor's own decisions (short-circuit on refusal, forward keyInfo, no rollback on a backend +// failure) are what this suite pins, so the platform ceremony and the backend call are mocked here. +jest.mock('@components/MultifactorAuthentication/biometrics/operations', () => ({ + ...jest.requireActual('@components/MultifactorAuthentication/biometrics/operations'), + createCredential: (...args: unknown[]) => mockCreateCredential(...args), +})); + +jest.mock('@userActions/MultifactorAuthentication/processing', () => ({ + ...jest.requireActual('@userActions/MultifactorAuthentication/processing'), + processRegistration: jest.fn(), +})); + +const processRegistrationMock = jest.mocked(processRegistration); + +const ACCOUNT_ID = 12345; + +const CREATE_CREDENTIAL_INPUT: CreateCredentialInput = { + accountID: ACCOUNT_ID, + registrationChallenge: MFA_TEST_REGISTRATION_CHALLENGE, +}; + +const KEY_INFO: RegistrationKeyInfo = { + rawId: 'credential-raw-id', + type: 'public-key', + response: {clientDataJSON: 'client-data-json', attestationObject: 'attestation-object'}, +}; + +/** Runs the machine's real `createCredential` actor logic to completion and returns its final snapshot. */ +async function runCreateCredentialActor() { + const {createCredential} = createActors(); + const actorRef = createActor(createCredential, {input: CREATE_CREDENTIAL_INPUT}); + actorRef.start(); + await waitFor(actorRef, (snapshot) => snapshot.status !== 'active'); + return actorRef.getSnapshot(); +} + +describe('createCredential actor', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('short-circuits on a platform refusal and never calls processRegistration', async () => { + const platformError = createLocalMFAError(REASON.LOCAL_ERRORS.WEBAUTHN.NOT_ALLOWED, 'User dismissed the passkey dialog'); + mockCreateCredential.mockResolvedValue({success: false, error: platformError}); + + const snapshot = await runCreateCredentialActor(); + + expect(snapshot.output).toEqual({success: false, error: platformError}); + expect(processRegistrationMock).not.toHaveBeenCalled(); + }); + + it('forwards the exact keyInfo to processRegistration and returns its result on success', async () => { + mockCreateCredential.mockResolvedValue({success: true, keyInfo: KEY_INFO}); + processRegistrationMock.mockResolvedValue({success: true}); + + const snapshot = await runCreateCredentialActor(); + + expect(processRegistrationMock).toHaveBeenCalledWith({keyInfo: KEY_INFO}); + expect(snapshot.output).toEqual({success: true}); + }); + + it('surfaces a backend failure unchanged and performs no rollback', async () => { + mockCreateCredential.mockResolvedValue({success: true, keyInfo: KEY_INFO}); + const backendError = createLocalMFAError(REASON.CLIENT_ERRORS.UNRECOGNIZED, 'Backend rejected the key'); + processRegistrationMock.mockResolvedValue({success: false, error: backendError}); + + const snapshot = await runCreateCredentialActor(); + + // No rollback: the actor's contract is to surface the backend result as-is, with no key + // deletion and no local-credential clearing attempted on this path. + expect(snapshot.output).toEqual({success: false, error: backendError}); + }); +}); diff --git a/tests/unit/components/MultifactorAuthentication/machine/credentialCreationTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/credentialCreationTransition.test.ts new file mode 100644 index 000000000000..6067b1af68b8 --- /dev/null +++ b/tests/unit/components/MultifactorAuthentication/machine/credentialCreationTransition.test.ts @@ -0,0 +1,150 @@ +import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine'; +import type {CreateCredentialInput, CreateCredentialOutput} from '@components/MultifactorAuthentication/machine/types'; + +import {createLocalMFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; + +import CONST from '@src/CONST'; + +import {createActorAtState, sendCreateCredentialDone, sendReadHasAcceptedSoftPromptDone} from 'tests/utils/mfa/flowActors'; +import createInitEvent, {MFA_TEST_REGISTRATION_CHALLENGE} from 'tests/utils/mfa/flowFixtures'; +import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; +import {createActor, fromPromise} from 'xstate'; + +const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE; +const REASON = CONST.MULTIFACTOR_AUTHENTICATION.REASON; + +// 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 two entries into +// credential creation and the actor-outcome routing by hand. `softPromptTransition.test.ts` keeps +// passing untouched because `createFlowContext` leaves `registrationChallenge` undefined. + +describe('MFA credential creation', () => { + describe('soft-prompt approval', () => { + it('moves to credential creation when a registration challenge is pending', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PROMPT]: MFA_STATE.AWAITING_SOFT_PROMPT}}, {registrationChallenge: MFA_TEST_REGISTRATION_CHALLENGE}); + + actor.start(); + actor.send({type: 'SOFT_PROMPT_APPROVED'}); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: MFA_STATE.CREATING_CREDENTIAL})).toBe(true); + expect(result.context.softPromptApproved).toBe(true); + + actor.stop(); + }); + + it('reaches the success outcome without a pending challenge (returning user)', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PROMPT]: MFA_STATE.AWAITING_SOFT_PROMPT}}); + + actor.start(); + actor.send({type: 'SOFT_PROMPT_APPROVED'}); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.SUCCESS}})).toBe(true); + + actor.stop(); + }); + }); + + describe('soft-prompt acceptance read', () => { + it('moves to credential creation when acceptance was already stored and a challenge is pending', () => { + const actor = createActorAtState( + {[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}}, + {registrationChallenge: MFA_TEST_REGISTRATION_CHALLENGE}, + ); + + actor.start(); + sendReadHasAcceptedSoftPromptDone(actor, true); + + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: MFA_STATE.CREATING_CREDENTIAL})).toBe(true); + + actor.stop(); + }); + + it('reaches the success outcome when acceptance was stored without a pending challenge', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}}); + + actor.start(); + sendReadHasAcceptedSoftPromptDone(actor, true); + + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.SUCCESS}})).toBe(true); + + actor.stop(); + }); + + it('shows the prompt when acceptance was not stored, regardless of a pending challenge', () => { + const actor = createActorAtState( + {[MFA_STATE.OPEN]: {[MFA_STATE.PREPARING]: MFA_STATE.CHECKING_SOFT_PROMPT_ACCEPTANCE}}, + {registrationChallenge: MFA_TEST_REGISTRATION_CHALLENGE}, + ); + + actor.start(); + sendReadHasAcceptedSoftPromptDone(actor, false); + + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.PROMPT]: MFA_STATE.AWAITING_SOFT_PROMPT}})).toBe(true); + + actor.stop(); + }); + }); + + describe('createCredential actor outcome', () => { + it('reaches the success outcome when the actor resolves successfully', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.CREATING_CREDENTIAL}, {registrationChallenge: MFA_TEST_REGISTRATION_CHALLENGE}); + + actor.start(); + sendCreateCredentialDone(actor, {success: true}); + + expect(actor.getSnapshot().matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.SUCCESS}})).toBe(true); + + actor.stop(); + }); + + it('reaches the failure outcome carrying the exact reason when the actor resolves with a failure', () => { + const actor = createActorAtState({[MFA_STATE.OPEN]: MFA_STATE.CREATING_CREDENTIAL}, {registrationChallenge: MFA_TEST_REGISTRATION_CHALLENGE}); + const failureError = createLocalMFAError(REASON.LOCAL_ERRORS.HSM.KEY_CREATION_FAILED, 'Credential creation transition spec failure'); + + actor.start(); + sendCreateCredentialDone(actor, {success: false, error: failureError}); + + const result = actor.getSnapshot(); + expect(result.matches({[MFA_STATE.OPEN]: {[MFA_STATE.OUTCOME]: MFA_STATE.FAILURE}})).toBe(true); + expect(result.context.error).toBe(failureError); + + actor.stop(); + }); + + it('reaches the failure outcome with an unhandled-exception error when the actor rejects', async () => { + const machine = mfaMachine.provide({ + actors: { + createCredential: fromPromise(() => Promise.reject(new Error('Credential registration exploded'))), + }, + }); + const actor = createActor(machine, { + snapshot: machine.resolveState({ + value: {[MFA_STATE.OPEN]: MFA_STATE.CREATING_CREDENTIAL}, + context: { + accountID: 12345, + error: undefined, + scenarioName: createInitEvent().scenarioName, + scenario: createInitEvent().scenario, + payload: undefined, + validateCode: undefined, + registrationChallenge: MFA_TEST_REGISTRATION_CHALLENGE, + softPromptApproved: false, + isCancelConfirmVisible: false, + }, + }), + }); + + actor.start(); + 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('Credential registration threw:'); + + actor.stop(); + }); + }); +}); diff --git a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx index d54fb4454fd0..4204258fbb1f 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx +++ b/tests/unit/components/MultifactorAuthentication/machine/graphTraversal/viewMatchesMachine.test.tsx @@ -18,6 +18,7 @@ import {getSettleableLeafStates} from 'tests/utils/mfa/leafStates'; import renderMfaUi from 'tests/utils/mfa/realUi/harness'; import { checkLocalCredentialsControl, + createCredentialControl, pendingModalClose, readHasAcceptedSoftPromptControl, requestRegistrationChallengeControl, @@ -167,6 +168,8 @@ function createMfaEventExecutors(executeScenario: ExecuteScenario) { [actorErrorEventType('checkLocalCredentials')]: () => settleActor(checkLocalCredentialsControl.reject), [actorDoneEventType('requestRegistrationChallenge')]: (step) => settleActor(() => requestRegistrationChallengeControl.resolve(getActorDoneOutput(step))), [actorErrorEventType('requestRegistrationChallenge')]: () => settleActor(requestRegistrationChallengeControl.reject), + [actorDoneEventType('createCredential')]: (step) => settleActor(() => createCredentialControl.resolve(getActorDoneOutput(step))), + [actorErrorEventType('createCredential')]: () => settleActor(createCredentialControl.reject), } satisfies MfaEventExecutors; } /* eslint-enable @typescript-eslint/naming-convention */ @@ -247,6 +250,20 @@ const testConfig = { expect(state.context.error).toBeUndefined(); expect(state.context.softPromptApproved).toBe(false); }, + // No entry navigation action fires for this state, so whatever screen was already on the + // stack stays up during the ceremony: the prompt when the user just approved it in this + // flow, or the magic-code screen (last navigated) when the persisted acceptance skipped it. + [`${MFA_STATE.OPEN}.${MFA_STATE.CREATING_CREDENTIAL}`]: (state: SnapshotFrom) => { + expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); + expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(0); + expect(state.context.registrationChallenge).toBeDefined(); + expect(state.context.error).toBeUndefined(); + if (state.context.softPromptApproved) { + expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT); + } else { + expect(mfaNavigationRef.getCurrentRoute()?.name).toBe(SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE); + } + }, [`${MFA_STATE.OPEN}.${MFA_STATE.OUTCOME}.${MFA_STATE.SUCCESS}`]: (state: SnapshotFrom) => { expect(screen.queryAllByTestId(TEST_ID.MODAL_BACKDROP)).toHaveLength(1); expect(screen.queryAllByTestId(TEST_ID.OUTCOME_SCREEN)).toHaveLength(1); diff --git a/tests/utils/mfa/flowActors.ts b/tests/utils/mfa/flowActors.ts index 2eea802ca3f4..138f073b620d 100644 --- a/tests/utils/mfa/flowActors.ts +++ b/tests/utils/mfa/flowActors.ts @@ -52,4 +52,18 @@ function sendCheckLocalCredentialsDone(actor: ReturnType, output: MfaActorOutput<'readHasAcceptedSoftPrompt'>) { + actor.send(createActorDoneEvent('readHasAcceptedSoftPrompt', output)); +} + +/** + * Completes the invoked credential-creation actor by sending its done event carrying the given output. + */ +function sendCreateCredentialDone(actor: ReturnType, output: MfaActorOutput<'createCredential'>) { + actor.send(createActorDoneEvent('createCredential', output)); +} + +export {createActorAtState, createFlowContext, sendCheckLocalCredentialsDone, sendCreateCredentialDone, sendReadHasAcceptedSoftPromptDone, sendValidateDeviceDone}; diff --git a/tests/utils/mfa/flowFixtures.ts b/tests/utils/mfa/flowFixtures.ts index ca69dc12b842..30f7588bdab1 100644 --- a/tests/utils/mfa/flowFixtures.ts +++ b/tests/utils/mfa/flowFixtures.ts @@ -2,7 +2,7 @@ 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 {createLocalMFAError, createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; import CONST from '@src/CONST'; @@ -22,6 +22,8 @@ const MFA_TEST_FATAL_REGISTRATION_CHALLENGE_ERROR = createMFAErrorFromApiRespons CONST.MULTIFACTOR_AUTHENTICATION.REASON.CLIENT_ERRORS.UNRECOGNIZED, 'Graph-traversal fatal registration challenge rejection', ); +// A reason outside the two device-check reasons, so the walk lands on the generic failure copy. +const MFA_TEST_CREDENTIAL_CREATION_ERROR = createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.HSM.KEY_CREATION_FAILED, 'Graph-traversal credential creation failure'); /** * Builds the INIT event fixture for the test scenario. @@ -37,4 +39,11 @@ function createInitEvent(): MultifactorAuthenticationInitEvent('readHasAcceptedSoftPrompt'); const checkLocalCredentialsControl = createControlledActor('checkLocalCredentials'); const requestRegistrationChallengeControl = createControlledActor('requestRegistrationChallenge'); +const createCredentialControl = createControlledActor('createCredential'); function resetMfaUiMocks() { pendingModalClose.clear(); @@ -100,6 +103,7 @@ function resetMfaUiMocks() { readHasAcceptedSoftPromptControl.reset(); checkLocalCredentialsControl.reset(); requestRegistrationChallengeControl.reset(); + createCredentialControl.reset(); } /** Replaces the machine's side-effect actors with controlled test implementations. */ @@ -109,6 +113,7 @@ function mfaActorsMock() { readHasAcceptedSoftPrompt: readHasAcceptedSoftPromptControl.actor, checkLocalCredentials: checkLocalCredentialsControl.actor, requestRegistrationChallenge: requestRegistrationChallengeControl.actor, + createCredential: createCredentialControl.actor, } satisfies ReturnType; return { @@ -203,6 +208,7 @@ export { readHasAcceptedSoftPromptControl, checkLocalCredentialsControl, requestRegistrationChallengeControl, + createCredentialControl, resetMfaUiMocks, mfaActorsMock, userActionsMock, From 91ca8362b9f07dd63b8263f799f2d57dbf2f5c3d Mon Sep 17 00:00:00 2001 From: jakubstec Date: Mon, 3 Aug 2026 13:34:22 +0200 Subject: [PATCH 2/2] feat(mfa): add credential creation to the state machine --- .../biometrics/operations/index.native.ts | 42 ++++++++- .../biometrics/operations/index.ts | 92 +++++++++++++++++-- .../machine/mfaActors.ts | 31 +++++-- .../machine/mfaMachine.ts | 46 +++++++++- .../Passkeys/WebAuthn.ts | 9 +- src/libs/actions/Passkey.ts | 9 +- .../biometricsOperationsWeb.test.ts | 40 +++++++- .../machine/createCredentialActor.test.ts | 24 +++++ .../credentialCreationTransition.test.ts | 34 ++++--- 9 files changed, 280 insertions(+), 47 deletions(-) diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts index 9ab4e9b540e8..3e5e1176bfa8 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.native.ts @@ -2,19 +2,20 @@ import type {CreateCredentialParams, CreateCredentialResult} from '@components/M import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import {decodeLibraryError, getKeyAlias} from '@libs/MultifactorAuthentication/NativeBiometricsHSM/helpers'; +import type NativeBiometricsHSMKeyInfo from '@libs/MultifactorAuthentication/NativeBiometricsHSM/types'; import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import Base64URL from '@src/utils/Base64URL'; -import {getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; +import {createKeys, getAllKeys, isSensorAvailable} from '@sbaiahmed1/react-native-biometrics'; import {mfaCredentialIDsSelector} from '@selectors/Account'; /** - * Platform-resolved biometric operations for the MFA machine's pre-screen checks and credential - * creation ceremony. These functions read no React state, so the machine actors and other - * non-React callers can import them directly. + * Platform-resolved biometric operations for the MFA machine's pre-screen checks and the + * credential-creation ceremony. These functions read no React state, so the machine actors and + * other non-React callers can import them directly. */ /** The authentication method this platform verifies with. Native verifies with HSM-backed biometrics. */ @@ -62,7 +63,38 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor /** Runs the platform HSM key-creation ceremony. */ async function createCredential(params: CreateCredentialParams): Promise { - throw new Error('Not implemented'); + const {accountID, registrationChallenge} = params; + try { + const keyAlias = getKeyAlias(accountID); + + /** + * createKeys called with: + * keyAlias - alias associated with the key stored on the device + * keyType: 'ec256' - Elliptic Curve P-256 key + * biometricStrength: undefined - currently ignored when allowDeviceCredentials is set to true + * allowDeviceCredentials: true - allow device credentials fallback when biometrics are unavailable + * failIfExists: false - overwrite any existing key for this alias to support re-registration + */ + const {publicKey} = await createKeys(keyAlias, 'ec256', undefined, true, false); + + const credentialID = Base64URL.base64ToBase64url(publicKey); + const clientDataJSON = JSON.stringify({challenge: registrationChallenge.challenge}); + const keyInfo: NativeBiometricsHSMKeyInfo = { + rawId: credentialID, + type: CONST.MULTIFACTOR_AUTHENTICATION.BIOMETRICS_HSM_TYPE, + response: { + clientDataJSON: Base64URL.encode(clientDataJSON), + biometric: { + publicKey: credentialID, + algorithm: CONST.COSE_ALGORITHM.ES256, + }, + }, + }; + + return {success: true, keyInfo}; + } catch (error) { + return {success: false, error: decodeLibraryError(error)}; + } } export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/biometrics/operations/index.ts b/src/components/MultifactorAuthentication/biometrics/operations/index.ts index dc719bcbf9ce..55ed5929580e 100644 --- a/src/components/MultifactorAuthentication/biometrics/operations/index.ts +++ b/src/components/MultifactorAuthentication/biometrics/operations/index.ts @@ -1,9 +1,20 @@ import type {CreateCredentialParams, CreateCredentialResult} from '@components/MultifactorAuthentication/biometrics/shared/types'; +import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; -import {isWebAuthnSupported} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; +import {getErrorMessage} from '@libs/ErrorUtils'; +import { + arrayBufferToBase64URL, + buildPublicKeyCredentialCreationOptions, + createPasskeyCredential, + decodeWebAuthnError, + extractAAGUID, + isSupportedTransport, + isWebAuthnSupported, +} from '@libs/MultifactorAuthentication/Passkeys/WebAuthn'; +import {createLocalMFAError} from '@libs/MultifactorAuthentication/shared/MFAResult'; import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; -import {getPasskeyOnyxKey} from '@userActions/Passkey'; +import {addLocalPasskeyCredential, getPasskeyOnyxKey, reconcileLocalPasskeysWithBackend} from '@userActions/Passkey'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -11,9 +22,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {mfaCredentialIDsSelector} from '@selectors/Account'; /** - * Platform-resolved biometric operations for the MFA machine's pre-screen checks and credential - * creation ceremony. These functions read no React state, so the machine actors and other - * non-React callers can import them directly. + * Platform-resolved biometric operations for the MFA machine's pre-screen checks and the + * credential-creation ceremony. 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. */ @@ -42,7 +53,76 @@ async function areLocalCredentialsKnownToServer(accountID: number, signal?: Abor /** Runs the platform passkey ceremony and persists the resulting credential locally. */ async function createCredential(params: CreateCredentialParams): Promise { - throw new Error('Not implemented'); + const {accountID, registrationChallenge, signal} = params; + const userId = String(accountID); + const [account, localPasskeyCredentials] = await Promise.all([readOnyxValueOnce(ONYXKEYS.ACCOUNT, signal), readOnyxValueOnce(getPasskeyOnyxKey(userId), signal)]); + + const backendCredentials = (mfaCredentialIDsSelector(account) ?? []).map((id) => ({id, type: CONST.PASSKEY_CREDENTIAL_TYPE})); + const reconciledExisting = reconcileLocalPasskeysWithBackend({userId, backendCredentials, localCredentials: localPasskeyCredentials ?? null}); + const publicKeyOptions = buildPublicKeyCredentialCreationOptions(registrationChallenge, reconciledExisting); + + let credential: PublicKeyCredential; + try { + // Cancelling the flow (CLOSE_MODAL) aborts `signal`, which closes the passkey dialog — the + // rejection below then gets handled like any other refusal. + credential = await createPasskeyCredential(publicKeyOptions, signal); + } catch (error) { + return {success: false, error: decodeWebAuthnError(error)}; + } + + if (!(credential.response instanceof AuthenticatorAttestationResponse)) { + return { + success: false, + error: createLocalMFAError( + CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.WEBAUTHN.UNEXPECTED_RESPONSE, + 'Registration credential response is not AuthenticatorAttestationResponse', + ), + }; + } + const attestationResponse = credential.response; + const credentialId = arrayBufferToBase64URL(credential.rawId); + const clientDataJSON = arrayBufferToBase64URL(attestationResponse.clientDataJSON); + const attestationObject = arrayBufferToBase64URL(attestationResponse.attestationObject); + + const transports = attestationResponse.getTransports?.().filter(isSupportedTransport); + + // getAuthenticatorData() is a WebAuthn Level 2 method — not available in older browsers. + // NOTE: A value of "00000000-0000-0000-0000-000000000000" is expected for Apple iCloud Keychain + const aaguid = attestationResponse.getAuthenticatorData ? extractAAGUID(attestationResponse.getAuthenticatorData()) : undefined; + + // Not every browser honors `signal` on create(), so the ceremony can still succeed after the flow + // was cancelled. Don't persist or register a credential nobody asked for anymore — the passkey + // itself is already on the device either way, that part can't be undone. + if (signal?.aborted) { + return {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'MFA flow canceled before the credential could be persisted')}; + } + + try { + // Reconciled list, not the stale pre-reconciliation read — reconciliation may have already + // dropped a duplicate id, and checking the stale list would throw for a credential that's + // no longer there. + await addLocalPasskeyCredential({ + userId, + credential: {id: credentialId, type: CONST.PASSKEY_CREDENTIAL_TYPE, transports, aaguid}, + existingCredentials: reconciledExisting, + }); + } catch (error) { + // A failed local write shouldn't throw away a ceremony that already succeeded — the backend + // call below is what matters, and the server stays the source of truth for + // `areLocalCredentialsKnownToServer`. + addMFABreadcrumb('Failed to persist local passkey credential', {message: getErrorMessage(error)}, 'error'); + } + + return { + success: true, + keyInfo: { + rawId: credentialId, + type: CONST.PASSKEY_CREDENTIAL_TYPE, + transports, + aaguid, + response: {clientDataJSON, attestationObject}, + }, + }; } export {areLocalCredentialsKnownToServer, createCredential, deviceVerificationType, deviceCheckFailureReason, doesDeviceSupportAuthenticationMethod}; diff --git a/src/components/MultifactorAuthentication/machine/mfaActors.ts b/src/components/MultifactorAuthentication/machine/mfaActors.ts index b8bff26a173b..3830c79ea32b 100644 --- a/src/components/MultifactorAuthentication/machine/mfaActors.ts +++ b/src/components/MultifactorAuthentication/machine/mfaActors.ts @@ -1,12 +1,16 @@ import checkDeviceEligibility from '@components/MultifactorAuthentication/biometrics/checkDeviceEligibility'; -import {areLocalCredentialsKnownToServer} from '@components/MultifactorAuthentication/biometrics/operations'; +import {areLocalCredentialsKnownToServer, createCredential} from '@components/MultifactorAuthentication/biometrics/operations'; +import addMFABreadcrumb from '@components/MultifactorAuthentication/observability/breadcrumbs'; import {isHttpSuccess} from '@libs/MultifactorAuthentication/shared/helpers'; import type {MFAResult} from '@libs/MultifactorAuthentication/shared/MFAResult'; -import {createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; +import {createLocalMFAError, createMFAErrorFromApiResponse} from '@libs/MultifactorAuthentication/shared/MFAResult'; import readOnyxValueOnce from '@libs/MultifactorAuthentication/shared/readOnyxValueOnce'; import {getDeviceBiometricsOnyxKey, requestRegistrationChallenge} from '@userActions/MultifactorAuthentication'; +import {processRegistration} from '@userActions/MultifactorAuthentication/processing'; + +import CONST from '@src/CONST'; import {fromPromise} from 'xstate'; @@ -54,11 +58,26 @@ const requestRegistrationChallengeActor = fromPromise(async () => { - throw new Error('Not implemented'); +const createCredentialActor = fromPromise(async ({input, signal}) => { + const creationResult = await createCredential({...input, signal}); + addMFABreadcrumb('Biometric registration completed', creationResult.success ? {success: true} : creationResult.error, creationResult.success ? 'info' : 'error'); + if (!creationResult.success) { + return creationResult; + } + // The flow may have been cancelled while the ceremony ran. Skip the backend call rather than + // registering a key nobody asked for — this only catches it before the request starts, there's + // no way to cancel one already in flight. + if (signal.aborted) { + return {success: false, error: createLocalMFAError(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED, 'MFA flow canceled before backend registration')}; + } + const registrationResult = await processRegistration({keyInfo: creationResult.keyInfo}); + addMFABreadcrumb('Backend registration completed', registrationResult.success ? {success: true} : registrationResult.error, registrationResult.success ? 'info' : 'error'); + return registrationResult; }); /** diff --git a/src/components/MultifactorAuthentication/machine/mfaMachine.ts b/src/components/MultifactorAuthentication/machine/mfaMachine.ts index da8764fe5448..153081be8405 100644 --- a/src/components/MultifactorAuthentication/machine/mfaMachine.ts +++ b/src/components/MultifactorAuthentication/machine/mfaMachine.ts @@ -26,6 +26,10 @@ 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; +const CREATING_CREDENTIAL_TARGET = `#${MFA_STATE.CREATING_CREDENTIAL}` as const; + +// One literal shared by both soft-prompt exits (approval and the persisted-acceptance skip), so they can't drift apart. +const SOFT_PROMPT_ACCEPTED_ACTIONS = ['approveSoftPrompt', 'persistSoftPromptAcceptance'] 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]; @@ -61,6 +65,7 @@ const MFAMachine = setup({ actors: createActors(), guards: { hasError: ({context}) => context.error !== undefined, + hasRegistrationChallenge: ({context}) => context.registrationChallenge !== undefined, }, actions: { // Seeds the flow's context from the INIT event. A named action's event is typed as the full @@ -207,7 +212,13 @@ const MFAMachine = setup({ } return {accountID: context.accountID}; }, - onDone: [{guard: ({event}) => event.output, target: OUTCOME_TARGET}, {target: PROMPT_TARGET}], + // Not accepted yet -> show the prompt. Accepted with a challenge pending -> create the + // credential. Accepted, nothing pending -> a returning user, straight to the outcome. + onDone: [ + {guard: ({event}) => !event.output, target: PROMPT_TARGET}, + {guard: 'hasRegistrationChallenge', target: CREATING_CREDENTIAL_TARGET}, + {target: OUTCOME_TARGET}, + ], onError: { target: OUTCOME_TARGET, actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Soft-prompt acceptance read', event.error)}), @@ -280,15 +291,40 @@ const MFAMachine = setup({ entry: ['navigateToPrompt'], initial: MFA_STATE.AWAITING_SOFT_PROMPT, on: { - SOFT_PROMPT_APPROVED: { - target: MFA_STATE.OUTCOME, - actions: ['approveSoftPrompt', 'persistSoftPromptAcceptance'], - }, + SOFT_PROMPT_APPROVED: [ + {guard: 'hasRegistrationChallenge', target: MFA_STATE.CREATING_CREDENTIAL, actions: SOFT_PROMPT_ACCEPTED_ACTIONS}, + {target: MFA_STATE.OUTCOME, actions: SOFT_PROMPT_ACCEPTED_ACTIONS}, + ], }, states: { [MFA_STATE.AWAITING_SOFT_PROMPT]: {}, }, }, + // Turns a pending registration challenge into a real credential: platform ceremony, then + // backend registration. Reached from both soft-prompt exits when a challenge is pending. + // No `entry` action on purpose — whatever screen is already up (prompt, or nothing) just + // stays visible during the ceremony, same as legacy. + [MFA_STATE.CREATING_CREDENTIAL]: { + id: MFA_STATE.CREATING_CREDENTIAL, + invoke: { + id: 'createCredential', + src: 'createCredential', + input: ({context}) => { + if (context.accountID === undefined || context.registrationChallenge === undefined) { + throw new Error('MFA account and registration challenge must be stored before creating a credential'); + } + return {accountID: context.accountID, registrationChallenge: context.registrationChallenge}; + }, + onDone: [ + {guard: ({event}) => !event.output.success, target: OUTCOME_TARGET, actions: assign({error: ({event}) => getMFAFailureError(event.output)})}, + {target: OUTCOME_TARGET}, + ], + onError: { + target: OUTCOME_TARGET, + actions: assign({error: ({event}) => createUnhandledExceptionMFAError('Credential registration', event.error)}), + }, + }, + }, [MFA_STATE.OUTCOME]: { id: MFA_STATE.OUTCOME, initial: MFA_STATE.RESOLVING_OUTCOME, diff --git a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts index 32742892f39c..bf7065feb512 100644 --- a/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts +++ b/src/libs/MultifactorAuthentication/Passkeys/WebAuthn.ts @@ -81,9 +81,12 @@ function isPublicKeyCredential(credential: Credential): credential is PublicKeyC return credential instanceof PublicKeyCredential; } -/** Prompts the user to create a new passkey credential via the platform authenticator. */ -async function createPasskeyCredential(options: PublicKeyCredentialCreationOptions): Promise { - const result = await navigator.credentials.create({publicKey: options}); +/** + * Prompts the user to create a new passkey credential via the platform authenticator. `signal` lets + * a caller close the dialog early (e.g. flow cancelled) — the promise rejects with an `AbortError`. + */ +async function createPasskeyCredential(options: PublicKeyCredentialCreationOptions, signal?: AbortSignal): Promise { + const result = await navigator.credentials.create({publicKey: options, signal}); if (!result || !isPublicKeyCredential(result)) { throw new Error('navigator.credentials.create did not return a PublicKeyCredential'); } diff --git a/src/libs/actions/Passkey.ts b/src/libs/actions/Passkey.ts index 7d5961382482..86a7bb192805 100644 --- a/src/libs/actions/Passkey.ts +++ b/src/libs/actions/Passkey.ts @@ -21,12 +21,13 @@ type SetLocalPasskeyCredentialsParams = PasskeyScope & { * Sets passkey credentials in Onyx storage. * We use Onyx.set() instead of Onyx.merge() because passkey entries contain an array of credentials * that needs to be fully replaced, not merged. Using merge() would append to the array instead of replacing it. + * Returns the write's promise so callers that care can await it. */ -function setLocalPasskeyCredentials({userId, entry}: SetLocalPasskeyCredentialsParams): void { +function setLocalPasskeyCredentials({userId, entry}: SetLocalPasskeyCredentialsParams): Promise { if (!userId) { throw new Error('userId is required to store passkey credentials'); } - Onyx.set(getPasskeyOnyxKey(userId), entry); + return Onyx.set(getPasskeyOnyxKey(userId), entry); } type AddLocalPasskeyCredentialParams = PasskeyScope & { @@ -34,14 +35,14 @@ type AddLocalPasskeyCredentialParams = PasskeyScope & { existingCredentials: LocalPasskeyCredentialsEntry | null; }; -function addLocalPasskeyCredential({userId, credential, existingCredentials}: AddLocalPasskeyCredentialParams): void { +function addLocalPasskeyCredential({userId, credential, existingCredentials}: AddLocalPasskeyCredentialParams): Promise { const credentials = existingCredentials ?? []; if (credentials.some((c) => c.id === credential.id)) { throw new Error(`Passkey credential with id "${credential.id}" already exists for user ${userId}`); } - setLocalPasskeyCredentials({userId, entry: [...credentials, credential]}); + return setLocalPasskeyCredentials({userId, entry: [...credentials, credential]}); } /** Deletes all passkey credentials for a user from Onyx storage */ diff --git a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts index 5a82b96e7053..f8abb4b18b32 100644 --- a/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts +++ b/tests/unit/components/MultifactorAuthentication/biometricsOperationsWeb.test.ts @@ -20,13 +20,13 @@ import Onyx from 'react-native-onyx'; import getOnyxValue from 'tests/utils/getOnyxValue'; import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; -const mockCreatePasskeyCredential = jest.fn, [PublicKeyCredentialCreationOptions]>(); +const mockCreatePasskeyCredential = jest.fn, [PublicKeyCredentialCreationOptions, AbortSignal | undefined]>(); // The navigator boundary is the only thing mocked here; the real option-building, extraction, and // error-decoding helpers stay under test, matching checkDeviceEligibility.test.ts's partial-mock shape. jest.mock('@libs/MultifactorAuthentication/Passkeys/WebAuthn', () => ({ ...jest.requireActual('@libs/MultifactorAuthentication/Passkeys/WebAuthn'), - createPasskeyCredential: (options: PublicKeyCredentialCreationOptions) => mockCreatePasskeyCredential(options), + createPasskeyCredential: (options: PublicKeyCredentialCreationOptions, signal?: AbortSignal) => mockCreatePasskeyCredential(options, signal), })); // jest-expo resolves the native variant by default, so load the web entry point explicitly. @@ -162,7 +162,9 @@ describe('biometrics operations (web)', () => { // No coverage exists yet for `usePasskeys.register()`'s ceremony; this pins it at the operation // level ahead of the hook being deleted. describe('createCredential', () => { - const KNOWN_CREDENTIAL_ID = 'known-cred-id'; + // Length must be a multiple of 4, so the decode/encode round trip below (used to inspect + // `excludeCredentials`) is lossless — anything else can silently drop trailing bits. + const KNOWN_CREDENTIAL_ID = 'known-cred-idxxx'; beforeEach(() => { mockCreatePasskeyCredential.mockReset(); @@ -265,5 +267,37 @@ describe('biometrics operations (web)', () => { const storedCredentials = await getOnyxValue(getPasskeyOnyxKey(String(ACCOUNT_ID))); expect(storedCredentials?.map((credential) => credential.id)).toEqual([duplicateCredentialId]); }); + + it('passes the abort signal through to the ceremony, so cancelling the flow can close the passkey dialog', async () => { + const controller = new AbortController(); + mockCreatePasskeyCredential.mockResolvedValue(buildFakeAttestationCredential(bytesToArrayBuffer([1, 2, 3]), buildFakeAttestationResponse())); + + await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE, signal: controller.signal}); + + expect(mockCreatePasskeyCredential.mock.calls.at(0)?.[1]).toBe(controller.signal); + }); + + it('does not persist or register a credential when the flow was cancelled while the ceremony resolved anyway', async () => { + // Some browsers don't honor `signal` on create(), so the ceremony can still succeed after + // the flow was already cancelled — simulated here by aborting from inside the mock. + const controller = new AbortController(); + const rawId = bytesToArrayBuffer([90, 91, 92]); + mockCreatePasskeyCredential.mockImplementation(async () => { + controller.abort(); + return buildFakeAttestationCredential(rawId, buildFakeAttestationResponse()); + }); + + const result = await createCredential({accountID: ACCOUNT_ID, registrationChallenge: REGISTRATION_CHALLENGE, signal: controller.signal}); + + expect(result.success).toBe(false); + if (result.success) { + throw new Error('Expected credential creation to fail'); + } + expect(result.error.reason).toBe(CONST.MULTIFACTOR_AUTHENTICATION.REASON.LOCAL_ERRORS.CANCELED); + + await waitForBatchedUpdates(); + const storedCredentials = await getOnyxValue(getPasskeyOnyxKey(String(ACCOUNT_ID))); + expect(storedCredentials ?? []).toEqual([]); + }); }); }); diff --git a/tests/unit/components/MultifactorAuthentication/machine/createCredentialActor.test.ts b/tests/unit/components/MultifactorAuthentication/machine/createCredentialActor.test.ts index 5f799039ba7e..71b580a9cadc 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/createCredentialActor.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/createCredentialActor.test.ts @@ -11,6 +11,7 @@ import type * as ProcessingActions from '@userActions/MultifactorAuthentication/ import CONST from '@src/CONST'; import {MFA_TEST_REGISTRATION_CHALLENGE} from 'tests/utils/mfa/flowFixtures'; +import waitForBatchedUpdates from 'tests/utils/waitForBatchedUpdates'; import {createActor, waitFor} from 'xstate'; const REASON = CONST.MULTIFACTOR_AUTHENTICATION.REASON; @@ -21,6 +22,7 @@ const mockCreateCredential = jest.fn(); // failure) are what this suite pins, so the platform ceremony and the backend call are mocked here. jest.mock('@components/MultifactorAuthentication/biometrics/operations', () => ({ ...jest.requireActual('@components/MultifactorAuthentication/biometrics/operations'), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return createCredential: (...args: unknown[]) => mockCreateCredential(...args), })); @@ -89,4 +91,26 @@ describe('createCredential actor', () => { // deletion and no local-credential clearing attempted on this path. expect(snapshot.output).toEqual({success: false, error: backendError}); }); + + it('does not call processRegistration once the flow was cancelled while the ceremony was still running', async () => { + // Stopping the actor (what CLOSE_MODAL does) can't interrupt the already-running ceremony + // promise, only its own reaction to it — this pins that the actor still checks `signal.aborted` + // before firing the backend call. + let resolveCeremony: (result: {success: true; keyInfo: RegistrationKeyInfo}) => void = () => {}; + mockCreateCredential.mockImplementation( + () => + new Promise((resolve) => { + resolveCeremony = resolve; + }), + ); + + const {createCredential} = createActors(); + const actorRef = createActor(createCredential, {input: CREATE_CREDENTIAL_INPUT}); + actorRef.start(); + actorRef.stop(); + resolveCeremony({success: true, keyInfo: KEY_INFO}); + await waitForBatchedUpdates(); + + expect(processRegistrationMock).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/components/MultifactorAuthentication/machine/credentialCreationTransition.test.ts b/tests/unit/components/MultifactorAuthentication/machine/credentialCreationTransition.test.ts index 6067b1af68b8..f998e9766e44 100644 --- a/tests/unit/components/MultifactorAuthentication/machine/credentialCreationTransition.test.ts +++ b/tests/unit/components/MultifactorAuthentication/machine/credentialCreationTransition.test.ts @@ -114,29 +114,33 @@ describe('MFA credential creation', () => { }); it('reaches the failure outcome with an unhandled-exception error when the actor rejects', async () => { + // `resolveState` can't jump straight into `creatingCredential` and have the invoke fire — + // XState only invokes an actor on a live transition into a state, not a snapshot resolved + // already inside it. So we start one hop earlier and send the real approval event, which + // drives an actual transition and lets the mocked actor genuinely run and reject. const machine = mfaMachine.provide({ actors: { createCredential: fromPromise(() => Promise.reject(new Error('Credential registration exploded'))), }, }); - const actor = createActor(machine, { - snapshot: machine.resolveState({ - value: {[MFA_STATE.OPEN]: MFA_STATE.CREATING_CREDENTIAL}, - context: { - accountID: 12345, - error: undefined, - scenarioName: createInitEvent().scenarioName, - scenario: createInitEvent().scenario, - payload: undefined, - validateCode: undefined, - registrationChallenge: MFA_TEST_REGISTRATION_CHALLENGE, - softPromptApproved: false, - isCancelConfirmVisible: false, - }, - }), + const snapshot = machine.resolveState({ + value: {[MFA_STATE.OPEN]: {[MFA_STATE.PROMPT]: MFA_STATE.AWAITING_SOFT_PROMPT}}, + context: { + accountID: 12345, + error: undefined, + scenarioName: createInitEvent().scenarioName, + scenario: createInitEvent().scenario, + payload: undefined, + validateCode: undefined, + registrationChallenge: MFA_TEST_REGISTRATION_CHALLENGE, + softPromptApproved: false, + isCancelConfirmVisible: false, + }, }); + const actor = createActor(machine, {snapshot}); actor.start(); + actor.send({type: 'SOFT_PROMPT_APPROVED'}); await waitForBatchedUpdates(); const result = actor.getSnapshot();