From 0f4222a908af5bfd00ed37879e00b412e37091e2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 5 Nov 2025 10:28:06 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=9E=20fix:=20Prevent=20Revoked=20Blob?= =?UTF-8?q?=20URLs=20in=20Uploaded=20Images=20(FileRow)=20(#10361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Chat/Input/Files/FileRow.tsx | 2 +- .../Input/Files/__tests__/FileRow.spec.tsx | 347 ++++++++++++++++++ 2 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Chat/Input/Files/__tests__/FileRow.spec.tsx diff --git a/client/src/components/Chat/Input/Files/FileRow.tsx b/client/src/components/Chat/Input/Files/FileRow.tsx index babb0aef69..ea0b648015 100644 --- a/client/src/components/Chat/Input/Files/FileRow.tsx +++ b/client/src/components/Chat/Input/Files/FileRow.tsx @@ -133,7 +133,7 @@ export default function FileRow({ > {isImage ? ( ({ + useLocalize: jest.fn(), +})); + +jest.mock('~/data-provider', () => ({ + useDeleteFilesMutation: jest.fn(), +})); + +jest.mock('~/hooks/Files', () => ({ + useFileDeletion: jest.fn(), +})); + +jest.mock('~/utils', () => ({ + logger: { + log: jest.fn(), + }, +})); + +jest.mock('../Image', () => { + return function MockImage({ url, progress, source }: any) { + return ( +
+ {url} + {progress} + {source} +
+ ); + }; +}); + +jest.mock('../FileContainer', () => { + return function MockFileContainer({ file }: any) { + return ( +
+ {file.filename} +
+ ); + }; +}); + +const mockUseLocalize = jest.requireMock('~/hooks').useLocalize; +const mockUseDeleteFilesMutation = jest.requireMock('~/data-provider').useDeleteFilesMutation; +const mockUseFileDeletion = jest.requireMock('~/hooks/Files').useFileDeletion; + +describe('FileRow', () => { + const mockSetFiles = jest.fn(); + const mockSetFilesLoading = jest.fn(); + const mockDeleteFile = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseLocalize.mockReturnValue((key: string) => { + const translations: Record = { + com_ui_deleting_file: 'Deleting file...', + }; + return translations[key] || key; + }); + + mockUseDeleteFilesMutation.mockReturnValue({ + mutateAsync: jest.fn(), + }); + + mockUseFileDeletion.mockReturnValue({ + deleteFile: mockDeleteFile, + }); + }); + + /** + * Creates a mock ExtendedFile with sensible defaults + */ + const createMockFile = (overrides: Partial = {}): ExtendedFile => ({ + file_id: 'test-file-id', + type: 'image/png', + preview: 'blob:http://localhost:3080/preview-blob-url', + filepath: '/images/user123/test-file-id__image.png', + filename: 'test-image.png', + progress: 1, + size: 1024, + source: FileSources.local, + ...overrides, + }); + + const renderFileRow = (files: Map) => { + return render( + , + ); + }; + + describe('Image URL Selection Logic', () => { + it('should use filepath instead of preview when progress is 1 (upload complete)', () => { + const file = createMockFile({ + file_id: 'uploaded-file', + preview: 'blob:http://localhost:3080/temp-preview', + filepath: '/images/user123/uploaded-file__image.png', + progress: 1, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const imageUrl = screen.getByTestId('image-url').textContent; + expect(imageUrl).toBe('/images/user123/uploaded-file__image.png'); + expect(imageUrl).not.toContain('blob:'); + }); + + it('should use preview when progress is less than 1 (uploading)', () => { + const file = createMockFile({ + file_id: 'uploading-file', + preview: 'blob:http://localhost:3080/temp-preview', + filepath: undefined, + progress: 0.5, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const imageUrl = screen.getByTestId('image-url').textContent; + expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview'); + }); + + it('should fallback to filepath when preview is undefined and progress is less than 1', () => { + const file = createMockFile({ + file_id: 'file-without-preview', + preview: undefined, + filepath: '/images/user123/file-without-preview__image.png', + progress: 0.7, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const imageUrl = screen.getByTestId('image-url').textContent; + expect(imageUrl).toBe('/images/user123/file-without-preview__image.png'); + }); + + it('should use filepath when both preview and filepath exist and progress is exactly 1', () => { + const file = createMockFile({ + file_id: 'complete-file', + preview: 'blob:http://localhost:3080/old-blob', + filepath: '/images/user123/complete-file__image.png', + progress: 1.0, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const imageUrl = screen.getByTestId('image-url').textContent; + expect(imageUrl).toBe('/images/user123/complete-file__image.png'); + }); + }); + + describe('Progress States', () => { + it('should pass correct progress value during upload', () => { + const file = createMockFile({ + progress: 0.65, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const progress = screen.getByTestId('image-progress').textContent; + expect(progress).toBe('0.65'); + }); + + it('should pass progress value of 1 when upload is complete', () => { + const file = createMockFile({ + progress: 1, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const progress = screen.getByTestId('image-progress').textContent; + expect(progress).toBe('1'); + }); + }); + + describe('File Source', () => { + it('should pass local source to Image component', () => { + const file = createMockFile({ + source: FileSources.local, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const source = screen.getByTestId('image-source').textContent; + expect(source).toBe(FileSources.local); + }); + + it('should pass openai source to Image component', () => { + const file = createMockFile({ + source: FileSources.openai, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const source = screen.getByTestId('image-source').textContent; + expect(source).toBe(FileSources.openai); + }); + }); + + describe('File Type Detection', () => { + it('should render Image component for image files', () => { + const file = createMockFile({ + type: 'image/jpeg', + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + expect(screen.getByTestId('mock-image')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-file-container')).not.toBeInTheDocument(); + }); + + it('should render FileContainer for non-image files', () => { + const file = createMockFile({ + type: 'application/pdf', + filename: 'document.pdf', + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + expect(screen.getByTestId('mock-file-container')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-image')).not.toBeInTheDocument(); + }); + }); + + describe('Multiple Files', () => { + it('should render multiple image files with correct URLs based on their progress', () => { + const filesMap = new Map(); + + const uploadingFile = createMockFile({ + file_id: 'file-1', + preview: 'blob:http://localhost:3080/preview-1', + filepath: undefined, + progress: 0.3, + }); + + const completedFile = createMockFile({ + file_id: 'file-2', + preview: 'blob:http://localhost:3080/preview-2', + filepath: '/images/user123/file-2__image.png', + progress: 1, + }); + + filesMap.set(uploadingFile.file_id, uploadingFile); + filesMap.set(completedFile.file_id, completedFile); + + renderFileRow(filesMap); + + const images = screen.getAllByTestId('mock-image'); + expect(images).toHaveLength(2); + + const urls = screen.getAllByTestId('image-url').map((el) => el.textContent); + expect(urls).toContain('blob:http://localhost:3080/preview-1'); + expect(urls).toContain('/images/user123/file-2__image.png'); + }); + + it('should deduplicate files with the same file_id', () => { + const filesMap = new Map(); + + const file1 = createMockFile({ file_id: 'duplicate-id' }); + const file2 = createMockFile({ file_id: 'duplicate-id' }); + + filesMap.set('key-1', file1); + filesMap.set('key-2', file2); + + renderFileRow(filesMap); + + const images = screen.getAllByTestId('mock-image'); + expect(images).toHaveLength(1); + }); + }); + + describe('Empty State', () => { + it('should render nothing when files map is empty', () => { + const filesMap = new Map(); + + const { container } = renderFileRow(filesMap); + + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when files is undefined', () => { + const { container } = render( + , + ); + + expect(container.firstChild).toBeNull(); + }); + }); + + describe('Regression: Blob URL Bug Fix', () => { + it('should NOT use revoked blob URL after upload completes', () => { + const file = createMockFile({ + file_id: 'regression-test', + preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b', + filepath: + '/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png', + progress: 1, + }); + + const filesMap = new Map(); + filesMap.set(file.file_id, file); + + renderFileRow(filesMap); + + const imageUrl = screen.getByTestId('image-url').textContent; + + expect(imageUrl).not.toContain('blob:'); + expect(imageUrl).toBe( + '/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png', + ); + }); + }); +});