Skip to content
Merged
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
3 changes: 2 additions & 1 deletion src/apps/main/interface.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { StoredValues } from './config/service.types';
import { AppStore } from './config';
import { ConfigTheme } from '../shared/types/Theme';
import { UserNotification } from '../../infra/drive-server/out/dto';
import { ChooseSyncRootResult } from './virtual-root-folder/service';

/** This interface and declare global will replace the preload.d.ts.
* The thing is that instead of that, we will gradually will be declaring the interface here as we generate tests
Expand Down Expand Up @@ -151,7 +152,7 @@ export interface IElectronAPI {
cancelScan: () => Promise<void>;
};
getVirtualDriveRoot(): Promise<string>;
chooseSyncRootWithDialog(): Promise<string | null>;
chooseSyncRootWithDialog(): Promise<ChooseSyncRootResult>;
getBackupErrorByFolder(folderId: number): Promise<BackupErrorRecord | undefined>;
getLastBackupHadIssues(): Promise<boolean>;
onBackupFatalErrorsChanged(fn: (backupErrors: Array<BackupErrorRecord>) => void): () => void;
Expand Down
94 changes: 93 additions & 1 deletion src/apps/main/virtual-root-folder/service.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { dialog } from 'electron';
import configStore, { type AppStore } from '../config';
import eventBus from '../event-bus';
import * as validateRootFolderChangeModule from './validate-root-folder-change';
import { chooseSyncRootWithDialog, getRootVirtualDrive } from './service';

vi.mock('electron', () => ({
Expand Down Expand Up @@ -36,10 +37,17 @@ vi.mock('../../shared/fs/ensure-folder-exists', () => ({
ensureFolderExists: vi.fn(),
}));

vi.mock('./validate-root-folder-change', () => ({
validateRootFolderChange: vi.fn(),
isPermissionError: vi.fn(),
}));

describe('service', () => {
const configGetMock = vi.mocked(configStore.get);
const configSetMock = vi.mocked(configStore.set);
const eventBusEmitMock = vi.mocked(eventBus.emit);
const validateRootFolderChangeMock = vi.mocked(validateRootFolderChangeModule.validateRootFolderChange);
const isPermissionErrorMock = vi.mocked(validateRootFolderChangeModule.isPermissionError);

beforeEach(() => {
const state = new Map<keyof AppStore, AppStore[keyof AppStore]>([
Expand All @@ -51,6 +59,9 @@ describe('service', () => {
configSetMock.mockImplementation((key, value) => {
state.set(key, value);
});

validateRootFolderChangeMock.mockResolvedValue(null);
isPermissionErrorMock.mockReturnValue(false);
});

it('should fallback to default root folder when no saved path exists', () => {
Expand Down Expand Up @@ -90,10 +101,91 @@ describe('service', () => {

const selectedPath = await chooseSyncRootWithDialog();

expect(selectedPath).toBe('/new/root/Internxt Drive/');
expect(selectedPath).toStrictEqual({ status: 'success', path: '/new/root/Internxt Drive/' });
expect(eventBusEmitMock).toHaveBeenCalledWith('SYNC_ROOT_CHANGED', {
oldPath: '/old/root/Internxt Drive/',
newPath: '/new/root/Internxt Drive/',
});
});

it('should return cancelled result when user dismisses folder picker', async () => {
vi.mocked(dialog.showOpenDialog).mockResolvedValue({
canceled: true,
filePaths: [],
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);

const selectedPath = await chooseSyncRootWithDialog();

expect(selectedPath).toStrictEqual({ status: 'cancelled' });
expect(validateRootFolderChangeMock).not.toHaveBeenCalled();
expect(eventBusEmitMock).not.toHaveBeenCalled();
});

it('should return validation error from validateRootFolderChange', async () => {
vi.mocked(dialog.showOpenDialog).mockResolvedValue({
canceled: false,
filePaths: ['/media/user/usb'],
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);
validateRootFolderChangeMock.mockResolvedValueOnce({
status: 'error',
code: 'REMOVABLE_DEVICE',
});

const selectedPath = await chooseSyncRootWithDialog();

expect(selectedPath).toStrictEqual({ status: 'error', code: 'REMOVABLE_DEVICE' });
expect(eventBusEmitMock).not.toHaveBeenCalled();
});

it('should not emit SYNC_ROOT_CHANGED when resulting mount path does not change', async () => {
const state = new Map<keyof AppStore, AppStore[keyof AppStore]>([
['virtualDriveRoot', '/old/root/'],
['lastSavedListing', ''],
]);

configGetMock.mockImplementation((key) => state.get(key) as AppStore[typeof key]);
configSetMock.mockImplementation((key, value) => {
state.set(key, value);
});

vi.mocked(dialog.showOpenDialog).mockResolvedValue({
canceled: false,
filePaths: ['/old/root'],
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);

const selectedPath = await chooseSyncRootWithDialog();

expect(selectedPath).toStrictEqual({ status: 'success', path: '/old/root/Internxt Drive/' });
expect(eventBusEmitMock).not.toHaveBeenCalled();
});

it('should return INSUFFICIENT_PERMISSION when an error is caught and identified as permission error', async () => {
vi.mocked(dialog.showOpenDialog).mockResolvedValue({
canceled: false,
filePaths: ['/restricted/path'],
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);

validateRootFolderChangeMock.mockRejectedValueOnce(new Error('permission denied'));
isPermissionErrorMock.mockReturnValueOnce(true);

const selectedPath = await chooseSyncRootWithDialog();

expect(selectedPath).toStrictEqual({ status: 'error', code: 'INSUFFICIENT_PERMISSION' });
expect(eventBusEmitMock).not.toHaveBeenCalled();
});

it('should return UNKNOWN when an unknown error is caught', async () => {
vi.mocked(dialog.showOpenDialog).mockResolvedValue({
canceled: false,
filePaths: ['/problematic/path'],
} as Awaited<ReturnType<typeof dialog.showOpenDialog>>);

validateRootFolderChangeMock.mockRejectedValueOnce(new Error('unexpected failure'));
isPermissionErrorMock.mockReturnValueOnce(false);

const selectedPath = await chooseSyncRootWithDialog();

expect(selectedPath).toStrictEqual({ status: 'error', code: 'UNKNOWN' });
expect(eventBusEmitMock).not.toHaveBeenCalled();
});
});
32 changes: 27 additions & 5 deletions src/apps/main/virtual-root-folder/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ import eventBus from '../event-bus';
import { exec } from 'node:child_process';
import { ensureFolderExists } from '../../shared/fs/ensure-folder-exists';
import { PATHS } from '../../../core/electron/paths';
import { ChooseSyncRootFailureCode, isPermissionError, validateRootFolderChange } from './validate-root-folder-change';

const VIRTUAL_DRIVE_FOLDER = PATHS.ROOT_DRIVE_FOLDER;
const VIRTUAL_DRIVE_FOLDER_NAME = PATHS.VIRTUAL_DRIVE_FOLDER_NAME;

export type ChooseSyncRootResult =
| { status: 'cancelled' }
| { status: 'success'; path: string }
| { status: 'error'; code: ChooseSyncRootFailureCode };

export async function clearDirectory(pathname: string): Promise<boolean> {
try {
await fs.rm(pathname, { recursive: true });
Expand Down Expand Up @@ -57,12 +63,24 @@ export function getRootVirtualDrive(): string {
return normalizePathname(mountPath);
}

export async function chooseSyncRootWithDialog(): Promise<string | null> {
export async function chooseSyncRootWithDialog(): Promise<ChooseSyncRootResult> {
const previousPath = getRootVirtualDrive();
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });

if (!result.canceled) {
if (result.canceled) {
return { status: 'cancelled' };
}

try {
const chosenPath = result.filePaths[0];
const validationResult = await validateRootFolderChange({
pathname: chosenPath,
virtualDriveFolderName: VIRTUAL_DRIVE_FOLDER_NAME,
});

if (validationResult) {
return validationResult;
}

setupRootFolder(chosenPath);
const nextPath = getRootVirtualDrive();
Expand All @@ -71,10 +89,14 @@ export async function chooseSyncRootWithDialog(): Promise<string | null> {
eventBus.emit('SYNC_ROOT_CHANGED', { oldPath: previousPath, newPath: nextPath });
}

return nextPath;
}
return { status: 'success', path: nextPath };
} catch (error) {
if (isPermissionError(error)) {
return { status: 'error', code: 'INSUFFICIENT_PERMISSION' };
}

return null;
return { status: 'error', code: 'UNKNOWN' };
}
}

export async function openVirtualDriveRootFolder() {
Expand Down
131 changes: 131 additions & 0 deletions src/apps/main/virtual-root-folder/validate-root-folder-change.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import fs from 'node:fs/promises';
import {
isPermissionError,
OTHER_CLOUD_PROVIDER_KEYWORDS,
REMOVABLE_PATH_PREFIXES,
validateRootFolderChange,
} from './validate-root-folder-change';

vi.mock('node:fs/promises', () => ({
default: {
access: vi.fn(),
mkdir: vi.fn(),
},
}));

describe('validate-root-folder-change', () => {
const fsAccessMock = vi.mocked(fs.access);
const fsMkdirMock = vi.mocked(fs.mkdir);

beforeEach(() => {
fsAccessMock.mockResolvedValue(undefined);
fsMkdirMock.mockResolvedValue(undefined);
});

it('should expose removable path prefixes', () => {
expect(REMOVABLE_PATH_PREFIXES).toMatchObject(['/media/', '/run/media/', '/mnt/']);
});

it('should expose known cloud provider keywords', () => {
expect(OTHER_CLOUD_PROVIDER_KEYWORDS).toContain('dropbox');
expect(OTHER_CLOUD_PROVIDER_KEYWORDS).toContain('google drive');
expect(OTHER_CLOUD_PROVIDER_KEYWORDS).toContain('onedrive');
});

it.each(['/media/user/usb', '/run/media/user/ssd', '/mnt/external'])(
'should return REMOVABLE_DEVICE for removable path %s',
async (pathname) => {
const result = await validateRootFolderChange({
pathname,
virtualDriveFolderName: 'Internxt Drive',
});

expect(result).toStrictEqual({ status: 'error', code: 'REMOVABLE_DEVICE' });
expect(fsAccessMock).not.toHaveBeenCalled();
expect(fsMkdirMock).not.toHaveBeenCalled();
},
);

it('should return OTHER_CLOUD_PROVIDER when pathname contains cloud provider keyword', async () => {
const result = await validateRootFolderChange({
pathname: '/home/user/Dropbox/Work',
virtualDriveFolderName: 'Internxt Drive',
});

expect(result).toStrictEqual({ status: 'error', code: 'OTHER_CLOUD_PROVIDER' });
expect(fsAccessMock).not.toHaveBeenCalled();
expect(fsMkdirMock).not.toHaveBeenCalled();
});

it('should match cloud provider keyword case-insensitively', async () => {
const result = await validateRootFolderChange({
pathname: '/home/user/Google Drive/My Folder',
virtualDriveFolderName: 'Internxt Drive',
});

expect(result).toStrictEqual({ status: 'error', code: 'OTHER_CLOUD_PROVIDER' });
});

it.each(['EACCES', 'EPERM'])('should return INSUFFICIENT_PERMISSION when access fails with %s', async (code) => {
fsAccessMock.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code }));

const result = await validateRootFolderChange({
pathname: '/home/user/folder',
virtualDriveFolderName: 'Internxt Drive',
});

expect(result).toStrictEqual({ status: 'error', code: 'INSUFFICIENT_PERMISSION' });
});

it('should return INSUFFICIENT_PERMISSION when mount folder creation fails with permission error', async () => {
fsMkdirMock.mockRejectedValueOnce(Object.assign(new Error('permission denied'), { code: 'EACCES' }));

const result = await validateRootFolderChange({
pathname: '/home/user/folder',
virtualDriveFolderName: 'Internxt Drive',
});

expect(result).toStrictEqual({ status: 'error', code: 'INSUFFICIENT_PERMISSION' });
});

it('should throw when fs fails with unknown error', async () => {
const unknownError = Object.assign(new Error('disk failure'), { code: 'EIO' });
fsAccessMock.mockRejectedValueOnce(unknownError);

await expect(
validateRootFolderChange({
pathname: '/home/user/folder',
virtualDriveFolderName: 'Internxt Drive',
}),
).rejects.toThrow('disk failure');
});

it('should return null when pathname is valid and accessible', async () => {
const result = await validateRootFolderChange({
pathname: '/home/user/folder',
virtualDriveFolderName: 'Internxt Drive',
});

expect(result).toBe(null);
expect(fsAccessMock).toHaveBeenCalledTimes(2);
expect(fsMkdirMock).toHaveBeenCalledWith('/home/user/folder/Internxt Drive', { recursive: true });
});

it.each(['EACCES', 'EPERM'])('isPermissionError should return true for %s', (code) => {
const result = isPermissionError(Object.assign(new Error('permission denied'), { code }));

expect(result).toBe(true);
});

it('isPermissionError should return false for unknown code', () => {
const result = isPermissionError(Object.assign(new Error('other'), { code: 'ENOENT' }));

expect(result).toBe(false);
});

it('isPermissionError should return false for non-objects', () => {
expect(isPermissionError(null)).toBe(false);
expect(isPermissionError(undefined)).toBe(false);
expect(isPermissionError('error')).toBe(false);
});
});
Loading
Loading