mediaDrop/tests/upload.test.ts
Nicolas b85607b096 Dépôt de dossiers + très gros fichiers
Dépôt de dossiers :
- flag `allowDirectories` (défaut false) par cible dans config.json + schéma.
- POST /upload accepte `?path=<chemin relatif>` : l'arborescence est recréée
  sous `dir`, nom d'origine conservé, fichier écrasé si présent.
- src/lib/paths.ts : assainissement segment par segment (pas de `..`, pas de
  séparateur) + vérification de confinement dans storage.ts -> aucune sortie
  possible de la cible. Refus 400 si la cible n'autorise pas les dossiers.
- GET /targets expose `allowDirectories`.
- Page : glisser-déposer d'un dossier (parcours récursif webkitGetAsEntry) +
  bouton « parcourir un dossier », visibles selon la cible.

Très gros fichiers (~10 Go) :
- `requestTimeout: 0` / `connectionTimeout: 0` : aucun timeout serveur imposé.
- écriture disque avec un tampon de 1 Mio.
- doc : `maxFileSizeMb` à l'échelle Go, réglages reverse proxy.

Tests : +paths.test.ts, +placement (storage), +dossier & gros fichier (upload) ;
60 tests.

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

265 lines
8.1 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 /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);
});
});