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 => { 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é 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); }); });