Types MIME configurables par cible d'upload
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 13s

`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>
This commit is contained in:
Nicolas 2026-09-10 22:22:30 +02:00
parent 60d7ba1c39
commit 615d0fd45a
10 changed files with 185 additions and 61 deletions

View file

@ -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=<nom>[&path=<p>]` | 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=<nom>[&path=<p>]` | 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=<nom>` choisit la cible (absent → **première** cible).
`?path=<chemin relatif>` (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.

View file

@ -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",

View file

@ -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]+))$"
}
}
]
}
}
}

View file

@ -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() {

View file

@ -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),

View file

@ -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}`);
}

View file

@ -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=<nom>[&path=<chemin relatif>]` — 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}`);
}

View file

@ -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') } }),

View file

@ -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<void> {
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<DropTarget, 'name' | 'dir'> & Partial<DropTarget>;
export interface ConfigOverrides extends Partial<Omit<AppConfig, 'targets'>> {
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> = {}): 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> = {}): AppConfig {
clientSecret: TEST_CLIENT_SECRET,
scopes: ['openid', 'profile', 'email'],
},
...overrides,
...rest,
allowedMimeTypes,
targets: resolved as [DropTarget, ...DropTarget[]],
};
}

View file

@ -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);