mediaDrop/tests/upload.test.ts
Nicolas 64a38b1e41
All checks were successful
Publier l'image Docker / test (push) Successful in 17s
Publier l'image Docker / publish (push) Successful in 11s
Upload fragmenté : pool de connexions partagé et configurable
Remplace les 3 workers par fichier par un pool global de morceaux en vol,
réparti équitablement entre les fichiers fragmentés actifs : 1 fichier
utilise tout le pool, N fichiers en reçoivent une part chacun
(ceil(taille / N)), la capacité libérée par un fichier terminé est
redistribuée. Un fichier en cours d'init compte dans la part pour ne pas
être affamé ; les morceaux déjà en vol ne sont jamais interrompus.

La taille du pool est réglable via `chunkPoolSize` (config.json, défaut
30, borné à 256) et transmise au navigateur par un nouveau `GET /config`.

Corps des morceaux repassé en streaming (writeChunkAt sur un flux + garde
`maxBytes`) : mémoire ~constante même à pool élevé, plus de bufferisation
de 16 Mo par requête.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:12:04 +02:00

283 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import type { FastifyInstance, LightMyRequestResponse } from 'fastify';
import FormData from 'form-data';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { buildApp } from '../src/app.js';
import type { OidcConfig } from '../src/config.js';
import {
login,
makeConfig,
makeTempDir,
oneTarget,
removeDir,
SESSION_COOKIE,
startStubIdp,
type StubIdp,
TINY_PNG,
} from './helpers.js';
let idp: StubIdp;
let session: string;
let app: FastifyInstance;
let dirA: string;
let dirB: string;
function oidcOverride(idpRef: StubIdp): OidcConfig {
return {
issuer: idpRef.issuer,
clientId: idpRef.clientId,
clientSecret: idpRef.clientSecret,
scopes: ['openid', 'profile', 'email'],
};
}
beforeAll(async () => {
idp = await startStubIdp();
});
afterAll(async () => {
await idp.close();
});
beforeEach(async () => {
dirA = await makeTempDir();
dirB = await makeTempDir();
app = await buildApp(
makeConfig({
targets: [
{ name: 'a', label: 'Dossier A', dir: dirA, allowDirectories: true },
{ name: 'b', label: 'Dossier B', dir: dirB, allowDirectories: false },
],
maxFileSizeBytes: 2000,
oidc: oidcOverride(idp),
}),
{ logger: false },
);
await app.ready();
session = await login(app, idp);
});
afterEach(async () => {
await app.close();
await removeDir(dirA);
await removeDir(dirB);
});
const postUpload = (
form: FormData,
opts: { cookie?: string; target?: string; path?: string } = {},
): Promise<LightMyRequestResponse> => {
const params = new URLSearchParams();
if (opts.target !== undefined) params.set('target', opts.target);
if (opts.path !== undefined) params.set('path', opts.path);
const query = params.toString();
return app.inject({
method: 'POST',
url: query ? `/upload?${query}` : '/upload',
payload: form,
headers: form.getHeaders(),
...(opts.cookie !== undefined ? { cookies: { [SESSION_COOKIE]: opts.cookie } } : {}),
});
};
function pngForm(field = 'file', body: Buffer = TINY_PNG): FormData {
const form = new FormData();
form.append(field, body, { filename: 'photo.png', contentType: 'image/png' });
return form;
}
describe('GET /health', () => {
it('répond 200 sans authentification', async () => {
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
expect(res.json<{ status: string }>().status).toBe('ok');
});
});
describe('GET /config', () => {
it('expose chunkPoolSize sans authentification', async () => {
const res = await app.inject({ method: 'GET', url: '/config' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ chunkPoolSize: 30 });
});
it('reflète la valeur configurée', async () => {
const custom = await buildApp(makeConfig({ chunkPoolSize: 12, oidc: oidcOverride(idp) }), {
logger: false,
});
await custom.ready();
const res = await custom.inject({ method: 'GET', url: '/config' });
expect(res.json<{ chunkPoolSize: number }>().chunkPoolSize).toBe(12);
await custom.close();
});
});
describe('GET /targets', () => {
it('exige une session', async () => {
const res = await app.inject({ method: 'GET', url: '/targets' });
expect(res.statusCode).toBe(401);
});
it('renvoie nom, libellé et allowDirectories (sans chemin disque)', async () => {
const res = await app.inject({
method: 'GET',
url: '/targets',
cookies: { [SESSION_COOKIE]: session },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual([
{ name: 'a', label: 'Dossier A', allowDirectories: true },
{ name: 'b', label: 'Dossier B', allowDirectories: false },
]);
});
});
describe('POST /upload', () => {
it('refuse sans session (401)', async () => {
const res = await postUpload(pngForm());
expect(res.statusCode).toBe(401);
});
it('refuse un cookie de session illisible (401)', async () => {
const res = await postUpload(pngForm(), { cookie: 'cookie-bidon' });
expect(res.statusCode).toBe(401);
});
it('dépose dans la cible par défaut (première) quand ?target est absent', async () => {
const res = await postUpload(pngForm(), { cookie: session });
expect(res.statusCode).toBe(201);
const body = res.json<{ target: string; size: number; mimeType: string }>();
expect(body.target).toBe('a');
expect(body.size).toBe(TINY_PNG.length);
expect(await readdir(dirA)).toHaveLength(1);
expect(await readdir(dirB)).toHaveLength(0);
});
it('dépose dans la cible demandée via ?target=', async () => {
const res = await postUpload(pngForm(), { cookie: session, target: 'b' });
expect(res.statusCode).toBe(201);
expect(res.json<{ target: string }>().target).toBe('b');
expect(await readdir(dirB)).toHaveLength(1);
});
it('refuse une cible inconnue (400)', async () => {
const res = await postUpload(pngForm(), { cookie: session, target: 'inconnu' });
expect(res.statusCode).toBe(400);
expect(await readdir(dirA)).toHaveLength(0);
});
it('refuse un mauvais nom de champ (400)', async () => {
const res = await postUpload(pngForm('autre'), { cookie: session });
expect(res.statusCode).toBe(400);
});
it('refuse un type de média non autorisé (400)', async () => {
const form = new FormData();
form.append('file', Buffer.from('PK\x03\x04'), {
filename: 'archive.zip',
contentType: 'application/zip',
});
const res = await postUpload(form, { cookie: session });
expect(res.statusCode).toBe(400);
});
it('refuse un fichier trop volumineux (413)', async () => {
const res = await postUpload(pngForm('file', Buffer.alloc(5000, 1)), { cookie: session });
expect(res.statusCode).toBe(413);
});
it('refuse une requête sans fichier (400)', async () => {
const form = new FormData();
form.append('title', 'juste du texte');
const res = await postUpload(form, { cookie: session });
expect(res.statusCode).toBe(400);
});
});
describe('POST /upload — dépôt de dossier (?path=)', () => {
it('recrée l’arborescence sous une cible qui l’autorise', async () => {
const res = await postUpload(pngForm(), {
cookie: session,
target: 'a',
path: 'vac/plage/photo.png',
});
expect(res.statusCode).toBe(201);
expect(res.json<{ storedName: string }>().storedName).toBe('vac/plage/photo.png');
expect(await readdir(join(dirA, 'vac', 'plage'))).toEqual(['photo.png']);
});
it('refuse ?path= sur une cible qui n’autorise pas les dossiers (400)', async () => {
const res = await postUpload(pngForm(), {
cookie: session,
target: 'b',
path: 'sub/photo.png',
});
expect(res.statusCode).toBe(400);
expect(await readdir(dirB)).toHaveLength(0);
});
it('ne permet pas de sortir de la cible avec ../', async () => {
const res = await postUpload(pngForm(), {
cookie: session,
target: 'a',
path: '../../evil/x.png',
});
expect(res.statusCode).toBe(201);
expect(res.json<{ storedName: string }>().storedName).toBe('evil/x.png');
expect(await readdir(join(dirA, 'evil'))).toEqual(['x.png']);
});
it('écrase un fichier déjà présent au même chemin', async () => {
const opts = { cookie: session, target: 'a', path: 'd/f.png' };
await postUpload(pngForm('file', Buffer.from('11111')), opts);
await postUpload(pngForm('file', Buffer.from('22')), opts);
expect(await readdir(join(dirA, 'd'))).toEqual(['f.png']);
expect(await readFile(join(dirA, 'd', 'f.png'))).toEqual(Buffer.from('22'));
});
});
describe('POST /upload — au-delà du bodyLimit Fastify', () => {
let bigApp: FastifyInstance;
let bigDir: string;
let bigSession: string;
beforeEach(async () => {
bigDir = await makeTempDir();
bigApp = await buildApp(
makeConfig({
targets: oneTarget(bigDir),
maxFileSizeBytes: 32 * 1024 * 1024,
oidc: oidcOverride(idp),
}),
{ logger: false },
);
await bigApp.ready();
bigSession = await login(bigApp, idp);
});
afterEach(async () => {
await bigApp.close();
await removeDir(bigDir);
});
it('accepte un fichier de 2 Mio (> 1 Mio, bodyLimit par défaut)', async () => {
const form = new FormData();
form.append('file', Buffer.alloc(2 * 1024 * 1024, 7), {
filename: 'clip.png',
contentType: 'image/png',
});
const res = await bigApp.inject({
method: 'POST',
url: '/upload',
payload: form,
headers: form.getHeaders(),
cookies: { [SESSION_COOKIE]: bigSession },
});
expect(res.statusCode).toBe(201);
expect(res.json<{ size: number }>().size).toBe(2 * 1024 * 1024);
});
});