272 lines
9.9 KiB
TypeScript
272 lines
9.9 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',
|
||
chunkPoolSize: 30
|
||
});
|
||
expect(config.targets).toEqual([
|
||
{
|
||
name: 'photos',
|
||
label: 'photos',
|
||
dir: './uploads/photos',
|
||
allowDirectories: false,
|
||
allowedMimeTypes: DEFAULT_ALLOWED_MIME,
|
||
permission:0o666
|
||
},
|
||
]);
|
||
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,
|
||
permission:0o666
|
||
});
|
||
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);
|
||
});
|
||
});
|