mattermost-mobile/app/utils/helpers.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

445 lines
18 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {NativeModules, Platform} from 'react-native';
import {License} from '@constants';
import {STATUS_BAR_HEIGHT} from '@constants/view';
import {
isMinimumServerVersion,
buildQueryString,
isEmail,
identity,
safeParseJSON,
safeParseJSONStringArray,
getCurrentMomentForTimezone,
getUtcOffsetForTimeZone,
toTitleCase,
getRoundedTime,
isTablet,
pluckUnique,
bottomSheetSnapPoint,
hasTrailingSpaces,
isMainActivity,
areBothStringArraysEqual,
hasArrayChanged,
isMinimumLicenseTier,
} from './helpers';
jest.mock('@mattermost/rnshare', () => ({
default: {
getCurrentActivityName: jest.fn().
mockReturnValueOnce('MainActivity').
mockReturnValue('SomeOtherActivity'),
},
}));
describe('Helpers', () => {
afterAll(() => {
Platform.OS = 'ios';
});
describe('isMinimumServerVersion', () => {
test('should return true if server version meets minimum requirements', () => {
expect(isMinimumServerVersion('4.6.0', 4, 6, 0)).toBe(true);
expect(isMinimumServerVersion('4.6.0', 4, 5, 0)).toBe(true);
expect(isMinimumServerVersion('4.6.1', 4, 6, 0)).toBe(true);
expect(isMinimumServerVersion('4')).toBe(true);
expect(isMinimumServerVersion('4.6', 4, 6)).toBe(true);
});
test('should return false if server version does not meet minimum requirements', () => {
expect(isMinimumServerVersion('4.5.0', 4, 6, 0)).toBe(false);
expect(isMinimumServerVersion('4.6.0', 4, 6, 1)).toBe(false);
});
test('currentVersion is not set or not a string', () => {
expect(isMinimumServerVersion()).toBe(false);
// @ts-expect-error argument should be a string
expect(isMinimumServerVersion(0)).toBe(false);
});
});
describe('buildQueryString', () => {
test('should build query string from object', () => {
const parameters = {key1: 'value1', key2: 'value2'};
expect(buildQueryString(parameters)).toBe('?key1=value1&key2=value2');
expect(buildQueryString({...parameters, key3: null})).toBe('?key1=value1&key2=value2');
expect(buildQueryString({key0: null, ...parameters, key3: null})).toBe('?key1=value1&key2=value2');
expect(buildQueryString({key1: 'value1', key0: null, key3: null, key2: 'value2'})).toBe('?key1=value1&key2=value2');
});
test('should handle empty object', () => {
expect(buildQueryString({})).toBe('');
});
});
describe('isEmail', () => {
test('should validate correct emails', () => {
expect(isEmail('test@example.com')).toBe(true);
expect(isEmail('another@test.com')).toBe(true);
});
test('should invalidate incorrect emails', () => {
expect(isEmail('invalid')).toBe(false);
expect(isEmail('test@')).toBe(false);
expect(isEmail('test@localhost')).toBe(true);
expect(isEmail('test@example.com')).toBe(true);
});
});
describe('identity', () => {
test('should return the same argument', () => {
expect(identity('test')).toBe('test');
expect(identity(123)).toBe(123);
const obj = {};
expect(identity(obj)).toBe(obj);
});
});
describe('safeParseJSON', () => {
test('should parse valid JSON string', () => {
const jsonString = '{"key": "value"}';
expect(safeParseJSON(jsonString)).toEqual({key: 'value'});
});
test('should handle invalid JSON', () => {
expect(safeParseJSON('invalid-json')).toBe('invalid-json');
});
test('should handle non-string input', () => {
expect(safeParseJSON({key: 'value'})).toEqual({key: 'value'});
});
});
describe('safeParseJSONStringArray', () => {
test('should parse valid JSON array with only strings', () => {
const jsonString = '["apple", "banana", "cherry"]';
expect(safeParseJSONStringArray(jsonString)).toEqual(['apple', 'banana', 'cherry']);
});
test('should filter out non-string values from array', () => {
const jsonString = '["apple", 123, "banana", true, "cherry", null, "date"]';
expect(safeParseJSONStringArray(jsonString)).toEqual(['apple', 'banana', 'cherry', 'date']);
});
test('should return empty array for non-array JSON', () => {
const jsonString = '{"key": "value"}';
expect(safeParseJSONStringArray(jsonString)).toEqual([]);
});
test('should return empty array for primitive JSON values', () => {
expect(safeParseJSONStringArray('"just a string"')).toEqual([]);
expect(safeParseJSONStringArray('123')).toEqual([]);
expect(safeParseJSONStringArray('true')).toEqual([]);
expect(safeParseJSONStringArray('null')).toEqual([]);
});
test('should return empty array for invalid JSON', () => {
expect(safeParseJSONStringArray('invalid-json')).toEqual([]);
expect(safeParseJSONStringArray('{invalid}')).toEqual([]);
expect(safeParseJSONStringArray('[')).toEqual([]);
});
test('should return empty array for empty string', () => {
expect(safeParseJSONStringArray('')).toEqual([]);
});
test('should handle array with only non-string values', () => {
const jsonString = '[123, true, null, 456, false]';
expect(safeParseJSONStringArray(jsonString)).toEqual([]);
});
test('should handle empty array', () => {
const jsonString = '[]';
expect(safeParseJSONStringArray(jsonString)).toEqual([]);
});
test('should handle array with mixed types including objects and arrays', () => {
const jsonString = '["string1", {"key": "value"}, "string2", [1, 2, 3], "string3"]';
expect(safeParseJSONStringArray(jsonString)).toEqual(['string1', 'string2', 'string3']);
});
test('should handle a string array', () => {
const jsonString = '["apple", "banana", "cherry"]';
expect(safeParseJSONStringArray(jsonString)).toEqual(['apple', 'banana', 'cherry']);
});
test('should handle an array with mixed types', () => {
const jsonString = '["apple", 123, "banana", true, "cherry", null]';
expect(safeParseJSONStringArray(jsonString)).toEqual(['apple', 'banana', 'cherry']);
});
test('should handle non string input', () => {
const input = {key: 'value'};
expect(safeParseJSONStringArray(input)).toEqual([]);
expect(safeParseJSONStringArray(null)).toEqual([]);
expect(safeParseJSONStringArray(undefined)).toEqual([]);
expect(safeParseJSONStringArray(123)).toEqual([]);
expect(safeParseJSONStringArray(true)).toEqual([]);
expect(safeParseJSONStringArray(false)).toEqual([]);
});
test('should handle empty string', () => {
expect(safeParseJSONStringArray('')).toEqual([]);
});
});
describe('getCurrentMomentForTimezone', () => {
test('should return current moment in specified timezone', () => {
const timezone = 'America/New_York';
const result = getCurrentMomentForTimezone(timezone);
expect(result.isValid()).toBe(true);
});
test('should return current moment if no timezone specified', () => {
const result = getCurrentMomentForTimezone(null);
expect(result.isValid()).toBe(true);
});
});
describe('toTitleCase', () => {
test('should convert string to title case', () => {
expect(toTitleCase('hello world')).toBe('Hello World');
});
});
describe('getRoundedTime', () => {
test('should round time to nearest interval', () => {
const time = moment('2024-06-01T12:34:00Z');
const result = getRoundedTime(time, 3);
expect(result.minute() % 3).toBe(0);
const time2 = moment('2024-06-01T12:00:00Z');
const result2 = getRoundedTime(time2, 7);
expect(result2.minute() % 7).toBe(0);
const time3 = moment('2024-06-01T12:34:00Z');
const result3 = getRoundedTime(time3, 11);
expect(result3.minute() % 11).toBe(0);
});
});
describe('isTablet', () => {
test('should identify tablet correctly', () => {
console.log('split mock', NativeModules.RNUtils.isRunningInSplitView());
expect(isTablet()).toBe(false);
const prevSplitViewModule = NativeModules.RNUtils.isRunningInSplitView;
NativeModules.RNUtils.isRunningInSplitView = jest.fn().mockReturnValue({isSplit: false, isTablet: true});
console.log('split mock', NativeModules.RNUtils.isRunningInSplitView());
expect(isTablet()).toBe(true);
NativeModules.RNUtils.isRunningInSplitView = jest.fn().mockReturnValue({isSplit: true, isTablet: true});
expect(isTablet()).toBe(false);
NativeModules.RNUtils.isRunningInSplitView = prevSplitViewModule; // Restore original value
});
});
// Add tests for other functions similarly
describe('areBothStringArraysEqual', () => {
test('should compare two string arrays', () => {
const arr1 = ['apple', 'banana', 'cherry'];
const arr2 = ['banana', 'cherry', 'apple'];
expect(areBothStringArraysEqual(arr1, arr2)).toBe(true);
});
test('should return false for unequal arrays or empty', () => {
const arr1 = ['apple', 'banana', 'cherry'];
const arr2 = ['banana', 'cherry', 'orange'];
expect(areBothStringArraysEqual(arr1, arr2)).toBe(false);
expect(areBothStringArraysEqual(arr1, [...arr1, ...arr2])).toBe(false);
expect(areBothStringArraysEqual([], [])).toBe(false);
});
});
describe('getUtcOffsetForTimeZone', () => {
test('should return UTC offset for timezone', () => {
const timezone = 'America/New_York';
const result = getUtcOffsetForTimeZone(timezone);
expect(result).toBe(moment.tz(timezone).utcOffset());
});
});
describe('pluckUnique', () => {
test('should pluck unique values based on key', () => {
const array = [
{id: 1, name: 'John'},
{id: 2, name: 'Jane'},
{id: 1, name: 'John'},
];
const result = pluckUnique('id')(array);
expect(result).toEqual([1, 2]);
});
});
describe('bottomSheetSnapPoint', () => {
test('should calculate bottom sheet snap point', () => {
const itemsCount = 5;
const itemHeight = 50;
const result = bottomSheetSnapPoint(itemsCount, itemHeight);
const expected = (itemsCount * itemHeight) + STATUS_BAR_HEIGHT;
expect(result).toBe(expected);
});
});
describe('hasTrailingSpaces', () => {
test('should detect trailing spaces', () => {
const term = 'Hello ';
const result = hasTrailingSpaces(term);
expect(result).toBe(true);
});
test('should not detect trailing spaces', () => {
const term = 'Hello';
const result = hasTrailingSpaces(term);
expect(result).toBe(false);
});
});
describe('isMainActivity', () => {
test('should return true on iOS', () => {
Platform.OS = 'ios';
const result = isMainActivity();
expect(result).toBe(true);
});
test('should return true if current activity is MainActivity on Android', () => {
Platform.OS = 'android';
const result = isMainActivity();
expect(result).toBe(true);
});
test('should return false if current activity is not MainActivity on Android', () => {
Platform.OS = 'android';
const result = isMainActivity();
expect(result).toBe(false);
});
});
describe('hasArrayChanged', () => {
test('should return true for arrays with different lengths', () => {
expect(hasArrayChanged(['a', 'b'], ['a', 'b', 'c'])).toBe(true);
expect(hasArrayChanged(['a', 'b', 'c'], ['a', 'b'])).toBe(true);
});
test('should return true for arrays with same length but different elements', () => {
expect(hasArrayChanged(['a', 'b'], ['a', 'c'])).toBe(true);
expect(hasArrayChanged(['x', 'y'], ['a', 'b'])).toBe(true);
});
test('should return false for arrays with same elements (same order)', () => {
expect(hasArrayChanged(['a', 'b', 'c'], ['a', 'b', 'c'])).toBe(false);
});
test('should return false for arrays with same elements (different order)', () => {
expect(hasArrayChanged(['a', 'b', 'c'], ['c', 'b', 'a'])).toBe(false);
expect(hasArrayChanged(['x', 'y', 'z'], ['z', 'x', 'y'])).toBe(false);
});
test('should return false for empty arrays', () => {
expect(hasArrayChanged([], [])).toBe(false);
});
test('should return true when one array is empty', () => {
expect(hasArrayChanged([], ['a'])).toBe(true);
expect(hasArrayChanged(['a'], [])).toBe(true);
});
test('should handle duplicate elements correctly', () => {
expect(hasArrayChanged(['a', 'a', 'b'], ['a', 'b', 'a'])).toBe(false);
expect(hasArrayChanged(['a', 'a'], ['a', 'b'])).toBe(true);
});
});
describe('isMinimumLicenseTier', () => {
test('should return false when license is not provided', () => {
expect(isMinimumLicenseTier(undefined, License.SKU_SHORT_NAME.Professional)).toBe(false);
});
test('should return false when license is not licensed', () => {
const license = {
IsLicensed: 'false',
SkuShortName: License.SKU_SHORT_NAME.Professional,
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.Professional)).toBe(false);
});
test('should return false when license SkuShortName is missing', () => {
const license = {
IsLicensed: 'true',
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.Professional)).toBe(false);
});
test('should return true when license tier equals target tier', () => {
const license = {
IsLicensed: 'true',
SkuShortName: License.SKU_SHORT_NAME.Professional,
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.Professional)).toBe(true);
});
test('should return true when license tier exceeds target tier - Enterprise >= Professional', () => {
const license = {
IsLicensed: 'true',
SkuShortName: License.SKU_SHORT_NAME.Enterprise,
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.Professional)).toBe(true);
});
test('should return true when license tier exceeds target tier - EnterpriseAdvanced >= Professional', () => {
const license = {
IsLicensed: 'true',
SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced,
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.Professional)).toBe(true);
});
test('should return true when license tier exceeds target tier - EnterpriseAdvanced >= Enterprise', () => {
const license = {
IsLicensed: 'true',
SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced,
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.Enterprise)).toBe(true);
});
test('should return false when license tier is below target tier - Professional < Enterprise', () => {
const license = {
IsLicensed: 'true',
SkuShortName: License.SKU_SHORT_NAME.Professional,
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.Enterprise)).toBe(false);
});
test('should return false when license tier is below target tier - Professional < EnterpriseAdvanced', () => {
const license = {
IsLicensed: 'true',
SkuShortName: License.SKU_SHORT_NAME.Professional,
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.EnterpriseAdvanced)).toBe(false);
});
test('should return false when license tier is below target tier - Enterprise < EnterpriseAdvanced', () => {
const license = {
IsLicensed: 'true',
SkuShortName: License.SKU_SHORT_NAME.Enterprise,
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.EnterpriseAdvanced)).toBe(false);
});
test('should handle partial license objects', () => {
const license = {
IsLicensed: 'true',
SkuShortName: License.SKU_SHORT_NAME.EnterpriseAdvanced,
};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.Professional)).toBe(true);
});
test('should return false for empty license object', () => {
const license = {};
expect(isMinimumLicenseTier(license, License.SKU_SHORT_NAME.Professional)).toBe(false);
});
});
});