Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/backend/common/rate-limit/transient-error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SyncError } from '../../../../shared/issues/SyncErrorCause';
const causeMap: Record<string, SyncError> = {
SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
TOO_MANY_REQUESTS: 'RATE_LIMITED',
FILE_TOO_BIG: 'FILE_TOO_BIG',
};

export async function overrideFileToBackend(params: OverrideFileProps): Promise<Result<void, DriveDesktopError>> {
Expand Down
36 changes: 36 additions & 0 deletions src/backend/features/backup/upload/update-file-to-backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ 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;

Expand All @@ -25,6 +29,8 @@ describe('update-file-to-backup', () => {

beforeEach(() => {
abortController = new AbortController();
configGetMock.mockReturnValue(0);
addMaxFileSizeRejectionMock.mockClear();
});

it('should update file successfully', async () => {
Expand All @@ -37,6 +43,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();

Expand Down Expand Up @@ -76,6 +98,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();
Expand Down
18 changes: 18 additions & 0 deletions src/backend/features/backup/upload/update-file-to-backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +19,16 @@ export type UpdateFileParams = {
};

async function updateFile(file: UpdateFileParams): Promise<Result<void, DriveDesktopError>> {
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({
Expand Down Expand Up @@ -49,6 +62,11 @@ async function updateFile(file: UpdateFileParams): Promise<Result<void, DriveDes
);

if (overrideResult.error) {
if (overrideResult.error.cause === 'FILE_TOO_BIG') {
addMaxFileSizeRejection({ path: file.path, fileSize: file.size, blockUploadPath: false });
return { data: undefined };
}

return { error: overrideResult.error };
}

Expand Down
39 changes: 39 additions & 0 deletions src/backend/features/backup/upload/upload-file-to-backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ import * as createFileModule from './create-file-to-backend';
import * as deleteFileModule from '../../../../infra/drive-server/services/files/services/delete-file-content-from-bucket';
import { uploadFileToBackup, UploadFileParams } from './upload-file-to-backup';
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');
const deleteFileMock = partialSpyOn(deleteFileModule, 'deleteFileFromStorageByFileId');
const configGetMock = partialSpyOn(configStore, 'get');
const addMaxFileSizeRejectionMock = partialSpyOn(maxFileSizeRejectionModule, 'addMaxFileSizeRejection');

let abortController: AbortController;

Expand All @@ -26,6 +30,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 () => {
Expand All @@ -40,6 +46,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 });
Expand Down Expand Up @@ -73,6 +95,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 () => {
Expand Down
34 changes: 34 additions & 0 deletions src/backend/features/backup/upload/upload-file-to-backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +23,25 @@ export type UploadFileParams = {
};

async function uploadFile(file: UploadFileParams): Promise<Result<File | null, DriveDesktopError>> {
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({
Expand Down Expand Up @@ -57,6 +79,18 @@ async function uploadFile(file: UploadFileParams): Promise<Result<File | null, D

if (metadataResult.error) {
await deleteFileFromStorageByFileId({ bucketId: file.bucket, fileId: contentsId });

if (metadataResult.error.cause === 'FILE_TOO_BIG') {
addMaxFileSizeRejection({ path: file.path, fileSize: file.size, blockUploadPath: false });
logger.warn({
tag: 'BACKUPS',
msg: 'Skipping backup file because backend rejected it by upload size limit',
path: file.path,
size: file.size,
});
return { data: null };
}

return { error: metadataResult.error };
}

Expand Down
Loading
Loading