Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user