Types MIME configurables par cible d'upload
`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:
parent
60d7ba1c39
commit
615d0fd45a
10 changed files with 185 additions and 61 deletions
17
README.md
17
README.md
|
|
@ -109,7 +109,7 @@ 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) |
|
||||
|
|
@ -117,7 +117,7 @@ pouvant écrire les paquets de l'organisation.
|
|||
| `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) |
|
||||
| `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`) |
|
||||
|
|
@ -188,16 +188,17 @@ Un seul fichier JSON (voir [`config.example.json`](config.example.json), schéma
|
|||
[`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` | — | **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)_ | `"all"`, ou tableau de types exacts et/ou jokers de préfixe (`"video/*"`, `"*"`) ; absent => liste par défaut |
|
||||
| `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 |
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]+))$"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') } }),
|
||||
|
|
|
|||
|
|
@ -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[]],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue