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:
3
tests/__stubs/astro-middleware.ts
Normal file
3
tests/__stubs/astro-middleware.ts
Normal 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
71
tests/api/files.test.ts
Normal 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
98
tests/api/upload.test.ts
Normal 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
47
tests/lib/auth.test.ts
Normal 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
77
tests/lib/content.test.ts
Normal 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
94
tests/middleware.test.ts
Normal 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
6
tests/setup.ts
Normal 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';
|
||||
Reference in New Issue
Block a user