mattermost-mobile-upstream/app/utils/security/app_state.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

143 lines
5.8 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {type AppStateStatus} from 'react-native';
import {handleAppStateResume, type ResumeGateState, type ResumeGateStrategy} from './app_state';
import {AuthenticationSource, PROMPT_AUTH_AFTER} from './constants';
jest.mock('@utils/log', () => ({
logDebug: jest.fn(),
logError: jest.fn(),
}));
const buildStrategy = (overrides: Partial<ResumeGateStrategy> = {}): ResumeGateStrategy => ({
isEnabled: () => true,
isGateOpen: () => false,
authenticate: jest.fn().mockResolvedValue(true),
promptWhenNotExpired: false,
source: AuthenticationSource.SecurityManager,
...overrides,
});
const resume = (state: ResumeGateState, strategy: ResumeGateStrategy) => {
return handleAppStateResume('active' as AppStateStatus, state, strategy);
};
describe('handleAppStateResume', () => {
test('should record when the app went to the background', async () => {
const state: ResumeGateState = {backgroundSince: 0, previousAppState: 'active'};
await handleAppStateResume('background' as AppStateStatus, state, buildStrategy());
expect(state.backgroundSince).toBeGreaterThan(0);
expect(state.previousAppState).toBe('background');
});
test('should preserve backgroundSince when a gate is already open', async () => {
const backgroundSince = Date.now() - PROMPT_AUTH_AFTER - 1000;
const state: ResumeGateState = {backgroundSince, previousAppState: 'background'};
const strategy = buildStrategy({isGateOpen: () => true});
await resume(state, strategy);
// Clearing it here would let the next resume compute authExpired === false.
expect(state.backgroundSince).toBe(backgroundSince);
expect(strategy.authenticate).not.toHaveBeenCalled();
});
test('should clear backgroundSince without prompting when the window has not elapsed', async () => {
const state: ResumeGateState = {backgroundSince: Date.now() - 1000, previousAppState: 'background'};
const strategy = buildStrategy();
await resume(state, strategy);
expect(state.backgroundSince).toBe(0);
expect(strategy.authenticate).not.toHaveBeenCalled();
});
test('should authenticate and clear backgroundSince when the window has elapsed', async () => {
const state: ResumeGateState = {backgroundSince: Date.now() - PROMPT_AUTH_AFTER - 1000, previousAppState: 'background'};
const strategy = buildStrategy();
await resume(state, strategy);
expect(strategy.authenticate).toHaveBeenCalledWith(true);
expect(state.backgroundSince).toBe(0);
});
test('should keep backgroundSince when authentication does not succeed', async () => {
const backgroundSince = Date.now() - PROMPT_AUTH_AFTER - 1000;
const state: ResumeGateState = {backgroundSince, previousAppState: 'background'};
const strategy = buildStrategy({authenticate: jest.fn().mockResolvedValue(false)});
await resume(state, strategy);
expect(state.backgroundSince).toBe(backgroundSince);
});
test('should not authenticate when blocked, and keep the window', async () => {
const backgroundSince = Date.now() - PROMPT_AUTH_AFTER - 1000;
const state: ResumeGateState = {backgroundSince, previousAppState: 'background'};
const strategy = buildStrategy({shouldBlock: jest.fn().mockResolvedValue(true)});
await resume(state, strategy);
expect(strategy.authenticate).not.toHaveBeenCalled();
expect(state.backgroundSince).toBe(backgroundSince);
});
test('should run the gate on a non-expired resume when promptWhenNotExpired is set', async () => {
const state: ResumeGateState = {backgroundSince: Date.now() - 1000, previousAppState: 'background'};
const strategy = buildStrategy({promptWhenNotExpired: true});
await resume(state, strategy);
expect(strategy.authenticate).toHaveBeenCalledWith(false);
});
test('should do nothing when disabled', async () => {
const backgroundSince = Date.now() - PROMPT_AUTH_AFTER - 1000;
const state: ResumeGateState = {backgroundSince, previousAppState: 'background'};
const strategy = buildStrategy({isEnabled: () => false});
await resume(state, strategy);
expect(strategy.authenticate).not.toHaveBeenCalled();
expect(state.backgroundSince).toBe(backgroundSince);
expect(state.previousAppState).toBe('active');
});
test('should authenticate across the background -> inactive -> active sequence iOS emits', async () => {
const state: ResumeGateState = {backgroundSince: 0, previousAppState: 'active'};
const strategy = buildStrategy();
await handleAppStateResume('background' as AppStateStatus, state, strategy);
state.backgroundSince = Date.now() - PROMPT_AUTH_AFTER - 1000;
await handleAppStateResume('inactive' as AppStateStatus, state, strategy);
await resume(state, strategy);
expect(strategy.authenticate).toHaveBeenCalledWith(true);
expect(state.backgroundSince).toBe(0);
});
test('should not treat a transient inactive as a resume', async () => {
const state: ResumeGateState = {backgroundSince: 0, previousAppState: 'active'};
const strategy = buildStrategy();
await handleAppStateResume('inactive' as AppStateStatus, state, strategy);
await resume(state, strategy);
expect(strategy.authenticate).not.toHaveBeenCalled();
});
test('should ignore a resume that did not come from the background', async () => {
const state: ResumeGateState = {backgroundSince: 0, previousAppState: 'inactive'};
const strategy = buildStrategy();
await resume(state, strategy);
expect(strategy.authenticate).not.toHaveBeenCalled();
});
});