Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
5a78af5
feat: install react-native-keychain
OtavioStasiak May 27, 2026
8691f37
feat(biometric-trust): add enrollment-bound trust store and Option C …
OtavioStasiak May 27, 2026
3a59438
feat(biometric-trust): invalidate biometric trust on enrollment change
OtavioStasiak May 27, 2026
d384c0c
feat(biometric-trust): show explanatory subtitle on enrollment-change…
OtavioStasiak May 27, 2026
c35c0c6
feat(biometric-trust): silent-bind migration for existing biometry us…
OtavioStasiak May 27, 2026
7ec65a4
feat: i18n translation
OtavioStasiak May 27, 2026
f091568
podfile
OtavioStasiak May 27, 2026
8b84535
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak May 28, 2026
898b8aa
fix(biometric-trust): revert ScreenLockConfig toggle when enrol fails
OtavioStasiak May 28, 2026
3dbb3e3
fix(screen-lock): defer modal resolve until close animation finishes
OtavioStasiak May 28, 2026
1565c0d
feat: add e2e tests
OtavioStasiak May 28, 2026
d01f17a
chore: format code and fix lint issues
OtavioStasiak May 28, 2026
76da4bc
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak May 28, 2026
f6f59cb
fix: e2e tests
OtavioStasiak May 29, 2026
952f38a
fix: test flow
OtavioStasiak May 29, 2026
fe0d66b
refactor: encapsulate biometric trust state and speed up screen-lock E2E
OtavioStasiak Jun 2, 2026
13136b6
refactor: route biometric enabled flag through trust store API
OtavioStasiak Jun 2, 2026
68a257f
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 2, 2026
fdb2cc1
refactor(biometric-trust): encapsulate biometry toggle and clarify tr…
OtavioStasiak Jun 2, 2026
256e90b
refactor(biometric-trust): type unlock outcome as discriminated union…
OtavioStasiak Jun 2, 2026
bc0bd7b
refactor(biometric-trust): remove dead mount-time auto-biometry and v…
OtavioStasiak Jun 2, 2026
063ec62
fix(screen-lock): prompt biometry from behind the passcode modal to s…
OtavioStasiak Jun 2, 2026
4e6a86e
fix(biometric-trust): mark install trust-initialized on enrol to clos…
OtavioStasiak Jun 2, 2026
66ab2f8
fix(biometric-trust): restore biometry opt-in prompt on first-passcod…
OtavioStasiak Jun 2, 2026
5969082
fix(biometric-trust): clear enabled flag on unavailable to fix iOS en…
OtavioStasiak Jun 2, 2026
b7e21c0
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 2, 2026
f9c2f86
chore: biometricTrustStore docs
OtavioStasiak Jun 2, 2026
43c18f4
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 2, 2026
80f688f
chore: format code and fix lint issues
OtavioStasiak Jun 2, 2026
ad26251
chore: doc improvement
OtavioStasiak Jun 2, 2026
dcff7c3
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 2, 2026
b566a47
chore: code improvements
OtavioStasiak Jun 2, 2026
fbfd4e2
chore: format code and fix lint issues
OtavioStasiak Jun 2, 2026
050b438
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 3, 2026
122bc6a
fix: persist passcode attempts across re-renders, guard toggle double…
OtavioStasiak Jun 3, 2026
6bd28b6
fix: reset attempts deterministically on lockout expiry and handle mo…
OtavioStasiak Jun 3, 2026
410d8b0
Merge branch 'develop' into feat.authentication-bypass-via-biometric-…
OtavioStasiak Jun 3, 2026
bb51c78
fix: passcode unlock and deep link cancellation regressions
OtavioStasiak Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .maestro/scripts/data-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -263,5 +263,6 @@ output.utils = {
post,
login,
getDeepLink,
createDM
createDM,
sleep
};
240 changes: 240 additions & 0 deletions .maestro/tests/assorted/screen-lock.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
appId: ${APP_ID}
name: Screen Lock
onFlowStart:
- runFlow: '../../helpers/setup.yaml'
onFlowComplete:
- evalScript: ${output.utils.deleteCreatedUsers()}
tags:
- test-8

---
- evalScript: ${output.user = output.utils.createUser()}
- runFlow:
file: '../../helpers/login-with-deeplink.yaml'
env:
USERNAME: ${output.user.username}
PASSWORD: ${output.user.password}

# Navigate to Screen Lock config
- extendedWaitUntil:
visible:
id: 'rooms-list-view'
timeout: 60000
- tapOn:
id: 'rooms-list-view-sidebar'
- extendedWaitUntil:
visible:
id: 'sidebar-settings'
timeout: 60000
- tapOn:
id: 'sidebar-settings'
- extendedWaitUntil:
visible:
id: 'settings-view-security-privacy'
timeout: 60000
- tapOn:
id: 'settings-view-security-privacy'
- extendedWaitUntil:
visible:
id: 'security-privacy-view-screen-lock'
timeout: 60000
- tapOn:
id: 'security-privacy-view-screen-lock'
- extendedWaitUntil:
visible:
id: 'screen-lock-config-view'
timeout: 60000

# Enable "Unlock with passcode" -> opens PasscodeChoose
- tapOn:
id: 'screen-lock-config-view-auto-lock'
- extendedWaitUntil:
visible:
id: 'passcode-button-1'
timeout: 60000

# Choose passcode 123456
- tapOn:
id: 'passcode-button-1'
- tapOn:
id: 'passcode-button-2'
- tapOn:
id: 'passcode-button-3'
- tapOn:
id: 'passcode-button-4'
- tapOn:
id: 'passcode-button-5'
- tapOn:
id: 'passcode-button-6'

# Confirm passcode 123456
- extendedWaitUntil:
visible:
text: 'Confirm your new passcode'
timeout: 60000
- tapOn:
id: 'passcode-button-1'
- tapOn:
id: 'passcode-button-2'
- tapOn:
id: 'passcode-button-3'
- tapOn:
id: 'passcode-button-4'
- tapOn:
id: 'passcode-button-5'
- tapOn:
id: 'passcode-button-6'

# Back on Screen Lock config; choose "After 1 minute"
- extendedWaitUntil:
visible:
id: 'screen-lock-config-view-auto-lock-time-60'
timeout: 60000
- tapOn:
id: 'screen-lock-config-view-auto-lock-time-60'

# Background the app, wait past the auto-lock interval, relaunch
- pressKey: Home
- evalScript: ${output.utils.sleep(60000)}
- launchApp:
appId: ${APP_ID}

# Screen Lock modal must appear; unlock with current passcode
- extendedWaitUntil:
visible:
id: 'passcode-button-1'
timeout: 60000
- tapOn:
id: 'passcode-button-1'
- tapOn:
id: 'passcode-button-2'
- tapOn:
id: 'passcode-button-3'
- tapOn:
id: 'passcode-button-4'
- tapOn:
id: 'passcode-button-5'
- tapOn:
id: 'passcode-button-6'

# After unlock, we land back on Screen Lock config. Tap "Change passcode"
- extendedWaitUntil:
visible:
id: 'rooms-list-view'
timeout: 60000
- tapOn:
id: 'rooms-list-view-sidebar'
- extendedWaitUntil:
visible:
id: 'sidebar-settings'
timeout: 60000
- tapOn:
id: 'sidebar-settings'
- extendedWaitUntil:
visible:
id: 'settings-view-security-privacy'
timeout: 60000
- tapOn:
id: 'settings-view-security-privacy'
- extendedWaitUntil:
visible:
id: 'security-privacy-view-screen-lock'
timeout: 60000
- tapOn:
id: 'security-privacy-view-screen-lock'
- extendedWaitUntil:
visible:
id: 'screen-lock-config-view'
timeout: 60000
- extendedWaitUntil:
visible:
id: 'screen-lock-config-view-change-passcode'
timeout: 60000
- tapOn:
id: 'screen-lock-config-view-change-passcode'

# Re-authenticate with current passcode (autoLock is on -> handleLocalAuthentication)
- extendedWaitUntil:
visible:
id: 'passcode-button-1'
timeout: 60000
- tapOn:
id: 'passcode-button-1'
- tapOn:
id: 'passcode-button-2'
- tapOn:
id: 'passcode-button-3'
- tapOn:
id: 'passcode-button-4'
- tapOn:
id: 'passcode-button-5'
- tapOn:
id: 'passcode-button-6'

# Now the ChangePasscodeView (PasscodeChoose) opens; choose new passcode 3455678
- extendedWaitUntil:
visible:
text: 'Choose your new passcode'
timeout: 60000
- tapOn:
id: 'passcode-button-3'
- tapOn:
id: 'passcode-button-4'
- tapOn:
id: 'passcode-button-5'
- tapOn:
id: 'passcode-button-6'
- tapOn:
id: 'passcode-button-7'
- tapOn:
id: 'passcode-button-8'

# Confirm new passcode 3455678
- extendedWaitUntil:
visible:
text: 'Confirm your new passcode'
timeout: 60000
- tapOn:
id: 'passcode-button-3'
- tapOn:
id: 'passcode-button-4'
- tapOn:
id: 'passcode-button-5'
- tapOn:
id: 'passcode-button-6'
- tapOn:
id: 'passcode-button-7'
- tapOn:
id: 'passcode-button-8'

# Modal closes; we are back on the Screen Lock config screen
- extendedWaitUntil:
visible:
id: 'screen-lock-config-view'
timeout: 60000
- assertVisible:
id: 'screen-lock-config-view-change-passcode'

# Background the app, wait past the auto-lock interval, relaunch
- pressKey: Home
- evalScript: ${output.utils.sleep(60000)}
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
- launchApp:
appId: ${APP_ID}

# Screen Lock modal must appear; unlock with current passcode
- extendedWaitUntil:
visible:
id: 'passcode-button-3'
timeout: 60000
- tapOn:
id: 'passcode-button-3'
- tapOn:
id: 'passcode-button-4'
- tapOn:
id: 'passcode-button-5'
- tapOn:
id: 'passcode-button-6'
- tapOn:
id: 'passcode-button-7'
- tapOn:
id: 'passcode-button-8'
114 changes: 114 additions & 0 deletions app/containers/Passcode/PasscodeEnter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react-native';

import PasscodeEnter from './PasscodeEnter';
import { biometryAuth } from '../../lib/methods/helpers/localAuthentication';
import { biometricTrustStore } from '../../lib/biometricTrustStore';
import UserPreferences from '../../lib/methods/userPreferences';
import { BIOMETRY_ENABLED_KEY } from '../../lib/constants/localAuthentication';

jest.mock('../../lib/methods/helpers/localAuthentication', () => ({
biometryAuth: jest.fn(),
resetAttempts: jest.fn(() => Promise.resolve())
}));

jest.mock('../../lib/biometricTrustStore', () => ({
biometricTrustStore: {
enrol: jest.fn(),
disenrol: jest.fn(() => Promise.resolve()),
verify: jest.fn(),
probeExists: jest.fn()
}
}));

jest.mock('../../lib/methods/userPreferences', () => ({
__esModule: true,
default: {
getBool: jest.fn(),
setBool: jest.fn(),
getString: jest.fn(),
setString: jest.fn()
},
useUserPreferences: () => [null, jest.fn()]
}));

jest.mock('../../i18n', () => ({ t: (key: string) => key }));

const mockedBiometryAuth = biometryAuth as jest.Mock;
const mockedDisenrol = biometricTrustStore.disenrol as jest.Mock;
const mockedSetBool = UserPreferences.setBool as jest.Mock;

describe('PasscodeEnter biometry button', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedDisenrol.mockResolvedValue(undefined);
});

it('enrollmentChanged from button press → disenrols, clears flag, hides biometry button', async () => {
mockedBiometryAuth.mockResolvedValueOnce({ kind: 'enrollmentChanged' });
const finishProcess = jest.fn();

const { getByTestId, queryByTestId } = render(<PasscodeEnter hasBiometry skipAutoBiometry finishProcess={finishProcess} />);

await waitFor(() => expect(getByTestId('biometry-button')).toBeTruthy());

fireEvent.press(getByTestId('biometry-button'));

await waitFor(() => expect(mockedDisenrol).toHaveBeenCalledTimes(1));
expect(mockedSetBool).toHaveBeenCalledWith(BIOMETRY_ENABLED_KEY, false);
expect(finishProcess).not.toHaveBeenCalled();
await waitFor(() => expect(queryByTestId('biometry-button')).toBeNull());
});

it('success from button press → finishes process, no invalidation', async () => {
mockedBiometryAuth.mockResolvedValueOnce({ kind: 'success' });
const finishProcess = jest.fn();

const { getByTestId } = render(<PasscodeEnter hasBiometry skipAutoBiometry finishProcess={finishProcess} />);

await waitFor(() => expect(getByTestId('biometry-button')).toBeTruthy());

fireEvent.press(getByTestId('biometry-button'));

await waitFor(() => expect(finishProcess).toHaveBeenCalledTimes(1));
expect(mockedDisenrol).not.toHaveBeenCalled();
expect(mockedSetBool).not.toHaveBeenCalled();
});

it('canceled from button press → flag untouched, biometry button stays', async () => {
mockedBiometryAuth.mockResolvedValueOnce({ kind: 'canceled' });
const finishProcess = jest.fn();

const { getByTestId } = render(<PasscodeEnter hasBiometry skipAutoBiometry finishProcess={finishProcess} />);

await waitFor(() => expect(getByTestId('biometry-button')).toBeTruthy());

fireEvent.press(getByTestId('biometry-button'));

await waitFor(() => expect(mockedBiometryAuth).toHaveBeenCalled());
expect(mockedDisenrol).not.toHaveBeenCalled();
expect(mockedSetBool).not.toHaveBeenCalled();
expect(finishProcess).not.toHaveBeenCalled();
expect(getByTestId('biometry-button')).toBeTruthy();
});
});

describe('PasscodeEnter enrollmentChanged subtitle', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders explanatory subtitle when reason === "enrollmentChanged"', () => {
const { getByText } = render(
<PasscodeEnter hasBiometry={false} skipAutoBiometry reason='enrollmentChanged' finishProcess={jest.fn()} />
);

expect(getByText('Local_authentication_biometric_enrollment_changed')).toBeTruthy();
});

it('does not render subtitle when reason is undefined', () => {
const { queryByText } = render(<PasscodeEnter hasBiometry={false} skipAutoBiometry finishProcess={jest.fn()} />);

expect(queryByText('Local_authentication_biometric_enrollment_changed')).toBeNull();
});
});
Loading
Loading