mediaDrop/tests/config.test.ts
Nicolas 615d0fd45a
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 13s
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>
2026-09-10 22:22:30 +02:00

270 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CONFIG_PATH_ENV, loadConfig, parseConfig } from '../src/config.js';
import { DEFAULT_ALLOWED_MIME } from '../src/lib/media-types.js';
import { ConfigError } from '../src/errors.js';
const KEY_B64 = Buffer.alloc(32, 1).toString('base64');
const BASE = {
publicUrl: 'http://localhost:3000',
targets: [{ name: 'photos', dir: './uploads/photos' }],
session: { key: KEY_B64 },
oidc: {
issuer: 'https://auth.exemple.fr/realms/demo',
clientId: 'mediadrop',
clientSecret: 's3cr3t',
},
};
describe('parseConfig', () => {
it('applique les valeurs par défaut', () => {
const config = parseConfig(structuredClone(BASE));
expect(config).toMatchObject({
port: 3000,
host: '0.0.0.0',
publicUrl: 'http://localhost:3000',
maxFileSizeBytes: 512 * 1024 * 1024,
logLevel: 'info',
chunkPoolSize: 30,
});
expect(config.targets).toEqual([
{
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);
expect(config.oidc).toEqual({
issuer: 'https://auth.exemple.fr/realms/demo',
clientId: 'mediadrop',
clientSecret: 's3cr3t',
scopes: ['openid', 'profile', 'email'],
});
});
it('rejette une racine ou un sous-objet non-objet', () => {
expect(() => parseConfig('nope')).toThrow(ConfigError);
expect(() => parseConfig({})).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, session: [] })).toThrow(ConfigError);
});
it('exige publicUrl, session.key et le bloc oidc complet', () => {
expect(() =>
parseConfig({ session: BASE.session, oidc: BASE.oidc, targets: BASE.targets }),
).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, session: {} })).toThrow(ConfigError);
expect(() =>
parseConfig({ ...BASE, oidc: { issuer: BASE.oidc.issuer, clientId: 'x' } }),
).toThrow(ConfigError);
});
it('valide les cibles de dépôt', () => {
expect(() => parseConfig({ ...BASE, targets: [] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, targets: 'photos' })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, targets: [{ name: 'ok' }] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, targets: [{ name: 'a/b', dir: './x' }] })).toThrow(
ConfigError,
);
expect(() =>
parseConfig({
...BASE,
targets: [
{ name: 'dup', dir: './a' },
{ name: 'dup', dir: './b' },
],
}),
).toThrow(ConfigError);
expect(() =>
parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: 'yes' }] }),
).toThrow(ConfigError);
expect(
parseConfig({ ...BASE, targets: [{ name: 'x', label: 'X', dir: './x' }] }).targets[0],
).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') } }),
).toThrow(ConfigError);
});
it('rejette un issuer / publicUrl non http(s) et normalise le slash final', () => {
expect(() => parseConfig({ ...BASE, publicUrl: 'pas-une-url' })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, oidc: { ...BASE.oidc, issuer: 'ftp://x.fr' } })).toThrow(
ConfigError,
);
expect(
parseConfig({ ...BASE, oidc: { ...BASE.oidc, issuer: 'https://x.fr/realm/' } }).oidc.issuer,
).toBe('https://x.fr/realm');
});
it('force le scope openid et lit maxAgeHours', () => {
expect(
parseConfig({ ...BASE, oidc: { ...BASE.oidc, scopes: ['profile'] } }).oidc.scopes,
).toEqual(['openid', 'profile']);
expect(
parseConfig({ ...BASE, session: { key: KEY_B64, maxAgeHours: 2 } }).session.maxAgeSeconds,
).toBe(7200);
});
it('valide port, maxFileSizeMb et logLevel', () => {
expect(() => parseConfig({ ...BASE, port: 3.5 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, maxFileSizeMb: 0 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, logLevel: 'verbose' })).toThrow(ConfigError);
});
it('lit et borne chunkPoolSize', () => {
expect(parseConfig({ ...BASE, chunkPoolSize: 8 }).chunkPoolSize).toBe(8);
expect(() => parseConfig({ ...BASE, chunkPoolSize: 0 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, chunkPoolSize: -4 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, chunkPoolSize: 12.5 })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, chunkPoolSize: 257 })).toThrow(ConfigError);
});
it('webhooks : absent => liste vide, valeurs par défaut appliquées', () => {
expect(parseConfig(structuredClone(BASE)).webhooks).toEqual([]);
expect(
parseConfig({ ...BASE, webhooks: [{ url: 'https://hook.example/mediadrop' }] }).webhooks,
).toEqual([
{
url: 'https://hook.example/mediadrop',
events: ['upload', 'error'],
headers: {},
timeoutMs: 5000,
},
]);
});
it('webhooks : lit events, headers, secret et timeoutMs', () => {
expect(
parseConfig({
...BASE,
webhooks: [
{
url: 'http://192.168.1.5:9000/notify',
events: ['error', 'error'],
headers: { 'X-Token': 'abc' },
secret: 's3cr3t',
timeoutMs: 1500,
},
],
}).webhooks,
).toEqual([
{
url: 'http://192.168.1.5:9000/notify',
events: ['error'],
headers: { 'X-Token': 'abc' },
secret: 's3cr3t',
timeoutMs: 1500,
},
]);
});
it('webhooks : rejette les entrées invalides', () => {
expect(() => parseConfig({ ...BASE, webhooks: {} })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, webhooks: [{}] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, webhooks: [{ url: 'pas-une-url' }] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, webhooks: [{ url: 'ftp://x.fr/hook' }] })).toThrow(
ConfigError,
);
expect(() =>
parseConfig({ ...BASE, webhooks: [{ url: 'https://x.fr/h', events: ['boom'] }] }),
).toThrow(ConfigError);
expect(() =>
parseConfig({ ...BASE, webhooks: [{ url: 'https://x.fr/h', events: [] }] }),
).toThrow(ConfigError);
expect(() =>
parseConfig({ ...BASE, webhooks: [{ url: 'https://x.fr/h', headers: { a: 1 } }] }),
).toThrow(ConfigError);
expect(() =>
parseConfig({ ...BASE, webhooks: [{ url: 'https://x.fr/h', timeoutMs: -1 }] }),
).toThrow(ConfigError);
expect(() =>
parseConfig({ ...BASE, webhooks: [{ url: 'https://x.fr/h', timeoutMs: 60000 }] }),
).toThrow(ConfigError);
});
it('interprète allowedMimeTypes', () => {
expect(
parseConfig({ ...BASE, allowedMimeTypes: ['image/png', 'IMAGE/WEBP'] }).allowedMimeTypes,
).toEqual(['image/png', 'image/webp']);
expect(parseConfig({ ...BASE, allowedMimeTypes: 'all' }).allowedMimeTypes).toBe('all');
expect(() => parseConfig({ ...BASE, allowedMimeTypes: 'image/png' })).toThrow(ConfigError);
});
it('accepte les jokers valides, rejette les jokers malformés', () => {
expect(
parseConfig({ ...BASE, allowedMimeTypes: ['video/*', 'image/png', '*/*'] }).allowedMimeTypes,
).toEqual(['video/*', 'image/png', '*/*']);
expect(() => parseConfig({ ...BASE, allowedMimeTypes: ['video/**'] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, allowedMimeTypes: ['vi*deo/mp4'] })).toThrow(ConfigError);
expect(() => parseConfig({ ...BASE, allowedMimeTypes: ['video/'] })).toThrow(ConfigError);
});
});
describe('loadConfig', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'mediadrop-cfg-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('lit et valide le fichier désigné par la variable d’environnement', async () => {
const file = path.join(dir, 'app.json');
await writeFile(file, JSON.stringify(BASE));
expect(loadConfig({ [CONFIG_PATH_ENV]: file }).oidc.clientId).toBe('mediadrop');
});
it('lève ConfigError si le fichier est absent ou si le JSON est cassé', async () => {
expect(() => loadConfig({ [CONFIG_PATH_ENV]: path.join(dir, 'absent.json') })).toThrow(
ConfigError,
);
const broken = path.join(dir, 'broken.json');
await writeFile(broken, '{ oidc: ');
expect(() => loadConfig({ [CONFIG_PATH_ENV]: broken })).toThrow(ConfigError);
});
});