Skip to content

Commit 3a59438

Browse files
OtavioStasiakclaude
andcommitted
feat(biometric-trust): invalidate biometric trust on enrollment change
Add handleBiometricTrustResult, a shared helper that maps a TrustResult into an (unlocked, modal-config) outcome. Both call sites — handleLocalAuthentication's upstream verify() preflight and PasscodeEnter's biometry-button retry — route through it so the invalidation policy lives in one place. On {kind: 'enrollmentChanged'} the helper runs disenrol() BEFORE clearing BIOMETRY_ENABLED_KEY, so a crash between the two leaves a state slice 04's reconciliation can clean up (a flipped flag with a live sentinel would otherwise look like a healthy enrolment). The resulting modal carries reason: 'enrollmentChanged' over LOCAL_AUTHENTICATE_EMITTER so slice 03 can render an explanatory subtitle. Cancel/error keep biometry available with skipAutoBiometry; unavailable is passcode-only; success unlocks without a modal. In PasscodeEnter the biometry button now mirrors hasBiometry/reason in local state so an enrolment-change triggered from the button hides the button within the same modal session without re-emitting the event (which would orphan the upstream openModal promise). Closes VLN-216. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8691f37 commit 3a59438

8 files changed

Lines changed: 408 additions & 20 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import React from 'react';
2+
import { fireEvent, render, waitFor } from '@testing-library/react-native';
3+
4+
import PasscodeEnter from './PasscodeEnter';
5+
import { biometryAuth } from '../../lib/methods/helpers/localAuthentication';
6+
import { biometricTrustStore } from '../../lib/biometricTrustStore';
7+
import UserPreferences from '../../lib/methods/userPreferences';
8+
import { BIOMETRY_ENABLED_KEY } from '../../lib/constants/localAuthentication';
9+
10+
jest.mock('../../lib/methods/helpers/localAuthentication', () => ({
11+
biometryAuth: jest.fn(),
12+
resetAttempts: jest.fn(() => Promise.resolve())
13+
}));
14+
15+
jest.mock('../../lib/biometricTrustStore', () => ({
16+
biometricTrustStore: {
17+
enrol: jest.fn(),
18+
disenrol: jest.fn(() => Promise.resolve()),
19+
verify: jest.fn(),
20+
probeExists: jest.fn()
21+
}
22+
}));
23+
24+
jest.mock('../../lib/methods/userPreferences', () => ({
25+
__esModule: true,
26+
default: {
27+
getBool: jest.fn(),
28+
setBool: jest.fn(),
29+
getString: jest.fn(),
30+
setString: jest.fn()
31+
},
32+
useUserPreferences: () => [null, jest.fn()]
33+
}));
34+
35+
jest.mock('../../i18n', () => ({ t: (key: string) => key }));
36+
37+
const mockedBiometryAuth = biometryAuth as jest.Mock;
38+
const mockedDisenrol = biometricTrustStore.disenrol as jest.Mock;
39+
const mockedSetBool = UserPreferences.setBool as jest.Mock;
40+
41+
describe('PasscodeEnter biometry button', () => {
42+
beforeEach(() => {
43+
jest.clearAllMocks();
44+
mockedDisenrol.mockResolvedValue(undefined);
45+
});
46+
47+
it('enrollmentChanged from button press → disenrols, clears flag, hides biometry button', async () => {
48+
mockedBiometryAuth.mockResolvedValueOnce({ kind: 'enrollmentChanged' });
49+
const finishProcess = jest.fn();
50+
51+
const { getByTestId, queryByTestId } = render(<PasscodeEnter hasBiometry skipAutoBiometry finishProcess={finishProcess} />);
52+
53+
await waitFor(() => expect(getByTestId('biometry-button')).toBeTruthy());
54+
55+
fireEvent.press(getByTestId('biometry-button'));
56+
57+
await waitFor(() => expect(mockedDisenrol).toHaveBeenCalledTimes(1));
58+
expect(mockedSetBool).toHaveBeenCalledWith(BIOMETRY_ENABLED_KEY, false);
59+
expect(finishProcess).not.toHaveBeenCalled();
60+
await waitFor(() => expect(queryByTestId('biometry-button')).toBeNull());
61+
});
62+
63+
it('success from button press → finishes process, no invalidation', async () => {
64+
mockedBiometryAuth.mockResolvedValueOnce({ kind: 'success' });
65+
const finishProcess = jest.fn();
66+
67+
const { getByTestId } = render(<PasscodeEnter hasBiometry skipAutoBiometry finishProcess={finishProcess} />);
68+
69+
await waitFor(() => expect(getByTestId('biometry-button')).toBeTruthy());
70+
71+
fireEvent.press(getByTestId('biometry-button'));
72+
73+
await waitFor(() => expect(finishProcess).toHaveBeenCalledTimes(1));
74+
expect(mockedDisenrol).not.toHaveBeenCalled();
75+
expect(mockedSetBool).not.toHaveBeenCalled();
76+
});
77+
78+
it('canceled from button press → flag untouched, biometry button stays', async () => {
79+
mockedBiometryAuth.mockResolvedValueOnce({ kind: 'canceled' });
80+
const finishProcess = jest.fn();
81+
82+
const { getByTestId } = render(<PasscodeEnter hasBiometry skipAutoBiometry finishProcess={finishProcess} />);
83+
84+
await waitFor(() => expect(getByTestId('biometry-button')).toBeTruthy());
85+
86+
fireEvent.press(getByTestId('biometry-button'));
87+
88+
await waitFor(() => expect(mockedBiometryAuth).toHaveBeenCalled());
89+
expect(mockedDisenrol).not.toHaveBeenCalled();
90+
expect(mockedSetBool).not.toHaveBeenCalled();
91+
expect(finishProcess).not.toHaveBeenCalled();
92+
expect(getByTestId('biometry-button')).toBeTruthy();
93+
});
94+
});

app/containers/Passcode/PasscodeEnter.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,31 +9,52 @@ import Locked from './Base/Locked';
99
import { TYPE } from './constants';
1010
import { ATTEMPTS_KEY, LOCKED_OUT_TIMER_KEY, MAX_ATTEMPTS, PASSCODE_KEY } from '../../lib/constants/localAuthentication';
1111
import { biometryAuth, resetAttempts } from '../../lib/methods/helpers/localAuthentication';
12+
import { handleBiometricTrustResult, type BiometricInvalidationReason } from '../../lib/biometricTrustStore/handleResult';
1213
import { getDiff, getLockedUntil } from './utils';
1314
import { useUserPreferences } from '../../lib/methods/userPreferences';
1415
import I18n from '../../i18n';
1516

1617
interface IPasscodePasscodeEnter {
1718
hasBiometry: boolean;
1819
skipAutoBiometry?: boolean;
20+
reason?: BiometricInvalidationReason;
1921
finishProcess: Function;
2022
}
2123

22-
const PasscodeEnter = ({ hasBiometry, skipAutoBiometry = false, finishProcess }: IPasscodePasscodeEnter) => {
24+
const PasscodeEnter = ({
25+
hasBiometry: initialHasBiometry,
26+
skipAutoBiometry = false,
27+
reason: initialReason,
28+
finishProcess
29+
}: IPasscodePasscodeEnter) => {
2330
const ref = useRef<IBase>(null);
2431
let attempts = 0;
2532
let lockedUntil: any = false;
2633
const [passcode] = useUserPreferences(PASSCODE_KEY);
2734
const [status, setStatus] = useState<TYPE | null>(null);
35+
// Mirror hasBiometry/reason locally so an enrolment-change invalidation triggered from the
36+
// biometry button immediately hides the button within the same modal session, without
37+
// re-emitting LOCAL_AUTHENTICATE_EMITTER (which would orphan the upstream openModal promise).
38+
const [hasBiometry, setHasBiometry] = useState<boolean>(initialHasBiometry);
39+
// `reason` is held for slice 03's subtitle copy; _reason intentionally unused for now.
40+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
41+
const [_reason, setReason] = useState<BiometricInvalidationReason | undefined>(initialReason);
2842
const { setItem: setAttempts } = useAsyncStorage(ATTEMPTS_KEY);
2943
const { setItem: setLockedUntil } = useAsyncStorage(LOCKED_OUT_TIMER_KEY);
3044

3145
const biometry = async () => {
32-
if (hasBiometry && status === TYPE.ENTER) {
33-
const result = await biometryAuth();
34-
if (result.kind === 'success') {
35-
finishProcess();
36-
}
46+
if (!hasBiometry || status !== TYPE.ENTER) {
47+
return;
48+
}
49+
const result = await biometryAuth();
50+
const { unlocked, modal } = await handleBiometricTrustResult(result);
51+
if (unlocked) {
52+
finishProcess();
53+
return;
54+
}
55+
if (modal) {
56+
setHasBiometry(modal.hasBiometry);
57+
setReason(modal.reason);
3758
}
3859
};
3960

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import UserPreferences from '../methods/userPreferences';
2+
import { BIOMETRY_ENABLED_KEY } from '../constants/localAuthentication';
3+
import { biometricTrustStore } from './index';
4+
import { handleBiometricTrustResult } from './handleResult';
5+
6+
jest.mock('../methods/userPreferences', () => ({
7+
__esModule: true,
8+
default: {
9+
getBool: jest.fn(),
10+
setBool: jest.fn(),
11+
getString: jest.fn(),
12+
setString: jest.fn()
13+
}
14+
}));
15+
16+
jest.mock('./index', () => ({
17+
biometricTrustStore: {
18+
enrol: jest.fn(),
19+
disenrol: jest.fn(() => Promise.resolve()),
20+
verify: jest.fn(),
21+
probeExists: jest.fn()
22+
}
23+
}));
24+
25+
const mockedSetBool = UserPreferences.setBool as jest.Mock;
26+
const mockedDisenrol = biometricTrustStore.disenrol as jest.Mock;
27+
28+
describe('handleBiometricTrustResult', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
it('success → unlocked, no modal, no invalidation', async () => {
34+
const outcome = await handleBiometricTrustResult({ kind: 'success' });
35+
36+
expect(outcome).toEqual({ unlocked: true });
37+
expect(mockedDisenrol).not.toHaveBeenCalled();
38+
expect(mockedSetBool).not.toHaveBeenCalled();
39+
});
40+
41+
it('enrollmentChanged → disenrol() runs before BIOMETRY_ENABLED_KEY is cleared', async () => {
42+
const order: string[] = [];
43+
mockedDisenrol.mockImplementation(() => {
44+
order.push('disenrol');
45+
return Promise.resolve();
46+
});
47+
mockedSetBool.mockImplementation((key: string, value: boolean) => {
48+
order.push(`setBool:${key}=${value}`);
49+
});
50+
51+
const outcome = await handleBiometricTrustResult({ kind: 'enrollmentChanged' });
52+
53+
expect(order).toEqual(['disenrol', `setBool:${BIOMETRY_ENABLED_KEY}=false`]);
54+
expect(outcome).toEqual({
55+
unlocked: false,
56+
modal: { hasBiometry: false, reason: 'enrollmentChanged' }
57+
});
58+
});
59+
60+
it('canceled → no disenrol, no flag clear, modal keeps biometry with skipAutoBiometry', async () => {
61+
const outcome = await handleBiometricTrustResult({ kind: 'canceled' });
62+
63+
expect(mockedDisenrol).not.toHaveBeenCalled();
64+
expect(mockedSetBool).not.toHaveBeenCalled();
65+
expect(outcome).toEqual({
66+
unlocked: false,
67+
modal: { hasBiometry: true, skipAutoBiometry: true }
68+
});
69+
});
70+
71+
it('error → no disenrol, no flag clear, modal keeps biometry with skipAutoBiometry', async () => {
72+
const outcome = await handleBiometricTrustResult({ kind: 'error', cause: new Error('boom') });
73+
74+
expect(mockedDisenrol).not.toHaveBeenCalled();
75+
expect(mockedSetBool).not.toHaveBeenCalled();
76+
expect(outcome).toEqual({
77+
unlocked: false,
78+
modal: { hasBiometry: true, skipAutoBiometry: true }
79+
});
80+
});
81+
82+
it('unavailable → passcode-only modal, no invalidation', async () => {
83+
const outcome = await handleBiometricTrustResult({ kind: 'unavailable' });
84+
85+
expect(mockedDisenrol).not.toHaveBeenCalled();
86+
expect(mockedSetBool).not.toHaveBeenCalled();
87+
expect(outcome).toEqual({ unlocked: false, modal: { hasBiometry: false } });
88+
});
89+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import UserPreferences from '../methods/userPreferences';
2+
import { BIOMETRY_ENABLED_KEY } from '../constants/localAuthentication';
3+
import { biometricTrustStore, type TrustResult } from './index';
4+
5+
export type BiometricInvalidationReason = 'enrollmentChanged';
6+
7+
export type BiometricModalRequest = {
8+
hasBiometry: boolean;
9+
skipAutoBiometry?: boolean;
10+
reason?: BiometricInvalidationReason;
11+
};
12+
13+
export type BiometricTrustOutcome = {
14+
unlocked: boolean;
15+
modal?: BiometricModalRequest;
16+
};
17+
18+
// Shared invalidation + modal-config resolution for both Option C call sites:
19+
// - handleLocalAuthentication (upstream verify() preflight)
20+
// - PasscodeEnter biometry button retry
21+
//
22+
// On enrollmentChanged we MUST disenrol() before clearing BIOMETRY_ENABLED_KEY so a crash between
23+
// the two leaves the app in a state slice 04's reconciliation can still clean up — a flipped flag
24+
// with a live sentinel would otherwise look like a healthy enrolment.
25+
export const handleBiometricTrustResult = async (result: TrustResult): Promise<BiometricTrustOutcome> => {
26+
switch (result.kind) {
27+
case 'success':
28+
return { unlocked: true };
29+
case 'enrollmentChanged':
30+
await biometricTrustStore.disenrol();
31+
UserPreferences.setBool(BIOMETRY_ENABLED_KEY, false);
32+
return { unlocked: false, modal: { hasBiometry: false, reason: 'enrollmentChanged' } };
33+
case 'unavailable':
34+
return { unlocked: false, modal: { hasBiometry: false } };
35+
case 'canceled':
36+
case 'error':
37+
default:
38+
return { unlocked: false, modal: { hasBiometry: true, skipAutoBiometry: true } };
39+
}
40+
};

app/lib/methods/helpers/events.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type TEventEmitterEmmitArgs =
1111
| { force: boolean }
1212
| { hasBiometry: boolean }
1313
| { skipAutoBiometry: boolean }
14+
| { reason: 'enrollmentChanged' }
1415
| { visible: boolean; onCancel?: null | Function }
1516
| { cancel: () => void }
1617
| { submit: (param: string) => void }

0 commit comments

Comments
 (0)