`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>
308 lines
9.5 KiB
TypeScript
308 lines
9.5 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,
|
||
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);
|
||
});
|
||
});
|