mediaDrop/tests/config.test.ts
Nicolas 64a38b1e41
All checks were successful
Publier l'image Docker / test (push) Successful in 17s
Publier l'image Docker / publish (push) Successful in 11s
Upload fragmenté : pool de connexions partagé et configurable
Remplace les 3 workers par fichier par un pool global de morceaux en vol,
réparti équitablement entre les fichiers fragmentés actifs : 1 fichier
utilise tout le pool, N fichiers en reçoivent une part chacun
(ceil(taille / N)), la capacité libérée par un fichier terminé est
redistribuée. Un fichier en cours d'init compte dans la part pour ne pas
être affamé ; les morceaux déjà en vol ne sont jamais interrompus.

La taille du pool est réglable via `chunkPoolSize` (config.json, défaut
30, borné à 256) et transmise au navigateur par un nouveau `GET /config`.

Corps des morceaux repassé en streaming (writeChunkAt sur un flux + garde
`maxBytes`) : mémoire ~constante même à pool élevé, plus de bufferisation
de 16 Mo par requête.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:12:04 +02:00

176 lines
6.8 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 },
]);
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 });
expect(
parseConfig({ ...BASE, targets: [{ name: 'x', dir: './x', allowDirectories: true }] })
.targets[0].allowDirectories,
).toBe(true);
});
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('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);
});
});