mediaDrop/tests/helpers.ts
Nicolas 615d0fd45a
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 13s
Types MIME configurables par cible d'upload
`targets[].allowedMimeTypes` : chaque cible peut définir sa propre
politique (mêmes formes : "all", types exacts, jokers "image/*"…).
Absente => la cible hérite du `allowedMimeTypes` global, qui devient
la valeur par défaut. Résolu au parsing : chaque `DropTarget` porte
toujours une politique.

- `POST /upload` et `POST /upload/init` valident contre
  `target.allowedMimeTypes` (au lieu de la valeur globale).
- `GET /targets` renvoie `allowedMimeTypes` ; la page en déduit
  l'attribut `accept` du sélecteur de fichiers (le serveur reste
  l'autorité — le glisser-déposer n'est pas filtré).

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

227 lines
7.6 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, 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;
}