mattermost-mobile/app/utils/alerts/index.test.ts
Elias Nahum 44eb76bed7
feat(iOS): Add Microsoft Intune MAM integration with multi-server support (#9312)
* refactor: implement custom ExpoImage wrapper for cache control

Add ExpoImage component with automatic cacheKey/cachePath management and replace all expo-image imports across the app

* refactor(ios): convert Gekidou to CocoaPods

Migrate from Swift Package Manager to CocoaPods, add Keychain write operations, refactor notification handler to remove react-native-notifications headers, and upgrade Swift to 5.0

* npm audit

* update fastlane

* feat(ci): integrate Intune MAM for enterprise builds with strict OSS protection

Add Intune submodule, CI actions, Fastlane configuration, developer scripts, pre-commit hooks, and validation workflows to enable internal MAM builds while protecting OSS repository

* fix tests by mocking @mattermost/intune

* feat: implement Intune MAM integration with comprehensive security enforcement

Add IntuneManager, refactor SecurityManager/SessionManager for MAM policies, implement native OIDC auth flow, add biometric enforcement, conditional launch blocking, and file protection controls

* fix alerts when no server database is present

* Unify cache strategy

* fix emit config changed after it was stored in the db

* Handle Mid-Session Enrollment Detection

* fix ADALLogOverrideDisabled missing in Fastfile

* fix flow for initial enrollment

* fix and add unit tests

* enable Intune configuration for PR and beta builds, CLIENT_ID should be changed before actual release

* Update intune submodule with addressed feedback

* fix validate-intune-clean workflow

* feat(intune): add comprehensive error handling and SAML+Entra support

Add production-ready error handling for native Entra authentication with
user-friendly i18n messages, comprehensive test coverage, and support for
Entra login when server requires SAML.

* update i18n

* update intune submodule

* update build-pr token

* fix race condition between server auth and intune enrollment

* fix CI workflow to build with intune

* use deploy key for intune submodule

* set the config directly in the submodule .git

* debug injection

* try setting GIT_SSH_COMMAND

* remove action debug

* fix server url input

* match pod cache with intune hash

* Fastfile and envs

* have workflows check for intune/.git

* have ci cache intune frameworks as well

* update Fastlane to set no-cache to artifacts uploaded

* fix s3 upload

* fix pblist template

* Attempt to remove the cache control for PR uploads to s3

* use hash from commit for S3 path

* Implement crash-resilient selective wipe with automatic retry and add removeInternetPassword to Gekidou Keychain

* Fix surface errors from intune login

* fix postinstall scripts

* use cacheKey for draft md images

* remove unnecessary double await during test

* Have isMinimumLicenseTier accept valid license sku tier as target

* Add missing Auth error messages

* remove the last period for intune errors in i18n

* do not call unenroll with wipe on manual logout

* Fix tests and Intune error messages

* do not filter any SSO type regardless of which is used for Intune

* fix 412 to not retry

* fix tests, app logs sharing and share_extension avatar cache

* apply setScreenCapturePolicy on license change

Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>

* re-apply screen capture on enrollment

Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>

* use userData from intunr login and prevent getMe

Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>

* Check for Biometrics and Jailbreak as we used to

---------

Co-authored-by: Eva Sarafianou <eva.sarafianou@mattermost.com>
2025-12-10 13:07:28 +02:00

600 lines
23 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Emm from '@mattermost/react-native-emm';
import {Alert, Platform, type AlertButton} from 'react-native';
import {switchToServer} from '@actions/app/server';
import {logout} from '@actions/remote/session';
import DatabaseManager from '@database/manager';
import {getTranslations} from '@i18n';
import {getServerCredentials} from '@init/credentials';
import {queryAllActiveServers} from '@queries/app/servers';
import {getConfigValue} from '@queries/servers/system';
import {
buildSecurityAlertOptions,
showDeviceNotTrustedAlert,
showNotSecuredAlert,
showBiometricFailureAlert,
messages,
} from '.';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database, Query} from '@nozbe/watermelondb';
import type ServersModel from '@typings/database/models/app/servers';
jest.mock('@mattermost/react-native-emm', () => ({
__esModule: true,
default: {
exitApp: jest.fn(),
openSecuritySettings: jest.fn(),
removeBlurEffect: jest.fn(),
addListener: jest.fn(),
getManagedConfig: jest.fn(() => ({})),
setAppGroupId: jest.fn(),
},
}));
jest.mock('@actions/remote/session');
jest.mock('@actions/app/server');
jest.mock('@database/manager', () => ({
getServerDatabaseAndOperator: jest.fn(),
getActiveServerUrl: jest.fn(),
}));
jest.mock('@init/credentials');
jest.mock('@queries/app/servers');
jest.mock('@queries/servers/system');
jest.mock('@i18n', () => ({
DEFAULT_LOCALE: 'en',
getTranslations: jest.fn(),
}));
describe('buildSecurityAlertOptions', () => {
const serverUrl = 'https://example.com';
const mockTranslations = {
[messages.logout.id]: 'Logout',
[messages.okay.id]: 'Okay',
[messages.switchServer.id]: 'Switch server',
[messages.exit.id]: 'Exit',
[messages.retry.id]: 'Retry',
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(getTranslations).mockReturnValue(mockTranslations);
});
it('should return logout button when server has session', async () => {
jest.mocked(getServerCredentials).mockResolvedValue({
serverUrl,
token: 'token',
userId: 'user_id',
} as ServerCredential);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([{url: serverUrl}]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations);
expect(buttons).toHaveLength(1);
expect(buttons[0].text).toBe('Logout');
expect(buttons[0].style).toBe('destructive');
});
it('should call logout when logout button is pressed', async () => {
jest.mocked(getServerCredentials).mockResolvedValue({
serverUrl,
token: 'token',
userId: 'user_id',
} as ServerCredential);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([{url: serverUrl}]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
const mockCallback = jest.fn();
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations, mockCallback);
await buttons[0].onPress?.();
expect(logout).toHaveBeenCalledWith(serverUrl, undefined);
expect(mockCallback).toHaveBeenCalledWith(true);
});
it('should return Exit button when there is only one server', async () => {
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([{url: serverUrl}]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations);
// When there's only one server total, otherServers will be empty, so Exit button is shown
expect(buttons).toHaveLength(1);
expect(buttons[0].text).toBe('Exit');
expect(buttons[0].style).toBe('destructive');
});
it('should return switch server button when there are multiple servers', async () => {
const otherServerUrl = 'https://other.example.com';
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([
{url: serverUrl},
{url: otherServerUrl},
]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations);
expect(buttons).toHaveLength(1);
expect(buttons[0].text).toBe('Switch server');
expect(buttons[0].style).toBe('cancel');
});
it('should call switchToServer when switch server button is pressed', async () => {
const otherServerUrl = 'https://other.example.com';
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([
{url: serverUrl},
{url: otherServerUrl},
]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
const mockCallback = jest.fn();
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations, mockCallback);
buttons[0].onPress?.();
expect(switchToServer).toHaveBeenCalledWith(otherServerUrl, expect.anything(), expect.anything());
expect(mockCallback).toHaveBeenCalledWith(true);
});
it('should return retry button when retryCallback is provided', async () => {
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([{url: serverUrl}]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
const mockRetryCallback = jest.fn();
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations, undefined, mockRetryCallback);
expect(buttons.length).toBeGreaterThanOrEqual(1);
const retryButton = buttons.find((b) => b.text === 'Retry');
expect(retryButton).toBeDefined();
expect(retryButton?.style).toBe('default');
});
it('should call retryCallback when retry button is pressed', async () => {
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([{url: serverUrl}]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
const mockRetryCallback = jest.fn();
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations, undefined, mockRetryCallback);
const retryButton = buttons.find((b) => b.text === 'Retry');
retryButton?.onPress?.();
expect(mockRetryCallback).toHaveBeenCalled();
});
it('should return exit button when there are no other options', async () => {
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue('');
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations);
expect(buttons).toHaveLength(1);
expect(buttons[0].text).toBe('Exit');
expect(buttons[0].style).toBe('destructive');
});
it('should call Emm.exitApp when exit button is pressed', async () => {
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue('');
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations);
buttons[0].onPress?.();
expect(Emm.exitApp).toHaveBeenCalled();
});
it('should handle logout and retry buttons together', async () => {
jest.mocked(getServerCredentials).mockResolvedValue({
serverUrl,
token: 'token',
userId: 'user_id',
} as ServerCredential);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([{url: serverUrl}]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
const mockRetryCallback = jest.fn();
const buttons = await buildSecurityAlertOptions(serverUrl, mockTranslations, undefined, mockRetryCallback);
expect(buttons).toHaveLength(2);
expect(buttons[0].text).toBe('Logout');
expect(buttons[1].text).toBe('Retry');
});
});
describe('showDeviceNotTrustedAlert', () => {
const serverUrl = 'https://example.com';
const mockDatabase = {} as unknown as Database;
const mockTranslations = {
[messages.blocked_by.id]: 'Blocked by {vendor}',
[messages.jailbreak.id]: 'Jailbroken or rooted devices are not trusted by {vendor}.',
[messages.logout.id]: 'Logout',
[messages.exit.id]: 'Exit',
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({
database: mockDatabase,
operator: {} as unknown as ServerDataOperator,
});
jest.mocked(getTranslations).mockReturnValue(mockTranslations);
jest.mocked(getConfigValue).mockResolvedValue('TestSite');
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue('');
jest.spyOn(Alert, 'alert');
});
it('should show alert with site name from parameter', async () => {
await showDeviceNotTrustedAlert(serverUrl, 'CustomSite', 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by CustomSite',
'Jailbroken or rooted devices are not trusted by CustomSite.',
expect.any(Array),
{cancelable: false},
);
});
it('should show alert with site name from server config when parameter not provided', async () => {
await showDeviceNotTrustedAlert(serverUrl, undefined, 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by TestSite',
'Jailbroken or rooted devices are not trusted by TestSite.',
expect.any(Array),
{cancelable: false},
);
});
it('should show alert with Mattermost as default when no site name available', async () => {
jest.mocked(getConfigValue).mockResolvedValue(undefined);
await showDeviceNotTrustedAlert(serverUrl, undefined, 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by Mattermost',
'Jailbroken or rooted devices are not trusted by Mattermost.',
expect.any(Array),
{cancelable: false},
);
});
it('should build security alert buttons', async () => {
jest.mocked(getServerCredentials).mockResolvedValue({
serverUrl,
token: 'token',
userId: 'user_id',
} as ServerCredential);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([{url: serverUrl}]),
} as unknown as Query<ServersModel>);
await showDeviceNotTrustedAlert(serverUrl, undefined, 'en');
expect(Alert.alert).toHaveBeenCalled();
const buttons = (Alert.alert as jest.Mock).mock.calls[0][2];
expect(buttons.length).toBeGreaterThan(0);
});
it('should handle errors gracefully', async () => {
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => {
throw new Error('Database error');
});
// Should not throw
await expect(showDeviceNotTrustedAlert(serverUrl, undefined, 'en')).resolves.not.toThrow();
});
});
describe('showNotSecuredAlert', () => {
const serverUrl = 'https://example.com';
const mockDatabase = {} as unknown as Database;
const mockTranslations = {
[messages.blocked_by.id]: 'Blocked by {vendor}',
[messages.not_secured_vendor_ios.id]: 'This device must be secured with biometrics or passcode to use {vendor}.\n\nGo to Settings > Face ID & Passcode.',
[messages.not_secured_vendor_android.id]: 'This device must be secured with a screen lock to use {vendor}.',
[messages.not_secured_ios.id]: 'This device must be secured with biometrics or passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.',
[messages.not_secured_android.id]: 'This device must be secured with a screen lock to use Mattermost.',
[messages.androidSettings.id]: 'Go to settings',
[messages.logout.id]: 'Logout',
[messages.exit.id]: 'Exit',
};
const originalSelect = Platform.select;
beforeAll(() => {
Platform.select = ({android, ios, default: dft}: any) => {
if (Platform.OS === 'android' && android) {
return android;
} else if (Platform.OS === 'ios' && ios) {
return ios;
}
return dft || ios;
};
});
afterAll(() => {
Platform.select = originalSelect;
});
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({
database: mockDatabase,
operator: {} as ServerDataOperator,
});
jest.mocked(getTranslations).mockReturnValue(mockTranslations);
jest.mocked(getConfigValue).mockResolvedValue('TestSite');
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue('');
jest.spyOn(Alert, 'alert');
Platform.OS = 'ios';
});
it('should show iOS-specific message with vendor name', async () => {
Platform.OS = 'ios';
await showNotSecuredAlert(serverUrl, 'CustomSite', 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by CustomSite',
expect.stringContaining('biometrics or passcode'),
expect.any(Array),
{cancelable: false},
);
});
it('should show Android-specific message with vendor name', async () => {
Platform.OS = 'android';
await showNotSecuredAlert(serverUrl, 'CustomSite', 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by CustomSite',
expect.stringContaining('screen lock'),
expect.any(Array),
{cancelable: false},
);
});
it('should show iOS-specific message without vendor name', async () => {
Platform.OS = 'ios';
jest.mocked(getConfigValue).mockResolvedValue(undefined);
await showNotSecuredAlert(serverUrl, undefined, 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by Mattermost',
expect.stringContaining('Mattermost'),
expect.any(Array),
{cancelable: false},
);
});
it('should show Android-specific message without vendor name', async () => {
Platform.OS = 'android';
jest.mocked(getConfigValue).mockResolvedValue(undefined);
await showNotSecuredAlert(serverUrl, undefined, 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by Mattermost',
expect.stringContaining('Mattermost'),
expect.any(Array),
{cancelable: false},
);
});
it('should add Android settings button on Android platform', async () => {
Platform.OS = 'android';
await showNotSecuredAlert(serverUrl, undefined, 'en');
const buttons = (Alert.alert as jest.Mock).mock.calls[0][2];
const settingsButton = buttons.find((b: AlertButton) => b.text === 'Go to settings');
expect(settingsButton).toBeDefined();
});
it('should call Emm.openSecuritySettings when Android settings button is pressed', async () => {
Platform.OS = 'android';
await showNotSecuredAlert(serverUrl, undefined, 'en');
const buttons = (Alert.alert as jest.Mock).mock.calls[0][2];
const settingsButton = buttons.find((b: AlertButton) => b.text === 'Go to settings');
settingsButton?.onPress();
expect(Emm.openSecuritySettings).toHaveBeenCalled();
});
it('should not add Android settings button on iOS platform', async () => {
Platform.OS = 'ios';
await showNotSecuredAlert(serverUrl, undefined, 'en');
const buttons = (Alert.alert as jest.Mock).mock.calls[0][2];
const settingsButton = buttons.find((b: AlertButton) => b.text === 'Go to settings');
expect(settingsButton).toBeUndefined();
});
it('should handle errors gracefully', async () => {
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => {
throw new Error('Database error');
});
// Should not throw
await expect(showNotSecuredAlert(serverUrl, undefined, 'en')).resolves.not.toThrow();
});
});
describe('showBiometricFailureAlert', () => {
const serverUrl = 'https://example.com';
const mockDatabase = {} as unknown as Database;
const mockTranslations = {
[messages.blocked_by.id]: 'Blocked by {vendor}',
[messages.biometric_failed.id]: 'Biometric or Passcode authentication failed.',
[messages.logout.id]: 'Logout',
[messages.exit.id]: 'Exit',
[messages.retry.id]: 'Retry',
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({
database: mockDatabase,
operator: {} as ServerDataOperator,
});
jest.mocked(getTranslations).mockReturnValue(mockTranslations);
jest.mocked(getConfigValue).mockResolvedValue('TestSite');
jest.mocked(getServerCredentials).mockResolvedValue(null);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([]),
} as unknown as Query<ServersModel>);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue('');
jest.spyOn(Alert, 'alert');
});
it('should show alert with correct title and message', async () => {
await showBiometricFailureAlert(serverUrl, false, 'CustomSite', 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by CustomSite',
'Biometric or Passcode authentication failed.',
expect.any(Array),
{cancelable: false},
);
});
it('should include retry button when retryCallback is provided', async () => {
const mockRetryCallback = jest.fn();
await showBiometricFailureAlert(serverUrl, false, undefined, 'en', mockRetryCallback);
const buttons = (Alert.alert as jest.Mock).mock.calls[0][2];
const retryButton = buttons.find((b: AlertButton) => b.text === 'Retry');
expect(retryButton).toBeDefined();
});
it('should call retryCallback when retry button is pressed', async () => {
const mockRetryCallback = jest.fn();
await showBiometricFailureAlert(serverUrl, false, undefined, 'en', mockRetryCallback);
const buttons = (Alert.alert as jest.Mock).mock.calls[0][2];
const retryButton = buttons.find((b: AlertButton) => b.text === 'Retry');
retryButton?.onPress();
expect(mockRetryCallback).toHaveBeenCalled();
});
it('should remove blur effect when callback is invoked with blurOnAuthenticate=true', async () => {
jest.mocked(getServerCredentials).mockResolvedValue({
serverUrl,
token: 'token',
userId: 'user_id',
} as ServerCredential);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([{url: serverUrl}]),
} as unknown as Query<ServersModel>);
await showBiometricFailureAlert(serverUrl, true, undefined, 'en');
const buttons = (Alert.alert as jest.Mock).mock.calls[0][2];
const logoutButton = buttons.find((b: AlertButton) => b.text === 'Logout');
await logoutButton?.onPress();
expect(Emm.removeBlurEffect).toHaveBeenCalled();
});
it('should not remove blur effect when blurOnAuthenticate=false', async () => {
jest.mocked(getServerCredentials).mockResolvedValue({
serverUrl,
token: 'token',
userId: 'user_id',
} as ServerCredential);
jest.mocked(queryAllActiveServers).mockReturnValue({
fetch: jest.fn().mockResolvedValue([{url: serverUrl}]),
} as unknown as Query<ServersModel>);
await showBiometricFailureAlert(serverUrl, false, undefined, 'en');
const buttons = (Alert.alert as jest.Mock).mock.calls[0][2];
const logoutButton = buttons.find((b: AlertButton) => b.text === 'Logout');
await logoutButton?.onPress();
expect(Emm.removeBlurEffect).not.toHaveBeenCalled();
});
it('should use site name from server config when not provided', async () => {
await showBiometricFailureAlert(serverUrl, false, undefined, 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by TestSite',
'Biometric or Passcode authentication failed.',
expect.any(Array),
{cancelable: false},
);
});
it('should use Mattermost as default when no site name available', async () => {
jest.mocked(getConfigValue).mockResolvedValue(undefined);
await showBiometricFailureAlert(serverUrl, false, undefined, 'en');
expect(Alert.alert).toHaveBeenCalledWith(
'Blocked by Mattermost',
'Biometric or Passcode authentication failed.',
expect.any(Array),
{cancelable: false},
);
});
it('should handle errors gracefully', async () => {
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => {
throw new Error('Database error');
});
// Should not throw
await expect(showBiometricFailureAlert(serverUrl, false, undefined, 'en')).resolves.not.toThrow();
});
});