mediaDrop/tests/upload.test.ts
Nicolas 615d0fd45a
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 13s
Types MIME configurables par cible d'upload
`targets[].allowedMimeTypes` : chaque cible peut définir sa propre
politique (mêmes formes : "all", types exacts, jokers "image/*"…).
Absente => la cible hérite du `allowedMimeTypes` global, qui devient
la valeur par défaut. Résolu au parsing : chaque `DropTarget` porte
toujours une politique.

- `POST /upload` et `POST /upload/init` valident contre
  `target.allowedMimeTypes` (au lieu de la valeur globale).
- `GET /targets` renvoie `allowedMimeTypes` ; la page en déduit
  l'attribut `accept` du sélecteur de fichiers (le serveur reste
  l'autorité — le glisser-déposer n'est pas filtré).

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

308 lines
9.5 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,
allowedMimeTypes: 'all',
},
],
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é, allowDirectories et allowedMimeTypes (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,
allowedMimeTypes: ['image/png', 'image/jpeg'],
},
{ name: 'b', label: 'Dossier B', allowDirectories: false, allowedMimeTypes: 'all' },
]);
});
});
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('applique la politique MIME propre à chaque cible', async () => {
const zip = (): FormData => {
const form = new FormData();
form.append('file', Buffer.from('PK\x03\x04'), {
filename: 'archive.zip',
contentType: 'application/zip',
});
return form;
};
// « a » n'accepte que les images ; « b » accepte tout.
expect((await postUpload(zip(), { cookie: session, target: 'a' })).statusCode).toBe(400);
expect((await postUpload(zip(), { cookie: session, target: 'b' })).statusCode).toBe(201);
});
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);
});
});