Checkpoint: Pagination serveur des établissements et des messages, tri serveur, navigation utilisateur, limitation de débit sur connexion locale, messages et demandes, avec 46 tests Vitest, typage TypeScript et build validés.

This commit is contained in:
Manus
2026-08-18 07:34:59 +00:00
parent 7c97bec6c1
commit 9d9ae68b18
8 changed files with 429 additions and 51 deletions

View File

@@ -1,4 +1,4 @@
import { and, desc, eq, ilike, inArray, like, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, ilike, inArray, like, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
InsertUser,
@@ -179,9 +179,13 @@ export async function searchEtablissements(filters: {
etatDeploiement?: string;
userId?: number;
sonumRole?: string;
page: number;
pageSize: number;
sortBy: "nom" | "region" | "typeActivite" | "tailleEffectifs";
sortDirection: "asc" | "desc";
}) {
const db = await getDb();
if (!db) return [];
if (!db) return { items: [], total: 0, page: filters.page, pageSize: filters.pageSize };
const conditions: any[] = [];
// Visibilité : si pas gestionnaire, on ne montre que les fiches "tous"
@@ -193,7 +197,7 @@ export async function searchEtablissements(filters: {
if (filters.sonumRole === "adherent" && filters.userId) {
const affectations = await getAffectationsByUser(filters.userId);
if (affectations.length === 0) {
return []; // Aucun établissement affecté
return { items: [], total: 0, page: filters.page, pageSize: filters.pageSize };
}
conditions.push(inArray(etablissements.id, affectations));
}
@@ -230,23 +234,44 @@ export async function searchEtablissements(filters: {
conditions.push(inArray(etablissements.id, subquery));
}
const result = await db
.select({
id: etablissements.id,
finess: etablissements.finess,
nom: etablissements.nom,
region: etablissements.region,
departement: etablissements.departement,
typeActivite: etablissements.typeActivite,
tailleEffectifs: etablissements.tailleEffectifs,
referentId: etablissements.referentId,
visibilite: etablissements.visibilite,
accepteMiseEnRelation: etablissements.accepteMiseEnRelation,
})
.from(etablissements)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(etablissements.nom);
return result;
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const sortColumn = {
nom: etablissements.nom,
region: etablissements.region,
typeActivite: etablissements.typeActivite,
tailleEffectifs: etablissements.tailleEffectifs,
}[filters.sortBy];
const orderBy = filters.sortDirection === "desc" ? desc(sortColumn) : asc(sortColumn);
// Les requêtes sont indépendantes : total global et page courante partent en parallèle.
const [items, totalRows] = await Promise.all([
db
.select({
id: etablissements.id,
finess: etablissements.finess,
nom: etablissements.nom,
region: etablissements.region,
departement: etablissements.departement,
typeActivite: etablissements.typeActivite,
tailleEffectifs: etablissements.tailleEffectifs,
referentId: etablissements.referentId,
visibilite: etablissements.visibilite,
accepteMiseEnRelation: etablissements.accepteMiseEnRelation,
})
.from(etablissements)
.where(whereClause)
.orderBy(orderBy, asc(etablissements.id))
.limit(filters.pageSize)
.offset((filters.page - 1) * filters.pageSize),
db.select({ total: sql<number>`COUNT(*)` }).from(etablissements).where(whereClause),
]);
return {
items,
total: Number(totalRows[0]?.total ?? 0),
page: filters.page,
pageSize: filters.pageSize,
};
}
// ─── Logiciels par Établissement ─────────────────────────────────────────────
@@ -1137,14 +1162,35 @@ export async function getCanauxForUser(userId: number, isGestionnaire: boolean)
/**
* Récupère les messages d'un canal.
*/
export async function getMessagesCanal(canalId: number) {
export async function getMessagesCanal(canalId: number, page: number, pageSize: number) {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(messagesCanaux)
.where(eq(messagesCanaux.canalId, canalId))
.orderBy(messagesCanaux.createdAt);
if (!db) return { items: [], total: 0, page, pageSize, hasMore: false };
/**
* La base lit les messages les plus récents en premier pour rendre le premier
* affichage rapide, puis la page est renversée avant l'affichage chronologique.
*/
const [newestFirst, totalRows] = await Promise.all([
db
.select()
.from(messagesCanaux)
.where(eq(messagesCanaux.canalId, canalId))
.orderBy(desc(messagesCanaux.createdAt), desc(messagesCanaux.id))
.limit(pageSize)
.offset((page - 1) * pageSize),
db
.select({ total: sql<number>`COUNT(*)` })
.from(messagesCanaux)
.where(eq(messagesCanaux.canalId, canalId)),
]);
const total = Number(totalRows[0]?.total ?? 0);
return {
items: newestFirst.reverse(),
total,
page,
pageSize,
hasMore: page * pageSize < total,
};
}
/**

33
server/rateLimit.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { getClientIp, SlidingWindowRateLimiter, type RateLimitRule } from "./rateLimit";
const shortRule: RateLimitRule = {
scope: "test",
maxAttempts: 2,
windowMs: 1_000,
};
describe("SlidingWindowRateLimiter", () => {
it("bloque après la limite et autorise à nouveau une fois la fenêtre expirée", () => {
const limiter = new SlidingWindowRateLimiter();
expect(limiter.consume(shortRule, "user-1", 1_000)).toMatchObject({ allowed: true, remaining: 1 });
expect(limiter.consume(shortRule, "user-1", 1_100)).toMatchObject({ allowed: true, remaining: 0 });
expect(limiter.consume(shortRule, "user-1", 1_200)).toMatchObject({ allowed: false, retryAfterMs: 800 });
// À 2 001 ms, la tentative de 1 100 ms est encore dans la fenêtre glissante.
expect(limiter.consume(shortRule, "user-1", 2_001)).toMatchObject({ allowed: true, remaining: 0 });
});
it("isole les compteurs de chaque clé", () => {
const limiter = new SlidingWindowRateLimiter();
limiter.consume(shortRule, "user-1", 1_000);
limiter.consume(shortRule, "user-1", 1_100);
expect(limiter.consume(shortRule, "user-2", 1_200)).toMatchObject({ allowed: true, remaining: 1 });
});
it("extrait uniquement la première adresse client transmise par le proxy", () => {
expect(getClientIp({ "x-forwarded-for": "203.0.113.8, 10.0.0.1" })).toBe("203.0.113.8");
expect(getClientIp({})).toBe("unknown-client");
});
});

114
server/rateLimit.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Limiteur à fenêtre glissante conçu pour l'instance Docker SONUM.
*
* Les clés sont conservées uniquement pendant leur fenêtre d'observation ; le
* nettoyage périodique évite une croissance non bornée lors de tentatives
* distribuées. Il ne stocke ni mot de passe ni contenu de message.
*/
export type RateLimitRule = {
scope: string;
maxAttempts: number;
windowMs: number;
};
export type RateLimitResult =
| { allowed: true; remaining: number }
| { allowed: false; retryAfterMs: number };
type Entry = {
timestamps: number[];
windowMs: number;
};
export class SlidingWindowRateLimiter {
private readonly entries = new Map<string, Entry>();
private lastCleanupAt = 0;
consume(rule: RateLimitRule, key: string, now = Date.now()): RateLimitResult {
this.cleanup(now);
const entryKey = `${rule.scope}:${key}`;
const cutoff = now - rule.windowMs;
const entry = this.entries.get(entryKey) ?? { timestamps: [], windowMs: rule.windowMs };
entry.timestamps = entry.timestamps.filter((timestamp) => timestamp > cutoff);
entry.windowMs = rule.windowMs;
if (entry.timestamps.length >= rule.maxAttempts) {
this.entries.set(entryKey, entry);
return {
allowed: false,
retryAfterMs: Math.max(1, entry.timestamps[0] + rule.windowMs - now),
};
}
entry.timestamps.push(now);
this.entries.set(entryKey, entry);
return { allowed: true, remaining: rule.maxAttempts - entry.timestamps.length };
}
/** Réinitialise exclusivement la clé concernée après une authentification réussie. */
reset(rule: RateLimitRule, key: string) {
this.entries.delete(`${rule.scope}:${key}`);
}
clear() {
this.entries.clear();
this.lastCleanupAt = 0;
}
private cleanup(now: number) {
// Une purge par minute est suffisante et évite un parcours de Map à chaque requête.
if (now - this.lastCleanupAt < 60_000) return;
this.lastCleanupAt = now;
this.entries.forEach((entry, entryKey) => {
const cutoff = now - entry.windowMs;
entry.timestamps = entry.timestamps.filter((timestamp) => timestamp > cutoff);
if (!entry.timestamps.length) this.entries.delete(entryKey);
});
}
}
export const rateLimiter = new SlidingWindowRateLimiter();
export const LOGIN_IDENTIFIER_RULE: RateLimitRule = {
scope: "login-identifier",
maxAttempts: 5,
windowMs: 15 * 60_000,
};
export const LOGIN_IP_RULE: RateLimitRule = {
scope: "login-ip",
maxAttempts: 20,
windowMs: 15 * 60_000,
};
export const CHANNEL_MESSAGE_RULE: RateLimitRule = {
scope: "channel-message",
maxAttempts: 30,
windowMs: 60_000,
};
export const CONTACT_REQUEST_RULE: RateLimitRule = {
scope: "contact-request",
maxAttempts: 5,
windowMs: 60 * 60_000,
};
export const INTRODUCTION_REQUEST_RULE: RateLimitRule = {
scope: "introduction-request",
maxAttempts: 5,
windowMs: 60 * 60_000,
};
/**
* Traefik renseigne `x-forwarded-for`; le premier maillon représente le client.
* En développement ou sans proxy, une clé neutre est utilisée plutôt qu'une IP
* non fiable issue d'un type Express implicite.
*/
export function getClientIp(headers: Record<string, string | string[] | undefined>): string {
const forwarded = headers["x-forwarded-for"];
const rawValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const ip = rawValue?.split(",")[0]?.trim();
return ip || "unknown-client";
}

View File

@@ -66,6 +66,16 @@ import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { protectedProcedure, publicProcedure, router } from "./_core/trpc";
import { notifyOwner } from "./_core/notification";
import {
CHANNEL_MESSAGE_RULE,
CONTACT_REQUEST_RULE,
getClientIp,
INTRODUCTION_REQUEST_RULE,
LOGIN_IDENTIFIER_RULE,
LOGIN_IP_RULE,
rateLimiter,
type RateLimitRule,
} from "./rateLimit";
import { getDb } from "./db";
import { etablissements } from "../drizzle/schema";
import { eq } from "drizzle-orm";
@@ -106,19 +116,35 @@ const gestionnaireWriteProcedure = gestionnaireProcedure.use(({ ctx, next }) =>
return next({ ctx });
});
/** Retourne un 429 cohérent, sans exposer l'état interne du limiteur. */
function enforceRateLimit(rule: RateLimitRule, key: string, message: string) {
const result = rateLimiter.consume(rule, key);
if (!result.allowed) {
const retryAfterMinutes = Math.max(1, Math.ceil(result.retryAfterMs / 60_000));
throw new TRPCError({
code: "TOO_MANY_REQUESTS",
message: `${message} Réessayez dans environ ${retryAfterMinutes} minute${retryAfterMinutes > 1 ? "s" : ""}.`,
});
}
}
/// ─── Canaux de Discussion ────────────────────────────────────────────────────
const canauxRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
return getCanauxForUser(ctx.user.id, isGestionnaire(ctx.user));
}),
messages: protectedProcedure
.input(z.object({ canalId: z.number().int() }))
.input(z.object({
canalId: z.number().int().positive(),
page: z.number().int().positive().default(1),
pageSize: z.number().int().min(10).max(100).default(50),
}))
.query(async ({ input, ctx }) => {
if (!isGestionnaire(ctx.user)) {
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
}
return getMessagesCanal(input.canalId);
return getMessagesCanal(input.canalId, input.page, input.pageSize);
}),
membres: protectedProcedure
.input(z.object({ canalId: z.number().int() }))
@@ -136,6 +162,7 @@ const canauxRouter = router({
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
}
enforceRateLimit(CHANNEL_MESSAGE_RULE, String(ctx.user.id), "Trop de messages envoyés.");
await sendMessageCanal({ canalId: input.canalId, auteurId: ctx.user.id, auteurNom: ctx.user.name ?? "Inconnu", contenu: input.contenu });
return { success: true };
}),
@@ -171,6 +198,7 @@ const miseEnRelationRouter = router({
etablissementDemandeurId: z.number().int().positive().optional(),
}))
.mutation(async ({ input, ctx }) => {
enforceRateLimit(INTRODUCTION_REQUEST_RULE, String(ctx.user.id), "Trop de demandes de mise en relation ont été envoyées.");
const id = await createDemandeMiseEnRelation({
demandeurId: ctx.user.id, demandeurNom: ctx.user.name ?? "Inconnu",
demandeurEmail: ctx.user.email ?? undefined,
@@ -211,10 +239,15 @@ export const appRouter = router({
password: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
const normalizedIdentifier = input.email.toLocaleLowerCase("fr-FR");
const clientIp = getClientIp(ctx.req.headers);
enforceRateLimit(LOGIN_IDENTIFIER_RULE, normalizedIdentifier, "Trop de tentatives de connexion pour cet identifiant.");
enforceRateLimit(LOGIN_IP_RULE, clientIp, "Trop de tentatives de connexion depuis cette adresse.");
const user = await authenticateLocalUser(input.email, input.password);
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect" });
}
rateLimiter.reset(LOGIN_IDENTIFIER_RULE, normalizedIdentifier);
// Créer un token de session avec l'openId de l'utilisateur local
// Le champ name doit être non vide pour passer la vérification JWT
@@ -360,6 +393,10 @@ export const appRouter = router({
typeActivite: z.string().optional(),
tailleEffectifs: z.string().optional(),
etatDeploiement: z.string().optional(),
page: z.number().int().positive().default(1),
pageSize: z.number().int().min(10).max(100).default(25),
sortBy: z.enum(["nom", "region", "typeActivite", "tailleEffectifs"]).default("nom"),
sortDirection: z.enum(["asc", "desc"]).default("asc"),
}))
.query(({ input, ctx }) => searchEtablissements({
...input,
@@ -504,6 +541,7 @@ export const appRouter = router({
message: z.string().trim().min(1).max(5000),
}))
.mutation(async ({ input, ctx }) => {
enforceRateLimit(CONTACT_REQUEST_RULE, String(ctx.user.id), "Trop de demandes de contact ont été envoyées.");
const etab = await getEtablissementById(input.etablissementCibleId);
if (!etab) throw new TRPCError({ code: "NOT_FOUND" });

View File

@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
import type { User } from "../drizzle/schema";
import { CHANNEL_MESSAGE_RULE, rateLimiter } from "./rateLimit";
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -41,6 +42,19 @@ function makeCtx(user: User | null = null): TrpcContext {
};
}
beforeEach(() => {
// Les limites sont intentionnellement globales à l'instance : isoler chaque scénario de test.
rateLimiter.clear();
});
async function expectNotRateLimited(operation: Promise<unknown>) {
try {
await operation;
} catch (error) {
expect(error).not.toMatchObject({ code: "TOO_MANY_REQUESTS" });
}
}
// ─── Tests : auth.me ──────────────────────────────────────────────────────────
describe("auth.me", () => {
@@ -287,3 +301,68 @@ describe("validation des contenus libres", () => {
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
// ─── Tests : contrats de pagination ───────────────────────────────────────────
describe("contrats de pagination", () => {
it("rejette une page d'établissements invalide avant tout accès aux données", async () => {
const caller = appRouter.createCaller(makeCtx(makeUser()));
await expect(
caller.etablissements.search({ page: 0, pageSize: 25, sortBy: "nom", sortDirection: "asc" })
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
it("rejette une taille de page de messages hors borne", async () => {
const caller = appRouter.createCaller(makeCtx(makeUser()));
await expect(
caller.canaux.messages({ canalId: 1, page: 1, pageSize: 101 })
).rejects.toMatchObject({ code: "BAD_REQUEST" });
});
});
// ─── Tests : application tRPC des limites de débit ─────────────────────────────
describe("limitation de débit des actions sensibles", () => {
it("bloque la sixième tentative de connexion locale pour le même identifiant", async () => {
const caller = appRouter.createCaller(makeCtx(null));
const credentials = { email: "brute-force@test.fr", password: "mot-de-passe-invalide" };
for (let index = 0; index < 5; index += 1) {
await expect(caller.auth.loginLocal(credentials)).rejects.toMatchObject({ code: "UNAUTHORIZED" });
}
await expect(caller.auth.loginLocal(credentials)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
});
it("bloque les demandes de contact répétées", async () => {
const caller = appRouter.createCaller(makeCtx(makeUser()));
const input = { etablissementCibleId: 999_999, message: "Message de test" };
for (let index = 0; index < 5; index += 1) {
await expectNotRateLimited(caller.contact.envoyer(input));
}
await expect(caller.contact.envoyer(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
});
it("bloque les demandes de mise en relation répétées", async () => {
const caller = appRouter.createCaller(makeCtx(makeUser()));
const input = { sujet: "Demande de test", message: "Message de test" };
for (let index = 0; index < 5; index += 1) {
await expectNotRateLimited(caller.miseEnRelation.soumettre(input));
}
await expect(caller.miseEnRelation.soumettre(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
});
it("applique la limite de messagerie à la procédure tRPC", async () => {
const caller = appRouter.createCaller(makeCtx(makeUser({ sonumRole: "gestionnaire" })));
const input = { canalId: 1, contenu: "Message de test" };
// Préremplit la fenêtre : le test vérifie que la procédure utilise bien cette règle et cette clé utilisateur.
for (let index = 0; index < 30; index += 1) {
rateLimiter.consume(CHANNEL_MESSAGE_RULE, "1");
}
await expect(caller.canaux.sendMessage(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS" });
});
});