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>
173 lines
5 KiB
TypeScript
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);
|
|
});
|
|
});
|