- .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>
72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
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);
|
|
});
|
|
});
|