chore: harden security, clean deps, add vitest baseline

- .env.example: vider les secrets (à rotater côté Plesk)
- src/lib/auth.ts: fail-fast lazy si ADMIN_JWT_SECRET < 32 chars
- src/pages/api/upload.ts: retirer .svg (anti-XSS) + try/catch FS
- src/pages/api/content/[section].ts: masquer err.message (sauf ZodError)
- retirer gsap/three/@types/three/@react-three/{fiber,drei}/lucide-react
- bump astro 6.1.10 (GHSA-xr5h-phrj-8vxv)
- zod 4: z.email()/z.url() au lieu de .email()/.url()
- nettoyer vars inutilisées + Header script is:inline

Vitest (38 tests, 615ms):
- tests/lib/auth.test.ts: verifyPassword + verifySession (round-trip, secrets distincts)
- tests/lib/content.test.ts: load/save + comportement seed
- tests/api/files.test.ts: path traversal (../, /, imbriqué) + 200
- tests/api/upload.test.ts: extension/taille/sanitize folder + bucket
- tests/middleware.test.ts: gating admin pages vs APIs vs routes publiques
- astro check: 0 errors / 0 warnings / 0 hints

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Johan LEROY
2026-05-13 16:07:05 +02:00
parent 859683b624
commit 7327959e9e
23 changed files with 1051 additions and 816 deletions

94
tests/middleware.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import { onRequest } from '../src/middleware';
import { createSession, ADMIN_COOKIE } from '../src/lib/auth';
const NEXT_SENTINEL = new Response('next-ok', { status: 200 });
const next = async () => NEXT_SENTINEL;
function makeContext(url: string, cookieValue?: string) {
return {
url: new URL(url),
cookies: {
get: (name: string) =>
name === ADMIN_COOKIE && cookieValue !== undefined ? { value: cookieValue } : undefined,
},
redirect: (path: string) =>
new Response(null, { status: 302, headers: { Location: path } }),
} as Parameters<typeof onRequest>[0];
}
async function run(url: string, cookieValue?: string) {
return (await onRequest(makeContext(url, cookieValue), next as Parameters<typeof onRequest>[1])) as Response;
}
describe('middleware — routes publiques', () => {
it('/ sans cookie → next', async () => {
const res = await run('http://localhost/');
expect(res).toBe(NEXT_SENTINEL);
});
it('/admin/login sans cookie → next (page de login publique)', async () => {
const res = await run('http://localhost/admin/login');
expect(res).toBe(NEXT_SENTINEL);
});
it('/api/auth/login sans cookie → next (route publique)', async () => {
const res = await run('http://localhost/api/auth/login');
expect(res).toBe(NEXT_SENTINEL);
});
});
describe('middleware — pages admin', () => {
it('/admin sans cookie → redirect /admin/login', async () => {
const res = await run('http://localhost/admin');
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login');
});
it('/admin/projets sans cookie → redirect /admin/login', async () => {
const res = await run('http://localhost/admin/projets');
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login');
});
it('/admin/projets avec token valide → next', async () => {
const token = await createSession();
const res = await run('http://localhost/admin/projets', token);
expect(res).toBe(NEXT_SENTINEL);
});
it('/admin/projets avec token invalide → redirect /admin/login', async () => {
const res = await run('http://localhost/admin/projets', 'not.a.real.jwt');
expect(res.status).toBe(302);
expect(res.headers.get('Location')).toBe('/admin/login');
});
});
describe('middleware — APIs admin', () => {
it('/api/content/profile sans cookie → 401 JSON', async () => {
const res = await run('http://localhost/api/content/profile');
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'unauthorized' });
});
it('/api/upload sans cookie → 401 JSON', async () => {
const res = await run('http://localhost/api/upload');
expect(res.status).toBe(401);
});
it('/api/auth/logout sans cookie → 401 JSON', async () => {
const res = await run('http://localhost/api/auth/logout');
expect(res.status).toBe(401);
});
it('/api/content/profile avec token invalide → 401 JSON', async () => {
const res = await run('http://localhost/api/content/profile', 'garbage');
expect(res.status).toBe(401);
});
it('/api/content/profile avec token valide → next', async () => {
const token = await createSession();
const res = await run('http://localhost/api/content/profile', token);
expect(res).toBe(NEXT_SENTINEL);
});
});