diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 70291e0..72761c3 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -38,10 +38,13 @@ export default function Home() { const [expandedEtab, setExpandedEtab] = useState(null); const [sortCol, setSortCol] = useState<"nom" | "region" | "typeActivite" | "tailleEffectifs">("nom"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const [page, setPage] = useState(1); + const pageSize = 25; const handleSort = (col: typeof sortCol) => { - if (sortCol === col) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + if (sortCol === col) setSortDir((direction) => (direction === "asc" ? "desc" : "asc")); else { setSortCol(col); setSortDir("asc"); } + setPage(1); }; const cguQuery = trpc.cgu.status.useQuery(undefined, { enabled: isAuthenticated }); @@ -68,18 +71,21 @@ export default function Home() { ); const solutionsQuery = trpc.referentiel.solutions.useQuery(solutionsInput); const cguFullyAccepted = sessionCguAccepted && (cguQuery.data?.accepted ?? false); - const searchQuery = trpc.etablissements.search.useQuery(filters, { enabled: isAuthenticated && cguFullyAccepted }); + const searchInput = useMemo( + () => ({ ...filters, page, pageSize, sortBy: sortCol, sortDirection: sortDir }), + [filters, page, pageSize, sortCol, sortDir] + ); + const searchQuery = trpc.etablissements.search.useQuery(searchInput, { enabled: isAuthenticated && cguFullyAccepted }); const recordConsultation = trpc.tracabilite.enregistrerConsultation.useMutation(); + const results = searchQuery.data?.items ?? []; + const totalPages = Math.max(1, Math.ceil((searchQuery.data?.total ?? 0) / pageSize)); - const sortedResults = useMemo(() => { - if (!searchQuery.data) return []; - return [...searchQuery.data].sort((a, b) => { - const aVal = (a[sortCol] ?? "").toString().toLowerCase(); - const bVal = (b[sortCol] ?? "").toString().toLowerCase(); - return sortDir === "asc" ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); - }); - }, [searchQuery.data, sortCol, sortDir]); + useEffect(() => { + // Un changement de filtre invalide la page courante : repartir de la première page. + setPage(1); + setExpandedEtab(null); + }, [filters]); const handleViewEtab = (id: number) => { setExpandedEtab(expandedEtab === id ? null : id); @@ -91,6 +97,7 @@ export default function Home() { const resetFilters = () => { setFilters({ blocFonctionnelId: undefined, solutionId: undefined, editeurId: undefined, region: undefined, typeActivite: undefined, tailleEffectifs: undefined }); setSearchText(""); + setPage(1); }; const activeFilterCount = Object.values(filters).filter(Boolean).length; @@ -282,7 +289,7 @@ export default function Home() {
- ) : searchQuery.data && searchQuery.data.length === 0 ? ( + ) : searchQuery.data && searchQuery.data.total === 0 ? (

Aucun établissement trouvé

@@ -293,7 +300,7 @@ export default function Home() { {searchQuery.data && (

- {searchQuery.data.length} établissement{searchQuery.data.length > 1 ? "s" : ""} trouvé{searchQuery.data.length > 1 ? "s" : ""} + {searchQuery.data.total} établissement{searchQuery.data.total > 1 ? "s" : ""} trouvé{searchQuery.data.total > 1 ? "s" : ""}

Cliquez sur un en-tête pour trier

@@ -323,7 +330,7 @@ export default function Home() { - {sortedResults.map((etab) => ( + {results.map((etab) => (
+ + {searchQuery.data && searchQuery.data.total > pageSize && ( +
+

+ Page {page} sur {totalPages} +

+
+ + +
+
+ )} )}
diff --git a/client/src/pages/MesEchanges.tsx b/client/src/pages/MesEchanges.tsx index f0801dd..bc8095c 100644 --- a/client/src/pages/MesEchanges.tsx +++ b/client/src/pages/MesEchanges.tsx @@ -33,25 +33,29 @@ export default function MesEchanges() { const { user } = useAuth(); const [selectedCanalId, setSelectedCanalId] = useState(null); const [newMessage, setNewMessage] = useState(""); + const [messagePage, setMessagePage] = useState(1); const messagesEndRef = useRef(null); const canauxQuery = trpc.canaux.list.useQuery(); const messagesQuery = trpc.canaux.messages.useQuery( - { canalId: selectedCanalId! }, - { enabled: !!selectedCanalId, refetchInterval: 5000 } + { canalId: selectedCanalId!, page: messagePage, pageSize: 50 }, + { enabled: !!selectedCanalId, refetchInterval: messagePage === 1 ? 5000 : false } ); const utils = trpc.useUtils(); const sendMutation = trpc.canaux.sendMessage.useMutation({ onSuccess: () => { setNewMessage(""); - utils.canaux.messages.invalidate({ canalId: selectedCanalId! }); + setMessagePage(1); + utils.canaux.messages.invalidate(); }, }); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messagesQuery.data]); + if (messagePage === 1) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [messagesQuery.data, messagePage]); const selectedCanal = canauxQuery.data?.find((c: Canal) => c.id === selectedCanalId); @@ -128,7 +132,7 @@ export default function MesEchanges() { {canauxQuery.data?.map((canal: Canal) => ( + )} + {messagesQuery.data?.hasMore && ( + + )} + + )} {messagesQuery.isLoading && (
{[1, 2, 3].map((i) => ( @@ -218,13 +244,13 @@ export default function MesEchanges() { ))}
)} - {!messagesQuery.isLoading && messagesQuery.data?.length === 0 && ( + {!messagesQuery.isLoading && messagesQuery.data?.items.length === 0 && (

Aucun message pour l'instant. Soyez le premier à écrire !

)} - {messagesQuery.data?.map((msg: Message) => { + {messagesQuery.data?.items.map((msg: Message) => { const isMe = msg.auteurId === user?.id; return (
diff --git a/server/db.ts b/server/db.ts index 635a02a..826328b 100644 --- a/server/db.ts +++ b/server/db.ts @@ -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`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`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, + }; } /** diff --git a/server/rateLimit.test.ts b/server/rateLimit.test.ts new file mode 100644 index 0000000..e127b2b --- /dev/null +++ b/server/rateLimit.test.ts @@ -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"); + }); +}); diff --git a/server/rateLimit.ts b/server/rateLimit.ts new file mode 100644 index 0000000..acd38bd --- /dev/null +++ b/server/rateLimit.ts @@ -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(); + 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 { + const forwarded = headers["x-forwarded-for"]; + const rawValue = Array.isArray(forwarded) ? forwarded[0] : forwarded; + const ip = rawValue?.split(",")[0]?.trim(); + return ip || "unknown-client"; +} diff --git a/server/routers.ts b/server/routers.ts index ed6eb92..7bca728 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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" }); diff --git a/server/sonum-v2.test.ts b/server/sonum-v2.test.ts index 1743309..5df2498 100644 --- a/server/sonum-v2.test.ts +++ b/server/sonum-v2.test.ts @@ -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) { + 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" }); + }); +}); diff --git a/todo.md b/todo.md index bc35760..9195e4d 100644 --- a/todo.md +++ b/todo.md @@ -119,3 +119,12 @@ - [x] Ajouter des commentaires ciblés pour les invariants métier et les décisions techniques non évidentes - [x] Ajouter des tests de non-régression pour les corrections critiques - [x] Exécuter TypeScript, les tests unitaires et le build de production avant livraison + +## Évolution — Pagination, sécurité et recette + +- [x] Paginer côté serveur les résultats d’établissements avec total et navigation utilisateur +- [x] Paginer côté serveur les messages de canal avec conservation de l’ordre chronologique +- [x] Limiter les tentatives de connexion locale par identifiant et adresse IP +- [x] Limiter l’envoi de messages et de demandes de mise en relation pour prévenir les abus +- [x] Ajouter les tests de pagination et de limitation de débit +- [ ] Déployer en recette via le pipeline Gitea et vérifier les parcours administrateur et référent