diff --git a/README.md b/README.md index 68d7d5a..db19445 100644 --- a/README.md +++ b/README.md @@ -108,21 +108,21 @@ pouvant écrire les paquets de l'organisation. ## Endpoints -| Route | Auth | Rôle | -| -------------------------------------- | ------- | ------------------------------------------------------------------ | -| `GET /` | non | Page de dépôt | -| `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` | -| `GET /auth/login` | non | Démarre le flux OIDC (redirige vers le fournisseur) | -| `GET /auth/callback` | non | Fin du flux : ouvre la session, redirige vers `/` | -| `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) | -| `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` | -| `GET /config` | non | Réglages client : `{ "chunkPoolSize": number }` | -| `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories" }]` (sans chemin) | -| `POST /upload?target=[&path=

]` | session | Dépôt direct d'un fichier `multipart/form-data`, champ `file` | -| `POST /upload/init` | session | Démarre un upload fragmenté → `{ id, chunkSize, chunkCount }` | -| `PUT /upload/:id/:index` | session | Envoie un morceau (corps brut `application/octet-stream`) | -| `POST /upload/:id/complete` | session | Assemble et finalise → `201` + métadonnées | -| `DELETE /upload/:id` | session | Abandonne (supprime le fichier partiel) | +| Route | Auth | Rôle | +| -------------------------------------- | ------- | ------------------------------------------------------------------------ | +| `GET /` | non | Page de dépôt | +| `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` | +| `GET /auth/login` | non | Démarre le flux OIDC (redirige vers le fournisseur) | +| `GET /auth/callback` | non | Fin du flux : ouvre la session, redirige vers `/` | +| `POST /auth/logout` | session | Ferme la session (+ déconnexion RP si disponible) | +| `GET /auth/me` | non | `{ "authenticated": bool, "name"?: string }` | +| `GET /config` | non | Réglages client : `{ "chunkPoolSize": number }` | +| `GET /targets` | session | Cibles : `[{ "name", "label", "allowDirectories", "allowedMimeTypes" }]` | +| `POST /upload?target=[&path=

]` | session | Dépôt direct d'un fichier `multipart/form-data`, champ `file` | +| `POST /upload/init` | session | Démarre un upload fragmenté → `{ id, chunkSize, chunkCount }` | +| `PUT /upload/:id/:index` | session | Envoie un morceau (corps brut `application/octet-stream`) | +| `POST /upload/:id/complete` | session | Assemble et finalise → `201` + métadonnées | +| `DELETE /upload/:id` | session | Abandonne (supprime le fichier partiel) | `POST /upload` — `?target=` choisit la cible (absent → **première** cible). `?path=` (URL-encodé) dépose dans un **sous-dossier** recréé sous @@ -187,24 +187,25 @@ Prévoir l'espace disque sur le volume de la cible. Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma dans [`config.schema.json`](config.schema.json)). Chemin résolu depuis le dossier courant. -| Clé JSON | Défaut | Rôle | -| ---------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| `port` | `3000` | Port d'écoute | -| `host` | `0.0.0.0` | Interface d'écoute | -| `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) | -| `targets` | — | **Obligatoire.** Liste `[{ name, label?, dir, allowDirectories? }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut | -| `targets[].allowDirectories` | `false` | Autoriser le dépôt de dossiers (arborescence recréée sous `dir`) | -| `maxFileSizeMb` | `512` | Taille maximale par fichier, en Mo (`10240` = 10 Go) | -| `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` | -| `chunkPoolSize` | `30` | Morceaux en vol simultanés (upload fragmenté), répartis entre fichiers actifs ; `1`–`256`. Utile en HTTP/2 | -| `allowedMimeTypes` | _(liste média)_ | `"all"`, ou tableau de types exacts et/ou jokers de préfixe (`"video/*"`, `"*"`) ; absent => liste par défaut | -| `webhooks` | `[]` | Liste de webhooks POST notifiés à chaque upload finalisé et/ou chaque échec (voir §Notifications) | -| `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) | -| `session.maxAgeHours` | `12` | Durée de vie absolue de la session | -| `oidc.issuer` | — | **Obligatoire.** Issuer OIDC | -| `oidc.clientId` | — | **Obligatoire.** `client_id` du client confidentiel | -| `oidc.clientSecret` | — | **Obligatoire.** Secret du client confidentiel | -| `oidc.scopes` | `["openid","profile","email"]` | Scopes demandés (`openid` forcé) | +| Clé JSON | Défaut | Rôle | +| ---------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| `port` | `3000` | Port d'écoute | +| `host` | `0.0.0.0` | Interface d'écoute | +| `publicUrl` | — | **Obligatoire.** URL externe de base (redirect_uri, post-logout) | +| `targets` | — | **Obligatoire.** Liste `[{ name, label?, dir, allowDirectories?, allowedMimeTypes? }]` (≥ 1) ; la 1ʳᵉ est la cible par défaut | +| `targets[].allowDirectories` | `false` | Autoriser le dépôt de dossiers (arborescence recréée sous `dir`) | +| `targets[].allowedMimeTypes` | _(global)_ | Politique MIME **propre à cette cible** ; absent => hérite du `allowedMimeTypes` global. Mêmes formes | +| `maxFileSizeMb` | `512` | Taille maximale par fichier, en Mo (`10240` = 10 Go) | +| `logLevel` | `info` | `fatal` \| `error` \| `warn` \| `info` \| `debug` \| `trace` \| `silent` | +| `chunkPoolSize` | `30` | Morceaux en vol simultanés (upload fragmenté), répartis entre fichiers actifs ; `1`–`256`. Utile en HTTP/2 | +| `allowedMimeTypes` | _(liste média)_ | Politique MIME **par défaut** des cibles. `"all"`, ou tableau de types exacts et/ou jokers (`"video/*"`, `"*"`) | +| `webhooks` | `[]` | Liste de webhooks POST notifiés à chaque upload finalisé et/ou chaque échec (voir §Notifications) | +| `session.key` | — | **Obligatoire.** 32 octets base64 (`openssl rand -base64 32`) | +| `session.maxAgeHours` | `12` | Durée de vie absolue de la session | +| `oidc.issuer` | — | **Obligatoire.** Issuer OIDC | +| `oidc.clientId` | — | **Obligatoire.** `client_id` du client confidentiel | +| `oidc.clientSecret` | — | **Obligatoire.** Secret du client confidentiel | +| `oidc.scopes` | `["openid","profile","email"]` | Scopes demandés (`openid` forcé) | Seule variable d'environnement : `MEDIADROP_CONFIG` (chemin du fichier, défaut `./config.json`). @@ -292,9 +293,9 @@ const ok = par refresh token — reconnexion à l'expiration. - Le **type MIME est celui annoncé par le client**, non vérifié via les octets de signature. Durcissement possible avec une bibliothèque de détection (`file-type`). -- Les limites (`maxFileSizeMb`, `allowedMimeTypes`) sont **globales** : pas encore de - réglage par cible. Le `name` de cible fourni par le client sert uniquement à - retrouver une entrée de `targets` (jamais concaténé à un chemin). +- `allowedMimeTypes` se règle **par cible** (`targets[].allowedMimeTypes`), avec repli + sur la valeur globale. `maxFileSizeMb` reste global. Le `name` de cible fourni par le + client sert uniquement à retrouver une entrée de `targets` (jamais concaténé à un chemin). - Pas de limitation de débit. Placer le service **derrière un reverse proxy HTTPS** en production et régler `publicUrl` sur l'URL publique. - `.env`, `config.json` et `uploads/` sont ignorés par Git. diff --git a/config.example.json b/config.example.json index 8e31aa7..b7ceeea 100644 --- a/config.example.json +++ b/config.example.json @@ -4,8 +4,20 @@ "host": "0.0.0.0", "publicUrl": "http://localhost:3000", "targets": [ - { "name": "photos", "label": "Photos", "dir": "./uploads/photos" }, - { "name": "videos", "label": "Vidéos", "dir": "./uploads/videos", "allowDirectories": true } + { + "name": "photos", + "label": "Photos", + "dir": "./uploads/photos", + "allowedMimeTypes": ["image/*"] + }, + { + "name": "videos", + "label": "Vidéos", + "dir": "./uploads/videos", + "allowDirectories": true, + "allowedMimeTypes": ["video/*"] + }, + { "name": "divers", "label": "Divers", "dir": "./uploads/divers" } ], "maxFileSizeMb": 512, "logLevel": "info", diff --git a/config.schema.json b/config.schema.json index d89f734..d810a73 100644 --- a/config.schema.json +++ b/config.schema.json @@ -34,6 +34,20 @@ "type": "boolean", "default": false, "description": "Autoriser le dépôt de dossiers (arborescence recréée sous dir)." + }, + "allowedMimeTypes": { + "description": "Types MIME acceptés par cette cible. Absent => hérite du allowedMimeTypes global. Mêmes formes : \"all\", ou liste de types exacts et/ou jokers (\"image/*\", \"*/*\", \"*\").", + "oneOf": [ + { "const": "all" }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "pattern": "^(\\*|\\*/\\*|[^/*\\s]+/(\\*|[^/*\\s]+))$" + } + } + ] } } } diff --git a/public/index.html b/public/index.html index 290a2a0..252c114 100644 --- a/public/index.html +++ b/public/index.html @@ -327,6 +327,13 @@ currentTarget = target ? target.name : null; currentTargetAllowsDirs = Boolean(target && target.allowDirectories); dirRow.hidden = !authenticated || !currentTargetAllowsDirs; + // Filtre le sélecteur natif de fichiers selon les types acceptés par la cible + // (le serveur reste l'autorité ; l'attribut ne bloque pas le glisser-déposer). + const mimes = target && target.allowedMimeTypes; + picker.accept = + Array.isArray(mimes) && !mimes.includes('*') && !mimes.includes('*/*') + ? mimes.join(',') + : ''; } function loadTargets() { diff --git a/src/config.ts b/src/config.ts index 37f991b..f2b6eff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -55,6 +55,11 @@ export interface DropTarget { * (`?path=...`), l'arborescence est recréée sous `dir`. Défaut : `false`. */ allowDirectories: boolean; + /** + * Types MIME acceptés par cette cible. Absent dans le JSON -> hérite de la + * valeur globale `allowedMimeTypes`. Toujours résolu après parsing. + */ + allowedMimeTypes: MimePolicy; } /** Liste non vide de cibles de dépôt ; la première est la cible par défaut. */ @@ -68,6 +73,7 @@ export interface AppConfig { publicUrl: string; targets: DropTargets; maxFileSizeBytes: number; + /** Politique MIME par défaut, héritée par les cibles sans `allowedMimeTypes` propre. */ allowedMimeTypes: MimePolicy; logLevel: LogLevel; /** @@ -176,7 +182,7 @@ function parseLogLevel(value: unknown): LogLevel { return value as LogLevel; } -function parseMimePolicy(value: unknown): MimePolicy { +function parseMimePolicy(value: unknown, label = 'allowedMimeTypes'): MimePolicy { if (value === undefined || value === null) { return DEFAULT_ALLOWED_MIME; } @@ -187,17 +193,17 @@ function parseMimePolicy(value: unknown): MimePolicy { !Array.isArray(value) || !value.every((entry): entry is string => typeof entry === 'string') ) { - throw new ConfigError('allowedMimeTypes doit être "all" ou un tableau de chaînes'); + throw new ConfigError(`${label} doit être "all" ou un tableau de chaînes`); } const list = value.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0); if (list.length === 0) { - throw new ConfigError('allowedMimeTypes ne contient aucun type MIME valide'); + throw new ConfigError(`${label} ne contient aucun type MIME valide`); } for (const entry of list) { const valid = entry === '*' || entry === '*/*' || /^[^/*\s]+\/(\*|[^/*\s]+)$/.test(entry); if (!valid) { throw new ConfigError( - `allowedMimeTypes : entrée invalide « ${entry} » ` + + `${label} : entrée invalide « ${entry} » ` + '(formes acceptées : "type/sous-type", "type/*", "*/*", "*")', ); } @@ -348,7 +354,7 @@ function parseWebhooks(value: unknown): readonly WebhookConfig[] { }); } -function parseTargets(value: unknown): DropTargets { +function parseTargets(value: unknown, defaultMimePolicy: MimePolicy): DropTargets { if (!Array.isArray(value) || value.length === 0) { throw new ConfigError('targets doit être un tableau non vide'); } @@ -376,6 +382,10 @@ function parseTargets(value: unknown): DropTargets { false, `targets[${String(index)}].allowDirectories`, ), + allowedMimeTypes: + raw.allowedMimeTypes === undefined || raw.allowedMimeTypes === null + ? defaultMimePolicy + : parseMimePolicy(raw.allowedMimeTypes, `targets[${String(index)}].allowedMimeTypes`), }; }; @@ -389,17 +399,18 @@ function parseTargets(value: unknown): DropTargets { */ export function parseConfig(raw: unknown): AppConfig { const root = asRecord(raw, 'La configuration'); + const allowedMimeTypes = parseMimePolicy(root.allowedMimeTypes); return { port: optionalPositiveInt(root.port, DEFAULTS.port, 'port'), host: optionalString(root.host, 'host') ?? DEFAULTS.host, publicUrl: normalizeHttpUrl(root.publicUrl, 'publicUrl'), - targets: parseTargets(root.targets), + targets: parseTargets(root.targets, allowedMimeTypes), maxFileSizeBytes: optionalPositiveInt(root.maxFileSizeMb, DEFAULTS.maxFileSizeMb, 'maxFileSizeMb') * 1024 * 1024, - allowedMimeTypes: parseMimePolicy(root.allowedMimeTypes), + allowedMimeTypes, logLevel: parseLogLevel(root.logLevel), chunkPoolSize: parseChunkPoolSize(root.chunkPoolSize), webhooks: parseWebhooks(root.webhooks), diff --git a/src/routes/upload-chunked.ts b/src/routes/upload-chunked.ts index 5eeeb19..267dbee 100644 --- a/src/routes/upload-chunked.ts +++ b/src/routes/upload-chunked.ts @@ -88,7 +88,7 @@ export const chunkedUploadRoutes: FastifyPluginCallback = (app, _options, done) if (totalSize > cfg.maxFileSizeBytes) { throw new PayloadTooLargeError(); } - if (!isAllowedMedia(mimeType, cfg.allowedMimeTypes)) { + if (!isAllowedMedia(mimeType, target.allowedMimeTypes)) { throw new ValidationError(`Type de média non autorisé : ${mimeType}`); } diff --git a/src/routes/upload.ts b/src/routes/upload.ts index 5c59842..3d8c182 100644 --- a/src/routes/upload.ts +++ b/src/routes/upload.ts @@ -2,7 +2,7 @@ import type { FastifyPluginCallback } from 'fastify'; import type { DropTarget } from '../config.js'; import { ValidationError } from '../errors.js'; -import { isAllowedMedia } from '../lib/media-types.js'; +import { isAllowedMedia, type MimePolicy } from '../lib/media-types.js'; import { resolvePlacement, resolveTarget } from '../lib/targets.js'; import { getSessionUser, requireSession } from '../session.js'; import { saveFile, type StoredFile } from '../lib/storage.js'; @@ -21,6 +21,8 @@ interface TargetSummary { name: string; label: string; allowDirectories: boolean; + /** `"all"`, ou la liste des types/jokers acceptés par cette cible. */ + allowedMimeTypes: MimePolicy; } interface ClientConfig { @@ -45,7 +47,7 @@ function toResponse(target: DropTarget, stored: StoredFile): UploadResponse { /** * - `GET /config` — réglages client non sensibles (taille du pool de morceaux) - * - `GET /targets` — liste des cibles (nom, libellé, dépôt de dossier autorisé) + * - `GET /targets` — cibles : nom, libellé, dépôt de dossier autorisé, types MIME acceptés * - `POST /upload?target=[&path=]` — dépôt d'un fichier multipart */ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => { @@ -56,6 +58,7 @@ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => { name: target.name, label: target.label, allowDirectories: target.allowDirectories, + allowedMimeTypes: target.allowedMimeTypes, })), ); @@ -79,7 +82,7 @@ export const uploadRoutes: FastifyPluginCallback = (app, _options, done) => { part.file.resume(); throw new ValidationError(`Le fichier doit être envoyé dans le champ « ${FIELD_NAME} »`); } - if (!isAllowedMedia(part.mimetype, app.appConfig.allowedMimeTypes)) { + if (!isAllowedMedia(part.mimetype, target.allowedMimeTypes)) { part.file.resume(); throw new ValidationError(`Type de média non autorisé : ${part.mimetype}`); } diff --git a/tests/config.test.ts b/tests/config.test.ts index e218152..e862579 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -34,8 +34,15 @@ describe('parseConfig', () => { chunkPoolSize: 30, }); expect(config.targets).toEqual([ - { name: 'photos', label: 'photos', dir: './uploads/photos', allowDirectories: false }, + { + name: 'photos', + label: 'photos', + dir: './uploads/photos', + allowDirectories: false, + allowedMimeTypes: DEFAULT_ALLOWED_MIME, + }, ]); + expect(config.targets[0]?.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME); expect(config.allowedMimeTypes).toBe(DEFAULT_ALLOWED_MIME); expect(config.session.maxAgeSeconds).toBe(12 * 3600); expect(config.session.key).toHaveLength(32); @@ -84,13 +91,37 @@ describe('parseConfig', () => { ).toThrow(ConfigError); expect( parseConfig({ ...BASE, targets: [{ name: 'x', label: 'X', dir: './x' }] }).targets[0], - ).toEqual({ name: 'x', label: 'X', dir: './x', allowDirectories: false }); + ).toEqual({ + name: 'x', + label: 'X', + dir: './x', + allowDirectories: false, + allowedMimeTypes: DEFAULT_ALLOWED_MIME, + }); expect( parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: true }] }) .targets[0].allowDirectories, ).toBe(true); }); + it('allowedMimeTypes par cible : hérite du global, ou le surcharge', () => { + const config = parseConfig({ + ...BASE, + allowedMimeTypes: ['image/png'], + targets: [ + { name: 'photos', dir: './a' }, + { name: 'videos', dir: './b', allowedMimeTypes: ['video/*'] }, + { name: 'tout', dir: './c', allowedMimeTypes: 'all' }, + ], + }); + expect(config.targets[0].allowedMimeTypes).toEqual(['image/png']); // hérité + expect(config.targets[1]?.allowedMimeTypes).toEqual(['video/*']); // surchargé + expect(config.targets[2]?.allowedMimeTypes).toBe('all'); + expect(() => + parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowedMimeTypes: ['bad**'] }] }), + ).toThrow(ConfigError); + }); + it('rejette une session.key qui ne fait pas 32 octets', () => { expect(() => parseConfig({ ...BASE, session: { key: Buffer.alloc(16).toString('base64') } }), diff --git a/tests/helpers.ts b/tests/helpers.ts index 997ef73..048a702 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -5,7 +5,8 @@ import path from 'node:path'; import Fastify, { type FastifyInstance } from 'fastify'; import { SignJWT, exportJWK, generateKeyPair } from 'jose'; -import type { AppConfig, DropTargets } from '../src/config.js'; +import type { AppConfig, DropTarget } from '../src/config.js'; +import type { MimePolicy } from '../src/lib/media-types.js'; export const TEST_PUBLIC_URL = 'http://localhost:3000'; export const TEST_CLIENT_ID = 'mediadrop-test'; @@ -31,19 +32,36 @@ export async function removeDir(dir: string): Promise { await rm(dir, { recursive: true, force: true }); } +/** Cible de test : `name` et `dir` requis, le reste prend des valeurs par défaut. */ +export type TestTarget = Pick & Partial; + +export interface ConfigOverrides extends Partial> { + targets?: readonly TestTarget[]; +} + +const DEFAULT_TEST_MIME: MimePolicy = ['image/png', 'image/jpeg']; + /** Liste à une seule cible pointant vers `dir`. */ -export function oneTarget(dir: string, allowDirectories = false): DropTargets { +export function oneTarget(dir: string, allowDirectories = false): readonly TestTarget[] { return [{ name: 'main', label: 'Principal', dir, allowDirectories }]; } -export function makeConfig(overrides: Partial = {}): AppConfig { +export function makeConfig(overrides: ConfigOverrides = {}): AppConfig { + const { targets, ...rest } = overrides; + const allowedMimeTypes = rest.allowedMimeTypes ?? DEFAULT_TEST_MIME; + const resolved = (targets ?? oneTarget('./uploads')).map((target): DropTarget => ({ + name: target.name, + label: target.label ?? target.name, + dir: target.dir, + allowDirectories: target.allowDirectories ?? false, + allowedMimeTypes: target.allowedMimeTypes ?? allowedMimeTypes, + })); + return { port: 0, host: '127.0.0.1', publicUrl: TEST_PUBLIC_URL, - targets: oneTarget('./uploads'), maxFileSizeBytes: 1024 * 1024, - allowedMimeTypes: ['image/png', 'image/jpeg'], logLevel: 'silent', chunkPoolSize: 30, webhooks: [], @@ -54,7 +72,9 @@ export function makeConfig(overrides: Partial = {}): AppConfig { clientSecret: TEST_CLIENT_SECRET, scopes: ['openid', 'profile', 'email'], }, - ...overrides, + ...rest, + allowedMimeTypes, + targets: resolved as [DropTarget, ...DropTarget[]], }; } diff --git a/tests/upload.test.ts b/tests/upload.test.ts index 97843fa..a96e93d 100644 --- a/tests/upload.test.ts +++ b/tests/upload.test.ts @@ -49,7 +49,13 @@ beforeEach(async () => { makeConfig({ targets: [ { name: 'a', label: 'Dossier A', dir: dirA, allowDirectories: true }, - { name: 'b', label: 'Dossier B', dir: dirB, allowDirectories: false }, + { + name: 'b', + label: 'Dossier B', + dir: dirB, + allowDirectories: false, + allowedMimeTypes: 'all', + }, ], maxFileSizeBytes: 2000, oidc: oidcOverride(idp), @@ -121,7 +127,7 @@ describe('GET /targets', () => { expect(res.statusCode).toBe(401); }); - it('renvoie nom, libellé et allowDirectories (sans chemin disque)', async () => { + it('renvoie nom, libellé, allowDirectories et allowedMimeTypes (sans chemin disque)', async () => { const res = await app.inject({ method: 'GET', url: '/targets', @@ -129,8 +135,13 @@ describe('GET /targets', () => { }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual([ - { name: 'a', label: 'Dossier A', allowDirectories: true }, - { name: 'b', label: 'Dossier B', allowDirectories: false }, + { + name: 'a', + label: 'Dossier A', + allowDirectories: true, + allowedMimeTypes: ['image/png', 'image/jpeg'], + }, + { name: 'b', label: 'Dossier B', allowDirectories: false, allowedMimeTypes: 'all' }, ]); }); }); @@ -185,6 +196,20 @@ describe('POST /upload', () => { 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);