mattermost-mobile-upstream/app/utils/file/index.test.ts
Pablo Vélez 7df4a0ab97
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
Mm 68219 show placeholder for redacted files (#9719)
* MM-68219 - Add redacted files placeholder and related functionality

* add feature flag control and e2e tests

* adjust placeholder design, polish code, fix permalink preview issue

* polish code and add missing test

* use import path with right path

* gate post refetch behind the feature flag, refetch the open thread and avoid observeCount

* restore filesInfo for the permalink files prop added upstream

* fix stale redaction on background servers, consume the flag on every fetch path and share the viewport emitter

* replace the in-memory redaction flag with a persisted per-post epoch so ABAC file decisions survive restarts and stale posts stay hidden

* raise the redaction epoch on every resync, capture the epoch counter, only invalidate channels on role changes, and store the newer epoch when a re-verified post is otherwise unchanged

* store superseded responses unverified instead of dropping them, keep the since-fetch when re-verifying, verify permalink embeds against the linked channel, and capture the websocket epoch on arrival

* nvalidate file decisions when a user attribute field changes or all its values are cleared

* batch channel invalidations, close file viewers only when affected, reset the epoch on unstamped redaction changes, and only  re-verify the newest page's attachment posts

* re-check older channel posts a page at a time instead of one request per post

* skip the unstamped epoch lookup for posts with no files, redacted count or permalink

* keep the attribute-view retry after later invalidations, retry linked posts unless refused for the current epoch, and stop older payloads from rolling back edits via the redaction check

* MM-70905 - disable and grey out the attachment button when an ABAC pe… (#10195)

* MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel

* only back off render permission fetches when the server request fails, and log other errors without retrying

* remove the disableServer helper and have removeServer do both steps

* split defaultAllowed into allowWhenUnenforced and allowWhenNotEvaluated, and treat render permissions as unenforced  on servers without the decisions API

* MM-69627: Add playbook task requirements support on mobile (#10035)

* MM-69627: Add playbook task requirements support on mobile

Allow filling required task fields when checking off checklist items during
a run, gated by the same Playbooks beta setting used on web.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address PR review feedback for task requirements

Propagate settings write failures, harden fill-requirements save/title handling, and add missing unit coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix TypeScript errors in new unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Use defineMessages for fill requirements titles

Keep i18n extraction able to find the header titles after moving them to the route.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address review feedback for task requirements

Update checklist items locally when saving requirement values instead of
refetching the run, add missing query/navigation tests, and apply small
callback/reduce cleanups from review.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix task requirements review follow-ups

Propagate local checklist write failures, clear a stale task-requirements
flag when playbooks is unsupported, and cap fill values at the desktop/
server max length.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Move requirement field into its own component

Keep one component per file on the fill-requirements screen.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69122: Add file element support to interactive dialogs (#9898)

* Implement Interactive Dialog - file upload

* update demo plugin version for e2etests

* fix and add e2etests

* review fixes

* Fix version bump checkout when a tag shares the branch name (#10229)

* Fix Android crashes caused by animated custom emoji (#10030)

* Route GIF decoding through Glide

- Keep APNG4Android for APNG, animated WebP, and animated AVIF support.
- Prepend Glide's GIF decoders to avoid APNG4Android's native GIF overflow.

* Clear Expo image resources with Glide targets

- Detach drawables when Glide clears their custom target.
- Prevent recycled GIF frame resources from remaining attached to Expo image views.

* Preserve foreground image during GIF target recycling

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>

* detox - fix clobbering of feature flags that cause failures in other suites.  Also small fix of a incorrectly set test-id

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>
Co-authored-by: Nate Schlossberg <nate.schlossberg@mattermost.com>
2026-10-15 15:59:13 -07:00

352 lines
17 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Directory, File} from 'expo-file-system';
import Permissions from 'react-native-permissions';
import {getIntlShape} from '@utils/general';
import {logError} from '@utils/log';
import {urlSafeBase64Encode} from '@utils/security';
import {
deleteDownloadedFiles,
deleteFileCache,
deleteFileCacheByDir,
extractFileInfo,
fileExists,
fileMaxWarning,
fileSizeWarning,
filterFileExtensions,
getAllFilesInCachesDirectory,
getAllowedServerMaxFileSize,
getExtensionFromContentDisposition,
getExtensionFromMime,
getFileType,
getFormattedFileSize,
getLocalFilePathFromFile,
getUploadErrorMessage,
hasWriteStoragePermission,
isDocument,
isGif,
isImage,
isVideo,
lookupMimeType,
pathWithPrefix,
uploadDisabledWarning,
} from './index';
import type FileModel from '@typings/database/models/servers/file';
import type {Asset} from 'react-native-image-picker';
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('deleteFileCache', () => {
it('should delete file cache', () => {
deleteFileCache('http://server.com');
expect(jest.mocked(Directory).mock.instances.length).toBeGreaterThan(0);
});
});
describe('deleteFileCacheByDir', () => {
it('should delete file cache by dir', () => {
deleteFileCacheByDir('someDir');
expect(jest.mocked(Directory).mock.instances.length).toBeGreaterThan(0);
});
});
describe('deleteDownloadedFiles', () => {
it('should remove both the recorded path and the default download path', () => {
// Opening a document downloads it to the default path without recording it on the row.
jest.mocked(File).mockClear();
const file = {id: 'fileid', name: 'doc.pdf', extension: 'pdf', mimeType: 'application/pdf', localPath: '/data/my%20doc.pdf'} as unknown as FileModel;
deleteDownloadedFiles('http://server.com', [file]);
const uris = jest.mocked(File).mock.calls.map(([uri]) => uri);
expect(uris).toEqual(['file:///data/my doc.pdf', pathWithPrefix('file://', getLocalFilePathFromFile('http://server.com', file))]);
for (const result of jest.mocked(File).mock.results) {
expect(result.value.delete).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);
expect(isImage({name: 'file.png'} as unknown as FileInfo)).toBe(true);
});
it('should return false for a file with no name and no extension', () => {
expect(isImage({mime_type: 'image/jpg', localPath: 'file:///draft.jpg'} as unknown as FileInfo)).toBe(false);
});
});
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));
});
it('should read the size from the local file when the asset has no file size', async () => {
jest.mocked(File).mockImplementationOnce(() => ({info: () => ({exists: true, size: 999})}) as unknown as File);
const result = await extractFileInfo([{uri: 'file:///tmp/A1B2C3.jpg', fileName: 'A1B2C3.jpg', type: 'image/jpg'}] as unknown as Asset[]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('A1B2C3.jpg');
expect(result[0].size).toBe(999);
expect(logError).not.toHaveBeenCalled();
});
});
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('getUploadErrorMessage', () => {
it('should map iOS network errors to user-friendly message', () => {
const msg = getUploadErrorMessage(intl, 'URLSessionTask failed with error: The Internet connection appears to be offline.');
expect(msg).toBe("File couldn't be uploaded. Check your connection and try again.");
});
it('should map Android network errors to user-friendly message', () => {
const msg = getUploadErrorMessage(intl, 'Network error', 'java.net.UnknownHostException');
expect(msg).toBe("File couldn't be uploaded. Check your connection and try again.");
});
it('should explain a refusal by a permission policy instead of showing the server text', () => {
const msg = getUploadErrorMessage(intl, 'server text', 'api.file.upload_file.abac_denied.app_error');
expect(msg).toBe('File uploads are restricted in this channel');
});
it('should return original message for non-network errors', () => {
const originalMessage = 'Some other error occurred';
const msg = getUploadErrorMessage(intl, originalMessage);
expect(msg).toBe(originalMessage);
});
});
describe('fileExists', () => {
it('should check if file exists', () => {
const exists = 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', () => {
const result = 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');
});
});
});