Le <form> de déconnexion envoie `application/x-www-form-urlencoded` ; Fastify n'ayant aucun parseur pour ce type répondait 415 (FST_ERR_CTP_INVALID_MEDIA_TYPE) avant même d'atteindre le handler. Ajout d'un parseur `application/x-www-form-urlencoded` (URLSearchParams -> objet, sans dépendance). Le test de déconnexion envoie désormais l'en-tête du navigateur pour couvrir le cas. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
102 lines
3.1 KiB
TypeScript
102 lines
3.1 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 (soumission de <form> HTML)', async () => {
|
||
const cookie = await login(app, idp);
|
||
// Le navigateur envoie un POST application/x-www-form-urlencoded : le serveur
|
||
// doit l'accepter (et non répondre 415).
|
||
const out = await app.inject({
|
||
method: 'POST',
|
||
url: '/auth/logout',
|
||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||
payload: '',
|
||
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');
|
||
});
|
||
});
|