import { eq } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildApp } from "../src/app"; import { hashPassword } from "../src/auth/password"; import { CSRF_COOKIE, CSRF_HEADER, SESSION_COOKIE } from "../src/auth/session"; import type { Database } from "../src/db/client"; import { users } from "../src/db/schema"; import { makeConfig, openTestDb } from "./helpers"; // Tests : pas de worker pino-pretty, error-handler en mode production. process.env.NODE_ENV = "production"; process.env.SEICHE_LOG_LEVEL = "silent"; let db: Database; let app: FastifyInstance; beforeEach(async () => { db = await openTestDb(); app = await buildApp({ config: makeConfig(), db }); }); afterEach(async () => { await app.close(); }); function asSetCookieList(raw: unknown): string[] { if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string"); if (raw === undefined) return []; return [String(raw)]; } function cookieValue(raw: unknown, name: string): string | undefined { for (const item of asSetCookieList(raw)) { const pair = item.split(";")[0] ?? ""; if (pair.startsWith(`${name}=`)) return pair.slice(name.length + 1); } return undefined; } function cookieHeader(raw: unknown, name: string): string | undefined { return asSetCookieList(raw).find((item) => item.startsWith(`${name}=`)); } async function seedUser(username: string, password: string): Promise { await db.insert(users).values({ username, passwordHash: await hashPassword(password) }); } describe("API /api (buildApp)", () => { it("GET /api/health → 200 ok", async () => { const res = await app.inject({ method: "GET", url: "/api/health" }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ status: "ok", uptimeSec: expect.any(Number) }); }); it("POST /api/auth/login : succès → 200, cookies httpOnly + csrfToken", async () => { await seedUser("alice", "correct-horse-battery"); const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "correct-horse-battery" }, }); expect(res.statusCode).toBe(200); const body = res.json() as { user: { username: string }; csrfToken: string; csrfHeader: string; }; expect(body.user.username).toBe("alice"); expect(body.csrfToken).toMatch(/^[a-f0-9]{64}$/); expect(body.csrfHeader).toBe(CSRF_HEADER); const raw = res.headers["set-cookie"]; expect(cookieValue(raw, SESSION_COOKIE)).toMatch(/^[a-f0-9]{64}$/); expect(cookieValue(raw, CSRF_COOKIE)).toBe(body.csrfToken); expect(cookieHeader(raw, SESSION_COOKIE)).toContain("HttpOnly"); expect(cookieHeader(raw, SESSION_COOKIE)).toContain("SameSite=Lax"); expect(cookieHeader(raw, CSRF_COOKIE)).not.toContain("HttpOnly"); }); it("POST /api/auth/login : mauvais mdp ou user inconnu → 401 identique", async () => { await seedUser("alice", "correct-horse-battery"); const wrong = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "nope" }, }); expect(wrong.statusCode).toBe(401); expect(wrong.json()).toEqual({ error: "invalid_credentials" }); const unknown = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "ghost", password: "nope" }, }); expect(unknown.statusCode).toBe(401); expect(unknown.json()).toEqual({ error: "invalid_credentials" }); }); it("POST /api/auth/login : corps invalide → 400 invalid_body", async () => { const empty = await app.inject({ method: "POST", url: "/api/auth/login", payload: {} }); expect(empty.statusCode).toBe(400); expect(empty.json()).toEqual({ error: "invalid_body" }); const missing = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice" }, }); expect(missing.statusCode).toBe(400); }); it("POST /api/auth/login : 6e tentative dans la minute → 429 (rate limit par IP)", async () => { await seedUser("alice", "correct-horse-battery"); for (let i = 0; i < 5; i++) { const res = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "wrong" }, }); expect(res.statusCode).toBe(401); } const blocked = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "correct-horse-battery" }, }); expect(blocked.statusCode).toBe(429); expect((blocked.json() as { error: string }).error).toBe("too_many_attempts"); }); it("POST /api/auth/logout sans session → 401 not_authenticated", async () => { const res = await app.inject({ method: "POST", url: "/api/auth/logout" }); expect(res.statusCode).toBe(401); expect(res.json()).toEqual({ error: "not_authenticated" }); }); it("CSRF double-submit : 403 sans/mauvais header, 200 avec le bon, session détruite", async () => { await seedUser("alice", "correct-horse-battery"); const login = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "alice", password: "correct-horse-battery" }, }); expect(login.statusCode).toBe(200); const body = login.json() as { csrfToken: string }; const session = cookieValue(login.headers["set-cookie"], SESSION_COOKIE); const csrf = cookieValue(login.headers["set-cookie"], CSRF_COOKIE); if (session === undefined || csrf === undefined) { throw new Error("cookies de session manquants"); } const cookies = { [SESSION_COOKIE]: session, [CSRF_COOKIE]: csrf }; const noHeader = await app.inject({ method: "POST", url: "/api/auth/logout", cookies }); expect(noHeader.statusCode).toBe(403); expect(noHeader.json()).toEqual({ error: "csrf_invalid" }); const wrongHeader = await app.inject({ method: "POST", url: "/api/auth/logout", cookies, headers: { [CSRF_HEADER]: "0".repeat(64) }, }); expect(wrongHeader.statusCode).toBe(403); const ok = await app.inject({ method: "POST", url: "/api/auth/logout", cookies, headers: { [CSRF_HEADER]: body.csrfToken }, }); expect(ok.statusCode).toBe(200); expect(ok.json()).toEqual({ ok: true }); expect(cookieValue(ok.headers["set-cookie"], SESSION_COOKIE)).toBe(""); const after = await app.inject({ method: "GET", url: "/api/auth/session", cookies }); expect(after.statusCode).toBe(401); }); it("error-handler : exception de route → 500 internal_error, sans fuite (prod)", async () => { app.post("/api/test-crash", async () => { throw new Error("boom interne"); }); const res = await app.inject({ method: "POST", url: "/api/test-crash" }); expect(res.statusCode).toBe(500); const body = res.json() as { error: string; details?: string }; expect(body.error).toBe("internal_error"); expect(body.details).toBeUndefined(); }); it("DB round-trip : insert → read → update → delete", async () => { const hash = await hashPassword("roundtrip"); const inserted = await db .insert(users) .values({ username: "bob", passwordHash: hash }) .returning(); const user = inserted[0]; if (user === undefined) throw new Error("insertion ratée"); const [found] = await db.select().from(users).where(eq(users.id, user.id)).limit(1); expect(found?.username).toBe("bob"); expect(found?.passwordHash).toBe(hash); const newHash = await hashPassword("roundtrip2"); await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, user.id)); const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1); expect(updated?.passwordHash).toBe(newHash); await db.delete(users).where(eq(users.id, user.id)); const remaining = await db.select().from(users).where(eq(users.id, user.id)).limit(1); expect(remaining).toHaveLength(0); }); });