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:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user