Skip to content

Commit f6f801b

Browse files
committed
test(mfa): add reachability and UI-walk tests
First split of the MFA state-machine test foundation (was #344). Carries only the two guarantees this slice needs and the minimal harness they depend on: - everyStateReachableTest: every settleable leaf state of mfaMachine is reachable. Derived from the chart via getShortestPaths, so new states are covered with no edit here. - realModalTest: walks the machine graph (getSimplePaths plus the lifecycle lap) through the real provider stack and modal navigator, drives each event as a real gesture, and asserts the modal/outcome DOM markers at every step. Harness: flowFixtures, flowPaths, reachableStates, and the realUi rig (renderModal, userGestures, mocks, jestMocks). The remaining specs (machine unit, lifecycle, snapshot mapper, @xstate/react binding) and the machineUnderTest helper follow in later splits. Test-only change, no production code touched.
1 parent 572ab19 commit f6f801b

9 files changed

Lines changed: 475 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {matchesState} from 'xstate';
2+
import {getShortestPaths} from 'xstate/graph';
3+
import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine';
4+
import getSettleableLeafStates from '../../../utils/mfa/reachableStates';
5+
6+
// With no `events` list passed, getShortestPaths synthesizes a bare event for every transition the
7+
// machine declares, so new events reach new states automatically. Pass `events` only to supply a payload a guard needs.
8+
describe('every MFA modal state is reachable', () => {
9+
const reachableSnapshots = getShortestPaths(mfaMachine).map((path) => path.state);
10+
11+
it.each(getSettleableLeafStates(mfaMachine))('reaches the $description state', ({value}) => {
12+
expect(reachableSnapshots.some((snapshot) => matchesState(value, snapshot.value))).toBe(true);
13+
});
14+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return -- jest.mock factories delegate to require()'d helpers, which resolve as `any`. */
2+
import {resetMfaNavigation} from '@components/MultifactorAuthentication/mfaNavigation';
3+
import CONST from '@src/CONST';
4+
import createMfaTestModel from '../../../utils/mfa/flowPaths';
5+
import {resetMfaUiMocks} from '../../../utils/mfa/realUi/mocks';
6+
import {flushMfaUi, isModalOverlayMounted, isOutcomeScreenVisible, renderMfaUi} from '../../../utils/mfa/realUi/renderModal';
7+
import mfaEventExecutors from '../../../utils/mfa/realUi/userGestures';
8+
9+
// Forces a wide layout so the navigator renders the backdrop overlay the assertions use as the mounted marker.
10+
jest.mock('@hooks/useResponsiveLayout');
11+
// Replaces the dev-only Stately inspector wiring with the plain @xstate/react adapter the provider needs.
12+
jest.mock('@hooks/useInspectedMachine', () => require('../../../utils/mfa/realUi/jestMocks').inspectedMachineMock());
13+
// Native / WebAuthn biometrics are out of scope for the modal-lifecycle contract.
14+
jest.mock('@components/MultifactorAuthentication/biometrics/useBiometrics', () => require('../../../utils/mfa/realUi/jestMocks').biometricsHookMock());
15+
// Browser/Android back-history wiring is a separate concern from the machine <-> UI contract.
16+
jest.mock('@components/MultifactorAuthentication/useSyncMfaModalNavigatorWithHistory', () => require('../../../utils/mfa/realUi/jestMocks').syncHistoryMock());
17+
// Navigation automock leaves methods undefined, so this supplies the methods the flow needs and no-ops the rest.
18+
jest.mock('@libs/Navigation/Navigation', () => require('../../../utils/mfa/realUi/jestMocks').navigationMock());
19+
20+
const MFA_STATE = CONST.MULTIFACTOR_AUTHENTICATION.MFA_STATE;
21+
22+
const testModel = createMfaTestModel();
23+
24+
// The createTestModel-style config maps each event to a UI gesture and each state to a per-state assertion.
25+
// State keys are dot-path values matched with `matchesState`, so they can target any depth, such as the
26+
// settled success leaf `open.outcome.success` rather than just the `open` parent.
27+
const testConfig = {
28+
events: mfaEventExecutors,
29+
states: {
30+
[MFA_STATE.CLOSED]: () => {
31+
expect(isModalOverlayMounted()).toBe(false);
32+
expect(isOutcomeScreenVisible()).toBe(false);
33+
},
34+
[`${MFA_STATE.OPEN}.${MFA_STATE.OUTCOME}.${MFA_STATE.SUCCESS}`]: () => {
35+
expect(isModalOverlayMounted()).toBe(true);
36+
expect(isOutcomeScreenVisible()).toBe(true);
37+
},
38+
[MFA_STATE.CLOSING]: () => {
39+
expect(isModalOverlayMounted()).toBe(true);
40+
expect(isOutcomeScreenVisible()).toBe(false);
41+
},
42+
},
43+
};
44+
45+
describe('the real MFA modal follows the machine', () => {
46+
beforeEach(() => {
47+
resetMfaUiMocks();
48+
resetMfaNavigation();
49+
});
50+
51+
afterEach(() => {
52+
jest.clearAllMocks();
53+
});
54+
55+
// getSimplePaths reaches every state. The lifecycle paths add the MODAL_CLOSED teardown that simple paths skip.
56+
const paths = [...testModel.getSimplePaths(), ...testModel.getLifecyclePaths()];
57+
58+
for (const path of paths) {
59+
it(`reaches ${JSON.stringify(path.state.value)} via [${path.description}]`, async () => {
60+
renderMfaUi();
61+
await flushMfaUi();
62+
await path.test(testConfig);
63+
});
64+
}
65+
});

tests/utils/mfa/flowFixtures.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {getScenarioConfig} from '@components/MultifactorAuthentication/config';
2+
import type {MultifactorAuthenticationInitEvent} from '@components/MultifactorAuthentication/machine/types';
3+
import CONST from '@src/CONST';
4+
5+
const MFA_TEST_SCENARIO_NAME = CONST.MULTIFACTOR_AUTHENTICATION.SCENARIO.BIOMETRICS_TEST;
6+
7+
function createInitEvent(): MultifactorAuthenticationInitEvent {
8+
return {
9+
type: 'INIT',
10+
scenarioName: MFA_TEST_SCENARIO_NAME,
11+
scenario: getScenarioConfig(MFA_TEST_SCENARIO_NAME),
12+
payload: undefined,
13+
};
14+
}
15+
16+
export default createInitEvent;
17+
export {MFA_TEST_SCENARIO_NAME};

tests/utils/mfa/flowPaths.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {matchesState} from 'xstate';
2+
import type {SnapshotFrom} from 'xstate';
3+
import {getPathsFromEvents, getSimplePaths} from 'xstate/graph';
4+
import mfaMachine from '@components/MultifactorAuthentication/machine/mfaMachine';
5+
import type {MfaEvent} from '@components/MultifactorAuthentication/machine/types';
6+
import createInitEvent from './flowFixtures';
7+
import {toStateValue} from './reachableStates';
8+
9+
/**
10+
* Wraps `xstate/graph`'s plain path generators in a small `createTestModel`-style harness. Each path
11+
* exposes `path.test({events, states})`, which replays the path through the real UI (drive the
12+
* matching event executor at each step, then run every matching state assertion).
13+
*
14+
* Why not the real `createTestModel`? Its constructor calls `validateMachine`, which throws "After
15+
* events on test machines are not supported", and `mfaMachine.closing` has an `after` (closeFallback)
16+
* transition. The plain generators have no such restriction.
17+
*
18+
* Paths are generated WITHOUT a pinned event list, so the graph synthesizes an event for every
19+
* transition the machine declares and auto-discovers new (sub)states as the chart grows. Each step's
20+
* `state` is the deepest settled configuration (e.g. `open.outcome.success`), so the `states` map
21+
* routes by `matchesState` and can assert at any depth. Transient routers such as `always` or
22+
* `initial` (for example `preparing` and `outcome`) are passed through and never become their own node.
23+
*/
24+
25+
type MfaSnapshot = SnapshotFrom<typeof mfaMachine>;
26+
27+
/** Performs the UI gesture that produces a machine event. It is an `events` map entry passed to `path.test`. */
28+
type MfaEventExecutor = () => void | Promise<void>;
29+
30+
/** Asserts the UI when the walk reaches a matching state. It is a `states` map entry passed to `path.test`. */
31+
type MfaStateAssertion = (state: MfaSnapshot) => void | Promise<void>;
32+
33+
type MfaPathTestConfig = {
34+
events: Partial<Record<MfaEvent['type'], MfaEventExecutor>>;
35+
/** Maps a dot-path state value (e.g. `open.outcome.success`) to its assertion, matched against each step with `matchesState`. */
36+
states: Record<string, MfaStateAssertion>;
37+
};
38+
39+
type RawPath = {
40+
state: MfaSnapshot;
41+
steps: ReadonlyArray<{state: MfaSnapshot; event: {type: string}}>;
42+
};
43+
44+
type MfaTestPath = {
45+
/** Holds the final-state snapshot, e.g. for `JSON.stringify(path.state.value)` test names. */
46+
state: MfaSnapshot;
47+
/** Names the driven event sequence, e.g. `INIT -> CLOSE_MODAL`. */
48+
description: string;
49+
/** Walks the path: for each step, runs the matching event executor then asserts every matching state. */
50+
test: (config: MfaPathTestConfig) => Promise<void>;
51+
};
52+
53+
// INIT carries the real scenario payload, while CLOSE_MODAL and MODAL_CLOSED are bare. These drive only
54+
// the explicit teardown sequence (getLifecyclePaths). Simple paths auto-discover events from the chart.
55+
const DRIVING_EVENTS: MfaEvent[] = [createInitEvent(), {type: 'CLOSE_MODAL'}, {type: 'MODAL_CLOSED'}];
56+
const INIT_STEP_EVENT_TYPE = 'xstate.init';
57+
const DELAYED_EVENT_PREFIX = 'xstate.after';
58+
59+
// Delayed (`after`) transitions are timers, not gestures, so this drops any path that would need to fire one.
60+
// The closing -> closed teardown they cover is driven explicitly via MODAL_CLOSED in getLifecyclePaths.
61+
function isGestureDrivablePath(path: RawPath): boolean {
62+
return path.steps.every((step) => !step.event.type.startsWith(DELAYED_EVENT_PREFIX));
63+
}
64+
65+
async function assertMatchingStates(snapshot: MfaSnapshot, states: MfaPathTestConfig['states']): Promise<void> {
66+
for (const [stateValue, assertState] of Object.entries(states)) {
67+
if (matchesState(toStateValue(stateValue.split('.')), snapshot.value)) {
68+
await assertState(snapshot);
69+
}
70+
}
71+
}
72+
73+
function wrapPath(graphPath: RawPath): MfaTestPath {
74+
const drivenEventTypes = graphPath.steps.map((step) => step.event.type).filter((type) => type !== INIT_STEP_EVENT_TYPE);
75+
76+
return {
77+
state: graphPath.state,
78+
description: drivenEventTypes.length > 0 ? drivenEventTypes.join(' -> ') : '(initial state)',
79+
test: async ({events, states}) => {
80+
const executorByEventType: Partial<Record<string, MfaEventExecutor>> = events;
81+
for (const step of graphPath.steps) {
82+
if (step.event.type !== INIT_STEP_EVENT_TYPE) {
83+
const executeEvent = executorByEventType[step.event.type];
84+
if (!executeEvent) {
85+
throw new Error(`No event executor provided for "${step.event.type}"`);
86+
}
87+
await executeEvent();
88+
}
89+
await assertMatchingStates(step.state, states);
90+
}
91+
},
92+
};
93+
}
94+
95+
function toTestPaths(rawPaths: RawPath[]): MfaTestPath[] {
96+
return rawPaths.filter(isGestureDrivablePath).map(wrapPath);
97+
}
98+
99+
function createMfaTestModel() {
100+
return {
101+
getSimplePaths: (): MfaTestPath[] => toTestPaths(getSimplePaths(mfaMachine)),
102+
// Covers the full teardown sequence (... -> MODAL_CLOSED -> closed) that simple paths skip because it revisits `closed`.
103+
getLifecyclePaths: (): MfaTestPath[] => toTestPaths(getPathsFromEvents(mfaMachine, DRIVING_EVENTS)),
104+
};
105+
}
106+
107+
export default createMfaTestModel;
108+
export type {MfaPathTestConfig, MfaTestPath};

tests/utils/mfa/reachableStates.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type {AnyStateMachine, AnyStateNode, StateValue} from 'xstate';
2+
3+
type SettleableLeafState = {description: string; value: StateValue};
4+
5+
function toStateValue(path: string[]): StateValue {
6+
if (path.length <= 1) {
7+
return path.at(0) ?? '';
8+
}
9+
const [head, ...rest] = path;
10+
return {[head]: toStateValue(rest)};
11+
}
12+
13+
function collectSettleableLeafStates(node: AnyStateNode): SettleableLeafState[] {
14+
const children = Object.values(node.states);
15+
if (children.length > 0) {
16+
return children.flatMap(collectSettleableLeafStates);
17+
}
18+
// A leaf with an `always` transition is a transient router that leaves on entry and never settles.
19+
if ((node.always?.length ?? 0) > 0) {
20+
return [];
21+
}
22+
return [{description: node.path.join('.'), value: toStateValue(node.path)}];
23+
}
24+
25+
function getSettleableLeafStates(machine: AnyStateMachine): SettleableLeafState[] {
26+
return collectSettleableLeafStates(machine.root);
27+
}
28+
29+
export default getSettleableLeafStates;
30+
export {toStateValue};
31+
export type {SettleableLeafState};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// eslint-disable-next-line no-restricted-imports -- this mock replaces useInspectedMachine with the plain @xstate/react adapter so the modal-lifecycle contract runs without the dev-only inspector wiring.
2+
import {useMachine} from '@xstate/react';
3+
import type {AnyStateMachine} from 'xstate';
4+
import {biometricsMock, pendingModalClose} from './mocks';
5+
6+
/**
7+
* jest.mock factory bodies for the model-based MFA UI test, kept out of the test file so it reads as
8+
* mock registrations plus test logic. Each is called from a `jest.mock(..., () => require(...).xMock())`
9+
* factory, so it returns a ready-to-use mock module (`__esModule` + `default`).
10+
*/
11+
12+
/** Drops the dev-only Stately inspector wiring. The provider just needs the plain @xstate/react adapter. */
13+
function inspectedMachineMock() {
14+
// Named `use*` so rules-of-hooks treats it as a custom hook (it calls useMachine).
15+
const useInspectedMachineMock = (machine: AnyStateMachine) => useMachine(machine);
16+
return {
17+
__esModule: true,
18+
default: useInspectedMachineMock,
19+
};
20+
}
21+
22+
/** Native / WebAuthn biometrics are out of scope for the modal-lifecycle contract, so this returns the shared biometrics mock. */
23+
function biometricsHookMock() {
24+
return {
25+
__esModule: true,
26+
default: () => biometricsMock,
27+
};
28+
}
29+
30+
/** Browser/Android back-history wiring is a separate concern from the machine <-> UI contract. */
31+
function syncHistoryMock() {
32+
return {
33+
__esModule: true,
34+
default: () => {},
35+
};
36+
}
37+
38+
/**
39+
* Automock leaves the default export's methods undefined, so this provides the methods the flow needs
40+
* and resolves any other `Navigation.*` the render path touches to a no-op jest.fn().
41+
*
42+
* `runAfterTransition` runs its callback immediately (no active navigation transition in jsdom).
43+
* `runAfterUpcomingTransition` captures the navigator's teardown callback so MODAL_CLOSED is driven
44+
* from the event map, not a timer.
45+
*/
46+
function navigationMock() {
47+
const navigationMethodStubs: Record<string, unknown> = {
48+
runAfterTransition: (callback: () => void) => {
49+
callback();
50+
return {cancel: () => {}};
51+
},
52+
runAfterUpcomingTransition: (callback: () => void) => {
53+
pendingModalClose.capture(callback);
54+
return {cancel: () => pendingModalClose.clear()};
55+
},
56+
isNavigationReady: () => Promise.resolve(),
57+
};
58+
return {
59+
__esModule: true,
60+
default: new Proxy(navigationMethodStubs, {
61+
get: (target, property) => {
62+
if (typeof property === 'string' && property in target) {
63+
return target[property];
64+
}
65+
return jest.fn();
66+
},
67+
}),
68+
};
69+
}
70+
71+
export {inspectedMachineMock, biometricsHookMock, syncHistoryMock, navigationMock};

tests/utils/mfa/realUi/mocks.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Shared, mutable mock seam for the model-based MFA UI test. The jest.mock factories in `jestMocks`
3+
* read from these holders, and the harness / event executors drive them. Keeping the seam in one
4+
* module means the future "mock the backend responses" work has a single place to grow.
5+
*/
6+
7+
type CapturedCallback = () => void;
8+
9+
let pendingCloseCallback: CapturedCallback | undefined;
10+
11+
/**
12+
* Holds the callback the modal navigator hands to `Navigation.runAfterUpcomingTransition` when it
13+
* starts closing. The real navigator runs it once the close animation ends. The test captures it and
14+
* runs it explicitly so the `closing` state stays observable and `MODAL_CLOSED` stays drivable from
15+
* the event map instead of resolving on a timer.
16+
*/
17+
const pendingModalClose = {
18+
capture: (callback: CapturedCallback) => {
19+
pendingCloseCallback = callback;
20+
},
21+
run: () => {
22+
const callback = pendingCloseCallback;
23+
pendingCloseCallback = undefined;
24+
callback?.();
25+
},
26+
clear: () => {
27+
pendingCloseCallback = undefined;
28+
},
29+
};
30+
31+
/**
32+
* Stands in for the native / WebAuthn biometrics hook. The flow only reads the credential fields
33+
* during `INIT` (captureCredentialsState). `authorize` is here for the scenarios that will later sign
34+
* a challenge through this seam.
35+
*/
36+
const biometricsMock = {
37+
serverKnownCredentialIDs: [] as string[],
38+
areLocalCredentialsKnownToServer: () => Promise.resolve(false),
39+
authorize: () => Promise.resolve(),
40+
};
41+
42+
function resetMfaUiMocks() {
43+
pendingModalClose.clear();
44+
biometricsMock.serverKnownCredentialIDs = [];
45+
}
46+
47+
export {pendingModalClose, biometricsMock, resetMfaUiMocks};

0 commit comments

Comments
 (0)