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:
@@ -38,10 +38,13 @@ export default function Home() {
|
||||
const [expandedEtab, setExpandedEtab] = useState<number | null>(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() {
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : searchQuery.data && searchQuery.data.length === 0 ? (
|
||||
) : searchQuery.data && searchQuery.data.total === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Building2 size={48} className="mx-auto text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground font-medium">Aucun établissement trouvé</p>
|
||||
@@ -293,7 +300,7 @@ export default function Home() {
|
||||
{searchQuery.data && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{searchQuery.data.length}</span> établissement{searchQuery.data.length > 1 ? "s" : ""} trouvé{searchQuery.data.length > 1 ? "s" : ""}
|
||||
<span className="font-semibold text-foreground">{searchQuery.data.total}</span> établissement{searchQuery.data.total > 1 ? "s" : ""} trouvé{searchQuery.data.total > 1 ? "s" : ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Cliquez sur un en-tête pour trier</p>
|
||||
</div>
|
||||
@@ -323,7 +330,7 @@ export default function Home() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{sortedResults.map((etab) => (
|
||||
{results.map((etab) => (
|
||||
<Fragment key={etab.id}>
|
||||
<tr
|
||||
className="hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
@@ -384,6 +391,32 @@ export default function Home() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{searchQuery.data && searchQuery.data.total > pageSize && (
|
||||
<div className="mt-4 flex items-center justify-between gap-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {page} sur {totalPages}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((currentPage) => Math.max(1, currentPage - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-2 text-sm rounded-lg border border-border bg-card hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((currentPage) => Math.min(totalPages, currentPage + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-2 text-sm rounded-lg border border-border bg-card hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,25 +33,29 @@ export default function MesEchanges() {
|
||||
const { user } = useAuth();
|
||||
const [selectedCanalId, setSelectedCanalId] = useState<number | null>(null);
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [messagePage, setMessagePage] = useState(1);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(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(() => {
|
||||
if (messagePage === 1) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messagesQuery.data]);
|
||||
}
|
||||
}, [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) => (
|
||||
<button
|
||||
key={canal.id}
|
||||
onClick={() => setSelectedCanalId(canal.id)}
|
||||
onClick={() => { setSelectedCanalId(canal.id); setMessagePage(1); }}
|
||||
className={`w-full text-left p-4 rounded-xl border transition-all ${
|
||||
selectedCanalId === canal.id
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
@@ -209,6 +213,28 @@ export default function MesEchanges() {
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{(messagePage > 1 || messagesQuery.data?.hasMore) && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{messagePage > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMessagePage((page) => page - 1)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs border border-border hover:bg-muted"
|
||||
>
|
||||
Messages plus récents
|
||||
</button>
|
||||
)}
|
||||
{messagesQuery.data?.hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMessagePage((page) => page + 1)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs border border-border hover:bg-muted"
|
||||
>
|
||||
Afficher les messages plus anciens
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{messagesQuery.isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
@@ -218,13 +244,13 @@ export default function MesEchanges() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!messagesQuery.isLoading && messagesQuery.data?.length === 0 && (
|
||||
{!messagesQuery.isLoading && messagesQuery.data?.items.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<MessageSquare size={32} className="text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">Aucun message pour l'instant. Soyez le premier à écrire !</p>
|
||||
</div>
|
||||
)}
|
||||
{messagesQuery.data?.map((msg: Message) => {
|
||||
{messagesQuery.data?.items.map((msg: Message) => {
|
||||
const isMe = msg.auteurId === user?.id;
|
||||
return (
|
||||
<div key={msg.id} className={`flex ${isMe ? "justify-end" : "justify-start"}`}>
|
||||
|
||||
68
server/db.ts
68
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,7 +234,18 @@ export async function searchEtablissements(filters: {
|
||||
conditions.push(inArray(etablissements.id, subquery));
|
||||
}
|
||||
|
||||
const result = await db
|
||||
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,
|
||||
@@ -244,9 +259,19 @@ export async function searchEtablissements(filters: {
|
||||
accepteMiseEnRelation: etablissements.accepteMiseEnRelation,
|
||||
})
|
||||
.from(etablissements)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(etablissements.nom);
|
||||
return result;
|
||||
.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
|
||||
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(messagesCanaux.createdAt);
|
||||
.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
33
server/rateLimit.test.ts
Normal 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
114
server/rateLimit.ts
Normal 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";
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
9
todo.md
9
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
|
||||
|
||||
Reference in New Issue
Block a user