mediaDrop/tests/helpers.ts
Nicolas 8eb0470474
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 10s
Notifications par webhook (upload finalisé / échec)
Nouvelle clé `webhooks` : liste de points de terminaison POST notifiés à
chaque upload finalisé (`upload`, direct comme fragmenté) et à chaque
requête `/upload*` terminée en 4xx/5xx (`error`).

Chaque entrée : `url` (http/https), `events` (défaut : les deux),
`headers` (auth du récepteur), `secret` (signature HMAC-SHA256 dans
`X-MediaDrop-Signature`), `timeoutMs` (défaut 5 s, max 30 s).

Corps JSON structuré + champs `text`/`content` portant un résumé lisible,
pour fonctionner tel quel avec Slack / Discord / ntfy ou un récepteur
custom. Livraison en parallèle, sans bloquer la réponse d'upload ; une
nouvelle tentative sur erreur réseau / 429 / 5xx ; échecs journalisés en
`warn` ; l'arrêt du serveur attend les envois en cours. Sans webhook
configuré, le notifieur est une non-opération.

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

207 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 } 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, allowDirectories = false): DropTargets {
return [{ name: 'main', label: 'Principal', dir, allowDirectories }];
}
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',
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'],
},
...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;
}