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>
78 lines
2.4 KiB
TypeScript
78 lines
2.4 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
|
||
import { UnauthorizedError } from '../src/errors.js';
|
||
import { createOidcClient, type OidcClient } from '../src/oidc.js';
|
||
import { makeConfig, startStubIdp, type StubIdp } from './helpers.js';
|
||
|
||
let idp: StubIdp;
|
||
let oidc: OidcClient;
|
||
|
||
const REDIRECT_URI = 'http://localhost:3000/auth/callback';
|
||
const CODE_VERIFIER = 'a'.repeat(64);
|
||
|
||
beforeAll(async () => {
|
||
idp = await startStubIdp();
|
||
oidc = await createOidcClient(
|
||
makeConfig({
|
||
oidc: {
|
||
issuer: idp.issuer,
|
||
clientId: idp.clientId,
|
||
clientSecret: idp.clientSecret,
|
||
scopes: ['openid', 'profile', 'email'],
|
||
},
|
||
}),
|
||
);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await idp.close();
|
||
});
|
||
|
||
describe('createOidcClient', () => {
|
||
it('découvre un issuer en http (stub) et expose le scope', () => {
|
||
expect(oidc.scope).toBe('openid profile email');
|
||
});
|
||
|
||
it('construit une URL d’autorisation PKCE', () => {
|
||
const url = oidc.authorizationUrl({
|
||
redirectUri: REDIRECT_URI,
|
||
state: 's1',
|
||
nonce: 'n1',
|
||
codeChallenge: 'challenge',
|
||
});
|
||
expect(url.origin).toBe(idp.issuer);
|
||
expect(url.searchParams.get('response_type')).toBe('code');
|
||
expect(url.searchParams.get('state')).toBe('s1');
|
||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||
expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI);
|
||
});
|
||
|
||
it('échange le code et renvoie l’identité', async () => {
|
||
idp.setNextIdToken({ nonce: 'n2', sub: 'zoe', name: 'Zoé' });
|
||
const user = await oidc.handleCallback({
|
||
currentUrl: new URL(`${REDIRECT_URI}?code=abc&state=s2`),
|
||
state: 's2',
|
||
nonce: 'n2',
|
||
codeVerifier: CODE_VERIFIER,
|
||
});
|
||
expect(user).toEqual({ sub: 'zoe', name: 'Zoé' });
|
||
});
|
||
|
||
it('rejette un nonce qui ne correspond pas', async () => {
|
||
idp.setNextIdToken({ nonce: 'autre', sub: 'zoe' });
|
||
await expect(
|
||
oidc.handleCallback({
|
||
currentUrl: new URL(`${REDIRECT_URI}?code=abc&state=s3`),
|
||
state: 's3',
|
||
nonce: 'n3',
|
||
codeVerifier: CODE_VERIFIER,
|
||
}),
|
||
).rejects.toBeInstanceOf(UnauthorizedError);
|
||
});
|
||
|
||
it('expose une URL de déconnexion RP-initiated', () => {
|
||
const url = oidc.endSessionUrl('http://localhost:3000/');
|
||
expect(url?.origin).toBe(idp.issuer);
|
||
expect(url?.searchParams.get('post_logout_redirect_uri')).toBe('http://localhost:3000/');
|
||
});
|
||
});
|