mediaDrop/tests/upload.test.ts
Nicolas fe8bd26bb7 Initial commit: mediaDrop — serveur de dépôt de fichiers médias
Serveur TypeScript (Fastify 5) permettant de déposer des fichiers médias
dans des dossiers nommés, via une page web de glisser-déposer.

- Authentification OpenID Connect gérée côté serveur (Authorization Code +
  PKCE, client confidentiel, openid-client) ; session via cookie chiffré
  (@fastify/secure-session).
- Configuration dans un fichier JSON (schéma fourni) ; l'environnement ne
  porte que le pointeur MEDIADROP_CONFIG.
- Cibles de dépôt nommées : liste { name, label, dir }, sélection par
  ?target=, première cible par défaut.
- Upload multipart en streaming : nom de fichier dérivé côté serveur (pas
  de path traversal), liste blanche de types MIME, plafond de taille.
- TypeScript strict, ESLint + Prettier sans warning, 46 tests Vitest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 23:06:53 +02:00

173 lines
5 KiB
TypeScript

import { readdir } from 'node:fs/promises';
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 {
login,
makeConfig,
makeTempDir,
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;
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 },
{ name: 'b', label: 'Dossier B', dir: dirB },
],
maxFileSizeBytes: 2000,
oidc: {
issuer: idp.issuer,
clientId: idp.clientId,
clientSecret: idp.clientSecret,
scopes: ['openid', 'profile', 'email'],
},
}),
{ 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 } = {},
): Promise<LightMyRequestResponse> =>
app.inject({
method: 'POST',
url:
opts.target !== undefined ? `/upload?target=${encodeURIComponent(opts.target)}` : '/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 les cibles (nom + libellé, sans chemin)', 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' },
{ name: 'b', label: 'Dossier B' },
]);
});
});
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.mimeType).toBe('image/png');
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(dirA)).toHaveLength(0);
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);
});
});