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:
@@ -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_PASSWORD_HASH=
|
||||||
ADMIN_JWT_SECRET=
|
ADMIN_JWT_SECRET=
|
||||||
|
|
||||||
# Port de production (Plesk Node.js)
|
# Port de production (Plesk Node.js)
|
||||||
PORT=3100
|
PORT=3100
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
|
|
||||||
ADMIN_PASSWORD_HASH=$2b$12$1nXI9CgStfBs/DrtSpJ2buF1hnUCUTpcx6rDZNRczlkuCHXb.AjbK
|
|
||||||
ADMIN_JWT_SECRET=wef4R2NWoSko/bnIYFOyaQamI0QqOLe3J4ZSjsGdmvfgtADIT26Y6piFoM0l8h5o
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ pnpm-debug.log*
|
|||||||
# runtime data (uploads + JSON content edited via /admin)
|
# runtime data (uploads + JSON content edited via /admin)
|
||||||
# kept on the server, not deployed via FTP
|
# kept on the server, not deployed via FTP
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
# test artifacts (coverage)
|
||||||
|
coverage/
|
||||||
|
|||||||
1353
package-lock.json
generated
1353
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -12,6 +12,9 @@
|
|||||||
"start": "HOST=0.0.0.0 PORT=3100 node ./dist/server/entry.mjs",
|
"start": "HOST=0.0.0.0 PORT=3100 node ./dist/server/entry.mjs",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"hash:password": "node scripts/hash-password.mjs"
|
"hash:password": "node scripts/hash-password.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -19,30 +22,26 @@
|
|||||||
"@astrojs/react": "^5.0.3",
|
"@astrojs/react": "^5.0.3",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@iconify-icon/react": "^3.0.3",
|
"@iconify-icon/react": "^3.0.3",
|
||||||
"@react-three/drei": "^10.7.7",
|
|
||||||
"@react-three/fiber": "^9.6.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"astro": "^6.1.8",
|
"astro": "^6.1.8",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"gsap": "^3.15.0",
|
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"lenis": "^1.3.23",
|
"lenis": "^1.3.23",
|
||||||
"lucide-react": "^1.8.0",
|
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.75.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"three": "^0.184.0",
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.8",
|
"@astrojs/check": "^0.9.8",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
"@types/three": "^0.184.0",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ const current = Astro.url.pathname;
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<script data-astro-rerun>
|
<script is:inline data-astro-rerun>
|
||||||
// Mobile menu
|
// Mobile menu
|
||||||
const btn = document.getElementById('mobile-menu-btn');
|
const btn = document.getElementById('mobile-menu-btn');
|
||||||
const menu = document.getElementById('mobile-menu');
|
const menu = document.getElementById('mobile-menu');
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface Props {
|
|||||||
const { project } = Astro.props;
|
const { project } = Astro.props;
|
||||||
const isArchived = project.status === 'archived';
|
const isArchived = project.status === 'archived';
|
||||||
const isPrivate = project.status === 'private';
|
const isPrivate = project.status === 'private';
|
||||||
const isWIP = project.status === 'wip';
|
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
project.status === 'live'
|
project.status === 'live'
|
||||||
? 'Live'
|
? 'Live'
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function PatchBay({ technical, soft }: Props) {
|
|||||||
Techniques
|
Techniques
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2.5">
|
<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">
|
<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 className="text-[color:var(--color-text-muted)] flex-1">{s}</span>
|
||||||
<span
|
<span
|
||||||
@@ -122,7 +122,7 @@ export default function PatchBay({ technical, soft }: Props) {
|
|||||||
Transverses
|
Transverses
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2.5">
|
<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">
|
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
|
||||||
<span
|
<span
|
||||||
data-jack="right"
|
data-jack="right"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const FormationSchema = z.object({
|
|||||||
id: z.string(),
|
id: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
school: z.string(),
|
school: z.string(),
|
||||||
schoolUrl: z.string().url().optional(),
|
schoolUrl: z.url().optional(),
|
||||||
period: z.string(),
|
period: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,11 +23,11 @@ export const ProfileSchema = z.object({
|
|||||||
.default([]),
|
.default([]),
|
||||||
}),
|
}),
|
||||||
socials: z.object({
|
socials: z.object({
|
||||||
email: z.string().email().optional(),
|
email: z.email().optional(),
|
||||||
github: z.string().url().optional(),
|
github: z.url().optional(),
|
||||||
gitea: z.string().url().optional(),
|
gitea: z.url().optional(),
|
||||||
linkedin: z.string().url().optional(),
|
linkedin: z.url().optional(),
|
||||||
website: z.string().url().optional(),
|
website: z.url().optional(),
|
||||||
}),
|
}),
|
||||||
cv: z.string().optional(),
|
cv: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export const ProjectSchema = z.object({
|
|||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
category: z.enum(['web', 'mobile', 'bot', 'infra', 'other']).default('web'),
|
category: z.enum(['web', 'mobile', 'bot', 'infra', 'other']).default('web'),
|
||||||
status: z.enum(['live', 'archived', 'private', 'wip']).default('live'),
|
status: z.enum(['live', 'archived', 'private', 'wip']).default('live'),
|
||||||
url: z.string().url().optional(),
|
url: z.url().optional(),
|
||||||
repoUrl: z.string().url().optional(),
|
repoUrl: z.url().optional(),
|
||||||
stack: z.array(z.string()).default([]),
|
stack: z.array(z.string()).default([]),
|
||||||
featured: z.boolean().default(false),
|
featured: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
export const SiteSchema = z.object({
|
export const SiteSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
url: z.string().url(),
|
url: z.url(),
|
||||||
keywords: z.array(z.string()).default([]),
|
keywords: z.array(z.string()).default([]),
|
||||||
ogImage: z.string(),
|
ogImage: z.string(),
|
||||||
twitter: z.string().optional(),
|
twitter: z.string().optional(),
|
||||||
|
|||||||
@@ -9,9 +9,22 @@ const env = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ADMIN_PASSWORD_HASH = (env.ADMIN_PASSWORD_HASH as string | undefined) ?? '';
|
const ADMIN_PASSWORD_HASH = (env.ADMIN_PASSWORD_HASH as string | undefined) ?? '';
|
||||||
const JWT_SECRET =
|
const JWT_SECRET = (env.ADMIN_JWT_SECRET as string | undefined) ?? '';
|
||||||
(env.ADMIN_JWT_SECRET as string | undefined) ?? 'dev-secret-change-me-in-production';
|
|
||||||
const SECRET_KEY = new TextEncoder().encode(JWT_SECRET);
|
// 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';
|
export const ADMIN_COOKIE = 'portfolio_admin';
|
||||||
|
|
||||||
@@ -28,13 +41,13 @@ export async function createSession(): Promise<string> {
|
|||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime('7d')
|
.setExpirationTime('7d')
|
||||||
.sign(SECRET_KEY);
|
.sign(getSecretKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifySession(token: string | undefined): Promise<boolean> {
|
export async function verifySession(token: string | undefined): Promise<boolean> {
|
||||||
if (!token) return false;
|
if (!token) return false;
|
||||||
try {
|
try {
|
||||||
await jwtVerify(token, SECRET_KEY);
|
await jwtVerify(token, getSecretKey());
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -19,10 +19,8 @@ export const GET: APIRoute = async ({ params }) => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return new Response(
|
console.error('[content] load_failed', section, err);
|
||||||
JSON.stringify({ error: 'load_failed', message: (err as Error).message }),
|
return new Response(JSON.stringify({ error: 'load_failed' }), { status: 500 });
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,8 +41,10 @@ export const PUT: APIRoute = async ({ params, request }) => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[content] validation_failed', section, err);
|
||||||
|
const message = err instanceof Error && err.name === 'ZodError' ? err.message : undefined;
|
||||||
return new Response(
|
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 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { join, extname, basename } from 'node:path';
|
|||||||
|
|
||||||
const UPLOAD_ROOT = join(process.cwd(), 'data', 'uploads');
|
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 DOCUMENT_EXT = new Set(['.pdf']);
|
||||||
|
|
||||||
const MAX_SIZE: Record<'image' | 'document', number> = {
|
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 safeFolder = folder.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
const bucket = kind === 'document' ? 'assets' : 'img';
|
const bucket = kind === 'document' ? 'assets' : 'img';
|
||||||
const targetDir = safeFolder ? join(UPLOAD_ROOT, bucket, safeFolder) : join(UPLOAD_ROOT, bucket);
|
const targetDir = safeFolder ? join(UPLOAD_ROOT, bucket, safeFolder) : join(UPLOAD_ROOT, bucket);
|
||||||
await mkdir(targetDir, { recursive: true });
|
|
||||||
|
|
||||||
const filename = sanitize(file.name);
|
const filename = sanitize(file.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await writeFile(join(targetDir, filename), buffer);
|
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}`;
|
const url = `/api/files/${bucket}/${safeFolder ? safeFolder + '/' : ''}${filename}`;
|
||||||
return new Response(JSON.stringify({ ok: true, url }), {
|
return new Response(JSON.stringify({ ok: true, url }), {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import BootSequence from '../components/islands/BootSequence';
|
|||||||
import { Icon } from '@iconify-icon/react';
|
import { Icon } from '@iconify-icon/react';
|
||||||
import { loadAll } from '../lib/content';
|
import { loadAll } from '../lib/content';
|
||||||
|
|
||||||
const { profile, skills, interests, site } = await loadAll();
|
const { profile, skills, interests } = await loadAll();
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
|
|||||||
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';
|
||||||
22
vitest.config.ts
Normal file
22
vitest.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user