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