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>
98 lines
2.9 KiB
TypeScript
98 lines
2.9 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||
|
||
import { buildApp } from '../src/app.js';
|
||
import {
|
||
login,
|
||
makeConfig,
|
||
makeTempDir,
|
||
oneTarget,
|
||
removeDir,
|
||
SESSION_COOKIE,
|
||
startStubIdp,
|
||
type StubIdp,
|
||
} from './helpers.js';
|
||
|
||
let idp: StubIdp;
|
||
let app: FastifyInstance;
|
||
let uploadDir: string;
|
||
|
||
beforeAll(async () => {
|
||
idp = await startStubIdp();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await idp.close();
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
uploadDir = await makeTempDir();
|
||
app = await buildApp(
|
||
makeConfig({
|
||
targets: oneTarget(uploadDir),
|
||
oidc: {
|
||
issuer: idp.issuer,
|
||
clientId: idp.clientId,
|
||
clientSecret: idp.clientSecret,
|
||
scopes: ['openid', 'profile', 'email'],
|
||
},
|
||
}),
|
||
{ logger: false },
|
||
);
|
||
await app.ready();
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await app.close();
|
||
await removeDir(uploadDir);
|
||
});
|
||
|
||
describe('flux d’authentification', () => {
|
||
it('GET /auth/login redirige vers l’issuer avec un défi PKCE et pose un cookie', async () => {
|
||
const res = await app.inject({ method: 'GET', url: '/auth/login' });
|
||
expect(res.statusCode).toBe(302);
|
||
const target = new URL(res.headers.location as string);
|
||
expect(target.origin).toBe(idp.issuer);
|
||
expect(target.searchParams.get('code_challenge')).toBeTruthy();
|
||
expect(target.searchParams.get('state')).toBeTruthy();
|
||
expect(res.cookies.some((c) => c.name === SESSION_COOKIE)).toBe(true);
|
||
});
|
||
|
||
it('GET /auth/me est anonyme par défaut, authentifié après login', async () => {
|
||
const anon = await app.inject({ method: 'GET', url: '/auth/me' });
|
||
expect(anon.json()).toEqual({ authenticated: false });
|
||
|
||
const cookie = await login(app, idp, { name: 'Alice' });
|
||
const me = await app.inject({
|
||
method: 'GET',
|
||
url: '/auth/me',
|
||
cookies: { [SESSION_COOKIE]: cookie },
|
||
});
|
||
expect(me.json()).toEqual({ authenticated: true, name: 'Alice' });
|
||
});
|
||
|
||
it('POST /auth/logout vide la session', async () => {
|
||
const cookie = await login(app, idp);
|
||
const out = await app.inject({
|
||
method: 'POST',
|
||
url: '/auth/logout',
|
||
cookies: { [SESSION_COOKIE]: cookie },
|
||
});
|
||
expect(out.statusCode).toBe(302);
|
||
|
||
const cleared = out.cookies.find((c) => c.name === SESSION_COOKIE);
|
||
const afterCookie = cleared ? cleared.value : cookie;
|
||
const me = await app.inject({
|
||
method: 'GET',
|
||
url: '/auth/me',
|
||
cookies: { [SESSION_COOKIE]: afterCookie },
|
||
});
|
||
expect(me.json()).toEqual({ authenticated: false });
|
||
});
|
||
|
||
it('GET /auth/callback sans transaction redirige avec ?login=expired', async () => {
|
||
const res = await app.inject({ method: 'GET', url: '/auth/callback?code=x&state=y' });
|
||
expect(res.statusCode).toBe(302);
|
||
expect(res.headers.location).toBe('http://localhost:3000/?login=expired');
|
||
});
|
||
});
|