mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 10:28:54 +00:00
* 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
143 lines
5.8 KiB
TypeScript
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();
|
|
});
|
|
});
|