From 05b5c3c0e2810375421346237a9c6ae110e3be0c Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Wed, 3 Jun 2026 14:29:02 +0200 Subject: [PATCH 1/5] feat: add validation on file upload --- .../add-max-file-size-rejection.test.ts | 109 ++++++++++++++++ .../add-max-file-size-rejection.ts | 111 ++++++++++++++++ .../calculate-projected-write-size.test.ts | 19 +++ .../calculate-projected-write-size.ts | 21 +++ .../user/file-size-limit/constants.ts | 2 + .../features/user/file-size-limit/index.ts | 14 ++ .../show-max-file-size-rejection-modal.ts | 15 +++ .../upload-size-limit-error.ts | 6 + .../validate-upload-file-size.test.ts | 42 ++++++ .../validate-upload-file-size.ts | 37 ++++++ .../operations/release.service.test.ts | 33 +++++ .../services/operations/release.service.ts | 28 ++++ .../services/operations/write.service.test.ts | 121 +++++++++++++++++- .../services/operations/write.service.ts | 43 +++++++ .../upload/TemporalFileUploader.test.ts | 68 ++++++++-- .../upload/TemporalFileUploader.ts | 14 ++ 16 files changed, 670 insertions(+), 13 deletions(-) create mode 100644 src/backend/features/user/file-size-limit/add-max-file-size-rejection.test.ts create mode 100644 src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts create mode 100644 src/backend/features/user/file-size-limit/calculate-projected-write-size.test.ts create mode 100644 src/backend/features/user/file-size-limit/calculate-projected-write-size.ts create mode 100644 src/backend/features/user/file-size-limit/constants.ts create mode 100644 src/backend/features/user/file-size-limit/index.ts create mode 100644 src/backend/features/user/file-size-limit/show-max-file-size-rejection-modal.ts create mode 100644 src/backend/features/user/file-size-limit/upload-size-limit-error.ts create mode 100644 src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts create mode 100644 src/backend/features/user/file-size-limit/validate-upload-file-size.ts diff --git a/src/backend/features/user/file-size-limit/add-max-file-size-rejection.test.ts b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.test.ts new file mode 100644 index 000000000..33931b381 --- /dev/null +++ b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.test.ts @@ -0,0 +1,109 @@ +import { + addMaxFileSizeRejection, + clearMaxFileSizeRejectionModal, + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, + markUploadSizeLimitBlockedPath, +} from './add-max-file-size-rejection'; +import * as modalModule from './show-max-file-size-rejection-modal'; + +describe('addMaxFileSizeRejection', () => { + const showModalMock = vi.spyOn(modalModule, 'showMaxFileSizeRejectionModal'); + const path = '/Documents/oversized.pdf'; + + beforeEach(() => { + vi.useFakeTimers(); + showModalMock.mockResolvedValue(undefined); + showModalMock.mockClear(); + clearMaxFileSizeRejectionModal(); + clearUploadSizeLimitBlockedPath(path); + clearUploadSizeLimitBlockedPath('/Documents/first.pdf'); + clearUploadSizeLimitBlockedPath('/Documents/second.pdf'); + }); + + afterEach(() => { + clearMaxFileSizeRejectionModal(); + clearUploadSizeLimitBlockedPath(path); + clearUploadSizeLimitBlockedPath('/Documents/first.pdf'); + clearUploadSizeLimitBlockedPath('/Documents/second.pdf'); + vi.useRealTimers(); + }); + + it('should track paths blocked by upload size limit', () => { + markUploadSizeLimitBlockedPath(path); + + expect(isUploadSizeLimitBlockedPath(path)).toBe(true); + }); + + it('should clear blocked paths', () => { + markUploadSizeLimitBlockedPath(path); + + clearUploadSizeLimitBlockedPath(path); + + expect(isUploadSizeLimitBlockedPath(path)).toBe(false); + }); + + it('should show single-file modal after debounce', () => { + addMaxFileSizeRejection({ + path, + fileSize: 101, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 100, showUpgradeCta: true }, + }); + + expect(isUploadSizeLimitBlockedPath(path)).toBe(true); + + vi.advanceTimersByTime(1_999); + expect(showModalMock).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + + expect(showModalMock).toHaveBeenCalledWith({ + variant: 'single', + showUpgradeCta: true, + maxFileSize: 100, + fileSize: 101, + }); + }); + + it('should aggregate multiple rejected files within debounce window', () => { + addMaxFileSizeRejection({ + path: '/Documents/first.pdf', + fileSize: 101, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 100, showUpgradeCta: true }, + }); + addMaxFileSizeRejection({ + path: '/Documents/second.pdf', + fileSize: 150, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 120, showUpgradeCta: true }, + }); + + expect(isUploadSizeLimitBlockedPath('/Documents/first.pdf')).toBe(true); + expect(isUploadSizeLimitBlockedPath('/Documents/second.pdf')).toBe(true); + + vi.advanceTimersByTime(2_000); + + expect(showModalMock).toHaveBeenCalledWith({ + variant: 'multiple', + showUpgradeCta: true, + maxFileSize: 100, + fileSize: 150, + }); + }); + + it('should keep upgrade CTA disabled for absolute cap rejections', () => { + addMaxFileSizeRejection({ + path, + fileSize: 150, + validation: { allowed: false, reason: 'ABSOLUTE_CAP_EXCEEDED', maxFileSize: 100, showUpgradeCta: false }, + }); + + vi.advanceTimersByTime(2_000); + + expect(showModalMock).toHaveBeenCalledWith({ + variant: 'single', + showUpgradeCta: false, + maxFileSize: 100, + fileSize: 150, + }); + }); +}); diff --git a/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts new file mode 100644 index 000000000..9d982740f --- /dev/null +++ b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts @@ -0,0 +1,111 @@ +import { MODAL_DEBOUNCE_MS } from './constants'; +import { showMaxFileSizeRejectionModal } from './show-max-file-size-rejection-modal'; +import { type UploadFileSizeValidation } from './validate-upload-file-size'; + +const uploadSizeLimitBlockedPaths = new Set(); + +type MaxFileSizeRejectionModalState = { + rejectedFilesCount: number; + showUpgradeCta: boolean; + maxFileSize?: number; + fileSize?: number; + timeout: NodeJS.Timeout; +}; + +type MaxFileSizeRejection = { + validation?: Extract; + fileSize: number; + path: string; +}; + +type MaxFileSizeRejectionModalDraft = Omit; + +let state: MaxFileSizeRejectionModalState | undefined; + +export function markUploadSizeLimitBlockedPath(path: string): void { + uploadSizeLimitBlockedPaths.add(path); +} + +export function isUploadSizeLimitBlockedPath(path: string): boolean { + return uploadSizeLimitBlockedPaths.has(path); +} + +export function clearUploadSizeLimitBlockedPath(path: string): void { + uploadSizeLimitBlockedPaths.delete(path); +} + +export function addMaxFileSizeRejection(rejection: MaxFileSizeRejection): void { + markUploadSizeLimitBlockedPath(rejection.path); + if (state) clearTimeout(state.timeout); + + state = { + ...saveMaxFileSizeRejection({ state, rejection }), + timeout: setTimeout(showMaxFileSizeModal, MODAL_DEBOUNCE_MS), + }; +} + +export function clearMaxFileSizeRejectionModal(): void { + if (state) clearTimeout(state.timeout); + state = undefined; +} + +function saveMaxFileSizeRejection({ + state, + rejection, +}: { + state: MaxFileSizeRejectionModalState | undefined; + rejection: MaxFileSizeRejection; +}): MaxFileSizeRejectionModalDraft { + const shouldShowUpgradeCta = rejection.validation?.showUpgradeCta ?? true; + + return { + rejectedFilesCount: (state?.rejectedFilesCount ?? 0) + 1, + showUpgradeCta: Boolean(state?.showUpgradeCta || shouldShowUpgradeCta), + maxFileSize: getMaxFileSizeToDisplay({ + currentMaxFileSizeToDisplay: state?.maxFileSize, + newRejectedFileMaxFileSize: rejection.validation?.maxFileSize, + }), + fileSize: getRejectedFileSizeToShow({ currentFileSize: state?.fileSize, rejection, shouldShowUpgradeCta }), + }; +} + +function showMaxFileSizeModal(): void { + if (!state) { + return; + } + + void showMaxFileSizeRejectionModal({ + variant: state.rejectedFilesCount === 1 ? 'single' : 'multiple', + showUpgradeCta: state.showUpgradeCta, + maxFileSize: state.maxFileSize, + fileSize: state.fileSize, + }); + + state = undefined; +} +function getMaxFileSizeToDisplay({ + currentMaxFileSizeToDisplay, + newRejectedFileMaxFileSize, +}: { + currentMaxFileSizeToDisplay?: number; + newRejectedFileMaxFileSize?: number; +}): number | undefined { + if (!newRejectedFileMaxFileSize) return currentMaxFileSizeToDisplay; + if (!currentMaxFileSizeToDisplay) return newRejectedFileMaxFileSize; + + return Math.min(currentMaxFileSizeToDisplay, newRejectedFileMaxFileSize); +} + +function getRejectedFileSizeToShow({ + currentFileSize, + rejection, + shouldShowUpgradeCta, +}: { + currentFileSize?: number; + rejection: MaxFileSizeRejection; + shouldShowUpgradeCta: boolean; +}): number { + if (shouldShowUpgradeCta) return Math.max(currentFileSize ?? 0, rejection.fileSize); + + return currentFileSize ?? rejection.fileSize; +} diff --git a/src/backend/features/user/file-size-limit/calculate-projected-write-size.test.ts b/src/backend/features/user/file-size-limit/calculate-projected-write-size.test.ts new file mode 100644 index 000000000..a612e8417 --- /dev/null +++ b/src/backend/features/user/file-size-limit/calculate-projected-write-size.test.ts @@ -0,0 +1,19 @@ +import { calculateProjectedWriteSize } from './calculate-projected-write-size'; + +describe('calculateProjectedWriteSize', () => { + it('should return offset plus incoming bytes when the write extends the file', () => { + expect(calculateProjectedWriteSize({ currentSize: 10, offset: 10, incomingBytes: 5 })).toBe(15); + }); + + it('should keep current size when the write stays inside the existing file', () => { + expect(calculateProjectedWriteSize({ currentSize: 10, offset: 2, incomingBytes: 5 })).toBe(10); + }); + + it('should account for sparse writes at a large offset', () => { + expect(calculateProjectedWriteSize({ currentSize: 0, offset: 1_000, incomingBytes: 5 })).toBe(1_005); + }); + + it('should account for out-of-order writes after a larger write already happened', () => { + expect(calculateProjectedWriteSize({ currentSize: 1_005, offset: 0, incomingBytes: 5 })).toBe(1_005); + }); +}); diff --git a/src/backend/features/user/file-size-limit/calculate-projected-write-size.ts b/src/backend/features/user/file-size-limit/calculate-projected-write-size.ts new file mode 100644 index 000000000..f621cbb5d --- /dev/null +++ b/src/backend/features/user/file-size-limit/calculate-projected-write-size.ts @@ -0,0 +1,21 @@ +type Props = { + currentSize: number; + offset: number; + incomingBytes: number; +}; + +/** + * Calculates the logical file size that would exist after accepting a FUSE write. + * + * FUSE write calls are chunk based: the app receives "write these bytes at this + * offset", not "here is the full source file". For copies into the mounted drive + * we also cannot rely on the origin path or origin size being available. + * + * Because writes can be sequential, out of order, or sparse, upload-size-limit + * validation must use the resulting logical size rather than the current chunk + * length. A small chunk written at a large offset can still create an oversized + * file. + */ +export function calculateProjectedWriteSize({ currentSize, offset, incomingBytes }: Props): number { + return Math.max(currentSize, offset + incomingBytes); +} diff --git a/src/backend/features/user/file-size-limit/constants.ts b/src/backend/features/user/file-size-limit/constants.ts new file mode 100644 index 000000000..fe857a553 --- /dev/null +++ b/src/backend/features/user/file-size-limit/constants.ts @@ -0,0 +1,2 @@ +export const ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT = 100 * 1024 * 1024 * 1024; +export const MODAL_DEBOUNCE_MS = 2_000; diff --git a/src/backend/features/user/file-size-limit/index.ts b/src/backend/features/user/file-size-limit/index.ts new file mode 100644 index 000000000..2b46d1734 --- /dev/null +++ b/src/backend/features/user/file-size-limit/index.ts @@ -0,0 +1,14 @@ +export { + addMaxFileSizeRejection, + clearMaxFileSizeRejectionModal, + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, + markUploadSizeLimitBlockedPath, +} from './add-max-file-size-rejection'; +export { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from './constants'; +export { calculateProjectedWriteSize } from './calculate-projected-write-size'; +export { resolveUserFileSizeLimit } from './resolve-user-file-size-limit'; +export { showMaxFileSizeRejectionModal } from './show-max-file-size-rejection-modal'; +export type { MaxFileSizeRejectionModalPayload } from './show-max-file-size-rejection-modal'; +export type { UploadFileSizeValidation } from './validate-upload-file-size'; +export { validateUploadFileSize } from './validate-upload-file-size'; diff --git a/src/backend/features/user/file-size-limit/show-max-file-size-rejection-modal.ts b/src/backend/features/user/file-size-limit/show-max-file-size-rejection-modal.ts new file mode 100644 index 000000000..01daa32f3 --- /dev/null +++ b/src/backend/features/user/file-size-limit/show-max-file-size-rejection-modal.ts @@ -0,0 +1,15 @@ +import { logger } from '@internxt/drive-desktop-core/build/backend'; + +export type MaxFileSizeRejectionModalPayload = { + variant: 'single' | 'multiple'; + showUpgradeCta: boolean; + maxFileSize?: number; + fileSize?: number; +}; + +export async function showMaxFileSizeRejectionModal(payload: MaxFileSizeRejectionModalPayload): Promise { + logger.warn({ + msg: 'TODO: Showing max file size rejection modal', + payload, + }); +} diff --git a/src/backend/features/user/file-size-limit/upload-size-limit-error.ts b/src/backend/features/user/file-size-limit/upload-size-limit-error.ts new file mode 100644 index 000000000..b5ce4d63a --- /dev/null +++ b/src/backend/features/user/file-size-limit/upload-size-limit-error.ts @@ -0,0 +1,6 @@ +export class UploadSizeLimitError extends Error { + constructor() { + super('UPLOAD_SIZE_LIMIT_EXCEEDED'); + this.name = 'UploadSizeLimitError'; + } +} diff --git a/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts b/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts new file mode 100644 index 000000000..87f0074c2 --- /dev/null +++ b/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts @@ -0,0 +1,42 @@ +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from './constants'; +import { validateUploadFileSize } from './validate-upload-file-size'; + +describe('validateUploadFileSize', () => { + it('should pass when file size is under the stored limit', () => { + expect(validateUploadFileSize({ size: 99, maxUploadFileSize: 100 })).toStrictEqual({ allowed: true }); + }); + + it('should pass when file size is equal to the stored limit', () => { + expect(validateUploadFileSize({ size: 100, maxUploadFileSize: 100 })).toStrictEqual({ allowed: true }); + }); + + it('should return plan limit error when file size is over the stored limit', () => { + expect(validateUploadFileSize({ size: 101, maxUploadFileSize: 100 })).toStrictEqual({ + allowed: false, + reason: 'PLAN_LIMIT_EXCEEDED', + maxFileSize: 100, + showUpgradeCta: true, + }); + }); + + it('should pass when stored limit is invalid and file does not exceed absolute cap', () => { + expect(validateUploadFileSize({ size: 101, maxUploadFileSize: -1 })).toStrictEqual({ allowed: true }); + }); + + it('should pass when stored limit is null and file does not exceed absolute cap', () => { + expect(validateUploadFileSize({ size: 101, maxUploadFileSize: null })).toStrictEqual({ allowed: true }); + }); + + it('should return absolute cap error even when stored limit is unavailable', () => { + expect(validateUploadFileSize({ size: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 1, maxUploadFileSize: null })).toStrictEqual({ + allowed: false, + reason: 'ABSOLUTE_CAP_EXCEEDED', + maxFileSize: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT, + showUpgradeCta: false, + }); + }); + + it('should ignore zero-byte files', () => { + expect(validateUploadFileSize({ size: 0, maxUploadFileSize: 100 })).toStrictEqual({ allowed: true }); + }); +}); diff --git a/src/backend/features/user/file-size-limit/validate-upload-file-size.ts b/src/backend/features/user/file-size-limit/validate-upload-file-size.ts new file mode 100644 index 000000000..f19250079 --- /dev/null +++ b/src/backend/features/user/file-size-limit/validate-upload-file-size.ts @@ -0,0 +1,37 @@ +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from './constants'; + +export type UploadFileSizeValidation = + | { allowed: true } + | { + allowed: false; + reason: 'PLAN_LIMIT_EXCEEDED' | 'ABSOLUTE_CAP_EXCEEDED'; + maxFileSize: number; + showUpgradeCta: boolean; + }; + +type Props = { + size: number; + maxUploadFileSize?: number | null; +}; + +export function validateUploadFileSize({ size, maxUploadFileSize }: Props): UploadFileSizeValidation { + if (size > ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT) { + return { + allowed: false, + reason: 'ABSOLUTE_CAP_EXCEEDED', + maxFileSize: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT, + showUpgradeCta: false, + }; + } + + if (maxUploadFileSize && size > maxUploadFileSize) { + return { + allowed: false, + reason: 'PLAN_LIMIT_EXCEEDED', + maxFileSize: maxUploadFileSize, + showUpgradeCta: true, + }; + } + + return { allowed: true }; +} diff --git a/src/backend/features/virtual-drive/services/operations/release.service.test.ts b/src/backend/features/virtual-drive/services/operations/release.service.test.ts index 5baabfb41..67f713cf8 100644 --- a/src/backend/features/virtual-drive/services/operations/release.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/release.service.test.ts @@ -10,7 +10,13 @@ import { File, FileAttributes } from '../../../../../context/virtual-drive/files import { ContentsId } from '../../../../../apps/main/database/entities/DriveFile'; import { FileStatuses } from '../../../../../context/virtual-drive/files/domain/FileStatus'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; +import { UploadSizeLimitError } from '../../../user/file-size-limit/upload-size-limit-error'; import { call, calls } from '../../../../../../tests/vitest/utils.helper'; +import { + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, + markUploadSizeLimitBlockedPath, +} from '../../../user/file-size-limit/add-max-file-size-rejection'; const fileAttrs: FileAttributes = { id: 1, @@ -47,6 +53,7 @@ describe('release', () => { container.get.calledWith(TemporalFileDeleter).mockReturnValue(deleter); container.get.calledWith(FirstsFileSearcher).mockReturnValue(fileSearcher); fileSearcher.run.mockResolvedValue(undefined); + clearUploadSizeLimitBlockedPath('/Documents/report.pdf'); }); describe('when no temporal file is found', () => { @@ -105,6 +112,32 @@ describe('release', () => { ]); }); + it('should delete partial temporal file and skip upload when write already blocked it by upload size limit', async () => { + const temporalFile = createTemporalFile('/Documents/report.pdf'); + finder.run.mockResolvedValue(temporalFile); + markUploadSizeLimitBlockedPath('/Documents/report.pdf'); + + const { data, error } = await release({ path: '/Documents/report.pdf', processName: 'cat', container }); + + expect(error).toBeUndefined(); + expect(data).toBeUndefined(); + calls(uploader.run).toHaveLength(0); + calls(fileSearcher.run).toHaveLength(0); + call(deleter.run).toStrictEqual('/Documents/report.pdf'); + expect(isUploadSizeLimitBlockedPath('/Documents/report.pdf')).toBe(false); + }); + + it('should preserve the temporal file and return success when upload is rejected by upload size limit during upload preflight', async () => { + finder.run.mockResolvedValue(createTemporalFile('/Documents/report.pdf')); + uploader.run.mockRejectedValue(new UploadSizeLimitError()); + + const { data, error } = await release({ path: '/Documents/report.pdf', processName: 'cat', container }); + + expect(error).toBeUndefined(); + expect(data).toBeUndefined(); + calls(deleter.run).toHaveLength(0); + }); + it('should delete the file and return EIO when upload fails', async () => { finder.run.mockResolvedValue(createTemporalFile('/Documents/report.pdf')); uploader.run.mockRejectedValue(new Error('Network error')); diff --git a/src/backend/features/virtual-drive/services/operations/release.service.ts b/src/backend/features/virtual-drive/services/operations/release.service.ts index f207f0f78..c08b9dea0 100644 --- a/src/backend/features/virtual-drive/services/operations/release.service.ts +++ b/src/backend/features/virtual-drive/services/operations/release.service.ts @@ -7,7 +7,12 @@ import { TemporalFileUploader } from '../../../../../context/storage/TemporalFil import { TemporalFileDeleter } from '../../../../../context/storage/TemporalFiles/application/deletion/TemporalFileDeleter'; import { FirstsFileSearcher } from '../../../../../context/virtual-drive/files/application/search/FirstsFileSearcher'; import { FileStatuses } from '../../../../../context/virtual-drive/files/domain/FileStatus'; +import { UploadSizeLimitError } from '../../../user/file-size-limit/upload-size-limit-error'; +import { + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, +} from '../../../user/file-size-limit/add-max-file-size-rejection'; type Props = { path: string; processName: string; @@ -38,10 +43,21 @@ export async function release({ path, processName, container }: Props): Promise< return { data: undefined }; } + if (isUploadSizeLimitBlockedPath(path)) { + logger.warn({ + msg: '[Release] Upload size limit blocked file detected, deleting partial temporal file without upload', + path, + processName, + }); + await container.get(TemporalFileDeleter).run(path); + return { data: undefined }; + } + if (uploadsInProgress.has(path)) { logger.debug({ msg: '[Release] Upload already in progress, skipping duplicate release', path, processName }); return { data: undefined }; } + uploadsInProgress.add(path); try { @@ -54,6 +70,16 @@ export async function release({ path, processName, container }: Props): Promise< logger.debug({ msg: '[Release] Temporal file uploaded', path, processName }); return { data: undefined }; } catch (uploadError) { + if (uploadError instanceof UploadSizeLimitError) { + logger.warn({ + msg: '[Release] Upload size limit exceeded during upload preflight, preserving temporal file without upload', + error: uploadError, + path, + processName, + }); + return { data: undefined }; + } + logger.error({ msg: '[Release] Upload failed, deleting temporal file', error: uploadError, path, processName }); await container.get(TemporalFileDeleter).run(path); return { error: new FuseIOError('Upload failed due to insufficient storage or network issues.') }; @@ -63,5 +89,7 @@ export async function release({ path, processName, container }: Props): Promise< } catch (err: unknown) { logger.error({ msg: '[Release] Unexpected error', error: err, path, processName }); return { error: new FuseIOError('An unexpected error occurred during file release.') }; + } finally { + clearUploadSizeLimitBlockedPath(path); } } diff --git a/src/backend/features/virtual-drive/services/operations/write.service.test.ts b/src/backend/features/virtual-drive/services/operations/write.service.test.ts index 0a299f2fc..f15c7090c 100644 --- a/src/backend/features/virtual-drive/services/operations/write.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/write.service.test.ts @@ -1,25 +1,46 @@ import { mockDeep } from 'vitest-mock-extended'; import { Container } from 'diod'; +import { loggerMock } from '../../../../../../tests/vitest/mocks.helper'; +import { partialSpyOn } from '../../../../../../tests/vitest/utils.helper'; +import configStore from '../../../../../apps/main/config'; +import { clearMaxFileSizeRejectionModal } from '../../../user/file-size-limit/add-max-file-size-rejection'; +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from '../../../user/file-size-limit/constants'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; import { TemporalFileCreator } from '../../../../../context/storage/TemporalFiles/application/creation/TemporalFileCreator'; import { TemporalFileByPathFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByPathFinder'; import { TemporalFileWriter } from '../../../../../context/storage/TemporalFiles/application/write/TemporalFileWriter'; import { TemporalFile } from '../../../../../context/storage/TemporalFiles/domain/TemporalFile'; +import { TemporalFilePath } from '../../../../../context/storage/TemporalFiles/domain/TemporalFilePath'; +import { TemporalFileSize } from '../../../../../context/storage/TemporalFiles/domain/TemporalFileSize'; +import { + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, + markUploadSizeLimitBlockedPath, +} from '../../../user/file-size-limit/add-max-file-size-rejection'; import { write } from './write.service'; describe('write', () => { + const configGetMock = partialSpyOn(configStore, 'get'); let container: ReturnType>; const temporalFileWriter = mockDeep(); const temporalFileByPathFinder = mockDeep(); const temporalFileCreator = mockDeep(); beforeEach(() => { - vi.restoreAllMocks(); + vi.clearAllMocks(); + configGetMock.mockReturnValue(100); container = mockDeep(); container.get.calledWith(TemporalFileWriter).mockReturnValue(temporalFileWriter); container.get.calledWith(TemporalFileByPathFinder).mockReturnValue(temporalFileByPathFinder); container.get.calledWith(TemporalFileCreator).mockReturnValue(temporalFileCreator); temporalFileByPathFinder.run.mockResolvedValue(undefined); + clearUploadSizeLimitBlockedPath('/some/file.txt'); + clearUploadSizeLimitBlockedPath('/.test-test-file.txt.swp'); + clearMaxFileSizeRejectionModal(); + }); + + afterEach(() => { + clearMaxFileSizeRejectionModal(); }); it('should write bytes into temporal file and return written length', async () => { @@ -39,6 +60,104 @@ describe('write', () => { expect(temporalFileWriter.run).toHaveBeenCalledWith('/some/file.txt', content, content.length, 7); }); + it('should reject writes when projected size exceeds the stored upload limit', async () => { + const content = Buffer.alloc(5); + vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(false); + temporalFileByPathFinder.run.mockResolvedValue( + TemporalFile.create(new TemporalFilePath('/some/file.txt'), new TemporalFileSize(10)), + ); + + const { data, error } = await write({ + path: '/some/file.txt', + content, + offset: 96, + container, + }); + + expect(data).toBeUndefined(); + expect(error?.code).toBe(FuseCodes.EFBIG); + expect(temporalFileWriter.run).not.toHaveBeenCalled(); + expect(isUploadSizeLimitBlockedPath('/some/file.txt')).toBe(true); + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.objectContaining({ + tag: 'SYNC-ENGINE', + msg: 'File size exceeds upload limit', + path: '/some/file.txt', + size: 101, + maxFileSize: 100, + reason: 'PLAN_LIMIT_EXCEEDED', + showUpgradeCta: true, + }), + ); + }); + + it('should reject already blocked paths without writing more bytes', async () => { + const content = Buffer.alloc(5); + vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(false); + markUploadSizeLimitBlockedPath('/some/file.txt'); + + const { data, error } = await write({ + path: '/some/file.txt', + content, + offset: 0, + container, + }); + + expect(data).toBeUndefined(); + expect(error?.code).toBe(FuseCodes.EFBIG); + expect(temporalFileByPathFinder.run).not.toHaveBeenCalled(); + expect(temporalFileWriter.run).not.toHaveBeenCalled(); + expect(loggerMock.warn).not.toHaveBeenCalled(); + }); + + it('should reject writes over the absolute cap when max upload file size is unavailable', async () => { + configGetMock.mockReturnValue(undefined); + const content = Buffer.alloc(5); + vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(false); + temporalFileByPathFinder.run.mockResolvedValue( + TemporalFile.create(new TemporalFilePath('/some/file.txt'), new TemporalFileSize(10)), + ); + + const { data, error } = await write({ + path: '/some/file.txt', + content, + offset: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 1, + container, + }); + + expect(data).toBeUndefined(); + expect(error?.code).toBe(FuseCodes.EFBIG); + expect(temporalFileWriter.run).not.toHaveBeenCalled(); + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.objectContaining({ + tag: 'SYNC-ENGINE', + msg: 'File size exceeds upload limit', + path: '/some/file.txt', + size: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 6, + maxFileSize: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT, + reason: 'ABSOLUTE_CAP_EXCEEDED', + showUpgradeCta: false, + }), + ); + }); + + it('should allow writes when max upload file size is unavailable', async () => { + configGetMock.mockReturnValue(undefined); + const content = Buffer.alloc(5); + vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(false); + + const { data, error } = await write({ + path: '/some/file.txt', + content, + offset: 1_000, + container, + }); + + expect(error).toBeUndefined(); + expect(data).toBe(content.length); + expect(temporalFileWriter.run).toHaveBeenCalledWith('/some/file.txt', content, content.length, 1_000); + }); + it('should create auxiliary temporal file on first write when missing', async () => { const content = Buffer.from('hello'); vi.spyOn(TemporalFile, 'isTemporaryPath').mockReturnValue(true); diff --git a/src/backend/features/virtual-drive/services/operations/write.service.ts b/src/backend/features/virtual-drive/services/operations/write.service.ts index bead71ddc..aeb0658d8 100644 --- a/src/backend/features/virtual-drive/services/operations/write.service.ts +++ b/src/backend/features/virtual-drive/services/operations/write.service.ts @@ -1,9 +1,18 @@ import { logger } from '@internxt/drive-desktop-core/build/backend'; import { Container } from 'diod'; +import configStore from '../../../../../apps/main/config'; import { FuseCodes } from '../../../../../apps/drive/fuse/callbacks/FuseCodes'; import { FuseError } from '../../../../../apps/drive/fuse/callbacks/FuseErrors'; import { Result } from '../../../../../context/shared/domain/Result'; +import { TemporalFileByPathFinder } from '../../../../../context/storage/TemporalFiles/application/find/TemporalFileByPathFinder'; import { TemporalFileWriter } from '../../../../../context/storage/TemporalFiles/application/write/TemporalFileWriter'; +import { TemporalFile } from '../../../../../context/storage/TemporalFiles/domain/TemporalFile'; +import { + addMaxFileSizeRejection, + isUploadSizeLimitBlockedPath, +} from '../../../user/file-size-limit/add-max-file-size-rejection'; +import { calculateProjectedWriteSize } from '../../../user/file-size-limit/calculate-projected-write-size'; +import { validateUploadFileSize } from '../../../user/file-size-limit/validate-upload-file-size'; import { ensureTemporalFileExistsForAuxiliaryPath } from './ensure-temporal-file-exists-for-auxiliary-path'; type WritePops = { @@ -16,6 +25,40 @@ type WritePops = { export async function write({ path, content, offset, container }: WritePops): Promise> { try { await ensureTemporalFileExistsForAuxiliaryPath({ path, container }); + + if (!TemporalFile.isTemporaryPath(path)) { + if (isUploadSizeLimitBlockedPath(path)) { + return { error: new FuseError(FuseCodes.EFBIG, `[FUSE - Write] File too large: ${path}`) }; + } + + const temporalFile = await container.get(TemporalFileByPathFinder).run(path); + if (temporalFile) { + const projectedSize = calculateProjectedWriteSize({ + currentSize: temporalFile.size.value, + offset, + incomingBytes: content.length, + }); + const validation = validateUploadFileSize({ + size: projectedSize, + maxUploadFileSize: configStore.get('maxUploadFileSizeInBytes'), + }); + + if (!validation.allowed) { + addMaxFileSizeRejection({ validation, fileSize: projectedSize, path }); + logger.warn({ + tag: 'SYNC-ENGINE', + msg: 'File size exceeds upload limit', + path, + size: projectedSize, + maxFileSize: validation.maxFileSize, + reason: validation.reason, + showUpgradeCta: validation.showUpgradeCta, + }); + return { error: new FuseError(FuseCodes.EFBIG, `[FUSE - Write] File too large: ${path}`) }; + } + } + } + await container.get(TemporalFileWriter).run(path, content, content.length, offset); return { data: content.length }; } catch (error: unknown) { diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts index 3cdb0c9c7..f62576f75 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts @@ -1,36 +1,48 @@ -import { Readable } from 'stream'; +import { Readable } from 'node:stream'; import { mockDeep } from 'vitest-mock-extended'; -import { call, calls } from '../../../../../../tests/vitest/utils.helper'; -import { TemporalFileUploader } from './TemporalFileUploader'; -import { TemporalFileRepository } from '../../domain/TemporalFileRepository'; -import { TemporalFileUploaderFactory } from '../../domain/upload/TemporalFileUploaderFactory'; +import { partialSpyOn } from '../../../../../../tests/vitest/utils.helper'; +import configStore from '../../../../../apps/main/config'; +import { + clearMaxFileSizeRejectionModal, + clearUploadSizeLimitBlockedPath, + isUploadSizeLimitBlockedPath, +} from '../../../../../backend/features/user/file-size-limit/add-max-file-size-rejection'; import { EventBus } from '../../../../virtual-drive/shared/domain/EventBus'; import { TemporalFile } from '../../domain/TemporalFile'; +import { TemporalFileRepository } from '../../domain/TemporalFileRepository'; +import { TemporalFileUploaderFactory } from '../../domain/upload/TemporalFileUploaderFactory'; +import { TemporalFileUploader } from './TemporalFileUploader'; +import { call, calls } from '../../../../../../tests/vitest/utils.helper'; describe('TemporalFileUploader', () => { + const configGetMock = partialSpyOn(configStore, 'get'); const repository = mockDeep(); const uploaderFactory = mockDeep(); const eventBus = mockDeep(); const temporalFile = TemporalFile.from({ - path: '/Documents/report.txt', - size: 100, - createdAt: new Date(), - modifiedAt: new Date(), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + modifiedAt: new Date('2026-01-01T00:00:00.000Z'), + path: '/file.txt', + size: 101, }); const stopWatching = vi.fn(); - beforeEach(() => { vi.resetAllMocks(); - repository.watchFile.mockReturnValue(stopWatching); eventBus.publish.mockResolvedValue(undefined); - uploaderFactory.read.mockReturnValue(uploaderFactory); uploaderFactory.document.mockReturnValue(uploaderFactory); uploaderFactory.replaces.mockReturnValue(uploaderFactory); uploaderFactory.abort.mockReturnValue(uploaderFactory); + uploaderFactory.build.mockReturnValue(async () => 'contents-id'); + repository.stream.mockResolvedValue(Readable.from(['content'])); + }); + + afterEach(() => { + clearMaxFileSizeRejectionModal(); + clearUploadSizeLimitBlockedPath('/file.txt'); }); it('retries content upload on RATE_LIMITED and succeeds', async () => { @@ -69,4 +81,36 @@ describe('TemporalFileUploader', () => { calls(eventBus.publish).toHaveLength(0); call(stopWatching).toStrictEqual([]); }); + + it('should reject oversized temporal files before opening the upload stream', async () => { + configGetMock.mockReturnValue(100); + + const uploader = new TemporalFileUploader(repository, uploaderFactory, eventBus); + + await expect(uploader.run(temporalFile)).rejects.toThrow('UPLOAD_SIZE_LIMIT_EXCEEDED'); + expect(isUploadSizeLimitBlockedPath('/file.txt')).toBe(true); + expect(uploaderFactory.build).not.toHaveBeenCalled(); + expect(eventBus.publish).not.toHaveBeenCalled(); + }); + + it('should continue upload when the stored limit is unavailable', async () => { + configGetMock.mockReturnValue(-1); + + const uploader = new TemporalFileUploader(repository, uploaderFactory, eventBus); + + await expect(uploader.run(temporalFile)).resolves.toBe('contents-id'); + expect(repository.stream).toHaveBeenCalledWith(temporalFile.path); + expect(uploaderFactory.build).toHaveBeenCalled(); + }); + + it('should upload temporal files when they fit the stored limit', async () => { + configGetMock.mockReturnValue(101); + + const uploader = new TemporalFileUploader(repository, uploaderFactory, eventBus); + + await expect(uploader.run(temporalFile)).resolves.toBe('contents-id'); + expect(repository.stream).toHaveBeenCalledWith(temporalFile.path); + expect(uploaderFactory.build).toHaveBeenCalled(); + expect(eventBus.publish).toHaveBeenCalled(); + }); }); diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts index a584b5e77..d519d543c 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts @@ -16,6 +16,10 @@ import { import { ContentsId } from '../../../../../apps/main/database/entities/DriveFile'; import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; import { Result } from '../../../../shared/domain/Result'; +import configStore from '../../../../../apps/main/config'; +import { addMaxFileSizeRejection } from '../../../../../backend/features/user/file-size-limit/add-max-file-size-rejection'; +import { UploadSizeLimitError } from '../../../../../backend/features/user/file-size-limit/upload-size-limit-error'; +import { validateUploadFileSize } from '../../../../../backend/features/user/file-size-limit/validate-upload-file-size'; @Service() export class TemporalFileUploader { @@ -26,6 +30,16 @@ export class TemporalFileUploader { ) {} async run(temporalFile: TemporalFile, replaces?: Replaces): Promise { + const validation = validateUploadFileSize({ + size: temporalFile.size.value, + maxUploadFileSize: configStore.get('maxUploadFileSizeInBytes'), + }); + + if (!validation.allowed) { + addMaxFileSizeRejection({ path: temporalFile.path.value, fileSize: temporalFile.size.value, validation }); + + throw new UploadSizeLimitError(); + } const controller = new AbortController(); const stopWatching = this.repository.watchFile(temporalFile.path, () => controller.abort()); From baa342e05147ffd2b630c7a50a723ce41288a617 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Thu, 4 Jun 2026 13:59:08 +0200 Subject: [PATCH 2/5] feat: add error handling uppon 402 from backend, restore locally the rejected files --- .../transient-error-handler.test.ts | 6 + .../upload/create-file-to-backend.test.ts | 9 + .../backup/upload/create-file-to-backend.ts | 1 + .../upload/override-file-to-backend.test.ts | 8 + .../backup/upload/override-file-to-backend.ts | 1 + .../add-max-file-size-rejection.test.ts | 19 ++ .../add-max-file-size-rejection.ts | 5 +- .../features/user/file-size-limit/index.ts | 1 + ...reserve-rejected-file-size-too-big.test.ts | 183 ++++++++++++++++++ .../preserve-rejected-file-size-too-big.ts | 125 ++++++++++++ .../validate-upload-file-size.test.ts | 8 +- .../domain/value-objects/BucketEntry.ts | 5 +- .../upload/TemporalFileUploader.test.ts | 2 +- .../upload/TemporalFileUploader.ts | 1 + .../upload/TemporalFileUploadedDomainEvent.ts | 5 + .../CreateFileOnOfflineFileUploaded.test.ts | 44 +++++ .../CreateFileOnTemporalFileUploaded.ts | 64 ++++++ .../override/FileOverrider.test.ts | 27 +++ .../application/override/FileOverrider.ts | 25 ++- .../files/domain/FileSize.test.ts | 33 +--- .../domain/__test-helpers__/FileSizeMother.ts | 3 +- .../SDKRemoteFileSystem.test.ts | 10 + .../infrastructure/SDKRemoteFileSystem.ts | 3 + src/core/electron/paths.ts | 2 + .../drive-server/drive-server.error.test.ts | 7 + src/infra/drive-server/drive-server.error.ts | 2 + .../files/services/create-file.test.ts | 9 + .../files/services/override-file.test.ts | 9 + 28 files changed, 582 insertions(+), 35 deletions(-) create mode 100644 src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts create mode 100644 src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts create mode 100644 src/infra/drive-server/drive-server.error.test.ts diff --git a/src/backend/common/rate-limit/transient-error-handler.test.ts b/src/backend/common/rate-limit/transient-error-handler.test.ts index 4199b0e72..f9b4c6802 100644 --- a/src/backend/common/rate-limit/transient-error-handler.test.ts +++ b/src/backend/common/rate-limit/transient-error-handler.test.ts @@ -11,6 +11,12 @@ describe('createTransientErrorHandler', () => { expect(handler(new DriveDesktopError('FILE_ALREADY_EXISTS'))).toBeNull(); }); + it('should not retry FILE_TOO_BIG errors', () => { + const handler = createTransientErrorHandler({ tag: 'SYNC-ENGINE', context: 'TEST', path: '/file.txt' }); + + expect(handler(new DriveDesktopError('FILE_TOO_BIG'))).toBeNull(); + }); + it('should return exponential backoff delay for INTERNAL_SERVER_ERROR', () => { const handler = createTransientErrorHandler({ tag: 'BACKUPS', context: 'TEST', path: '/file.txt' }); const error = new DriveDesktopError('INTERNAL_SERVER_ERROR'); diff --git a/src/backend/features/backup/upload/create-file-to-backend.test.ts b/src/backend/features/backup/upload/create-file-to-backend.test.ts index 5b6ea0814..65a1c61aa 100644 --- a/src/backend/features/backup/upload/create-file-to-backend.test.ts +++ b/src/backend/features/backup/upload/create-file-to-backend.test.ts @@ -71,6 +71,15 @@ describe('createFileToBackend', () => { expect(result.error?.cause).toBe('UNKNOWN'); }); + it('should return FILE_TOO_BIG on FILE_TOO_BIG', async () => { + createFileMock.mockResolvedValue({ error: new DriveServerError('FILE_TOO_BIG', 402) }); + + const result = await createFileToBackend(baseParams); + + expect(result.error).toBeInstanceOf(DriveDesktopError); + expect(result.error?.cause).toBe('FILE_TOO_BIG'); + }); + it('should return INTERNAL_SERVER_ERROR on SERVER_ERROR', async () => { createFileMock.mockResolvedValue({ error: new DriveServerError('SERVER_ERROR', 500) }); diff --git a/src/backend/features/backup/upload/create-file-to-backend.ts b/src/backend/features/backup/upload/create-file-to-backend.ts index 78758043b..7bf8b6238 100644 --- a/src/backend/features/backup/upload/create-file-to-backend.ts +++ b/src/backend/features/backup/upload/create-file-to-backend.ts @@ -74,6 +74,7 @@ export async function createFileToBackend({ CONFLICT: 'FILE_ALREADY_EXISTS', SERVER_ERROR: 'INTERNAL_SERVER_ERROR', TOO_MANY_REQUESTS: 'RATE_LIMITED', + FILE_TOO_BIG: 'FILE_TOO_BIG', }; const cause = causeMap[response.error.cause] ?? 'UNKNOWN'; diff --git a/src/backend/features/backup/upload/override-file-to-backend.test.ts b/src/backend/features/backup/upload/override-file-to-backend.test.ts index fcefc93f7..1f1d51887 100644 --- a/src/backend/features/backup/upload/override-file-to-backend.test.ts +++ b/src/backend/features/backup/upload/override-file-to-backend.test.ts @@ -39,6 +39,14 @@ describe('override-file-to-backend', () => { expect(result.error?.cause).toBe('RATE_LIMITED'); }); + it('should return FILE_TOO_BIG when server returns FILE_TOO_BIG', async () => { + overrideFileMock.mockResolvedValue({ error: new DriveServerError('FILE_TOO_BIG', 402) }); + + const result = await overrideFileToBackend(baseParams); + + expect(result.error?.cause).toBe('FILE_TOO_BIG'); + }); + it('should return UNKNOWN for any other server error', async () => { overrideFileMock.mockResolvedValue({ error: new DriveServerError('NOT_FOUND', 404) }); diff --git a/src/backend/features/backup/upload/override-file-to-backend.ts b/src/backend/features/backup/upload/override-file-to-backend.ts index ddfd82aaa..290265a1a 100644 --- a/src/backend/features/backup/upload/override-file-to-backend.ts +++ b/src/backend/features/backup/upload/override-file-to-backend.ts @@ -6,6 +6,7 @@ import { SyncError } from '../../../../shared/issues/SyncErrorCause'; const causeMap: Record = { SERVER_ERROR: 'INTERNAL_SERVER_ERROR', TOO_MANY_REQUESTS: 'RATE_LIMITED', + FILE_TOO_BIG: 'FILE_TOO_BIG', }; export async function overrideFileToBackend(params: OverrideFileProps): Promise> { diff --git a/src/backend/features/user/file-size-limit/add-max-file-size-rejection.test.ts b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.test.ts index 33931b381..c9940557b 100644 --- a/src/backend/features/user/file-size-limit/add-max-file-size-rejection.test.ts +++ b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.test.ts @@ -106,4 +106,23 @@ describe('addMaxFileSizeRejection', () => { fileSize: 150, }); }); + + it('should allow backend rejections to skip blocked path tracking', () => { + addMaxFileSizeRejection({ + path, + fileSize: 150, + blockUploadPath: false, + }); + + expect(isUploadSizeLimitBlockedPath(path)).toBe(false); + + vi.advanceTimersByTime(2_000); + + expect(showModalMock).toHaveBeenCalledWith({ + variant: 'single', + showUpgradeCta: true, + maxFileSize: undefined, + fileSize: 150, + }); + }); }); diff --git a/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts index 9d982740f..c3702f328 100644 --- a/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts +++ b/src/backend/features/user/file-size-limit/add-max-file-size-rejection.ts @@ -16,6 +16,7 @@ type MaxFileSizeRejection = { validation?: Extract; fileSize: number; path: string; + blockUploadPath?: boolean; }; type MaxFileSizeRejectionModalDraft = Omit; @@ -35,7 +36,9 @@ export function clearUploadSizeLimitBlockedPath(path: string): void { } export function addMaxFileSizeRejection(rejection: MaxFileSizeRejection): void { - markUploadSizeLimitBlockedPath(rejection.path); + if (rejection.blockUploadPath ?? true) { + markUploadSizeLimitBlockedPath(rejection.path); + } if (state) clearTimeout(state.timeout); state = { diff --git a/src/backend/features/user/file-size-limit/index.ts b/src/backend/features/user/file-size-limit/index.ts index 2b46d1734..4cf69b6fa 100644 --- a/src/backend/features/user/file-size-limit/index.ts +++ b/src/backend/features/user/file-size-limit/index.ts @@ -7,6 +7,7 @@ export { } from './add-max-file-size-rejection'; export { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from './constants'; export { calculateProjectedWriteSize } from './calculate-projected-write-size'; +export { preserveRejectedFileSizeTooBig } from './rejected-file-size-too-big/preserve-rejected-file-size-too-big'; export { resolveUserFileSizeLimit } from './resolve-user-file-size-limit'; export { showMaxFileSizeRejectionModal } from './show-max-file-size-rejection-modal'; export type { MaxFileSizeRejectionModalPayload } from './show-max-file-size-rejection-modal'; diff --git a/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts new file mode 100644 index 000000000..2e396230c --- /dev/null +++ b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts @@ -0,0 +1,183 @@ +import { constants } from 'node:fs'; +import { copyFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { + copyWithoutOverwriting, + createCopyPath, + createLastResortCopyPath, + createRecoveredPath, + preserveRejectedFileSizeTooBig, +} from './preserve-rejected-file-size-too-big'; + +vi.mock('node:fs/promises', () => ({ + copyFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), +})); + +const copyFileMock = vi.mocked(copyFile); +const mkdirMock = vi.mocked(mkdir); +const recoveryRoot = '/rejected-files-size-too-big'; +const temporalContentPath = '/tmp/internxt-drive-tmp/temporal-content'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + copyFileMock.mockResolvedValue(undefined); + mkdirMock.mockResolvedValue(undefined); +}); + +describe('preserve-rejected-file-size-too-big', () => { + describe('preserveRejectedFileSizeTooBig', () => { + it('should preserve the rejected file inside its original path structure', async () => { + const result = await preserveRejectedFileSizeTooBig({ + rootFolder: recoveryRoot, + originalPath: '/fotos/a/b/c/photo.jpg', + temporalContentPath, + size: 36_000_000, + }); + + const expectedFolderPath = path.join(recoveryRoot, 'fotos', 'a', 'b', 'c'); + const expectedFilePath = path.join(expectedFolderPath, 'photo.jpg'); + + expect(mkdirMock).toHaveBeenCalledWith(expectedFolderPath, { recursive: true }); + expect(copyFileMock).toHaveBeenCalledWith(temporalContentPath, expectedFilePath, constants.COPYFILE_EXCL); + expect(result).toStrictEqual({ data: { folderPath: expectedFolderPath, filePath: expectedFilePath } }); + }); + + it('should reuse existing recovered folders and only create missing path segments', async () => { + const result = await preserveRejectedFileSizeTooBig({ + rootFolder: recoveryRoot, + originalPath: '/fotos/a/b/c/beach/photo.jpg', + temporalContentPath, + size: 12_000_000, + }); + + const expectedFolderPath = path.join(recoveryRoot, 'fotos', 'a', 'b', 'c', 'beach'); + const expectedFilePath = path.join(expectedFolderPath, 'photo.jpg'); + + expect(mkdirMock).toHaveBeenCalledWith(expectedFolderPath, { recursive: true }); + expect(result).toStrictEqual({ data: { folderPath: expectedFolderPath, filePath: expectedFilePath } }); + }); + + it('should return an error when the recovery folder cannot be created', async () => { + const error = new Error('mkdir failed'); + mkdirMock.mockRejectedValueOnce(error); + + const result = await preserveRejectedFileSizeTooBig({ + rootFolder: recoveryRoot, + originalPath: '/fotos/a/b/c/photo.jpg', + temporalContentPath, + size: 36_000_000, + }); + + expect(result).toStrictEqual({ error }); + expect(copyFileMock).not.toHaveBeenCalled(); + }); + + it('should return an error when the temporal file cannot be copied', async () => { + const error = new Error('copy failed'); + copyFileMock.mockRejectedValueOnce(error); + + const result = await preserveRejectedFileSizeTooBig({ + rootFolder: recoveryRoot, + originalPath: '/fotos/a/b/c/photo.jpg', + temporalContentPath, + size: 36_000_000, + }); + + expect(result).toStrictEqual({ error }); + }); + }); + + describe('createRecoveredPath', () => { + it('should recover a file under the rejected-files root preserving its path segments', () => { + expect(createRecoveredPath({ rootFolder: recoveryRoot, originalPath: '/fotos/a/b/c/photo.jpg' })).toBe( + path.join(recoveryRoot, 'fotos', 'a', 'b', 'c', 'photo.jpg'), + ); + }); + + it('should preserve the file inside the rejected-files root when the original path has no valid segments', () => { + expect(createRecoveredPath({ rootFolder: recoveryRoot, originalPath: '/' })).toBe( + path.join(recoveryRoot, 'rejected-file'), + ); + }); + }); + + describe('copyWithoutOverwriting', () => { + it('should copy to the target path when it does not exist', async () => { + const targetPath = path.join(recoveryRoot, 'photo.jpg'); + + await expect(copyWithoutOverwriting({ sourcePath: temporalContentPath, targetPath })).resolves.toStrictEqual({ + data: targetPath, + }); + expect(copyFileMock).toHaveBeenCalledWith(temporalContentPath, targetPath, constants.COPYFILE_EXCL); + }); + + it('should copy to the first available copy path when the target already exists', async () => { + const targetPath = path.join(recoveryRoot, 'photo.jpg'); + const copyPath = path.join(recoveryRoot, 'photo (copy 1).jpg'); + copyFileMock.mockRejectedValueOnce(createFileAlreadyExistsError()).mockResolvedValueOnce(undefined); + + await expect(copyWithoutOverwriting({ sourcePath: temporalContentPath, targetPath })).resolves.toStrictEqual({ + data: copyPath, + }); + expect(copyFileMock).toHaveBeenNthCalledWith(1, temporalContentPath, targetPath, constants.COPYFILE_EXCL); + expect(copyFileMock).toHaveBeenNthCalledWith(2, temporalContentPath, copyPath, constants.COPYFILE_EXCL); + }); + + it('should return the copy error when copy fails for a reason different from an existing target', async () => { + const error = new Error('copy failed'); + copyFileMock.mockRejectedValueOnce(error); + + await expect( + copyWithoutOverwriting({ sourcePath: temporalContentPath, targetPath: path.join(recoveryRoot, 'photo.jpg') }), + ).resolves.toStrictEqual({ error }); + }); + + it('should use a timestamp and random number on the last copy attempt', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171); + vi.spyOn(Math, 'random').mockReturnValue(0.123456); + let calls = 0; + copyFileMock.mockImplementation(async () => { + calls += 1; + if (calls < 102) { + throw createFileAlreadyExistsError(); + } + }); + + const targetPath = path.join(recoveryRoot, 'photo.jpg'); + const lastResortPath = path.join(recoveryRoot, 'photo (copy 1717171717171-123456).jpg'); + + await expect(copyWithoutOverwriting({ sourcePath: temporalContentPath, targetPath })).resolves.toStrictEqual({ + data: lastResortPath, + }); + expect(copyFileMock).toHaveBeenCalledTimes(102); + expect(copyFileMock).toHaveBeenLastCalledWith(temporalContentPath, lastResortPath, constants.COPYFILE_EXCL); + }); + }); + + describe('createCopyPath', () => { + it('should append the copy number before the file extension', () => { + expect(createCopyPath({ targetPath: path.join(recoveryRoot, 'fotos', 'photo.jpg'), copyNumber: 3 })).toBe( + path.join(recoveryRoot, 'fotos', 'photo (copy 3).jpg'), + ); + }); + }); + + describe('createLastResortCopyPath', () => { + it('should append a timestamp and random number before the file extension', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171); + vi.spyOn(Math, 'random').mockReturnValue(0.123456); + + expect(createLastResortCopyPath({ targetPath: path.join(recoveryRoot, 'fotos', 'photo.jpg') })).toBe( + path.join(recoveryRoot, 'fotos', 'photo (copy 1717171717171-123456).jpg'), + ); + }); + }); +}); + +function createFileAlreadyExistsError(): NodeJS.ErrnoException { + const error = new Error('File already exists') as NodeJS.ErrnoException; + error.code = 'EEXIST'; + return error; +} diff --git a/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts new file mode 100644 index 000000000..ee67b4ff7 --- /dev/null +++ b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts @@ -0,0 +1,125 @@ +import { constants } from 'node:fs'; +import { copyFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { PATHS } from '../../../../../core/electron/paths'; +import { Result } from '../../../../../context/shared/domain/Result'; + +type Props = { + originalPath: string; + temporalContentPath: string; + size: number; + rootFolder?: string; +}; + +type PreservedRejectedFileSizeTooBig = { + folderPath: string; + filePath: string; +}; + +export async function preserveRejectedFileSizeTooBig({ + originalPath, + temporalContentPath, + rootFolder = PATHS.REJECTED_FILES_SIZE_TOO_BIG, +}: Props): Promise> { + const recoveredPath = createRecoveredPath({ rootFolder, originalPath }); + const folderPath = path.dirname(recoveredPath); + try { + await mkdir(folderPath, { recursive: true }); + const { data: filePath, error } = await copyWithoutOverwriting({ + sourcePath: temporalContentPath, + targetPath: recoveredPath, + }); + + if (error) { + return { error }; + } + + return { data: { folderPath: path.dirname(filePath), filePath } }; + } catch (error) { + return { error: error instanceof Error ? error : new Error(`Failed to create directory: ${error}`) }; + } +} + +export function createRecoveredPath({ + rootFolder, + originalPath, +}: { + rootFolder: string; + originalPath: string; +}): string { + const originalPathSegments = originalPath + .split('/') + .filter((segment) => segment && segment !== '.' && segment !== '..'); + + return path.join(rootFolder, ...(originalPathSegments.length ? originalPathSegments : ['rejected-file'])); +} + +export async function copyWithoutOverwriting({ + sourcePath, + targetPath, +}: { + sourcePath: string; + targetPath: string; +}): Promise> { + for (let copyNumber = 0; copyNumber <= 101; copyNumber += 1) { + const candidatePath = createCandidatePath({ targetPath, copyNumber }); + // eslint-disable-next-line no-await-in-loop + const { data: copiedPath, error } = await tryCopyWithoutOverwriting({ sourcePath, targetPath: candidatePath }); + + if (error) { + return { error }; + } + + if (copiedPath) { + return { data: copiedPath }; + } + } + return { + error: new Error(`Unable to preserve rejected file because all copy candidates already exist: ${targetPath}`), + }; +} + +function createCandidatePath({ targetPath, copyNumber }: { targetPath: string; copyNumber: number }): string { + if (copyNumber === 0) { + return targetPath; + } + if (copyNumber <= 100) { + return createCopyPath({ targetPath, copyNumber }); + } + return createLastResortCopyPath({ targetPath }); +} + +async function tryCopyWithoutOverwriting({ + sourcePath, + targetPath, +}: { + sourcePath: string; + targetPath: string; +}): Promise> { + try { + await copyFile(sourcePath, targetPath, constants.COPYFILE_EXCL); + return { data: targetPath }; + } catch (error) { + if (isFileAlreadyExistsError(error)) { + return { data: undefined }; + } + return { error: error instanceof Error ? error : new Error(`Failed to copy file: ${error}`) }; + } +} + +export function createCopyPath({ targetPath, copyNumber }: { targetPath: string; copyNumber: number }): string { + const parsedPath = path.parse(targetPath); + return path.join(parsedPath.dir, `${parsedPath.name} (copy ${copyNumber})${parsedPath.ext}`); +} + +export function createLastResortCopyPath({ targetPath }: { targetPath: string }): string { + const parsedPath = path.parse(targetPath); + const timestamp = Date.now(); + const randomNumber = Math.floor(Math.random() * 1_000_000); + + return path.join(parsedPath.dir, `${parsedPath.name} (copy ${timestamp}-${randomNumber})${parsedPath.ext}`); +} + +function isFileAlreadyExistsError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'EEXIST'; +} diff --git a/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts b/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts index 87f0074c2..77daba79f 100644 --- a/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts +++ b/src/backend/features/user/file-size-limit/validate-upload-file-size.test.ts @@ -19,8 +19,8 @@ describe('validateUploadFileSize', () => { }); }); - it('should pass when stored limit is invalid and file does not exceed absolute cap', () => { - expect(validateUploadFileSize({ size: 101, maxUploadFileSize: -1 })).toStrictEqual({ allowed: true }); + it('should pass when stored limit is 0 and file does not exceed absolute cap', () => { + expect(validateUploadFileSize({ size: 101, maxUploadFileSize: 0 })).toStrictEqual({ allowed: true }); }); it('should pass when stored limit is null and file does not exceed absolute cap', () => { @@ -28,7 +28,9 @@ describe('validateUploadFileSize', () => { }); it('should return absolute cap error even when stored limit is unavailable', () => { - expect(validateUploadFileSize({ size: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 1, maxUploadFileSize: null })).toStrictEqual({ + expect( + validateUploadFileSize({ size: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 1, maxUploadFileSize: null }), + ).toStrictEqual({ allowed: false, reason: 'ABSOLUTE_CAP_EXCEEDED', maxFileSize: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT, diff --git a/src/context/shared/domain/value-objects/BucketEntry.ts b/src/context/shared/domain/value-objects/BucketEntry.ts index 1b55854b3..d5c2e76cf 100644 --- a/src/context/shared/domain/value-objects/BucketEntry.ts +++ b/src/context/shared/domain/value-objects/BucketEntry.ts @@ -1,15 +1,14 @@ +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from '../../../../backend/features/user/file-size-limit'; import { ValueObject } from './ValueObject'; export class BucketEntry extends ValueObject { - public static MAX_SIZE = 40 * 1024 * 1024 * 1024; - constructor(value: number) { super(value); this.ensureIsValid(value); } private ensureIsValid(value: number) { - if (value > BucketEntry.MAX_SIZE) { + if (value > ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT) { throw new Error('File size to big'); } diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts index f62576f75..dda83ff03 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts @@ -94,7 +94,7 @@ describe('TemporalFileUploader', () => { }); it('should continue upload when the stored limit is unavailable', async () => { - configGetMock.mockReturnValue(-1); + configGetMock.mockReturnValue(0); const uploader = new TemporalFileUploader(repository, uploaderFactory, eventBus); diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts index d519d543c..d1de1c99f 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.ts @@ -115,6 +115,7 @@ export class TemporalFileUploader { path: temporalFile.path.value, replaces: replaces?.contentsId, fileBuffer, + contentFilePath: temporalFile.contentFilePath, }); await this.eventBus.publish([contentsUploadedEvent]); diff --git a/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts b/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts index 926424577..dbb264f0a 100644 --- a/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts +++ b/src/context/storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent.ts @@ -7,6 +7,7 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { readonly path: string; readonly replaces: string | undefined; readonly fileBuffer: Buffer | undefined; + readonly contentFilePath: string | undefined; constructor({ aggregateId, @@ -14,12 +15,14 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { path, replaces, fileBuffer, + contentFilePath, }: { aggregateId: string; size: number; path: string; replaces?: string; fileBuffer?: Buffer; + contentFilePath?: string; }) { super({ aggregateId, @@ -30,6 +33,7 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { this.path = path; this.replaces = replaces; this.fileBuffer = fileBuffer; + this.contentFilePath = contentFilePath; } toPrimitives() { @@ -38,6 +42,7 @@ export class TemporalFileUploadedDomainEvent extends DomainEvent { size: this.size, path: this.path, replaces: this.replaces, + contentFilePath: this.contentFilePath, }; } } diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts index d4349f546..c3be4779f 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts @@ -1,15 +1,39 @@ import { Environment } from '@internxt/inxt-js'; +import { + clearMaxFileSizeRejectionModal, + isUploadSizeLimitBlockedPath, +} from '../../../../../backend/features/user/file-size-limit/add-max-file-size-rejection'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; import { CreateFileOnTemporalFileUploaded } from './CreateFileOnTemporalFileUploaded'; import { FileCreatorTestClass } from '../../__test-helpers__/FileCreatorTestClass'; import { FileOverriderTestClass } from '../../__test-helpers__/FileOverriderTestClass'; import { FileMother } from '../../domain/__test-helpers__/FileMother'; import { OfflineContentsUploadedDomainEventMother } from '../../domain/events/__test-helpers__/OfflineContentsUploadedDomainEventMother'; import { call } from 'tests/vitest/utils.helper'; +import { preserveRejectedFileSizeTooBig } from '../../../../../backend/features/user/file-size-limit'; + +vi.mock('../../../../../backend/features/user/file-size-limit', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + preserveRejectedFileSizeTooBig: vi.fn().mockResolvedValue({ + data: { + filePath: '/home/user/.config/internxt/rejected-files-size-too-big/rejected/file.pdf', + }, + }), + }; +}); describe('Create File On Offline File Uploaded', () => { const environment = {} as Environment; const bucket = 'test-bucket'; + afterEach(() => { + clearMaxFileSizeRejectionModal(); + vi.clearAllMocks(); + }); + it('creates a new file when event replaces field is undefined', async () => { const creator = new FileCreatorTestClass(); const overrider = new FileOverriderTestClass(); @@ -54,4 +78,24 @@ describe('Create File On Offline File Uploaded', () => { call(overrider.mock).toMatchObject([uploadedEvent.replaces, uploadedEvent.aggregateId, uploadedEvent.size]); }); + + it('preserves and queues max file size rejection when backend rejects metadata creation by file size', async () => { + const creator = new FileCreatorTestClass(); + const overrider = new FileOverriderTestClass(); + creator.mock.mockRejectedValue(new DriveDesktopError('FILE_TOO_BIG')); + + const uploadedEvent = OfflineContentsUploadedDomainEventMother.doesNotReplace(); + Object.assign(uploadedEvent, { contentFilePath: '/tmp/internxt-drive-tmp/staged-file' }); + + const sut = new CreateFileOnTemporalFileUploaded(creator, overrider, environment, bucket); + + await sut.on(uploadedEvent); + + expect(preserveRejectedFileSizeTooBig).toHaveBeenCalledWith({ + originalPath: uploadedEvent.path, + temporalContentPath: '/tmp/internxt-drive-tmp/staged-file', + size: uploadedEvent.size, + }); + expect(isUploadSizeLimitBlockedPath(uploadedEvent.path)).toBe(false); + }); }); diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts index 8e4070eff..79b737121 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnTemporalFileUploaded.ts @@ -1,13 +1,16 @@ import { Environment } from '@internxt/inxt-js'; import { Service } from 'diod'; import { logger } from '@internxt/drive-desktop-core/build/backend'; +import { addMaxFileSizeRejection } from '../../../../../backend/features/user/file-size-limit/add-max-file-size-rejection'; import { generateThumbnail } from '../../../../../backend/features/thumbnails/generate-thumbnail'; import { uploadAndCreateThumbnail } from '../../../../../backend/features/thumbnails/upload-and-create-thumbnail'; import { TemporalFileUploadedDomainEvent } from '../../../../storage/TemporalFiles/domain/upload/TemporalFileUploadedDomainEvent'; import { DomainEventClass } from '../../../../shared/domain/DomainEvent'; import { DomainEventSubscriber } from '../../../../shared/domain/DomainEventSubscriber'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; import { FileCreator } from './FileCreator'; import { FileOverrider } from '../override/FileOverrider'; +import { preserveRejectedFileSizeTooBig } from '../../../../../backend/features/user/file-size-limit'; @Service() export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber { @@ -52,10 +55,71 @@ export class CreateFileOnTemporalFileUploaded implements DomainEventSubscriber { + if (!event.contentFilePath) { + logger.error({ + msg: '[CreateFileOnOfflineFileUploaded] Backend rejected oversized file but temporal content path is unavailable', + path: event.path, + size: event.size, + }); + return false; + } + + try { + const { data, error } = await preserveRejectedFileSizeTooBig({ + originalPath: event.path, + temporalContentPath: event.contentFilePath, + size: event.size, + }); + + if (error) { + logger.error({ + msg: '[CreateFileOnOfflineFileUploaded] Failed to preserve backend-rejected oversized file', + error, + path: event.path, + size: event.size, + temporalContentPath: event.contentFilePath, + }); + return false; + } + + logger.warn({ + msg: '[CreateFileOnOfflineFileUploaded] Backend rejected file because it exceeds upload size limit, preserved local copy', + path: event.path, + size: event.size, + preservedFilePath: data.filePath, + }); + return true; + } catch (preserveError) { + logger.error({ + msg: '[CreateFileOnOfflineFileUploaded] Failed to preserve backend-rejected oversized file', + error: preserveError, + path: event.path, + size: event.size, + temporalContentPath: event.contentFilePath, + }); + return false; + } + } } diff --git a/src/context/virtual-drive/files/application/override/FileOverrider.test.ts b/src/context/virtual-drive/files/application/override/FileOverrider.test.ts index d312f56d6..44ad1bbf5 100644 --- a/src/context/virtual-drive/files/application/override/FileOverrider.test.ts +++ b/src/context/virtual-drive/files/application/override/FileOverrider.test.ts @@ -7,11 +7,17 @@ import { FileSizeMother } from '../../domain/__test-helpers__/FileSizeMother'; import { FileNotFoundError } from '../../domain/errors/FileNotFoundError'; import { FileOverriddenDomainEvent } from '../../domain/events/FileOverriddenDomainEvent'; import * as overrideFileModule from '../../../../../infra/drive-server/services/files/services/override-file'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; +import { DriveServerError } from '../../../../../infra/drive-server/drive-server.error'; import { call, partialSpyOn } from '../../../../../../tests/vitest/utils.helper'; describe('File Overrider', () => { const overrideFileMock = partialSpyOn(overrideFileModule, 'overrideFile'); + beforeEach(() => { + overrideFileMock.mockResolvedValue({ data: true }); + }); + it('throws an error if no file is founded with the given fileId', async () => { const repository = new FileRepositoryMock(); const eventBus = new EventBusMock(); @@ -53,6 +59,27 @@ describe('File Overrider', () => { }); }); + it('throws FILE_TOO_BIG when backend rejects the override size', async () => { + const repository = new FileRepositoryMock(); + const eventBus = new EventBusMock(); + + const overrider = new FileOverrider(repository, eventBus); + + const file = FileMother.any(); + const updatedContentsId = BucketEntryIdMother.random(); + const updatedSize = FileSizeMother.random(); + + repository.searchByContentsIdMock.mockReturnValueOnce(file); + overrideFileMock.mockResolvedValueOnce({ error: new DriveServerError('FILE_TOO_BIG', 402) }); + + await expect(overrider.run(file.contentsId, updatedContentsId.value, updatedSize.value)).rejects.toMatchObject({ + cause: 'FILE_TOO_BIG', + } satisfies Partial); + + expect(repository.updateMock).not.toHaveBeenCalled(); + expect(eventBus.publishMock).not.toHaveBeenCalled(); + }); + it('emits the FileOverridden domain event when successfully overridden ', async () => { const repository = new FileRepositoryMock(); const eventBus = new EventBusMock(); diff --git a/src/context/virtual-drive/files/application/override/FileOverrider.ts b/src/context/virtual-drive/files/application/override/FileOverrider.ts index 9e3165286..0b61c4e3d 100644 --- a/src/context/virtual-drive/files/application/override/FileOverrider.ts +++ b/src/context/virtual-drive/files/application/override/FileOverrider.ts @@ -1,4 +1,6 @@ import { Service } from 'diod'; +import { parseRetryAfterMs } from '../../../../../backend/common/rate-limit/transient-error-handler'; +import { DriveDesktopError } from '../../../../shared/domain/errors/DriveDesktopError'; import { EventBus } from '../../../shared/domain/EventBus'; import { File } from '../../domain/File'; import { FileRepository } from '../../domain/FileRepository'; @@ -6,6 +8,7 @@ import { FileSize } from '../../domain/FileSize'; import { FileNotFoundError } from '../../domain/errors/FileNotFoundError'; import { FileContentsId } from '../../domain/FileContentsId'; import { overrideFile } from '../../../../../infra/drive-server/services/files/services/override-file'; +import { DriveServerError } from '../../../../../infra/drive-server/drive-server.error'; @Service() export class FileOverrider { @@ -27,12 +30,16 @@ export class FileOverrider { file.changeContents(new FileContentsId(newContentsId), new FileSize(newSize)); - await overrideFile({ + const result = await overrideFile({ fileUuid: file.uuid, fileContentsId: file.contentsId, fileSize: file.size, }); + if (result.error) { + throw mapOverrideFileError(result.error); + } + await this.repository.update(file); this.eventBus.publish(file.pullDomainEvents()); @@ -40,3 +47,19 @@ export class FileOverrider { return file; } } + +function mapOverrideFileError(error: DriveServerError): DriveDesktopError { + if (error.cause === 'FILE_TOO_BIG') { + return new DriveDesktopError('FILE_TOO_BIG', error.message); + } + + if (error.cause === 'TOO_MANY_REQUESTS') { + return new DriveDesktopError('RATE_LIMITED', String(parseRetryAfterMs(error.message))); + } + + if (error.cause === 'SERVER_ERROR') { + return new DriveDesktopError('INTERNAL_SERVER_ERROR', error.message); + } + + return new DriveDesktopError('UNKNOWN', error.message); +} diff --git a/src/context/virtual-drive/files/domain/FileSize.test.ts b/src/context/virtual-drive/files/domain/FileSize.test.ts index 7d99f67c5..75777a3eb 100644 --- a/src/context/virtual-drive/files/domain/FileSize.test.ts +++ b/src/context/virtual-drive/files/domain/FileSize.test.ts @@ -1,37 +1,20 @@ +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from '../../../../../src/backend/features/user/file-size-limit'; import { FileSize } from '../../../../../src/context/virtual-drive/files/domain/FileSize'; describe('File Size', () => { - const twentyGB = 20 * 1024 * 1024 * 1024; - - it('can create a file size of 20GB', () => { - try { - new FileSize(twentyGB); - } catch (err) { - expect(err).not.toBeDefined(); - } + it('can create a file size up to the absolute upload limit', () => { + expect(() => new FileSize(ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT)).not.toThrow(); }); it('can create a file size of 0', () => { - try { - new FileSize(0); - } catch (err) { - expect(err).not.toBeDefined(); - } + expect(() => new FileSize(0)).not.toThrow(); }); - it('cannot create a file size of negatives values', () => { - try { - new FileSize(-1); - } catch (err) { - expect(err).toBeDefined(); - } + it('cannot create a file size of negative values', () => { + expect(() => new FileSize(-1)).toThrow(); }); - it('cannot create a file size greater than 20GB', () => { - try { - new FileSize(twentyGB + 1); - } catch (err) { - expect(err).toBeDefined(); - } + it('cannot create a file size greater than the absolute upload limit', () => { + expect(() => new FileSize(ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT + 1)).toThrow(); }); }); diff --git a/src/context/virtual-drive/files/domain/__test-helpers__/FileSizeMother.ts b/src/context/virtual-drive/files/domain/__test-helpers__/FileSizeMother.ts index 3e8d78aee..c06bcbb02 100644 --- a/src/context/virtual-drive/files/domain/__test-helpers__/FileSizeMother.ts +++ b/src/context/virtual-drive/files/domain/__test-helpers__/FileSizeMother.ts @@ -1,10 +1,11 @@ import Chance from 'chance'; import { FileSize } from '../FileSize'; +import { ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT } from '../../../../../backend/features/user/file-size-limit'; const chance = new Chance(); export class FileSizeMother { static random() { - return new FileSize(chance.integer({ min: 0, max: FileSize.MAX_SIZE })); + return new FileSize(chance.integer({ min: 0, max: ABSOLUTE_UPLOAD_FILE_SIZE_LIMIT })); } static primitive(): number { diff --git a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.test.ts b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.test.ts index 4e449856f..e011bf773 100644 --- a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.test.ts +++ b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.test.ts @@ -59,6 +59,16 @@ describe('SDKRemoteFileSystem', () => { expect(result.getLeft().message).toBe('30000'); }); + it('maps FILE_TOO_BIG to FILE_TOO_BIG', async () => { + createFileMock.mockResolvedValue({ error: new DriveServerError('FILE_TOO_BIG', 402, 'too large') }); + + const result = await sut.persist(dataToPersist); + + expect(result.isLeft()).toBe(true); + expect(result.getLeft().cause).toBe('FILE_TOO_BIG'); + expect(result.getLeft().message).toBe('too large'); + }); + it('returns persisted file data on success', async () => { createFileMock.mockResolvedValue({ data: fileResponse }); diff --git a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts index 5d2b714df..a7c2f3c7e 100644 --- a/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts +++ b/src/context/virtual-drive/files/infrastructure/SDKRemoteFileSystem.ts @@ -55,6 +55,9 @@ export class SDKRemoteFileSystem implements RemoteFileSystem { if (errorCause === 'TOO_MANY_REQUESTS') { return left(new DriveDesktopError('RATE_LIMITED', String(parseRetryAfterMs(error.message)))); } + if (errorCause === 'FILE_TOO_BIG') { + return left(new DriveDesktopError('FILE_TOO_BIG', error.message)); + } return left(new DriveDesktopError('UNKNOWN', `Creating file ${plainName}: ${error}`)); } } diff --git a/src/core/electron/paths.ts b/src/core/electron/paths.ts index 491834c6b..0948170ea 100644 --- a/src/core/electron/paths.ts +++ b/src/core/electron/paths.ts @@ -12,6 +12,7 @@ const ROOT_DRIVE_FOLDER = join(HOME_FOLDER_PATH, 'Internxt Drive'); const THUMBNAILS_FOLDER = path.join(os.homedir(), '.cache', 'thumbnails'); const TEMPORAL_FOLDER = app.getPath('temp'); const INTERNXT_DRIVE_TMP = path.join(TEMPORAL_FOLDER, 'internxt-drive-tmp'); +const REJECTED_FILES_SIZE_TOO_BIG = join(INTERNXT, 'rejected-files-size-too-big'); const DOWNLOADED = join(INTERNXT, 'downloaded'); const FUSE_DAEMON_LOG = join(LOGS, 'fuse-daemon.log'); const FUSE_DAEMON_SOCKET = join(process.env.XDG_RUNTIME_DIR ?? '/tmp', 'internxt-fuse.sock'); @@ -30,6 +31,7 @@ export const PATHS = { THUMBNAILS_FOLDER, TEMPORAL_FOLDER, INTERNXT_DRIVE_TMP, + REJECTED_FILES_SIZE_TOO_BIG, ROOT_DRIVE_FOLDER, DOWNLOADED, FUSE_DAEMON_LOG, diff --git a/src/infra/drive-server/drive-server.error.test.ts b/src/infra/drive-server/drive-server.error.test.ts new file mode 100644 index 000000000..85d7eee62 --- /dev/null +++ b/src/infra/drive-server/drive-server.error.test.ts @@ -0,0 +1,7 @@ +import { mapStatusToErrorCause } from './drive-server.error'; + +describe('mapStatusToErrorCause', () => { + it('maps 402 to FILE_TOO_BIG', () => { + expect(mapStatusToErrorCause(402)).toBe('FILE_TOO_BIG'); + }); +}); diff --git a/src/infra/drive-server/drive-server.error.ts b/src/infra/drive-server/drive-server.error.ts index e36b39337..ec501af5b 100644 --- a/src/infra/drive-server/drive-server.error.ts +++ b/src/infra/drive-server/drive-server.error.ts @@ -8,6 +8,7 @@ const DriveServerErrorCauses = [ 'NETWORK_ERROR', 'TOO_MANY_REQUESTS', 'CONFLICT', + 'FILE_TOO_BIG', 'UNKNOWN', ] as const; export type DriveServerErrorCause = (typeof DriveServerErrorCauses)[number]; @@ -26,6 +27,7 @@ export function mapStatusToErrorCause(status: number): DriveServerErrorCause { if (status === 403) return 'FORBIDDEN'; if (status === 404) return 'NOT_FOUND'; if (status === 409) return 'CONFLICT'; + if (status === 402) return 'FILE_TOO_BIG'; if (status === 429) return 'TOO_MANY_REQUESTS'; if (status >= 400 && status < 500) return 'BAD_REQUEST'; if (status >= 500) return 'SERVER_ERROR'; diff --git a/src/infra/drive-server/services/files/services/create-file.test.ts b/src/infra/drive-server/services/files/services/create-file.test.ts index cd457116a..1b6313759 100644 --- a/src/infra/drive-server/services/files/services/create-file.test.ts +++ b/src/infra/drive-server/services/files/services/create-file.test.ts @@ -32,4 +32,13 @@ describe('createFile', () => { expect(result.error).toBe(error); }); + + it('should return FILE_TOO_BIG when the backend rejects the file size', async () => { + const error = new DriveServerError('FILE_TOO_BIG', 402); + driveServerPostMock.mockResolvedValue({ data: undefined, error } as object); + + const result = await createFile({} as CreateFileDto); + + expect(result.error).toBe(error); + }); }); diff --git a/src/infra/drive-server/services/files/services/override-file.test.ts b/src/infra/drive-server/services/files/services/override-file.test.ts index 2ff0d2d38..b89b8da60 100644 --- a/src/infra/drive-server/services/files/services/override-file.test.ts +++ b/src/infra/drive-server/services/files/services/override-file.test.ts @@ -29,4 +29,13 @@ describe('overrideFile', () => { expect(result.error).toBe(error); }); + + it('should return FILE_TOO_BIG when the backend rejects the file size', async () => { + const error = new DriveServerError('FILE_TOO_BIG', 402); + driveServerPutMock.mockResolvedValue({ data: undefined, error } as object); + + const result = await overrideFile({ fileUuid: 'file-uuid', fileContentsId: 'contents-id', fileSize: 1024 }); + + expect(result.error).toBe(error); + }); }); From 29d367ef74a5dfbba92b1b968d5a983244042d82 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Thu, 4 Jun 2026 14:12:11 +0200 Subject: [PATCH 3/5] feat: add prevalidation for backups and error handling uppon 402 from backend --- .../upload/update-file-to-backup.test.ts | 37 +++++++++++++++++ .../backup/upload/update-file-to-backup.ts | 18 +++++++++ .../upload/upload-file-to-backup.test.ts | 40 +++++++++++++++++++ .../backup/upload/upload-file-to-backup.ts | 34 ++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/src/backend/features/backup/upload/update-file-to-backup.test.ts b/src/backend/features/backup/upload/update-file-to-backup.test.ts index 47f79deca..e6355d600 100644 --- a/src/backend/features/backup/upload/update-file-to-backup.test.ts +++ b/src/backend/features/backup/upload/update-file-to-backup.test.ts @@ -7,10 +7,15 @@ import * as overrideFileModule from '../../../../infra/drive-server/services/fil import { BucketEntryIdMother } from '../../../../context/virtual-drive/shared/domain/__test-helpers__/BucketEntryIdMother'; import { UuidMother } from '../../../../context/shared/domain/__test-helpers__/UuidMother'; import { Environment } from '@internxt/inxt-js'; +import configStore from '../../../../apps/main/config'; +import * as maxFileSizeRejectionModule from '../../user/file-size-limit/add-max-file-size-rejection'; + describe('update-file-to-backup', () => { const uploadContentMock = partialSpyOn(uploadContentToEnvironmentModule, 'uploadContentToEnvironment'); const overrideFileMock = partialSpyOn(overrideFileModule, 'overrideFile'); + const configGetMock = partialSpyOn(configStore, 'get'); + const addMaxFileSizeRejectionMock = partialSpyOn(maxFileSizeRejectionModule, 'addMaxFileSizeRejection'); let abortController: AbortController; @@ -25,6 +30,8 @@ describe('update-file-to-backup', () => { beforeEach(() => { abortController = new AbortController(); + configGetMock.mockReturnValue(0); + addMaxFileSizeRejectionMock.mockClear(); }); it('should update file successfully', async () => { @@ -37,6 +44,22 @@ describe('update-file-to-backup', () => { expect(result.error).toBeUndefined(); }); + it('should skip file update when local upload size validation fails', async () => { + configGetMock.mockReturnValue(100); + + const result = await updateFileToBackup({ ...baseParams, size: 101, signal: abortController.signal }); + + expect(result).toStrictEqual({ data: undefined }); + expect(uploadContentMock).not.toHaveBeenCalled(); + expect(overrideFileMock).not.toHaveBeenCalled(); + expect(addMaxFileSizeRejectionMock).toHaveBeenCalledWith({ + path: baseParams.path, + fileSize: 101, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 100, showUpgradeCta: true }, + blockUploadPath: false, + }); + }); + it('should return ABORTED error when signal is already aborted', async () => { abortController.abort(); @@ -76,6 +99,20 @@ describe('update-file-to-backup', () => { expect(result.error?.cause).toBe('UNKNOWN'); }); + it('should skip file when backend rejects override by upload size limit', async () => { + uploadContentMock.mockResolvedValue({ data: BucketEntryIdMother.primitive() }); + overrideFileMock.mockResolvedValue({ error: new DriveServerError('FILE_TOO_BIG', 402) }); + + const result = await updateFileToBackup({ ...baseParams, signal: abortController.signal }); + + expect(result).toStrictEqual({ data: undefined }); + expect(addMaxFileSizeRejectionMock).toHaveBeenCalledWith({ + path: baseParams.path, + fileSize: baseParams.size, + blockUploadPath: false, + }); + }); + it('should return null data when signal is aborted during content upload', async () => { uploadContentMock.mockImplementation(async () => { abortController.abort(); diff --git a/src/backend/features/backup/upload/update-file-to-backup.ts b/src/backend/features/backup/upload/update-file-to-backup.ts index 1d15e2b6c..8486e1149 100644 --- a/src/backend/features/backup/upload/update-file-to-backup.ts +++ b/src/backend/features/backup/upload/update-file-to-backup.ts @@ -5,6 +5,9 @@ import { uploadContentToEnvironment } from './upload-content-to-environment'; import { retryWithBackoff } from '../../../../shared/retry-with-backoff'; import { createTransientErrorHandler } from '../../../../backend/common/rate-limit/transient-error-handler'; import { overrideFileToBackend } from './override-file-to-backend'; +import configStore from '../../../../apps/main/config'; +import { addMaxFileSizeRejection } from '../../user/file-size-limit/add-max-file-size-rejection'; +import { validateUploadFileSize } from '../../user/file-size-limit/validate-upload-file-size'; export type UpdateFileParams = { path: string; @@ -16,6 +19,16 @@ export type UpdateFileParams = { }; async function updateFile(file: UpdateFileParams): Promise> { + const validation = validateUploadFileSize({ + size: file.size, + maxUploadFileSize: configStore.get('maxUploadFileSizeInBytes'), + }); + + if (!validation.allowed) { + addMaxFileSizeRejection({ path: file.path, fileSize: file.size, validation, blockUploadPath: false }); + return { data: undefined }; + } + const { data: contentsId, error } = await retryWithBackoff( () => uploadContentToEnvironment({ @@ -49,6 +62,11 @@ async function updateFile(file: UpdateFileParams): Promise { const uploadContentMock = partialSpyOn(uploadContentModule, 'uploadContentToEnvironment'); const createFileToBackendMock = partialSpyOn(createFileModule, 'createFileToBackend'); const deleteFileMock = partialSpyOn(deleteFileModule, 'deleteFileFromStorageByFileId'); + const configGetMock = partialSpyOn(configStore, 'get'); + const addMaxFileSizeRejectionMock = partialSpyOn(maxFileSizeRejectionModule, 'addMaxFileSizeRejection'); let abortController: AbortController; @@ -26,6 +31,8 @@ describe('upload-file-to-backup', () => { beforeEach(() => { abortController = new AbortController(); + configGetMock.mockReturnValue(0); + addMaxFileSizeRejectionMock.mockClear(); }); it('should upload the file content and create metadata on backend successfully', async () => { @@ -40,6 +47,22 @@ describe('upload-file-to-backup', () => { expect(result.error).toBeUndefined(); }); + it('should skip file upload when local upload size validation fails', async () => { + configGetMock.mockReturnValue(100); + + const result = await uploadFileToBackup({ ...baseParams, size: 101, signal: abortController.signal }); + + expect(result).toStrictEqual({ data: null }); + expect(uploadContentMock).not.toHaveBeenCalled(); + expect(createFileToBackendMock).not.toHaveBeenCalled(); + expect(addMaxFileSizeRejectionMock).toHaveBeenCalledWith({ + path: baseParams.path, + fileSize: 101, + validation: { allowed: false, reason: 'PLAN_LIMIT_EXCEEDED', maxFileSize: 100, showUpgradeCta: true }, + blockUploadPath: false, + }); + }); + it('should return error when content upload fails with a non-retryable error', async () => { const uploadError = new DriveDesktopError('UNKNOWN', 'Upload failed'); uploadContentMock.mockResolvedValue({ error: uploadError }); @@ -73,6 +96,23 @@ describe('upload-file-to-backup', () => { expect(deleteFileMock).toHaveBeenCalledWith({ bucketId: baseParams.bucket, fileId: contentsId }); }); + it('should skip file when backend rejects metadata creation by upload size limit', async () => { + const contentsId = 'contents-id-123'; + uploadContentMock.mockResolvedValue({ data: contentsId }); + createFileToBackendMock.mockResolvedValue({ error: new DriveDesktopError('FILE_TOO_BIG', 'File too big') }); + deleteFileMock.mockResolvedValue({ data: true }); + + const result = await uploadFileToBackup({ ...baseParams, signal: abortController.signal }); + + expect(result).toStrictEqual({ data: null }); + expect(deleteFileMock).toHaveBeenCalledWith({ bucketId: baseParams.bucket, fileId: contentsId }); + expect(addMaxFileSizeRejectionMock).toHaveBeenCalledWith({ + path: baseParams.path, + fileSize: baseParams.size, + blockUploadPath: false, + }); + }); + it('should return null data and skip metadata when signal is aborted during content upload', async () => { const contentsId = 'contents-id-123'; uploadContentMock.mockImplementation(async () => { diff --git a/src/backend/features/backup/upload/upload-file-to-backup.ts b/src/backend/features/backup/upload/upload-file-to-backup.ts index 73eefc9d2..be8771b5c 100644 --- a/src/backend/features/backup/upload/upload-file-to-backup.ts +++ b/src/backend/features/backup/upload/upload-file-to-backup.ts @@ -8,6 +8,9 @@ import { Result } from '../../../../context/shared/domain/Result'; import { deleteFileFromStorageByFileId } from '../../../../infra/drive-server/services/files/services/delete-file-content-from-bucket'; import { retryWithBackoff } from '../../../../shared/retry-with-backoff'; import { createTransientErrorHandler } from '../../../../backend/common/rate-limit/transient-error-handler'; +import configStore from '../../../../apps/main/config'; +import { addMaxFileSizeRejection } from '../../user/file-size-limit/add-max-file-size-rejection'; +import { validateUploadFileSize } from '../../user/file-size-limit/validate-upload-file-size'; export type UploadFileParams = { path: string; @@ -20,6 +23,25 @@ export type UploadFileParams = { }; async function uploadFile(file: UploadFileParams): Promise> { + const validation = validateUploadFileSize({ + size: file.size, + maxUploadFileSize: configStore.get('maxUploadFileSizeInBytes'), + }); + + if (!validation.allowed) { + addMaxFileSizeRejection({ path: file.path, fileSize: file.size, validation, blockUploadPath: false }); + logger.warn({ + tag: 'BACKUPS', + msg: 'Skipping backup file because it exceeds upload size limit', + path: file.path, + size: file.size, + maxFileSize: validation.maxFileSize, + reason: validation.reason, + showUpgradeCta: validation.showUpgradeCta, + }); + return { data: null }; + } + const { data: contentsId, error } = await retryWithBackoff( () => uploadContentToEnvironment({ @@ -57,6 +79,18 @@ async function uploadFile(file: UploadFileParams): Promise Date: Thu, 4 Jun 2026 17:54:30 +0200 Subject: [PATCH 4/5] fix: format --- src/backend/features/backup/upload/update-file-to-backup.test.ts | 1 - src/backend/features/backup/upload/upload-file-to-backup.test.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/backend/features/backup/upload/update-file-to-backup.test.ts b/src/backend/features/backup/upload/update-file-to-backup.test.ts index e6355d600..9d7da6711 100644 --- a/src/backend/features/backup/upload/update-file-to-backup.test.ts +++ b/src/backend/features/backup/upload/update-file-to-backup.test.ts @@ -10,7 +10,6 @@ import { Environment } from '@internxt/inxt-js'; import configStore from '../../../../apps/main/config'; import * as maxFileSizeRejectionModule from '../../user/file-size-limit/add-max-file-size-rejection'; - describe('update-file-to-backup', () => { const uploadContentMock = partialSpyOn(uploadContentToEnvironmentModule, 'uploadContentToEnvironment'); const overrideFileMock = partialSpyOn(overrideFileModule, 'overrideFile'); diff --git a/src/backend/features/backup/upload/upload-file-to-backup.test.ts b/src/backend/features/backup/upload/upload-file-to-backup.test.ts index af98fe984..a2c6e906e 100644 --- a/src/backend/features/backup/upload/upload-file-to-backup.test.ts +++ b/src/backend/features/backup/upload/upload-file-to-backup.test.ts @@ -9,7 +9,6 @@ import { Environment } from '@internxt/inxt-js'; import configStore from '../../../../apps/main/config'; import * as maxFileSizeRejectionModule from '../../user/file-size-limit/add-max-file-size-rejection'; - describe('upload-file-to-backup', () => { const uploadContentMock = partialSpyOn(uploadContentModule, 'uploadContentToEnvironment'); const createFileToBackendMock = partialSpyOn(createFileModule, 'createFileToBackend'); From 7a2f7ab41f77c4504175be4e0004857ca089a842 Mon Sep 17 00:00:00 2001 From: AlexisMora Date: Fri, 5 Jun 2026 18:08:14 +0200 Subject: [PATCH 5/5] fix: solve pr comments --- .../preserve-rejected-file-size-too-big.test.ts | 9 +++++++-- .../preserve-rejected-file-size-too-big.ts | 5 ++--- .../services/operations/write.service.test.ts | 1 - .../application/upload/TemporalFileUploader.test.ts | 1 - .../create/CreateFileOnOfflineFileUploaded.test.ts | 1 - 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts index 2e396230c..42d9d60ce 100644 --- a/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts +++ b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.test.ts @@ -1,6 +1,7 @@ import { constants } from 'node:fs'; import { copyFile, mkdir } from 'node:fs/promises'; import path from 'node:path'; +import { randomInt } from 'node:crypto'; import { copyWithoutOverwriting, createCopyPath, @@ -14,8 +15,13 @@ vi.mock('node:fs/promises', () => ({ mkdir: vi.fn().mockResolvedValue(undefined), })); +vi.mock('node:crypto', () => ({ + randomInt: vi.fn(() => 123456), +})); + const copyFileMock = vi.mocked(copyFile); const mkdirMock = vi.mocked(mkdir); +const randomIntMock = vi.mocked(randomInt as (max: number) => number); const recoveryRoot = '/rejected-files-size-too-big'; const temporalContentPath = '/tmp/internxt-drive-tmp/temporal-content'; @@ -24,6 +30,7 @@ afterEach(() => { vi.clearAllMocks(); copyFileMock.mockResolvedValue(undefined); mkdirMock.mockResolvedValue(undefined); + randomIntMock.mockReturnValue(123456); }); describe('preserve-rejected-file-size-too-big', () => { @@ -136,7 +143,6 @@ describe('preserve-rejected-file-size-too-big', () => { it('should use a timestamp and random number on the last copy attempt', async () => { vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171); - vi.spyOn(Math, 'random').mockReturnValue(0.123456); let calls = 0; copyFileMock.mockImplementation(async () => { calls += 1; @@ -167,7 +173,6 @@ describe('preserve-rejected-file-size-too-big', () => { describe('createLastResortCopyPath', () => { it('should append a timestamp and random number before the file extension', () => { vi.spyOn(Date, 'now').mockReturnValue(1_717_171_717_171); - vi.spyOn(Math, 'random').mockReturnValue(0.123456); expect(createLastResortCopyPath({ targetPath: path.join(recoveryRoot, 'fotos', 'photo.jpg') })).toBe( path.join(recoveryRoot, 'fotos', 'photo (copy 1717171717171-123456).jpg'), diff --git a/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts index ee67b4ff7..8c362bdb2 100644 --- a/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts +++ b/src/backend/features/user/file-size-limit/rejected-file-size-too-big/preserve-rejected-file-size-too-big.ts @@ -1,6 +1,7 @@ import { constants } from 'node:fs'; import { copyFile, mkdir } from 'node:fs/promises'; import path from 'node:path'; +import { randomInt } from 'node:crypto'; import { PATHS } from '../../../../../core/electron/paths'; import { Result } from '../../../../../context/shared/domain/Result'; @@ -115,9 +116,7 @@ export function createCopyPath({ targetPath, copyNumber }: { targetPath: string; export function createLastResortCopyPath({ targetPath }: { targetPath: string }): string { const parsedPath = path.parse(targetPath); const timestamp = Date.now(); - const randomNumber = Math.floor(Math.random() * 1_000_000); - - return path.join(parsedPath.dir, `${parsedPath.name} (copy ${timestamp}-${randomNumber})${parsedPath.ext}`); + return path.join(parsedPath.dir, `${parsedPath.name} (copy ${timestamp}-${randomInt(1_000_000)})${parsedPath.ext}`); } function isFileAlreadyExistsError(error: unknown): boolean { diff --git a/src/backend/features/virtual-drive/services/operations/write.service.test.ts b/src/backend/features/virtual-drive/services/operations/write.service.test.ts index f15c7090c..024570841 100644 --- a/src/backend/features/virtual-drive/services/operations/write.service.test.ts +++ b/src/backend/features/virtual-drive/services/operations/write.service.test.ts @@ -27,7 +27,6 @@ describe('write', () => { const temporalFileCreator = mockDeep(); beforeEach(() => { - vi.clearAllMocks(); configGetMock.mockReturnValue(100); container = mockDeep(); container.get.calledWith(TemporalFileWriter).mockReturnValue(temporalFileWriter); diff --git a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts index dda83ff03..90d96b98c 100644 --- a/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts +++ b/src/context/storage/TemporalFiles/application/upload/TemporalFileUploader.test.ts @@ -29,7 +29,6 @@ describe('TemporalFileUploader', () => { const stopWatching = vi.fn(); beforeEach(() => { - vi.resetAllMocks(); repository.watchFile.mockReturnValue(stopWatching); eventBus.publish.mockResolvedValue(undefined); uploaderFactory.read.mockReturnValue(uploaderFactory); diff --git a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts index c3be4779f..482ecfbcd 100644 --- a/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts +++ b/src/context/virtual-drive/files/application/create/CreateFileOnOfflineFileUploaded.test.ts @@ -31,7 +31,6 @@ describe('Create File On Offline File Uploaded', () => { afterEach(() => { clearMaxFileSizeRejectionModal(); - vi.clearAllMocks(); }); it('creates a new file when event replaces field is undefined', async () => {