fix(recipes): make batch-import directory browser work on Windows (#1106)

The browse endpoint and its frontend were written with POSIX-only
assumptions, so on Windows pressing Browse immediately failed with
"Access denied to this directory":

- The frontend opened the browser at "/", which resolves to the
  current drive root on Windows.
- The allowlist check used Path("/"), which has no drive letter on
  Windows, so relative_to() rejected every drive-qualified path —
  anything outside the user profile was denied.

Fixes:
- Empty browse path now defaults to the user home directory instead of
  erroring; the frontend sends "" rather than the POSIX-only "/".
- The access check is platform-aware (drive-qualified on Windows,
  absolute on POSIX).
- Parent navigation uses the server-provided parent_path; the root
  check is now path.parent == path (the old str/anchor comparison
  self-looped at Windows drive roots).
- Browsing up from a Windows drive root shows a virtual list of
  available drives so users can switch drives without typing a path.
This commit is contained in:
Will Miao
2026-09-11 22:23:24 +08:00
parent 3112869a21
commit 91b2735dad
4 changed files with 297 additions and 43 deletions
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderTemplate } from '../utils/domFixtures.js';
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (key, params = {}, fallback = null) => fallback ?? key,
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
WS_ENDPOINTS: {},
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getStorageItem: vi.fn(() => true),
setStorageItem: vi.fn(),
}));
describe('BatchImportManager directory browser (#1106)', () => {
let batchImportManager;
let fetchMock;
let showToast;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
document.body.innerHTML = '';
renderTemplate('components/batch_import_modal.html');
fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ success: true }),
}));
vi.stubGlobal('fetch', fetchMock);
const uiHelpers = await import('../../../static/js/utils/uiHelpers.js');
showToast = uiHelpers.showToast;
const batchModule = await import('../../../static/js/managers/BatchImportManager.js');
batchImportManager = new batchModule.BatchImportManager();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function lastRequestBody() {
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
}
it('opens the browser with an empty path so the server picks the default', async () => {
// The old POSIX-only "/" initial path fails the access check on Windows.
document.getElementById('batchDirectoryInput').value = '';
batchImportManager.toggleDirectoryBrowser();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(lastRequestBody().path).toBe('');
expect(document.getElementById('batchDirectoryBrowser').style.display).toBe('block');
});
it('navigates to the parent using the server-provided path (Windows-safe)', async () => {
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({
success: true,
current_path: 'C:\\Users\\miao\\Pictures',
parent_path: 'C:\\Users\\miao',
directories: [],
image_files: [],
image_count: 0,
directory_count: 0,
}),
}));
await batchImportManager.loadDirectory('C:\\Users\\miao\\Pictures');
fetchMock.mockClear();
batchImportManager.navigateToParentDirectory();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(lastRequestBody().path).toBe('C:\\Users\\miao');
});
it('refuses to select a virtual level that has no current path (drive list)', async () => {
fetchMock.mockImplementation(async () => ({
ok: true,
status: 200,
json: async () => ({
success: true,
current_path: '',
parent_path: null,
directories: [{ name: 'C:\\', path: 'C:\\', is_parent: false }],
image_files: [],
image_count: 0,
directory_count: 1,
}),
}));
await batchImportManager.loadDirectory('__drives__');
batchImportManager.selectCurrentDirectory();
expect(showToast).toHaveBeenCalledWith('toast.recipes.batchImportNoDirectory', {}, 'error');
expect(document.getElementById('batchDirectoryInput').value).toBe('');
});
});