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 9b7bc5334e
commit c3db6bfa2b
23 changed files with 1051 additions and 816 deletions

View File

@@ -1,10 +1,7 @@
# Admin credentials — générer via `npm run hash:password -- monMotDePasse`
# Admin credentials — générer via `npm run hash:password -- 'monMotDePasse'`
ADMIN_PASSWORD_HASH=
ADMIN_JWT_SECRET=
# Port de production (Plesk Node.js)
PORT=3100
HOST=0.0.0.0
ADMIN_PASSWORD_HASH=$2b$12$1nXI9CgStfBs/DrtSpJ2buF1hnUCUTpcx6rDZNRczlkuCHXb.AjbK
ADMIN_JWT_SECRET=wef4R2NWoSko/bnIYFOyaQamI0QqOLe3J4ZSjsGdmvfgtADIT26Y6piFoM0l8h5o

3
.gitignore vendored
View File

@@ -30,3 +30,6 @@ pnpm-debug.log*
# runtime data (uploads + JSON content edited via /admin)
# kept on the server, not deployed via FTP
data/
# test artifacts (coverage)
coverage/

1353
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,9 @@
"start": "HOST=0.0.0.0 PORT=3100 node ./dist/server/entry.mjs",
"astro": "astro",
"check": "astro check",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"hash:password": "node scripts/hash-password.mjs"
},
"dependencies": {
@@ -19,30 +22,26 @@
"@astrojs/react": "^5.0.3",
"@hookform/resolvers": "^5.2.2",
"@iconify-icon/react": "^3.0.3",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"astro": "^6.1.8",
"bcryptjs": "^3.0.3",
"framer-motion": "^12.38.0",
"gsap": "^3.15.0",
"jose": "^6.2.2",
"lenis": "^1.3.23",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.75.0",
"tailwindcss": "^4.2.2",
"three": "^0.184.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@astrojs/check": "^0.9.8",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^25.6.0",
"@types/three": "^0.184.0",
"typescript": "^5.9.3"
"@vitest/coverage-v8": "^4.1.6",
"typescript": "^5.9.3",
"vitest": "^4.1.6"
}
}

View File

@@ -198,7 +198,7 @@ const current = Astro.url.pathname;
</div>
</header>
<script data-astro-rerun>
<script is:inline data-astro-rerun>
// Mobile menu
const btn = document.getElementById('mobile-menu-btn');
const menu = document.getElementById('mobile-menu');

View File

@@ -7,7 +7,6 @@ interface Props {
const { project } = Astro.props;
const isArchived = project.status === 'archived';
const isPrivate = project.status === 'private';
const isWIP = project.status === 'wip';
const statusLabel =
project.status === 'live'
? 'Live'

View File

@@ -94,7 +94,7 @@ export default function PatchBay({ technical, soft }: Props) {
Techniques
</h3>
<ul className="space-y-2.5">
{technical.map((s, i) => (
{technical.map((s) => (
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
<span className="text-[color:var(--color-text-muted)] flex-1">{s}</span>
<span
@@ -122,7 +122,7 @@ export default function PatchBay({ technical, soft }: Props) {
Transverses
</h3>
<ul className="space-y-2.5">
{soft.map((s, i) => (
{soft.map((s) => (
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
<span
data-jack="right"

View File

@@ -4,7 +4,7 @@ export const FormationSchema = z.object({
id: z.string(),
title: z.string(),
school: z.string(),
schoolUrl: z.string().url().optional(),
schoolUrl: z.url().optional(),
period: z.string(),
description: z.string().optional(),
});

View File

@@ -23,11 +23,11 @@ export const ProfileSchema = z.object({
.default([]),
}),
socials: z.object({
email: z.string().email().optional(),
github: z.string().url().optional(),
gitea: z.string().url().optional(),
linkedin: z.string().url().optional(),
website: z.string().url().optional(),
email: z.email().optional(),
github: z.url().optional(),
gitea: z.url().optional(),
linkedin: z.url().optional(),
website: z.url().optional(),
}),
cv: z.string().optional(),
});

View File

@@ -8,8 +8,8 @@ export const ProjectSchema = z.object({
image: z.string().optional(),
category: z.enum(['web', 'mobile', 'bot', 'infra', 'other']).default('web'),
status: z.enum(['live', 'archived', 'private', 'wip']).default('live'),
url: z.string().url().optional(),
repoUrl: z.string().url().optional(),
url: z.url().optional(),
repoUrl: z.url().optional(),
stack: z.array(z.string()).default([]),
featured: z.boolean().default(false),
});

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
export const SiteSchema = z.object({
title: z.string(),
description: z.string(),
url: z.string().url(),
url: z.url(),
keywords: z.array(z.string()).default([]),
ogImage: z.string(),
twitter: z.string().optional(),

View File

@@ -9,9 +9,22 @@ const env = {
};
const ADMIN_PASSWORD_HASH = (env.ADMIN_PASSWORD_HASH as string | undefined) ?? '';
const JWT_SECRET =
(env.ADMIN_JWT_SECRET as string | undefined) ?? 'dev-secret-change-me-in-production';
const SECRET_KEY = new TextEncoder().encode(JWT_SECRET);
const JWT_SECRET = (env.ADMIN_JWT_SECRET as string | undefined) ?? '';
// Lazy : on ne throw pas au chargement du module (Astro build importe ce fichier
// en SSR sans forcément avoir le .env). On throw au premier usage runtime.
let cachedKey: Uint8Array | null = null;
function getSecretKey(): Uint8Array {
if (cachedKey) return cachedKey;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
throw new Error(
'[auth] ADMIN_JWT_SECRET manquant ou trop court (>= 32 chars). ' +
'Générer via `npm run hash:password -- <motdepasse>` et reporter dans .env.',
);
}
cachedKey = new TextEncoder().encode(JWT_SECRET);
return cachedKey;
}
export const ADMIN_COOKIE = 'portfolio_admin';
@@ -28,13 +41,13 @@ export async function createSession(): Promise<string> {
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(SECRET_KEY);
.sign(getSecretKey());
}
export async function verifySession(token: string | undefined): Promise<boolean> {
if (!token) return false;
try {
await jwtVerify(token, SECRET_KEY);
await jwtVerify(token, getSecretKey());
return true;
} catch {
return false;

View File

@@ -19,10 +19,8 @@ export const GET: APIRoute = async ({ params }) => {
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
return new Response(
JSON.stringify({ error: 'load_failed', message: (err as Error).message }),
{ status: 500 },
);
console.error('[content] load_failed', section, err);
return new Response(JSON.stringify({ error: 'load_failed' }), { status: 500 });
}
};
@@ -43,8 +41,10 @@ export const PUT: APIRoute = async ({ params, request }) => {
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
console.error('[content] validation_failed', section, err);
const message = err instanceof Error && err.name === 'ZodError' ? err.message : undefined;
return new Response(
JSON.stringify({ error: 'validation_failed', message: (err as Error).message }),
JSON.stringify(message ? { error: 'validation_failed', message } : { error: 'validation_failed' }),
{ status: 400 },
);
}

View File

@@ -6,7 +6,7 @@ import { join, extname, basename } from 'node:path';
const UPLOAD_ROOT = join(process.cwd(), 'data', 'uploads');
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg', '.gif']);
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
const DOCUMENT_EXT = new Set(['.pdf']);
const MAX_SIZE: Record<'image' | 'document', number> = {
@@ -43,11 +43,16 @@ export const POST: APIRoute = async ({ request }) => {
const safeFolder = folder.replace(/[^a-zA-Z0-9_-]/g, '');
const bucket = kind === 'document' ? 'assets' : 'img';
const targetDir = safeFolder ? join(UPLOAD_ROOT, bucket, safeFolder) : join(UPLOAD_ROOT, bucket);
await mkdir(targetDir, { recursive: true });
const filename = sanitize(file.name);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(join(targetDir, filename), buffer);
try {
await mkdir(targetDir, { recursive: true });
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(join(targetDir, filename), buffer);
} catch (err) {
console.error('[upload] write_failed', err);
return new Response(JSON.stringify({ error: 'write_failed' }), { status: 500 });
}
const url = `/api/files/${bucket}/${safeFolder ? safeFolder + '/' : ''}${filename}`;
return new Response(JSON.stringify({ ok: true, url }), {

View File

@@ -9,7 +9,7 @@ import BootSequence from '../components/islands/BootSequence';
import { Icon } from '@iconify-icon/react';
import { loadAll } from '../lib/content';
const { profile, skills, interests, site } = await loadAll();
const { profile, skills, interests } = await loadAll();
---
<BaseLayout>

View File

@@ -0,0 +1,3 @@
// Stub pour `astro:middleware` (virtual module Astro non résolu en environnement Vitest).
// On expose juste `defineMiddleware` qui retourne la fonction passée telle quelle.
export const defineMiddleware = <T extends (...args: never[]) => unknown>(fn: T): T => fn;

71
tests/api/files.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { APIRoute } from 'astro';
let workDir: string;
let GET: APIRoute;
// PNG 1x1 transparent minimal
const PNG_1X1 = Buffer.from(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c63000100000005000100' +
'0d0a2db40000000049454e44ae426082',
'hex',
);
async function setupTmpRoot() {
workDir = await mkdtemp(join(tmpdir(), 'portfolio-files-'));
await mkdir(join(workDir, 'data', 'uploads', 'img'), { recursive: true });
await writeFile(join(workDir, 'data', 'uploads', 'img', 'test.png'), PNG_1X1);
vi.spyOn(process, 'cwd').mockReturnValue(workDir);
vi.resetModules();
const mod = await import('../../src/pages/api/files/[...path]');
GET = mod.GET as APIRoute;
}
async function cleanup() {
vi.restoreAllMocks();
if (workDir) await rm(workDir, { recursive: true, force: true });
}
beforeEach(setupTmpRoot);
afterEach(cleanup);
function call(pathParam: string) {
return GET({ params: { path: pathParam } } as unknown as Parameters<APIRoute>[0]) as Promise<Response>;
}
describe('GET /api/files/[...path]', () => {
it('retourne 404 si path vide', async () => {
const res = await call('');
expect(res.status).toBe(404);
});
it('bloque le path traversal classique (../../etc/passwd)', async () => {
const res = await call('../../etc/passwd');
expect(res.status).toBe(403);
});
it('bloque le path traversal imbriqué (img/../../../etc/passwd)', async () => {
const res = await call('img/../../../etc/passwd');
expect(res.status).toBe(403);
});
it('chemin absolu (/etc/passwd) est aplati dans UPLOADS_ROOT → 404 sans accès au vrai /etc/passwd', async () => {
const res = await call('/etc/passwd');
expect(res.status).toBe(404);
});
it('retourne 404 sur un fichier inexistant dans uploads', async () => {
const res = await call('img/missing.png');
expect(res.status).toBe(404);
});
it('sert un fichier valide avec le bon Content-Type', async () => {
const res = await call('img/test.png');
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('image/png');
const body = Buffer.from(await res.arrayBuffer());
expect(body.equals(PNG_1X1)).toBe(true);
});
});

98
tests/api/upload.test.ts Normal file
View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, mkdir, stat, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { APIRoute } from 'astro';
let workDir: string;
let POST: APIRoute;
async function setupTmpRoot() {
workDir = await mkdtemp(join(tmpdir(), 'portfolio-upload-'));
await mkdir(join(workDir, 'data', 'uploads'), { recursive: true });
vi.spyOn(process, 'cwd').mockReturnValue(workDir);
vi.resetModules();
const mod = await import('../../src/pages/api/upload');
POST = mod.POST as APIRoute;
}
async function cleanup() {
vi.restoreAllMocks();
if (workDir) await rm(workDir, { recursive: true, force: true });
}
beforeEach(setupTmpRoot);
afterEach(cleanup);
function call(form: FormData) {
const request = new Request('http://localhost/api/upload', { method: 'POST', body: form });
return POST({ request } as unknown as Parameters<APIRoute>[0]) as Promise<Response>;
}
describe('POST /api/upload', () => {
it('400 no_file quand aucun fichier', async () => {
const res = await call(new FormData());
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'no_file' });
});
it('400 invalid_extension sur .exe', async () => {
const form = new FormData();
form.append('file', new File(['x'], 'malware.exe', { type: 'application/octet-stream' }));
const res = await call(form);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'invalid_extension' });
});
it('400 invalid_extension sur .svg (retiré pour anti-XSS)', async () => {
const form = new FormData();
form.append('file', new File(['<svg/>'], 'evil.svg', { type: 'image/svg+xml' }));
const res = await call(form);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'invalid_extension' });
});
it('400 file_too_large quand image > 5 MB', async () => {
const form = new FormData();
const bigBuffer = new Uint8Array(5 * 1024 * 1024 + 1);
form.append('file', new File([bigBuffer], 'big.png', { type: 'image/png' }));
const res = await call(form);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'file_too_large' });
});
it('200 ok sur une image PNG valide', async () => {
const form = new FormData();
form.append('file', new File([new Uint8Array(1024)], 'photo.png', { type: 'image/png' }));
const res = await call(form);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.url).toBe('/api/files/img/photo.png');
const written = await stat(join(workDir, 'data', 'uploads', 'img', 'photo.png'));
expect(written.size).toBe(1024);
});
it('200 ok sur un PDF avec kind=document → bucket assets', async () => {
const form = new FormData();
form.append('file', new File([new Uint8Array(2048)], 'cv.pdf', { type: 'application/pdf' }));
form.append('kind', 'document');
const res = await call(form);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.url).toBe('/api/files/assets/cv.pdf');
const written = await stat(join(workDir, 'data', 'uploads', 'assets', 'cv.pdf'));
expect(written.size).toBe(2048);
});
it('sanitize folder : "evil/../etc" → "eviletc" (caractères non alphanum retirés)', async () => {
const form = new FormData();
form.append('file', new File([new Uint8Array(10)], 'a.png', { type: 'image/png' }));
form.append('folder', 'evil/../etc');
const res = await call(form);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.url).toBe('/api/files/img/eviletc/a.png');
const written = await stat(join(workDir, 'data', 'uploads', 'img', 'eviletc', 'a.png'));
expect(written.size).toBe(10);
});
});

47
tests/lib/auth.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest';
import { SignJWT } from 'jose';
import { createSession, verifySession, verifyPassword } from '../../src/lib/auth';
describe('verifyPassword', () => {
it('accepte le mot de passe correct', async () => {
expect(await verifyPassword('test-password-123')).toBe(true);
});
it('rejette un mauvais mot de passe', async () => {
expect(await verifyPassword('wrong')).toBe(false);
});
it('rejette un mot de passe vide', async () => {
expect(await verifyPassword('')).toBe(false);
});
});
describe('verifySession', () => {
it('rejette un token undefined', async () => {
expect(await verifySession(undefined)).toBe(false);
});
it('rejette un token vide', async () => {
expect(await verifySession('')).toBe(false);
});
it('rejette un token mal formé', async () => {
expect(await verifySession('not.a.jwt')).toBe(false);
});
it('rejette un JWT signé avec un autre secret', async () => {
const otherKey = new TextEncoder().encode('different-secret-of-32-chars-min-aaaa');
const token = await new SignJWT({ role: 'admin' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(otherKey);
expect(await verifySession(token)).toBe(false);
});
it('round-trip : createSession puis verifySession → true', async () => {
const token = await createSession();
expect(token).toMatch(/^eyJ/);
expect(await verifySession(token)).toBe(true);
});
});

77
tests/lib/content.test.ts Normal file
View File

@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtemp, mkdir, readFile, writeFile, rm, copyFile, readdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
const PROJECT_ROOT = resolve(import.meta.dirname, '..', '..');
const REAL_SEED_DIR = join(PROJECT_ROOT, 'public', 'content');
let workDir: string;
type ContentModule = typeof import('../../src/lib/content');
let content: ContentModule;
async function setupTmpRoot() {
workDir = await mkdtemp(join(tmpdir(), 'portfolio-content-'));
await mkdir(join(workDir, 'data', 'content'), { recursive: true });
const tmpSeedDir = join(workDir, 'public', 'content');
await mkdir(tmpSeedDir, { recursive: true });
for (const file of await readdir(REAL_SEED_DIR)) {
await copyFile(join(REAL_SEED_DIR, file), join(tmpSeedDir, file));
}
vi.spyOn(process, 'cwd').mockReturnValue(workDir);
vi.resetModules();
content = await import('../../src/lib/content');
}
async function cleanup() {
vi.restoreAllMocks();
if (workDir) await rm(workDir, { recursive: true, force: true });
}
beforeEach(setupTmpRoot);
afterEach(cleanup);
describe('loadContent', () => {
it('copie le seed depuis public/content quand data/content/<key>.json est absent', async () => {
const profile = await content.loadContent('profile');
expect(profile.name).toBeTruthy();
const dataFile = await readFile(join(workDir, 'data', 'content', 'profile.json'), 'utf-8');
expect(JSON.parse(dataFile).name).toBe(profile.name);
});
it('lit data/content/<key>.json quand il existe (pas le seed)', async () => {
const seed = JSON.parse(
await readFile(join(workDir, 'public', 'content', 'profile.json'), 'utf-8'),
);
const customized = { ...seed, name: 'CustomFromData' };
await writeFile(
join(workDir, 'data', 'content', 'profile.json'),
JSON.stringify(customized),
'utf-8',
);
const profile = await content.loadContent('profile');
expect(profile.name).toBe('CustomFromData');
});
});
describe('saveContent', () => {
it('écrit data/content/<key>.json et retourne les données validées', async () => {
const seed = JSON.parse(
await readFile(join(workDir, 'public', 'content', 'profile.json'), 'utf-8'),
);
const updated = { ...seed, name: 'NouveauNom' };
const saved = await content.saveContent('profile', updated);
expect(saved.name).toBe('NouveauNom');
const onDisk = JSON.parse(
await readFile(join(workDir, 'data', 'content', 'profile.json'), 'utf-8'),
);
expect(onDisk.name).toBe('NouveauNom');
});
it('rejette un payload invalide (ZodError)', async () => {
await expect(content.saveContent('profile', { bogus: true })).rejects.toThrow();
});
it('rejette null', async () => {
await expect(content.saveContent('profile', null)).rejects.toThrow();
});
});

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);
});
});

6
tests/setup.ts Normal file
View File

@@ -0,0 +1,6 @@
// Injection des env vars AVANT tout import des modules `src/`.
// `src/lib/auth.ts` snapshote ADMIN_PASSWORD_HASH et ADMIN_JWT_SECRET au top-level.
//
// Hash bcrypt cost=4 du mot de passe 'test-password-123' (cost réduit pour la rapidité).
process.env.ADMIN_PASSWORD_HASH = '$2b$04$T.yij5GLkm.PKoC.YD5JfedQWRgtrymdp.Nk2U16LdUSkkjonMJ1C';
process.env.ADMIN_JWT_SECRET = 'test-jwt-secret-at-least-32-characters-long-aaaa';

22
vitest.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
export default defineConfig({
resolve: {
alias: {
'astro:middleware': fileURLToPath(new URL('./tests/__stubs/astro-middleware.ts', import.meta.url)),
},
},
test: {
environment: 'node',
globals: false,
pool: 'forks',
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/lib/**', 'src/middleware.ts', 'src/pages/api/**'],
reporter: ['text', 'html'],
},
},
});