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:
1 parent
56e5db2059
commit
2e78dfef1e
12 files changed
+144
-2
No files matched your search
@@ -70,6 +70,7 @@ class ServerResource extends ClientResource
|
||||
'name' => $server->name,
|
||||
'node' => $server->node->name,
|
||||
'is_node_under_maintenance' => $server->node->isUnderMaintenance(),
|
||||
'upload_size' => $server->node->upload_size,
|
||||
'sftp_details' => [
|
||||
'ip' => $server->node->fqdn,
|
||||
'port' => $server->node->daemonSFTP,
|
||||
|
||||
@@ -1231,6 +1231,7 @@ export type ClientServerAttributes = {
|
||||
name: string;
|
||||
node: string;
|
||||
is_node_under_maintenance: boolean;
|
||||
upload_size: number;
|
||||
sftp_details: {
|
||||
ip: string;
|
||||
port: number;
|
||||
|
||||
@@ -1364,6 +1364,7 @@ export type ClientServerAttributes = {
|
||||
name: string;
|
||||
node: string;
|
||||
is_node_under_maintenance: boolean;
|
||||
upload_size: number;
|
||||
sftp_details: {
|
||||
ip: string;
|
||||
port: number;
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function createTestServer(options: TestServerOptions = {}): Server {
|
||||
name: options.name ?? 'Test server',
|
||||
node: 'Test node',
|
||||
is_node_under_maintenance: false,
|
||||
upload_size: 100,
|
||||
sftp_details: { ip: '127.0.0.1', port: 2022 },
|
||||
description: null,
|
||||
limits: { memory: 1024, swap: 0, disk: 1024, io: 500, cpu: 100, threads: null, oom_disabled: true },
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -420,6 +421,7 @@
|
||||
"name": "Admin Lab",
|
||||
"node": "node-beta",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 256,
|
||||
"sftp_details": {
|
||||
"ip": "beta.example.test",
|
||||
"port": 2222
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -200,6 +201,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -106,6 +107,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -203,6 +205,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -291,6 +294,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"name": "Admin Lab",
|
||||
"node": "node-beta",
|
||||
"is_node_under_maintenance": true,
|
||||
"upload_size": 256,
|
||||
"sftp_details": {
|
||||
"ip": "beta.example.test",
|
||||
"port": 2222
|
||||
@@ -131,6 +132,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -237,6 +239,7 @@
|
||||
"name": "Creative",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -338,6 +341,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -451,6 +455,7 @@
|
||||
"name": "Creative",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -561,6 +566,7 @@
|
||||
"name": "Admin Lab",
|
||||
"node": "node-beta",
|
||||
"is_node_under_maintenance": true,
|
||||
"upload_size": 256,
|
||||
"sftp_details": {
|
||||
"ip": "beta.example.test",
|
||||
"port": 2222
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -123,6 +124,7 @@
|
||||
"name": "Creative",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -224,6 +226,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -391,6 +394,7 @@
|
||||
"name": "Creative",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -521,6 +525,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -648,6 +653,7 @@
|
||||
"name": "Creative",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -771,6 +777,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -905,6 +912,7 @@
|
||||
"name": "Creative",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -130,6 +131,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -253,6 +255,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
@@ -423,6 +426,7 @@
|
||||
"name": "Survival",
|
||||
"node": "node-alpha",
|
||||
"is_node_under_maintenance": false,
|
||||
"upload_size": 100,
|
||||
"sftp_details": {
|
||||
"ip": "alpha.example.test",
|
||||
"port": 2022
|
||||
|
||||
Reference in new issue
Block a user