Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)

This commit is contained in:
bot-claude
2026-08-25 09:55:13 +02:00
commit 5174c37a31
61 changed files with 7117 additions and 0 deletions
+210
View File
@@ -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);
});
});
+33
View File
@@ -0,0 +1,33 @@
import path from "node:path";
import type { Config } from "../src/config";
import { type Database, openInMemoryDatabase } from "../src/db/client";
export const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle");
export function makeConfig(overrides: Partial<Config> = {}): Config {
return {
port: 8787,
host: "127.0.0.1",
dataDir: "/tmp/seiche-tests",
sessionTtlMs: 8 * 60 * 60 * 1000,
sessionAbsoluteTtlMs: 7 * 24 * 60 * 60 * 1000,
maxLoginAttemptsPerMinute: 5,
publicDir: null,
external: {
adapter: "example",
baseUrl: "",
apiKey: "",
healthPath: "/health",
listPath: "/items",
timeoutMs: 10_000,
retries: 2,
cacheTtlMs: 30_000,
},
...overrides,
};
}
/** Base SQLite in-memory avec les migrations jouées. */
export async function openTestDb(): Promise<Database> {
return openInMemoryDatabase(MIGRATIONS_FOLDER);
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "../src/auth/password";
describe("hashPassword / verifyPassword (argon2id)", () => {
it("roundtrip", async () => {
const hash = await hashPassword("correct horse battery staple");
expect(hash).toMatch(/^\$argon2id\$/);
expect(await verifyPassword(hash, "correct horse battery staple")).toBe(true);
});
it("mauvais mot de passe → false", async () => {
const hash = await hashPassword("correct horse battery staple");
expect(await verifyPassword(hash, "nope")).toBe(false);
});
it("hash invalide → false (jamais d'exception)", async () => {
expect(await verifyPassword("$argon2id$garbage", "x")).toBe(false);
expect(await verifyPassword("", "x")).toBe(false);
});
it("salé : même mot de passe → hashes différents", async () => {
const a = await hashPassword("même mot de passe");
const b = await hashPassword("même mot de passe");
expect(a).not.toBe(b);
expect(await verifyPassword(a, "même mot de passe")).toBe(true);
expect(await verifyPassword(b, "même mot de passe")).toBe(true);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { RateLimiter } from "../src/auth/rate-limit";
const T0 = new Date("2026-01-01T00:00:00Z");
const at = (ms: number) => new Date(T0.getTime() + ms);
describe("RateLimiter (fenêtre glissante)", () => {
it("autorise jusqu'à maxHits puis bloque", () => {
const rl = new RateLimiter(3, 60_000);
expect(rl.hit("a", T0)).toBe(true);
expect(rl.hit("a", at(1000))).toBe(true);
expect(rl.hit("a", at(2000))).toBe(true);
expect(rl.hit("a", at(3000))).toBe(false);
});
it("les hits anciens sortent de la fenêtre", () => {
const rl = new RateLimiter(2, 60_000);
expect(rl.hit("a", T0)).toBe(true);
expect(rl.hit("a", at(30_000))).toBe(true);
expect(rl.hit("a", at(31_000))).toBe(false);
// Le hit de T0 a expiré de la fenêtre (60 s).
expect(rl.hit("a", at(61_001))).toBe(true);
});
it("reset vide la clé", () => {
const rl = new RateLimiter(1, 60_000);
expect(rl.hit("a", T0)).toBe(true);
expect(rl.hit("a", at(1000))).toBe(false);
rl.reset("a");
expect(rl.hit("a", at(2000))).toBe(true);
});
it("remaining reflète les tentatives restantes", () => {
const rl = new RateLimiter(2, 60_000);
expect(rl.remaining("a", T0)).toBe(2);
rl.hit("a", T0);
expect(rl.remaining("a", at(1000))).toBe(1);
});
it("les clés sont isolées", () => {
const rl = new RateLimiter(1, 60_000);
expect(rl.hit("a", T0)).toBe(true);
expect(rl.hit("b", T0)).toBe(true);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { createHash, randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { beforeAll, describe, expect, it } from "vitest";
import { SessionManager } from "../src/auth/session";
import type { Database } from "../src/db/client";
import { sessions, users } from "../src/db/schema";
import { makeConfig, openTestDb } from "./helpers";
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function newToken(): string {
return randomBytes(32).toString("hex");
}
describe("SessionManager", () => {
let db: Database;
let manager: SessionManager;
let userId: number;
beforeAll(async () => {
db = await openTestDb();
manager = new SessionManager(db, makeConfig());
const inserted = await db
.insert(users)
.values({ username: "alice", passwordHash: "x" })
.returning();
const user = inserted[0];
if (user === undefined) throw new Error("utilisateur de test non inséré");
userId = user.id;
});
it("create : token + CSRF 256 bits, getValid renvoie le contexte", async () => {
const { token, csrfToken } = await manager.create(userId);
expect(token).toMatch(/^[a-f0-9]{64}$/);
expect(csrfToken).toMatch(/^[a-f0-9]{64}$/);
const ctx = await manager.getValid(token);
expect(ctx?.userId).toBe(userId);
expect(ctx?.username).toBe("alice");
expect(ctx?.sessionExpiresAt).toBeInstanceOf(Date);
});
it("getValid : token absent ou inconnu → null", async () => {
expect(await manager.getValid(undefined)).toBeNull();
expect(await manager.getValid(newToken())).toBeNull();
});
it("le token est stocké haché (pas en clair)", async () => {
const { token } = await manager.create(userId);
const rows = await db
.select()
.from(sessions)
.where(eq(sessions.tokenHash, sha256Hex(token)));
expect(rows).toHaveLength(1);
expect(rows[0]?.tokenHash).not.toBe(token);
});
it("TTL glissant : expiration = maintenant + ttl", async () => {
const { token } = await manager.create(userId);
const before = Date.now();
const ctx = await manager.getValid(token);
expect(ctx?.sessionExpiresAt.getTime()).toBeGreaterThanOrEqual(before + 8 * 3600 * 1000 - 2000);
expect(ctx?.sessionExpiresAt.getTime()).toBeLessThanOrEqual(before + 8 * 3600 * 1000 + 5000);
});
it("TTL glissant borné par l'expiration absolue", async () => {
const m = new SessionManager(
db,
makeConfig({ sessionTtlMs: 3_600_000, sessionAbsoluteTtlMs: 300_000 }),
);
const { token } = await m.create(userId);
const before = Date.now();
const ctx = await m.getValid(token);
expect(ctx?.sessionExpiresAt.getTime()).toBeGreaterThanOrEqual(before + 300_000 - 1_000);
expect(ctx?.sessionExpiresAt.getTime()).toBeLessThanOrEqual(before + 300_000 + 1_000);
});
it("expiration absolue dépassée → null + ligne supprimée", async () => {
const token = newToken();
const past = new Date(Date.now() - 1000);
await db.insert(sessions).values({
tokenHash: sha256Hex(token),
userId,
createdAt: past,
expiresAt: past,
absoluteExpiryAt: past,
lastActivityAt: past,
});
expect(await manager.getValid(token)).toBeNull();
const rows = await db
.select()
.from(sessions)
.where(eq(sessions.tokenHash, sha256Hex(token)));
expect(rows).toHaveLength(0);
});
it("destroy invalidate le token", async () => {
const { token } = await manager.create(userId);
await manager.destroy(token);
expect(await manager.getValid(token)).toBeNull();
});
it("5 sessions max par utilisateur (exactement 5 des 6 restent valides)", async () => {
const inserted = await db
.insert(users)
.values({ username: "bob", passwordHash: "x" })
.returning();
const bob = inserted[0];
if (bob === undefined) throw new Error("utilisateur bob non inséré");
const m2 = new SessionManager(db, makeConfig());
const tokens: string[] = [];
for (let i = 0; i < 6; i += 1) {
tokens.push((await m2.create(bob.id)).token);
}
const rows = await db.select().from(sessions).where(eq(sessions.userId, bob.id));
expect(rows).toHaveLength(5);
let validCount = 0;
for (const t of tokens) {
const ctx = await m2.getValid(t);
if (ctx !== null) validCount += 1;
}
expect(validCount).toBe(5);
});
});