mediaDrop/tests/helpers.ts
Nicolas 246b078fec fix(server): destination des uploads fragmentés et gestion des erreurs
- init fragmenté : lit target/path dans la query (corps en repli) (#1)
- erreurs client interrompu / disque plein / fichier partiel absent mappées
  en 400/507/404 sans webhook d'échec pour les interruptions (#2)
- nettoyage des sessions expirées et des .part orphelins sécurisé,
  log des unhandledRejection (#2)
- plafond de sessions porté à 256 pour l'usage multi-fenêtres (#3)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:32:17 +02:00

227 lines
7.6 KiB
TypeScript
Raw Permalink 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, 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<string> {
return mkdtemp(path.join(tmpdir(), 'mediadrop-'));
}
export async function removeDir(dir: string): Promise<void> {
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<DropTarget, 'name' | 'dir'> & Partial<DropTarget>;
export interface ConfigOverrides extends Partial<Omit<AppConfig, 'targets'>> {
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<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;
}