Skip to content
Open
Show file tree
Hide file tree
Changes from all 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