mediaDrop/tests/helpers.ts
Nicolas fe8bd26bb7 Initial commit: mediaDrop — serveur de dépôt de fichiers médias
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>
2026-09-09 23:06:53 +02:00

205 lines
6.7 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 } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import Fastify, { type FastifyInstance } from 'fastify';
import { SignJWT, exportJWK, generateKeyPair } from 'jose';
import type { AppConfig, DropTargets } from '../src/config.js';
export const TEST_PUBLIC_URL = 'http://localhost:3000';
export const TEST_CLIENT_ID = 'mediadrop-test';
export const TEST_CLIENT_SECRET = 'test-client-secret';
export const SESSION_COOKIE = 'mediadrop_session';
const TEST_KID = 'test-key';
/** Clé de session déterministe (32 octets) pour @fastify/secure-session. */
export const TEST_SESSION_KEY_B64 = Buffer.alloc(32, 7).toString('base64');
/** Petit PNG 1x1 (signature valide) ; seul le type MIME annoncé compte côté serveur. */
export const TINY_PNG = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000' +
'000a49444154789c6360000000020001e221bc330000000049454e44ae426082',
'hex',
);
export async function makeTempDir(): Promise<string> {
return mkdtemp(path.join(tmpdir(), 'mediadrop-'));
}
export async function removeDir(dir: string): Promise<void> {
await rm(dir, { recursive: true, force: true });
}
/** Liste à une seule cible pointant vers `dir`. */
export function oneTarget(dir: string): DropTargets {
return [{ name: 'main', label: 'Principal', dir }];
}
export function makeConfig(overrides: Partial<AppConfig> = {}): AppConfig {
return {
port: 0,
host: '127.0.0.1',
publicUrl: TEST_PUBLIC_URL,
targets: oneTarget('./uploads'),
maxFileSizeBytes: 1024 * 1024,
allowedMimeTypes: ['image/png', 'image/jpeg'],
logLevel: 'silent',
session: { key: Buffer.alloc(32, 7), maxAgeSeconds: 3600 },
oidc: {
issuer: 'https://idp.test',
clientId: TEST_CLIENT_ID,
clientSecret: TEST_CLIENT_SECRET,
scopes: ['openid', 'profile', 'email'],
},
...overrides,
};
}
export interface NextIdToken {
nonce?: string;
sub?: string;
name?: string;
email?: string;
}
/** Fournisseur OIDC factice : `.well-known`, JWKS et `POST /token` signant un id_token RS256. */
export interface StubIdp {
issuer: string;
clientId: string;
clientSecret: string;
/** Claims du prochain id_token émis par `POST /token`. */
setNextIdToken: (claims: NextIdToken) => void;
close: () => Promise<void>;
}
export async function startStubIdp(): Promise<StubIdp> {
const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true });
const publicJwk = { ...(await exportJWK(publicKey)), kid: TEST_KID, alg: 'RS256', use: 'sig' };
const jwks = { keys: [publicJwk] };
let next: Required<NextIdToken> = {
nonce: '',
sub: 'user-123',
name: 'Test User',
email: 'user@example.test',
};
const server = Fastify({ logger: false });
// openid-client POSTe le token request en x-www-form-urlencoded ; on accepte sans le parser.
server.addContentTypeParser(
'application/x-www-form-urlencoded',
{ parseAs: 'string' },
(_request, body, done) => {
done(null, body);
},
);
server.get('/jwks', () => jwks);
server.get('/.well-known/openid-configuration', (request) => {
const base = `http://${request.headers.host ?? '127.0.0.1'}`;
return {
issuer: base,
authorization_endpoint: `${base}/authorize`,
token_endpoint: `${base}/token`,
jwks_uri: `${base}/jwks`,
end_session_endpoint: `${base}/logout`,
userinfo_endpoint: `${base}/userinfo`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
code_challenge_methods_supported: ['S256'],
scopes_supported: ['openid', 'profile', 'email'],
};
});
server.post('/token', async (request) => {
const base = `http://${request.headers.host ?? '127.0.0.1'}`;
const idToken = await new SignJWT({
...(next.nonce.length > 0 ? { nonce: next.nonce } : {}),
name: next.name,
preferred_username: next.name,
email: next.email,
auth_time: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ alg: 'RS256', kid: TEST_KID })
.setIssuer(base)
.setSubject(next.sub)
.setAudience(TEST_CLIENT_ID)
.setIssuedAt()
.setExpirationTime('5m')
.sign(privateKey);
return {
access_token: 'stub-access-token',
token_type: 'Bearer',
expires_in: 300,
scope: 'openid profile email',
id_token: idToken,
};
});
await server.listen({ port: 0, host: '127.0.0.1' });
const address = server.server.address();
if (address === null || typeof address === 'string') {
await server.close();
throw new Error('stub IdP : adresse d’écoute inattendue');
}
return {
issuer: `http://127.0.0.1:${String(address.port)}`,
clientId: TEST_CLIENT_ID,
clientSecret: TEST_CLIENT_SECRET,
setNextIdToken(claims: NextIdToken): void {
next = {
nonce: '',
sub: 'user-123',
name: 'Test User',
email: 'user@example.test',
...claims,
};
},
close: () => server.close(),
};
}
/**
* Déroule le flux de connexion via `app.inject` et renvoie la valeur du cookie de
* session authentifié, à repasser dans `cookies` pour les requêtes protégées.
*/
export async function login(
app: FastifyInstance,
idp: StubIdp,
user: { sub?: string; name?: string } = {},
): Promise<string> {
const start = await app.inject({ method: 'GET', url: '/auth/login' });
const location = start.headers.location;
if (typeof location !== 'string') {
throw new Error(`login: /auth/login n'a pas redirigé (${String(start.statusCode)})`);
}
const authUrl = new URL(location);
const state = authUrl.searchParams.get('state') ?? '';
const nonce = authUrl.searchParams.get('nonce') ?? '';
const txnCookie = start.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
if (txnCookie === undefined) {
throw new Error('login: cookie de transaction absent');
}
idp.setNextIdToken({ nonce, sub: user.sub ?? 'user-123', name: user.name ?? 'Test User' });
const callback = await app.inject({
method: 'GET',
url: `/auth/callback?code=stub-code&state=${encodeURIComponent(state)}`,
cookies: { [SESSION_COOKIE]: txnCookie.value },
});
const sessionCookie = callback.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
if (sessionCookie === undefined || callback.headers.location !== `${TEST_PUBLIC_URL}/`) {
throw new Error(
`login: callback en échec (${String(callback.statusCode)} -> ${String(callback.headers.location)})`,
);
}
return sessionCookie.value;
}