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, DropTarget } from '../src/config.js'; import type { MimePolicy } from '../src/lib/media-types.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 { return mkdtemp(path.join(tmpdir(), 'mediadrop-')); } export async function removeDir(dir: string): Promise { await rm(dir, { recursive: true, force: true }); } /** Cible de test : `name` et `dir` requis, le reste prend des valeurs par défaut. */ export type TestTarget = Pick & Partial; export interface ConfigOverrides extends Partial> { targets?: readonly TestTarget[]; } const DEFAULT_TEST_MIME: MimePolicy = ['image/png', 'image/jpeg']; /** Liste à une seule cible pointant vers `dir`. */ export function oneTarget(dir: string, allowDirectories = false): readonly TestTarget[] { return [{ name: 'main', label: 'Principal', dir, allowDirectories }]; } export function makeConfig(overrides: ConfigOverrides = {}): AppConfig { const { targets, ...rest } = overrides; const allowedMimeTypes = rest.allowedMimeTypes ?? DEFAULT_TEST_MIME; const resolved = (targets ?? oneTarget('./uploads')).map((target): DropTarget => ({ name: target.name, label: target.label ?? target.name, dir: target.dir, allowDirectories: target.allowDirectories ?? false, allowedMimeTypes: target.allowedMimeTypes ?? allowedMimeTypes })); return { port: 0, host: '127.0.0.1', publicUrl: TEST_PUBLIC_URL, maxFileSizeBytes: 1024 * 1024, logLevel: 'silent', chunkPoolSize: 30, webhooks: [], 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'], }, ...rest, allowedMimeTypes, targets: resolved as [DropTarget, ...DropTarget[]], }; } 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; } export async function startStubIdp(): Promise { 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 = { 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 { 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; }