Validate upload size against the node limit before uploading

Expose the node's upload_size on the client server resource and reject
files over it in the file manager before any bytes are sent.
This commit is contained in:
dualfroz committed 2026-10-14 18:16:57 +02:00
1 parent 56e5db2059
commit 2e78dfef1e
12 files changed
+144 -2

No files matched your search

@@ -0,0 +1,97 @@
/** @vitest-environment jsdom */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, cleanup, renderHook } from '@testing-library/react';
import type { PropsWithChildren } from 'react';
import { toast } from 'sonner';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type * as FileQueries from '@/api/server/files/queries';
import { serverQueryOptions } from '@/api/server/queries';
import type * as RouterParams from '@/router/params';
import type * as ServerState from '@/state/server';
import { Provider } from '@/state/server';
import { createTestServer } from '@/views/fixtures';
import useFileUploader from './useFileUploader';
const upload = vi.hoisted(() => vi.fn());
vi.mock('@/router/params', async (importOriginal) => ({
...(await importOriginal<typeof RouterParams>()),
useServerRouteId: () => 'test-server',
}));
vi.mock('@/state/server', async (importOriginal) => ({
...(await importOriginal<typeof ServerState>()),
useServerDirectory: () => '/',
}));
vi.mock('@/api/server/files/queries', async (importOriginal) => ({
...(await importOriginal<typeof FileQueries>()),
useUploadFiles: () => ({ mutateAsync: upload }),
}));
const mebibyte = 1024 * 1024;
const server = createTestServer();
let client: QueryClient;
const file = (name: string, size: number) => new File([new Uint8Array(size)], name, { type: 'application/zip' });
const renderUploader = () =>
renderHook(() => useFileUploader(), {
wrapper: ({ children }: PropsWithChildren) => (
<QueryClientProvider client={client}>
<Provider>{children}</Provider>
</QueryClientProvider>
),
});
beforeEach(() => {
client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
client.setQueryData(serverQueryOptions('test-server').queryKey, {
...server,
attributes: { ...server.attributes, upload_size: 1 },
});
upload.mockReset().mockResolvedValue({ uploaded: [], cancelled: [], failed: [] });
});
afterEach(() => {
cleanup();
client.clear();
vi.restoreAllMocks();
});
it('refuses a file over the node upload limit before sending anything', async () => {
const error = vi.spyOn(toast, 'error');
const { result } = renderUploader();
await act(async () => {
await expect(result.current([file('small.zip', mebibyte), file('world.zip', mebibyte + 1)])).rejects.toThrow(
'world.zip is larger than the 1 MiB upload limit.'
);
});
expect(error).toHaveBeenCalledExactlyOnceWith('world.zip is larger than the 1 MiB upload limit.');
expect(upload).not.toHaveBeenCalled();
});
it('counts every file over the limit', async () => {
const error = vi.spyOn(toast, 'error');
const { result } = renderUploader();
await act(async () => {
await expect(
result.current([file('a.zip', 2 * mebibyte), file('b.zip', 10), file('c.zip', 3 * mebibyte)])
).rejects.toThrow('2 files are larger than the 1 MiB upload limit.');
});
expect(error).toHaveBeenCalledExactlyOnceWith('2 files are larger than the 1 MiB upload limit.');
expect(upload).not.toHaveBeenCalled();
});
it('uploads files up to the limit', async () => {
const error = vi.spyOn(toast, 'error');
const files = [file('a.zip', mebibyte), file('b.zip', 10)];
const { result } = renderUploader();
await act(() => result.current(files));
expect(error).not.toHaveBeenCalled();
expect(upload).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ uuid: server.attributes.uuid, files }));
});
@@ -1,16 +1,19 @@
import { useCallback, useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useCurrentServerUuid } from '@/api/server/queries';
import { useCurrentServer, useCurrentServerUuid, type Server } from '@/api/server/queries';
import { useUploadFiles } from '@/api/server/files/queries';
import { useFileUploadActions, useServerDirectory } from '@/state/server';
const UPLOADED_DISPLAY_MS = 500;
const selectUploadSize = (server: Server) => server.attributes.upload_size;
/** Uploads into the current directory; rejects when any file failed, after its error toast. */
export default function useFileUploader(): (files: readonly File[]) => Promise<void> {
const removals = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const { mutateAsync } = useUploadFiles();
const uuid = useCurrentServerUuid()!;
const uploadSize = useCurrentServer(selectUploadSize)!;
const directory = useServerDirectory();
const { removeFileUpload, pushFileUpload, setUploadProgress } = useFileUploadActions();
@@ -34,6 +37,18 @@ export default function useFileUploader(): (files: readonly File[]) => Promise<v
throw new Error('Folder uploads are not supported.');
}
const oversized = files.filter((file) => file.size > uploadSize * 1024 * 1024);
if (oversized.length > 0) {
const message =
oversized.length === 1
? `${oversized[0].name} is larger than the ${uploadSize} MiB upload limit.`
: `${oversized.length} files are larger than the ${uploadSize} MiB upload limit.`;
toast.error(message);
throw new Error(message);
}
const { failed } = await mutateAsync({
uuid,
directory,
@@ -68,6 +83,6 @@ export default function useFileUploader(): (files: readonly File[]) => Promise<v
);
}
},
[mutateAsync, uuid, directory, removeFileUpload, pushFileUpload, setUploadProgress]
[mutateAsync, uuid, uploadSize, directory, removeFileUpload, pushFileUpload, setUploadProgress]
);
}