* 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>
307 lines
14 KiB
TypeScript
307 lines
14 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {getInfoAsync, deleteAsync} from 'expo-file-system';
|
|
import {Platform} from 'react-native';
|
|
import Permissions from 'react-native-permissions';
|
|
|
|
import {getIntlShape} from '@utils/general';
|
|
import {logError} from '@utils/log';
|
|
import {urlSafeBase64Encode} from '@utils/security';
|
|
|
|
import {
|
|
deleteFileCache,
|
|
deleteFileCacheByDir,
|
|
deleteV1Data,
|
|
extractFileInfo,
|
|
fileExists,
|
|
fileMaxWarning,
|
|
fileSizeWarning,
|
|
filterFileExtensions,
|
|
getAllFilesInCachesDirectory,
|
|
getAllowedServerMaxFileSize,
|
|
getExtensionFromContentDisposition,
|
|
getExtensionFromMime,
|
|
getFileType,
|
|
getFormattedFileSize,
|
|
getLocalFilePathFromFile,
|
|
hasWriteStoragePermission,
|
|
isDocument,
|
|
isGif,
|
|
isImage,
|
|
isVideo,
|
|
lookupMimeType,
|
|
pathWithPrefix,
|
|
uploadDisabledWarning,
|
|
} from '.';
|
|
|
|
jest.mock('expo-file-system');
|
|
jest.mock('react-native', () => {
|
|
const RN = jest.requireActual('react-native');
|
|
return {
|
|
Platform: {
|
|
...RN.Platform,
|
|
OS: 'ios',
|
|
},
|
|
Alert: {alert: jest.fn()},
|
|
Linking: {openSettings: jest.fn()},
|
|
NativeModules: {
|
|
...RN.NativeModules,
|
|
RNUtils: {
|
|
getConstants: () => ({
|
|
appGroupIdentifier: 'group.mattermost.rnbeta',
|
|
appGroupSharedDirectory: {
|
|
sharedDirectory: '',
|
|
databasePath: '',
|
|
},
|
|
}),
|
|
addListener: jest.fn(),
|
|
removeListeners: jest.fn(),
|
|
isRunningInSplitView: jest.fn().mockReturnValue({isSplit: false, isTablet: false}),
|
|
|
|
getDeliveredNotifications: jest.fn().mockResolvedValue([]),
|
|
removeChannelNotifications: jest.fn().mockImplementation(),
|
|
removeThreadNotifications: jest.fn().mockImplementation(),
|
|
removeServerNotifications: jest.fn().mockImplementation(),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
jest.mock('react-native-permissions', () => ({
|
|
check: jest.fn(),
|
|
request: jest.fn(),
|
|
RESULTS: {
|
|
GRANTED: 'granted',
|
|
DENIED: 'denied',
|
|
BLOCKED: 'blocked',
|
|
},
|
|
PERMISSIONS: {ANDROID: {WRITE_EXTERNAL_STORAGE: 'WRITE_EXTERNAL_STORAGE'}},
|
|
}));
|
|
jest.mock('@utils/log', () => ({logError: jest.fn()}));
|
|
jest.mock('@utils/mattermost_managed', () => ({
|
|
getIOSAppGroupDetails: () => ({appGroupSharedDirectory: 'appGroupSharedDirectory'}),
|
|
deleteEntitiesFile: jest.fn(),
|
|
}));
|
|
jest.mock('@utils/security', () => ({urlSafeBase64Encode: (url: string) => btoa(url)}));
|
|
|
|
describe('Image utils', () => {
|
|
const intl = getIntlShape();
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('filterFileExtensions', () => {
|
|
it('should return correct filter for each type', () => {
|
|
expect(filterFileExtensions('ALL')).toBe('');
|
|
expect(filterFileExtensions('AUDIO')).toEqual(['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'].map((e) => `ext:${e}`).join(' '));
|
|
expect(filterFileExtensions('CODE')).toEqual([
|
|
'as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs',
|
|
'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++',
|
|
'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus',
|
|
'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go',
|
|
'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt',
|
|
'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl',
|
|
'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor',
|
|
'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'ts', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml',
|
|
'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'].map((e) => `ext:${e}`).join(' '));
|
|
expect(filterFileExtensions('DOCUMENTS')).toEqual(['doc', 'docx', 'odt', 'pdf', 'txt', 'rtf'].map((e) => `ext:${e}`).join(' '));
|
|
expect(filterFileExtensions('IMAGES')).toEqual(['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif', 'svg', 'psd', 'xcf'].map((e) => `ext:${e}`).join(' '));
|
|
expect(filterFileExtensions('PRESENTATIONS')).toEqual(['ppt', 'pptx', 'odp'].map((e) => `ext:${e}`).join(' '));
|
|
expect(filterFileExtensions('SPREADSHEETS')).toEqual(['xls', 'xlsx', 'csv', 'ods'].map((e) => `ext:${e}`).join(' '));
|
|
expect(filterFileExtensions('VIDEOS')).toEqual(['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv', 'ogm', 'mpeg'].map((e) => `ext:${e}`).join(' '));
|
|
expect(filterFileExtensions()).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('deleteV1Data', () => {
|
|
it('should delete V1 data', async () => {
|
|
await deleteV1Data();
|
|
expect(deleteAsync).toHaveBeenCalled();
|
|
Platform.OS = 'android';
|
|
await deleteV1Data();
|
|
expect(deleteAsync).toHaveBeenCalled();
|
|
Platform.OS = 'ios';
|
|
});
|
|
});
|
|
|
|
describe('deleteFileCache', () => {
|
|
it('should delete file cache', async () => {
|
|
await deleteFileCache('http://server.com');
|
|
expect(deleteAsync).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('deleteFileCacheByDir', () => {
|
|
it('should delete file cache by dir', async () => {
|
|
await deleteFileCacheByDir('someDir');
|
|
expect(deleteAsync).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('lookupMimeType', () => {
|
|
it('should return correct mime type', () => {
|
|
expect(lookupMimeType('file.txt')).toBe('text/plain');
|
|
expect(lookupMimeType('file.jpg')).toBe('image/jpeg');
|
|
expect(lookupMimeType('file.era')).toBe('application/octet-stream');
|
|
});
|
|
});
|
|
|
|
describe('getExtensionFromMime', () => {
|
|
it('should return correct extension from mime type', () => {
|
|
expect(getExtensionFromMime('application/json')).toBe('json');
|
|
expect(getExtensionFromMime('image/png')).toBe('png');
|
|
expect(getExtensionFromMime('video/mp4')).toBe('mp4');
|
|
});
|
|
});
|
|
|
|
describe('getExtensionFromContentDisposition', () => {
|
|
it('should return correct extension from content disposition', () => {
|
|
expect(getExtensionFromContentDisposition('inline;filename="file.txt";')).toBe('txt');
|
|
expect(getExtensionFromContentDisposition('inline;filename="file.jpg";')).toBe('jpg');
|
|
expect(getExtensionFromContentDisposition('inline;')).toBe(null);
|
|
});
|
|
});
|
|
|
|
describe('getAllowedServerMaxFileSize', () => {
|
|
it('should return correct max file size', () => {
|
|
expect(getAllowedServerMaxFileSize({MaxFileSize: '10485760'} as ClientConfig)).toBe(10485760);
|
|
expect(getAllowedServerMaxFileSize({MaxFileSize: '10485a60'} as ClientConfig)).toBe(10485);
|
|
expect(getAllowedServerMaxFileSize({MaxFileSize: ''} as ClientConfig)).toBe(50 * 1024 * 1024);
|
|
});
|
|
});
|
|
|
|
describe('isGif', () => {
|
|
it('should correctly identify gif files', () => {
|
|
expect(isGif({name: 'file.gif', mimeType: 'image/gif'} as unknown as FileInfo)).toBe(true);
|
|
expect(isGif({name: 'file.png', mimeType: 'image/gif'} as unknown as FileInfo)).toBe(true);
|
|
expect(isGif({name: 'file.png', mimeType: 'image/png'} as unknown as FileInfo)).toBe(false);
|
|
expect(isGif()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isImage', () => {
|
|
it('should correctly identify image files', () => {
|
|
expect(isImage({name: 'file.png', mimeType: 'image/png'} as unknown as FileInfo)).toBe(true);
|
|
expect(isImage({name: 'file.jpg', mimeType: 'image/jpeg'} as unknown as FileInfo)).toBe(true);
|
|
expect(isImage({name: 'file.png', mimeType: 'text/plain'} as unknown as FileInfo)).toBe(false);
|
|
expect(isImage({name: 'file.png', extension: '.png'} as unknown as FileInfo)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('isDocument', () => {
|
|
it('should correctly identify document files', () => {
|
|
expect(isDocument({name: 'file.pdf', mimeType: 'application/pdf'} as unknown as FileInfo)).toBe(true);
|
|
expect(isDocument({name: 'file.doc', mimeType: 'application/vnd.apple.pages'} as unknown as FileInfo)).toBe(true);
|
|
expect(isDocument({name: 'file.mp4', mimeType: 'video/mp4'} as unknown as FileInfo)).toBe(false);
|
|
expect(isDocument({name: 'file.doc', mimeType: 'application/msword'} as unknown as FileInfo)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('isVideo', () => {
|
|
it('should correctly identify video files', () => {
|
|
expect(isVideo({name: 'file.mp4', mimeType: 'video/mp4'} as unknown as FileInfo)).toBe(true);
|
|
expect(isVideo({name: 'file.mov', mimeType: 'video/quicktime'} as unknown as FileInfo)).toBe(true);
|
|
expect(isVideo({name: 'file.mkv', mimeType: 'video/x-matroska'} as unknown as FileInfo)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getFormattedFileSize', () => {
|
|
it('should return correct formatted file size', () => {
|
|
expect(getFormattedFileSize(102)).toBe('102 B');
|
|
expect(getFormattedFileSize(1024)).toBe('1024 B');
|
|
expect(getFormattedFileSize(1025)).toBe('1 KB');
|
|
expect(getFormattedFileSize(10 * 1024 * 1024)).toBe('10 MB');
|
|
expect(getFormattedFileSize(10 * 1024 * 1024 * 1024)).toBe('10 GB');
|
|
expect(getFormattedFileSize(10 * 1024 * 1024 * 1024 * 1024)).toBe('10 TB');
|
|
});
|
|
});
|
|
|
|
describe('getFileType', () => {
|
|
it('should return correct file type', () => {
|
|
expect(getFileType({extension: 'png'} as unknown as FileInfo)).toBe('image');
|
|
expect(getFileType({extension: 'mp4'} as unknown as FileInfo)).toBe('video');
|
|
expect(getFileType({extension: 'pdf'} as unknown as FileInfo)).toBe('pdf');
|
|
expect(getFileType({extension: 'arr'} as unknown as FileInfo)).toBe('other');
|
|
expect(getFileType({} as unknown as FileInfo)).toBe('other');
|
|
});
|
|
});
|
|
|
|
describe('getLocalFilePathFromFile', () => {
|
|
it('should return correct local file path from file', () => {
|
|
expect(getLocalFilePathFromFile('http://server.com', {id: 'someid', name: 'image.png', extension: 'png'} as unknown as FileInfo)).toBe(`file://test-cache-directory/${urlSafeBase64Encode('http://server.com')}/Files/image-someid.png`);
|
|
expect(getLocalFilePathFromFile('http://server.com', {id: 'someid', name: 'image.png'} as unknown as FileInfo)).toBe(`file://test-cache-directory/${urlSafeBase64Encode('http://server.com')}/Files/image-someid.png`);
|
|
expect(getLocalFilePathFromFile('http://server.com', {id: 'someid', extension: 'png'} as unknown as FileInfo)).toBe(`file://test-cache-directory/${urlSafeBase64Encode('http://server.com')}/Files/someid.png`);
|
|
expect(getLocalFilePathFromFile('http://server.com', {id: 'someid'} as unknown as FileInfo)).toBe(`file://test-cache-directory/${urlSafeBase64Encode('http://server.com')}/Files/someid`);
|
|
expect(() => getLocalFilePathFromFile('http://server.com', {extension: 'png'} as unknown as FileInfo)).toThrow('File path could not be set');
|
|
});
|
|
});
|
|
|
|
describe('extractFileInfo', () => {
|
|
it('should extract file info correctly', async () => {
|
|
const files = [{uri: 'file://somefile', fileSize: 12345, fileName: 'file.png', type: 'image/png'}];
|
|
let result = await extractFileInfo([]);
|
|
expect(result).toEqual([]);
|
|
result = await extractFileInfo([{fileName: 'file.png'}]);
|
|
expect(result).toEqual([]);
|
|
expect(logError).toHaveBeenCalled();
|
|
result = await extractFileInfo(files);
|
|
expect(result).toEqual(expect.any(Array));
|
|
result = await extractFileInfo([{uri: 'file://somefile', size: 12345, fileName: 'file.png', type: 'image/png'}]);
|
|
expect(result).toEqual(expect.any(Array));
|
|
});
|
|
});
|
|
|
|
describe('fileSizeWarning', () => {
|
|
it('should return correct file size warning', () => {
|
|
const msg = fileSizeWarning(intl, 10485760);
|
|
expect(msg).toBe('Files must be less than 10 MB');
|
|
});
|
|
});
|
|
|
|
describe('fileMaxWarning', () => {
|
|
it('should return correct file max warning', () => {
|
|
const msg = fileMaxWarning(intl, 10);
|
|
expect(msg).toBe('Uploads limited to 10 files maximum.');
|
|
});
|
|
});
|
|
|
|
describe('uploadDisabledWarning', () => {
|
|
it('should return correct upload disabled warning', () => {
|
|
const msg = uploadDisabledWarning(intl);
|
|
expect(msg).toBe('File uploads from mobile are disabled.');
|
|
});
|
|
});
|
|
|
|
describe('fileExists', () => {
|
|
it('should check if file exists', async () => {
|
|
// @ts-expect-error type def
|
|
getInfoAsync.mockResolvedValue({exists: true});
|
|
const exists = await fileExists('somePath');
|
|
expect(exists).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('hasWriteStoragePermission', () => {
|
|
it('should check write storage permission', async () => {
|
|
// @ts-expect-error type def
|
|
Permissions.check.mockResolvedValue(Permissions.RESULTS.GRANTED);
|
|
const result = await hasWriteStoragePermission(intl);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('getAllFilesInCachesDirectory', () => {
|
|
it('should get all files in caches directory', async () => {
|
|
const result = await getAllFilesInCachesDirectory('http://server.com');
|
|
expect(result.files).toEqual(expect.any(Array));
|
|
});
|
|
});
|
|
|
|
describe('pathWithPrefix', () => {
|
|
it('should return correct path with prefix', () => {
|
|
expect(pathWithPrefix('file://', 'file://something')).toEqual('file://something');
|
|
expect(pathWithPrefix('file://', 'something')).toEqual('file://something');
|
|
});
|
|
});
|
|
});
|
|
|