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