mattermost-mobile-upstream/app/utils/security/authentication.test.ts
Elias Nahum f00701ec9a
On iOS, resuming after the 5-minute window showed no biometric prompt — (#10102)
* On iOS, resuming after the 5-minute window showed no biometric prompt —
just a blocking alert whose only button logged the user out. A failure to
evaluate device security was indistinguishable from a verdict that the
device is not secured. Android had the same class of bug, where
user-cancel codes led to logout.

Outcomes are now classified three ways, so only a genuine credential
rejection or a genuinely unsecured device acts destructively; cancels and
indeterminate results offer a retry. SecurityManager and ManagedApp share
the policy-free parts via app/utils/security/.

Also fixes a resume stacking a second prompt behind an open alert, a
backgroundSince bypass, and a latched flag that disabled the MDM PIN.

* feedback review

* gate app state

* fix tests
2026-09-02 15:03:13 +08:00

89 lines
3.5 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm, {AuthenticationOutcome} from '@mattermost/react-native-emm';
import {advanceTimers, disableFakeTimers, enableFakeTimers} from '@test/timer_helpers';
import {getAuthenticationOutcome, probeDeviceSecured} from './authentication';
import {AuthenticationSource, DEVICE_SECURED_RETRY_DELAY} from './constants';
jest.mock('@mattermost/react-native-emm', () => ({
AuthenticationOutcome: {
Failed: 'E_AUTH_FAILED',
Cancelled: 'E_CANCELLED',
Indeterminate: 'E_INDETERMINATE',
},
isDeviceSecured: jest.fn(),
}));
const rejectWith = (outcome: AuthenticationOutcome) => {
const error = new Error(outcome) as Error & {outcome: AuthenticationOutcome};
error.outcome = outcome;
return error;
};
describe('getAuthenticationOutcome', () => {
test('should return the outcome carried by the error', () => {
expect(getAuthenticationOutcome(rejectWith(AuthenticationOutcome.Failed))).toBe(AuthenticationOutcome.Failed);
expect(getAuthenticationOutcome(rejectWith(AuthenticationOutcome.Cancelled))).toBe(AuthenticationOutcome.Cancelled);
});
test('should treat an unrecognised error as indeterminate so it never looks like a failure', () => {
expect(getAuthenticationOutcome(new Error('boom'))).toBe(AuthenticationOutcome.Indeterminate);
expect(getAuthenticationOutcome(undefined)).toBe(AuthenticationOutcome.Indeterminate);
});
});
describe('probeDeviceSecured', () => {
beforeEach(() => {
jest.clearAllMocks();
enableFakeTimers();
});
afterEach(() => {
disableFakeTimers();
});
test('should report secured without retrying', async () => {
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(true);
await expect(probeDeviceSecured(AuthenticationSource.SecurityManager)).resolves.toBe('secured');
expect(Emm.isDeviceSecured).toHaveBeenCalledTimes(1);
});
test('should not retry a device that is genuinely not secured', async () => {
jest.mocked(Emm.isDeviceSecured).mockResolvedValue(false);
await expect(probeDeviceSecured(AuthenticationSource.SecurityManager)).resolves.toBe('notSecured');
expect(Emm.isDeviceSecured).toHaveBeenCalledTimes(1);
});
test('should retry once and succeed when the first check cannot be performed', async () => {
jest.mocked(Emm.isDeviceSecured).
mockRejectedValueOnce(rejectWith(AuthenticationOutcome.Indeterminate)).
mockResolvedValueOnce(true);
const result = probeDeviceSecured(AuthenticationSource.SecurityManager);
// Let the first rejection settle so the retry delay is actually pending.
await new Promise(process.nextTick);
await advanceTimers(DEVICE_SECURED_RETRY_DELAY);
await expect(result).resolves.toBe('secured');
expect(Emm.isDeviceSecured).toHaveBeenCalledTimes(2);
});
test('should report interrupted rather than notSecured when both attempts fail', async () => {
jest.mocked(Emm.isDeviceSecured).mockRejectedValue(rejectWith(AuthenticationOutcome.Indeterminate));
const result = probeDeviceSecured(AuthenticationSource.SecurityManager);
await new Promise(process.nextTick);
await advanceTimers(DEVICE_SECURED_RETRY_DELAY);
await expect(result).resolves.toBe('interrupted');
expect(Emm.isDeviceSecured).toHaveBeenCalledTimes(2);
});
});