* 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>
387 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|