mattermost-mobile/app/managers/intune_manager/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

387 lines
18 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Unmock IntuneManager for this test file since we're testing it
jest.unmock('@managers/intune_manager');
import {Platform, type EventSubscription} from 'react-native';
import {License} from '@constants';
import DatabaseManager from '@database/manager';
import {getConfig, getLicense} from '@queries/servers/system';
import {isMinimumLicenseTier} from '@utils/helpers';
import IntuneManager from '.';
import type {MSALIdentity, MSALTokens, IntunePolicy} from './types';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
jest.mock('@database/manager', () => ({
getServerDatabaseAndOperator: jest.fn(),
getActiveServerUrl: jest.fn(),
}));
jest.mock('@queries/servers/system');
jest.mock('@utils/alerts');
jest.mock('@utils/helpers');
jest.mock('@mattermost/react-native-emm');
jest.mock('@managers/security_manager', () => ({
__esModule: true,
default: {},
}));
const mockedGetConfig = jest.mocked(getConfig);
const mockedGetLicense = jest.mocked(getLicense);
const mockedIsMinimumLicenseTier = jest.mocked(isMinimumLicenseTier);
// Get mocked Intune from the global mock
const Intune = jest.requireMock<{default: any}>('@mattermost/intune').default;
describe('IntuneManager', () => {
const serverUrl = 'https://example.com';
const mockDatabase = {} as unknown as Database;
beforeEach(() => {
jest.clearAllMocks();
Platform.OS = 'ios';
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockReturnValue({
database: mockDatabase,
operator: {} as ServerDataOperator,
});
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue(serverUrl);
});
describe('isIntuneMAMEnabledForServer', () => {
it('should return false when database does not exist', async () => {
jest.mocked(DatabaseManager.getServerDatabaseAndOperator).mockImplementation(() => {
throw new Error('Database not found');
});
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
expect(result).toBe(false);
});
it('should return false when license is below EnterpriseAdvanced tier', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(false);
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
expect(result).toBe(false);
expect(mockedIsMinimumLicenseTier).toHaveBeenCalledWith({}, License.SKU_SHORT_NAME.EnterpriseAdvanced);
});
it('should return false when IntuneMAMEnabled is false', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'false', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(true);
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
expect(result).toBe(false);
});
it('should return false when IntuneScope is missing', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneAuthService: 'office365'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(true);
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
expect(result).toBe(false);
});
it('should return false when IntuneAuthService is missing', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(true);
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
expect(result).toBe(false);
});
it('should return true when all conditions are met', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(true);
const result = await IntuneManager.isIntuneMAMEnabledForServer(serverUrl);
expect(result).toBe(true);
});
});
describe('login', () => {
const scopes = ['User.Read', 'custom.scope'];
const mockTokens: MSALTokens = {
idToken: 'id_token',
accessToken: 'access_token',
identity: {upn: 'user@example.com', tid: 'tenant_id', oid: 'object_id'},
};
it('should return tokens on successful login', async () => {
jest.mocked(Intune.login).mockResolvedValue(mockTokens);
const result = await IntuneManager.login(serverUrl, scopes);
expect(result).toEqual(mockTokens);
expect(jest.mocked(Intune.login)).toHaveBeenCalledWith(serverUrl, scopes);
});
it('should throw error on login failure', async () => {
const error = new Error('Login failed');
jest.mocked(Intune.login).mockRejectedValue(error);
await expect(IntuneManager.login(serverUrl, scopes)).rejects.toThrow('Login failed');
});
});
describe('enrollServer', () => {
const mockIdentity: MSALIdentity = {
upn: 'user@example.com',
tid: 'tenant_id',
oid: 'object_id',
};
it('should call enrollInMAM on successful enrollment', async () => {
jest.mocked(Intune.enrollInMAM).mockResolvedValue();
await IntuneManager.enrollServer(serverUrl, mockIdentity);
expect(jest.mocked(Intune.enrollInMAM)).toHaveBeenCalledWith(serverUrl, mockIdentity);
});
it('should throw error on enrollment failure', async () => {
const error = new Error('Enrollment failed');
jest.mocked(Intune.enrollInMAM).mockRejectedValue(error);
await expect(IntuneManager.enrollServer(serverUrl, mockIdentity)).rejects.toThrow('Enrollment failed');
});
});
describe('unenrollServer', () => {
it('should skip unenrollment when server not enrolled', async () => {
jest.mocked(Intune.isManagedServer).mockResolvedValue(false);
await IntuneManager.unenrollServer(serverUrl, false);
expect(jest.mocked(Intune.deregisterAndUnenroll)).not.toHaveBeenCalled();
});
it('should clear current identity when unenrolling active server', async () => {
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
jest.mocked(Intune.setCurrentIdentity).mockResolvedValue();
jest.mocked(Intune.deregisterAndUnenroll).mockResolvedValue();
await IntuneManager.unenrollServer(serverUrl, false);
expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(null);
expect(jest.mocked(Intune.deregisterAndUnenroll)).toHaveBeenCalledWith(serverUrl, false);
});
it('should not clear identity when unenrolling non-active server', async () => {
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
jest.mocked(DatabaseManager.getActiveServerUrl).mockResolvedValue('https://other.com');
jest.mocked(Intune.deregisterAndUnenroll).mockResolvedValue();
await IntuneManager.unenrollServer(serverUrl, false);
expect(jest.mocked(Intune.setCurrentIdentity)).not.toHaveBeenCalled();
expect(jest.mocked(Intune.deregisterAndUnenroll)).toHaveBeenCalledWith(serverUrl, false);
});
it('should call deregisterAndUnenroll with doWipe = true', async () => {
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
jest.mocked(Intune.deregisterAndUnenroll).mockResolvedValue();
await IntuneManager.unenrollServer(serverUrl, true);
expect(jest.mocked(Intune.deregisterAndUnenroll)).toHaveBeenCalledWith(serverUrl, true);
});
it('should catch and log errors on unenrollment failure', async () => {
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
jest.mocked(Intune.deregisterAndUnenroll).mockRejectedValue(new Error('Unenroll failed'));
await IntuneManager.unenrollServer(serverUrl, false);
});
});
describe('isManagedServer', () => {
it('should return true when server is managed', async () => {
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
const result = await IntuneManager.isManagedServer(serverUrl);
expect(result).toBe(true);
expect(jest.mocked(Intune.isManagedServer)).toHaveBeenCalledWith(serverUrl);
});
it('should return false when server is not managed', async () => {
jest.mocked(Intune.isManagedServer).mockResolvedValue(false);
const result = await IntuneManager.isManagedServer(serverUrl);
expect(result).toBe(false);
});
it('should return false on error', async () => {
jest.mocked(Intune.isManagedServer).mockRejectedValue(new Error('Check failed'));
const result = await IntuneManager.isManagedServer(serverUrl);
expect(result).toBe(false);
});
});
describe('setCurrentIdentity', () => {
it('should set null identity', async () => {
jest.mocked(Intune.setCurrentIdentity).mockResolvedValue();
await IntuneManager.setCurrentIdentity(null);
expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(null);
});
it('should set identity when server is licensed', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(true);
jest.mocked(Intune.setCurrentIdentity).mockResolvedValue();
await IntuneManager.setCurrentIdentity(serverUrl);
expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(serverUrl);
});
it('should clear identity when server is not licensed', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'false'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(false);
jest.mocked(Intune.setCurrentIdentity).mockResolvedValue();
await IntuneManager.setCurrentIdentity(serverUrl);
expect(jest.mocked(Intune.setCurrentIdentity)).toHaveBeenCalledWith(null);
});
});
describe('getPolicy', () => {
const mockPolicy: IntunePolicy = {
isPINRequired: true,
isContactSyncAllowed: false,
isWidgetContentSyncAllowed: false,
isSpotlightIndexingAllowed: false,
areSiriIntentsAllowed: false,
areAppIntentsAllowed: false,
isAppSharingAllowed: false,
isManagedBrowserRequired: false,
isFileEncryptionRequired: false,
isScreenCaptureAllowed: false,
shouldFileProviderEncryptFiles: false,
notificationPolicy: 0,
allowedSaveLocations: {
Other: false,
OneDriveForBusiness: true,
SharePoint: true,
LocalDrive: false,
PhotoLibrary: false,
CameraRoll: false,
FilesApp: false,
iCloudDrive: false,
},
allowedOpenLocations: 0,
};
it('should return null when Intune not enabled for server', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'false'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(false);
const result = await IntuneManager.getPolicy(serverUrl);
expect(result).toBeNull();
});
it('should return null when server not managed', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(true);
jest.mocked(Intune.isManagedServer).mockResolvedValue(false);
const result = await IntuneManager.getPolicy(serverUrl);
expect(result).toBeNull();
});
it('should return policy when server is managed', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'saml'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(true);
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
jest.mocked(Intune.getPolicy).mockResolvedValue(mockPolicy);
const result = await IntuneManager.getPolicy(serverUrl);
expect(result).toEqual(mockPolicy);
expect(jest.mocked(Intune.getPolicy)).toHaveBeenCalledWith(serverUrl);
});
it('should return null on error', async () => {
mockedGetConfig.mockResolvedValue({IntuneMAMEnabled: 'true', IntuneScope: 'scope', IntuneAuthService: 'office365'} as ClientConfig);
mockedGetLicense.mockResolvedValue({} as ClientLicense);
mockedIsMinimumLicenseTier.mockReturnValue(true);
jest.mocked(Intune.isManagedServer).mockResolvedValue(true);
jest.mocked(Intune.getPolicy).mockRejectedValue(new Error('Get policy failed'));
const result = await IntuneManager.getPolicy(serverUrl);
expect(result).toBeNull();
});
});
describe('Event Subscription Methods', () => {
const mockHandler = jest.fn();
const mockSubscription = {remove: jest.fn()} as unknown as EventSubscription;
beforeEach(() => {
jest.mocked(Intune.onIntunePolicyChanged).mockReturnValue(mockSubscription);
jest.mocked(Intune.onIntuneEnrollmentChanged).mockReturnValue(mockSubscription);
jest.mocked(Intune.onIntuneWipeRequested).mockReturnValue(mockSubscription);
jest.mocked(Intune.onIntuneAuthRequired).mockReturnValue(mockSubscription);
jest.mocked(Intune.onIntuneConditionalLaunchBlocked).mockReturnValue(mockSubscription);
jest.mocked(Intune.onIntuneIdentitySwitchRequired).mockReturnValue(mockSubscription);
});
describe('subscribeToPolicyChanges', () => {
it('should return EventSubscription when Intune library available', () => {
const result = IntuneManager.subscribeToPolicyChanges(mockHandler);
expect(result).toBe(mockSubscription);
expect(jest.mocked(Intune.onIntunePolicyChanged)).toHaveBeenCalledWith(mockHandler);
});
});
describe('subscribeToEnrollmentChanges', () => {
it('should return EventSubscription when Intune library available', () => {
const result = IntuneManager.subscribeToEnrollmentChanges(mockHandler);
expect(result).toBe(mockSubscription);
expect(jest.mocked(Intune.onIntuneEnrollmentChanged)).toHaveBeenCalledWith(mockHandler);
});
});
describe('subscribeToWipeRequests', () => {
it('should return EventSubscription when Intune library available', () => {
const result = IntuneManager.subscribeToWipeRequests(mockHandler);
expect(result).toBe(mockSubscription);
expect(jest.mocked(Intune.onIntuneWipeRequested)).toHaveBeenCalledWith(mockHandler);
});
});
describe('subscribeToAuthRequired', () => {
it('should return EventSubscription when Intune library available', () => {
const result = IntuneManager.subscribeToAuthRequired(mockHandler);
expect(result).toBe(mockSubscription);
expect(jest.mocked(Intune.onIntuneAuthRequired)).toHaveBeenCalledWith(mockHandler);
});
});
describe('subscribeToConditionalLaunchBlocked', () => {
it('should return EventSubscription when Intune library available', () => {
const result = IntuneManager.subscribeToConditionalLaunchBlocked(mockHandler);
expect(result).toBe(mockSubscription);
expect(jest.mocked(Intune.onIntuneConditionalLaunchBlocked)).toHaveBeenCalledWith(mockHandler);
});
});
describe('subscribeToIdentitySwitchRequired', () => {
it('should return EventSubscription when Intune library available', () => {
const result = IntuneManager.subscribeToIdentitySwitchRequired(mockHandler);
expect(result).toBe(mockSubscription);
expect(jest.mocked(Intune.onIntuneIdentitySwitchRequired)).toHaveBeenCalledWith(mockHandler);
});
});
});
});