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