Serveur TypeScript (Fastify 5) permettant de déposer des fichiers médias
dans des dossiers nommés, via une page web de glisser-déposer.
- Authentification OpenID Connect gérée côté serveur (Authorization Code +
PKCE, client confidentiel, openid-client) ; session via cookie chiffré
(@fastify/secure-session).
- Configuration dans un fichier JSON (schéma fourni) ; l'environnement ne
porte que le pointeur MEDIADROP_CONFIG.
- Cibles de dépôt nommées : liste { name, label, dir }, sélection par
?target=, première cible par défaut.
- Upload multipart en streaming : nom de fichier dérivé côté serveur (pas
de path traversal), liste blanche de types MIME, plafond de taille.
- TypeScript strict, ESLint + Prettier sans warning, 46 tests Vitest.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
149 lines
5.4 KiB
TypeScript
149 lines
5.4 KiB
TypeScript
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',
|
||
});
|
||
expect(config.targets).toEqual([{ name: 'photos', label: 'photos', dir: './uploads/photos' }]);
|
||
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', label: 'X', dir: './x' }] }).targets[0],
|
||
).toEqual({ name: 'x', label: 'X', dir: './x' });
|
||
});
|
||
|
||
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('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);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|