feat(bridge): Bloc 5 rate limit + cache invalidation cote writes
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
This commit is contained in:
parent
571f5c3426
commit
0cf6533885
15 changed files with 987 additions and 8 deletions
|
|
@ -1,6 +1,24 @@
|
|||
# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit Bloc 4 OIDC)
|
||||
# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit Bloc 5)
|
||||
|
||||
## CHANGELOG depuis derniere update (Bloc 4 — auth OIDC-ready)
|
||||
## CHANGELOG depuis derniere update (Bloc 5 — rate limit + cache invalidation cote writes)
|
||||
|
||||
- **Bloc 5 livre (rate limit defensif + invalidation cache writes)** :
|
||||
- Nouveau module `src/middleware/rate-limit.ts` : middleware Hono autour de `RedisCache.checkRateLimit` (sliding window deja teste integration). Cle derivee de l'identite avec priorites : `tokenId` (service token) > `email` OIDC (lower-cased) > `sub` OIDC > IP via `x-forwarded-for` (avec WARN log car spoofable) > `anonymous`. Throw `errors.rateLimited(windowSeconds)` avec headers `X-RateLimit-Limit/Remaining/Reset`. Helper exporte `defaultRateLimitKey` pour composer (`${default}:mut`).
|
||||
- Nouveau module `src/lib/cache.ts` : `invalidateEntity(redis, entity, id?)` qui mirror la logique cascade de `webhooks/baserow-handler.ts` (attribution -> module + personne, intervention -> tache + personne, etc.). Volontairement duplique plutot qu'extrait commun car les contextes sont differents (event_type webhook vs intent route).
|
||||
- Wire `src/index.ts` :
|
||||
- `rateLimit(redis, {global})` sur `/api/v1/*` apres l'auth middleware.
|
||||
- `rateLimit(redis, {mutation, keyFrom: ...:mut})` ajoute conditionnellement sur POST/PATCH/PUT/DELETE — compteur Redis distinct, plus strict.
|
||||
- **Pas de rate limit** sur `/api/health`, `/api/ready`, `/api/webhooks/*` (ces dernieres ont HMAC + idempotence Redis qui couvrent).
|
||||
- Routes mutation appellent `invalidateEntity()` apres write reussi (3 routes : `POST /modules/:id/attribuer`, `POST /interventions`, `PATCH /attributions/:id/heures-realisees`). Ferme la fenetre stale entre l'ecriture Baserow et l'arrivee du webhook (idempotent avec l'invalidation webhook qui suivra).
|
||||
- Config zod : 4 vars ajoutees avec defauts `100/60s` global et `30/60s` mutation. Toutes coercees + optionnelles via env (`RATE_LIMIT_GLOBAL_MAX`, etc.).
|
||||
- `.env.example` : section rate limit reecrite (commentee, defauts documentes), ancienne triple-var Phase 1 supprimee.
|
||||
- Tests : **29 tests ajoutes** (290 -> 319). 11 tests rate-limit middleware (cles, priorites, 429, headers, mutation independance, anonymous fallback), 11 tests cache helper (cascade par entite, idempotence, total returned), 7 tests integration `/api/v1/*` vs health/webhooks. Pas de fake timers : `RedisCache.checkRateLimit` est mocke, on simule la reset par mutation du compteur fake (equivalent fonctionnel).
|
||||
- Coverage : `src/middleware/rate-limit.ts` = **100% lines / 100% branches / 100% funcs**. `src/lib/cache.ts` = **100% lines / 100% branches / 100% funcs**. Coverage globale 87.7% (+0.3pt).
|
||||
- vitest.config.ts thresholds : ajout `src/middleware/rate-limit.ts` et `src/lib/cache.ts` a 85%.
|
||||
|
||||
# SESSION RESUME — formation-hub Acadenice (Bloc 4 — auth OIDC-ready)
|
||||
|
||||
## CHANGELOG (Bloc 4 — auth OIDC-ready)
|
||||
|
||||
- **Bloc 4 livre (middleware OIDC-ready, dual mode)** :
|
||||
- Nouveau module `src/middleware/oidc-verifier.ts` : verification JWT via `jose` + JWKS remote (cache 10min). Algorithmes acceptes : RS256/RS384/RS512 (pas HS* puisque cle publique). Throw `errors.authInvalid()` sur tout echec (signature, expired, issuer mismatch, audience mismatch).
|
||||
|
|
@ -91,7 +109,7 @@ Stack live + bridge testes :
|
|||
| 3.2 — Refactor erreurs domain typees + routes /blocs /clients /taches | TODO | DomainError sub-classes (RGViolationError, ConflictError) pour remplacer mapping par texte |
|
||||
| 4 — Auth middleware OIDC-ready | DONE | dual mode service-token + Authentik JWT/cookie, mode OIDC desactive en local (vars env absentes), 27 tests coverage 94.11% |
|
||||
| 4b — Docmost OIDC fork (rebrand DocAdenice + login Authentik) | TODO | docmost-fork-dev — depend Bloc 4 |
|
||||
| 5 — Rate limit + cache invalidation | TODO | RedisCache.checkRateLimit existe deja, faut middleware Hono qui l'appelle |
|
||||
| 5 — Rate limit + cache invalidation | DONE | middleware/rate-limit.ts (100%), lib/cache.ts (100%), wire global + mutation sur /api/v1/*, invalidation 3 routes write |
|
||||
| 6 — Tests integration adapters | DONE | `1528017`, 59 tests, redis-cache 100% / baserow 99% / docmost 97.7% lines |
|
||||
| 7a — Webhook Baserow (HMAC + idempotence + invalidation cache) | DONE | webhooks/* + routes/webhooks.ts, 100% coverage |
|
||||
| 7b — Webhook Docmost (stub) | STUB | log-only, handlers metier en Bloc 8 |
|
||||
|
|
|
|||
|
|
@ -37,7 +37,13 @@ BRIDGE_API_TOKENS=
|
|||
# AUTH_GROUPS_SCOPES_MAP={"formation-hub-formateurs":["formation:read","intervention:write"],"formation-hub-admins":["admin:*"]}
|
||||
# AUTH_STRICT_MAPPING=true # false -> autorise les emails OIDC sans Personne (scopes des groups uniquement)
|
||||
|
||||
# Rate limiting (par token + endpoint)
|
||||
RATE_LIMIT_READ_PER_MIN=600
|
||||
RATE_LIMIT_WRITE_PER_MIN=60
|
||||
RATE_LIMIT_WEBHOOK_PER_MIN=1000
|
||||
# Rate limiting (Bloc 5) — sliding window Redis sur /api/v1/*
|
||||
# (hors /api/health, /api/ready, /api/webhooks/* qui ont leur propre defense).
|
||||
# Global s'applique sur tous les verbes ; Mutation s'ajoute sur POST/PATCH/PUT/DELETE
|
||||
# avec un compteur Redis distinct (suffixe `:mut`) volontairement plus strict.
|
||||
# Cle derivee de l'identite : tokenId (service token) > email OIDC > sub OIDC > IP > anonymous.
|
||||
# Defauts conservateurs ci-dessous, override si besoin.
|
||||
# RATE_LIMIT_GLOBAL_MAX=100
|
||||
# RATE_LIMIT_GLOBAL_WINDOW=60
|
||||
# RATE_LIMIT_MUTATION_MAX=30
|
||||
# RATE_LIMIT_MUTATION_WINDOW=60
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { getContainer, initContainer } from './lib/container.js';
|
|||
import { logger } from './lib/logger.js';
|
||||
import { type AuthVariables, authMiddleware } from './middleware/auth.js';
|
||||
import { errorHandler } from './middleware/error-handler.js';
|
||||
import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js';
|
||||
import { attributionsRoutes } from './routes/attributions.js';
|
||||
import { formationsRoutes } from './routes/formations.js';
|
||||
import { interventionsRoutes } from './routes/interventions.js';
|
||||
|
|
@ -57,6 +58,30 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
|||
logger: ctn.logger,
|
||||
}),
|
||||
);
|
||||
// Rate limit global sur toute identite authentifiee. Place APRES authMiddleware
|
||||
// pour que `c.var.user` soit deja peuple — sinon la cle tombe sur IP.
|
||||
v1.use(
|
||||
'*',
|
||||
rateLimit(ctn.redis, {
|
||||
maxRequests: ctn.config.rateLimitGlobalMax,
|
||||
windowSeconds: ctn.config.rateLimitGlobalWindow,
|
||||
}),
|
||||
);
|
||||
// Rate limit mutation : compteur dedie (suffixe `:mut`) qui ne s'applique
|
||||
// qu'aux verbes ecrivants. Plus strict que le global car bursts d'ecriture =
|
||||
// plus dangereux (charge Baserow + integrite donnees).
|
||||
const mutationLimiter = rateLimit(ctn.redis, {
|
||||
maxRequests: ctn.config.rateLimitMutationMax,
|
||||
windowSeconds: ctn.config.rateLimitMutationWindow,
|
||||
keyFrom: (c) => `${defaultRateLimitKey(c)}:mut`,
|
||||
});
|
||||
v1.use('*', async (c, next) => {
|
||||
const method = c.req.method.toUpperCase();
|
||||
if (method === 'POST' || method === 'PATCH' || method === 'PUT' || method === 'DELETE') {
|
||||
return mutationLimiter(c, next);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
v1.route('/personnes', personnesRoutes);
|
||||
v1.route('/formations', formationsRoutes);
|
||||
v1.route('/projets', projetsRoutes);
|
||||
|
|
|
|||
73
bridge/src/lib/cache.ts
Normal file
73
bridge/src/lib/cache.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Helpers d'invalidation cache cote bridge.
|
||||
*
|
||||
* Quand une route REST `/api/v1/*` mute Baserow (POST/PATCH/PUT/DELETE), Baserow
|
||||
* va emettre un webhook qui invalidera le cache via `webhooks/baserow-handler.ts`.
|
||||
* MAIS la latence webhook est variable (ms a quelques secondes selon la conf
|
||||
* Baserow + reseau) — entre l'ecriture et l'arrivee du webhook, une lecture
|
||||
* concurrente peut servir une valeur stale. L'invalidation immediate cote write
|
||||
* ferme cette fenetre et evite la double-source-of-truth temporaire.
|
||||
*
|
||||
* Volontairement pas de coordination avec le webhook : si les deux invalidations
|
||||
* tombent (write local puis webhook), `invalidatePattern` est idempotent (un
|
||||
* pattern qui ne matche rien retourne 0, pas d'erreur).
|
||||
*/
|
||||
|
||||
import type { TableName } from '../repos/baserow-repo.js';
|
||||
|
||||
export interface CacheInvalidator {
|
||||
invalidatePattern: (pattern: string) => Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalide le cache local pour une entite + cascade sur les rollups parents.
|
||||
* Mirror de la logique webhook (`buildInvalidationPatterns`) — duplique
|
||||
* volontairement ici plutot que d'extraire car les contextes sont differents
|
||||
* (event_type webhook vs intent route).
|
||||
*
|
||||
* Si `id` fourni : invalide la row precise + la liste. Sinon : juste la liste
|
||||
* (utile sur les creates ou on n'a pas encore l'id parent a invalider).
|
||||
*/
|
||||
export async function invalidateEntity(
|
||||
redis: CacheInvalidator,
|
||||
entity: TableName,
|
||||
id?: number,
|
||||
): Promise<number> {
|
||||
const patterns: string[] = [`bridge:${entity}:list:*`];
|
||||
if (typeof id === 'number') {
|
||||
patterns.push(`bridge:${entity}:row:${id}`);
|
||||
}
|
||||
|
||||
// Cascade rollups parent : aligned avec webhooks/baserow-handler.ts.
|
||||
switch (entity) {
|
||||
case 'attribution':
|
||||
patterns.push('bridge:module:row:*', 'bridge:module:list:*');
|
||||
patterns.push('bridge:personne:row:*', 'bridge:personne:list:*');
|
||||
break;
|
||||
case 'intervention':
|
||||
patterns.push('bridge:tache:row:*', 'bridge:tache:list:*');
|
||||
patterns.push('bridge:personne:row:*', 'bridge:personne:list:*');
|
||||
break;
|
||||
case 'module':
|
||||
patterns.push('bridge:bloc:row:*', 'bridge:bloc:list:*');
|
||||
patterns.push('bridge:formation:row:*', 'bridge:formation:list:*');
|
||||
break;
|
||||
case 'bloc':
|
||||
patterns.push('bridge:formation:row:*', 'bridge:formation:list:*');
|
||||
break;
|
||||
case 'tache':
|
||||
patterns.push('bridge:projet:row:*', 'bridge:projet:list:*');
|
||||
break;
|
||||
case 'projet':
|
||||
patterns.push('bridge:client:row:*', 'bridge:client:list:*');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const pattern of patterns) {
|
||||
total += await redis.invalidatePattern(pattern);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
|
@ -24,6 +24,13 @@ const ConfigSchema = z.object({
|
|||
// Si false : un JWT OIDC valide dont l'email n'a pas de Personne attache passe quand meme
|
||||
// (scopes derives uniquement des groups Authentik). Defaut strict.
|
||||
authStrictMapping: z.coerce.boolean().default(true),
|
||||
// Rate limiting (Bloc 5). Global s'applique sur tout /api/v1/* ; mutation s'ajoute
|
||||
// sur POST/PATCH/PUT/DELETE et est volontairement plus strict pour proteger
|
||||
// contre les bursts buggy / scripts mal configures.
|
||||
rateLimitGlobalMax: z.coerce.number().int().positive().default(100),
|
||||
rateLimitGlobalWindow: z.coerce.number().int().positive().default(60),
|
||||
rateLimitMutationMax: z.coerce.number().int().positive().default(30),
|
||||
rateLimitMutationWindow: z.coerce.number().int().positive().default(60),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
|
@ -46,6 +53,10 @@ export function loadConfig(): Config {
|
|||
authentikAudience: process.env.AUTHENTIK_AUDIENCE,
|
||||
authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP,
|
||||
authStrictMapping: process.env.AUTH_STRICT_MAPPING,
|
||||
rateLimitGlobalMax: process.env.RATE_LIMIT_GLOBAL_MAX,
|
||||
rateLimitGlobalWindow: process.env.RATE_LIMIT_GLOBAL_WINDOW,
|
||||
rateLimitMutationMax: process.env.RATE_LIMIT_MUTATION_MAX,
|
||||
rateLimitMutationWindow: process.env.RATE_LIMIT_MUTATION_WINDOW,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
|
|
|
|||
100
bridge/src/middleware/rate-limit.ts
Normal file
100
bridge/src/middleware/rate-limit.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Rate limit middleware bridge — defensif contre les bursts buggy + abus.
|
||||
*
|
||||
* Strategie : sliding window via `RedisCache.checkRateLimit` (deja teste en
|
||||
* integration). Cle derivee de l'identite authentifiee :
|
||||
* 1. tokenId (service token `brg_*`) — identite forte
|
||||
* 2. email (OIDC) — identite forte
|
||||
* 3. sub (OIDC) — fallback si email absent
|
||||
* 4. IP via `x-forwarded-for` — identite faible (trompable derriere proxy mal configure)
|
||||
* 5. 'anonymous' — derniere ligne avant 401
|
||||
*
|
||||
* Le cas IP est volontairement note `WARN` au log : un attaquant peut spoofer
|
||||
* `X-Forwarded-За-For` si Traefik/nginx ne sanitize pas. En prod le rate limit
|
||||
* doit s'appliquer apres l'auth middleware (qui rejette les anonymous), donc
|
||||
* ce path est principalement reserve aux endpoints publics futurs.
|
||||
*
|
||||
* Differencier global / mutation : utiliser `keyFrom: c => `${default}:mut`` pour
|
||||
* un compteur Redis dedie aux writes. Ainsi un user qui fait 20 GET + 20 POST
|
||||
* consomme deux compteurs distincts.
|
||||
*/
|
||||
|
||||
import type { Context, MiddlewareHandler } from 'hono';
|
||||
import { errors } from '../lib/errors.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import type { AuthVariables } from './auth.js';
|
||||
|
||||
export interface RateLimitDeps {
|
||||
/** Compatible avec `RedisCache.checkRateLimit`. */
|
||||
checkRateLimit: (key: string, maxRequests: number, windowSeconds: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RateLimitOptions {
|
||||
/** Nombre max de requetes dans la fenetre. */
|
||||
maxRequests: number;
|
||||
/** Largeur de la fenetre en secondes. */
|
||||
windowSeconds: number;
|
||||
/**
|
||||
* Source de la clef rate limit. Defaut : tokenId service-token, sinon email
|
||||
* OIDC, sinon sub OIDC, sinon IP (x-forwarded-for), sinon 'anonymous'.
|
||||
*/
|
||||
keyFrom?: (c: Context<{ Variables: AuthVariables }>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cle par defaut + warning si on tombe sur l'IP fallback.
|
||||
* Exporte pour reuse dans `keyFrom` custom (`c => `${defaultRateLimitKey(c)}:mut``).
|
||||
*/
|
||||
export function defaultRateLimitKey(c: Context<{ Variables: AuthVariables }>): string {
|
||||
// c.var.user n'est pas garanti present : le middleware peut etre monte avant
|
||||
// ou apres l'auth selon la route. On lit defensivement.
|
||||
const user = c.get('user') as AuthVariables['user'] | undefined;
|
||||
if (user?.tokenId) return `token:${user.tokenId}`;
|
||||
if (user?.email) return `email:${user.email.toLowerCase()}`;
|
||||
if (user?.sub) return `sub:${user.sub}`;
|
||||
|
||||
// Fallback IP — identite faible.
|
||||
const xff = c.req.header('x-forwarded-for');
|
||||
if (xff) {
|
||||
// x-forwarded-for peut contenir une chaine "client, proxy1, proxy2" ;
|
||||
// on prend le premier (le plus eloigne du serveur).
|
||||
const ip = xff.split(',')[0]?.trim();
|
||||
if (ip) {
|
||||
logger.warn(
|
||||
{ ip, path: c.req.path },
|
||||
'rate limit using IP fallback — identite faible, vulnerable au spoof XFF',
|
||||
);
|
||||
return `ip:${ip}`;
|
||||
}
|
||||
}
|
||||
logger.warn({ path: c.req.path }, 'rate limit on anonymous identity (no token, no IP)');
|
||||
return 'anonymous';
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le middleware. Le `prefix` permet d'isoler plusieurs compteurs
|
||||
* pour la meme identite (ex : global vs mutation).
|
||||
*/
|
||||
export function rateLimit(
|
||||
deps: RateLimitDeps,
|
||||
opts: RateLimitOptions,
|
||||
): MiddlewareHandler<{ Variables: AuthVariables }> {
|
||||
const { maxRequests, windowSeconds, keyFrom = defaultRateLimitKey } = opts;
|
||||
|
||||
return async (c, next) => {
|
||||
const baseKey = keyFrom(c);
|
||||
const allowed = await deps.checkRateLimit(baseKey, maxRequests, windowSeconds);
|
||||
|
||||
if (!allowed) {
|
||||
// X-RateLimit-Reset = secondes avant que la fenetre se vide. On donne
|
||||
// `windowSeconds` au pire, l'utilisateur peut retry plus tot mais c'est
|
||||
// un upper bound sur.
|
||||
c.header('X-RateLimit-Limit', String(maxRequests));
|
||||
c.header('X-RateLimit-Remaining', '0');
|
||||
c.header('X-RateLimit-Reset', String(windowSeconds));
|
||||
throw errors.rateLimited(windowSeconds);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { invalidateEntity } from '../lib/cache.js';
|
||||
import { getContainer } from '../lib/container.js';
|
||||
import { errors } from '../lib/errors.js';
|
||||
import { dec, parseBody } from '../lib/http.js';
|
||||
|
|
@ -41,6 +42,10 @@ attributionsRoutes.patch('/:id/heures-realisees', requireScope('write:attributio
|
|||
|
||||
await repos.attributions.updateHeuresRealisees(id, attribution.heuresRealisees);
|
||||
|
||||
// Invalidation cache locale apres write — ferme la fenetre stale pre-webhook.
|
||||
const { redis } = getContainer();
|
||||
await invalidateEntity(redis, 'attribution', id);
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
attribution_id: id,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { invalidateEntity } from '../lib/cache.js';
|
||||
import { getContainer } from '../lib/container.js';
|
||||
import { errors } from '../lib/errors.js';
|
||||
import { dec, parseBody } from '../lib/http.js';
|
||||
|
|
@ -61,6 +62,10 @@ interventionsRoutes.post('/', requireScope('write:interventions'), async (c) =>
|
|||
throw err;
|
||||
}
|
||||
|
||||
// Invalidation cache locale apres create — cascade tache + personne (rollups).
|
||||
const { redis } = getContainer();
|
||||
await invalidateEntity(redis, 'intervention', createdId);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import { Decimal } from 'decimal.js';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { invalidateEntity } from '../lib/cache.js';
|
||||
import { getContainer } from '../lib/container.js';
|
||||
import { errors } from '../lib/errors.js';
|
||||
import { dec, parseBody } from '../lib/http.js';
|
||||
|
|
@ -86,6 +87,10 @@ modulesRoutes.post('/:id/attribuer', requireScope('write:attributions'), async (
|
|||
throw err;
|
||||
}
|
||||
|
||||
// Invalidation cache locale apres create — cascade module + personne (rollups).
|
||||
const { redis } = getContainer();
|
||||
await invalidateEntity(redis, 'attribution', createdId);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -63,12 +63,23 @@ export interface TestContainerOverrides {
|
|||
tokens?: ApiTokenRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub Redis minimal pour les tests routes : juste les methodes que les routes
|
||||
* appellent (invalidatePattern + checkRateLimit). No-op qui ne refuse jamais.
|
||||
*/
|
||||
function buildNoopRedis(): RedisCache {
|
||||
return {
|
||||
invalidatePattern: async (_pattern: string) => 0,
|
||||
checkRateLimit: async (_key: string, _max: number, _win: number) => true,
|
||||
} as unknown as RedisCache;
|
||||
}
|
||||
|
||||
export function installTestContainer(over: TestContainerOverrides): Container {
|
||||
const tokensMap = new Map<string, ApiTokenRecord>();
|
||||
for (const t of over.tokens ?? TEST_TOKENS) tokensMap.set(t.token, t);
|
||||
|
||||
const fakeBaserow = over.baserow ?? ({} as BaserowClient);
|
||||
const fakeRedis = over.redis ?? ({} as RedisCache);
|
||||
const fakeRedis = over.redis ?? buildNoopRedis();
|
||||
|
||||
const container: Container = {
|
||||
config: {
|
||||
|
|
@ -82,6 +93,10 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
|||
docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars',
|
||||
bridgeApiTokens: undefined,
|
||||
authStrictMapping: true,
|
||||
rateLimitGlobalMax: 10000,
|
||||
rateLimitGlobalWindow: 60,
|
||||
rateLimitMutationMax: 10000,
|
||||
rateLimitMutationWindow: 60,
|
||||
},
|
||||
baserow: fakeBaserow,
|
||||
redis: fakeRedis,
|
||||
|
|
|
|||
278
bridge/tests/integration/rate-limit-app.test.ts
Normal file
278
bridge/tests/integration/rate-limit-app.test.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
/**
|
||||
* Integration test : verifie que le rate limit s'applique sur /api/v1/* et
|
||||
* PAS sur /api/health, /api/ready, /api/webhooks/*. Et que l'invalidation
|
||||
* cache est declenchee apres POST/PATCH/PUT/DELETE sur les routes mutation.
|
||||
*
|
||||
* On reconstitue une mini app proche de buildApp() (sans serve()) avec un
|
||||
* fake Redis qui compte les calls invalidatePattern + checkRateLimit.
|
||||
*/
|
||||
|
||||
import { Decimal } from 'decimal.js';
|
||||
import { Hono } from 'hono';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
||||
import type { Personne } from '../../src/domain/personne.js';
|
||||
import { setContainer } from '../../src/lib/container.js';
|
||||
import { logger } from '../../src/lib/logger.js';
|
||||
import { type AuthVariables, authMiddleware } from '../../src/middleware/auth.js';
|
||||
import { errorHandler } from '../../src/middleware/error-handler.js';
|
||||
import { defaultRateLimitKey, rateLimit } from '../../src/middleware/rate-limit.js';
|
||||
import { attributionsRoutes } from '../../src/routes/attributions.js';
|
||||
import { interventionsRoutes } from '../../src/routes/interventions.js';
|
||||
import { modulesRoutes } from '../../src/routes/modules.js';
|
||||
import { personnesRoutes } from '../../src/routes/personnes.js';
|
||||
import { webhooksRoutes } from '../../src/routes/webhooks.js';
|
||||
import { buildFakeRepos } from '../helpers/fake-repos.js';
|
||||
import { makeAttribution, makePersonne } from '../helpers/fixtures.js';
|
||||
|
||||
const TABLE_IDS = {
|
||||
personne: 1,
|
||||
formation: 2,
|
||||
bloc: 3,
|
||||
module: 4,
|
||||
attribution: 5,
|
||||
client: 6,
|
||||
projet: 7,
|
||||
tache: 8,
|
||||
intervention: 9,
|
||||
} as const;
|
||||
|
||||
class FakeRedis {
|
||||
public invalidations: string[] = [];
|
||||
public rateChecks: Array<{ key: string; max: number }> = [];
|
||||
public counts = new Map<string, number>();
|
||||
public eventIds = new Set<string>();
|
||||
|
||||
async invalidatePattern(pattern: string): Promise<number> {
|
||||
this.invalidations.push(pattern);
|
||||
return 1;
|
||||
}
|
||||
|
||||
async checkRateLimit(key: string, max: number, _window: number): Promise<boolean> {
|
||||
this.rateChecks.push({ key, max });
|
||||
const next = (this.counts.get(key) ?? 0) + 1;
|
||||
this.counts.set(key, next);
|
||||
return next <= max;
|
||||
}
|
||||
|
||||
async checkAndStoreEventId(id: string): Promise<boolean> {
|
||||
if (this.eventIds.has(id)) return true;
|
||||
this.eventIds.add(id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const READ_TOKEN = 'brg_read';
|
||||
const WRITE_TOKEN = 'brg_write';
|
||||
|
||||
function installContainer(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) {
|
||||
const personne = makePersonne({ id: 1, roles: ['formateur'] });
|
||||
const attribution = makeAttribution({ id: 500 });
|
||||
const repos = buildFakeRepos({ personnes: [personne], attributions: [attribution] });
|
||||
|
||||
setContainer({
|
||||
config: {
|
||||
nodeEnv: 'test',
|
||||
port: 0,
|
||||
logLevel: 'fatal',
|
||||
baserowApiUrl: 'http://localhost',
|
||||
baserowApiToken: 'fake',
|
||||
redisUrl: 'redis://localhost',
|
||||
baserowWebhookSecret: 'fake-secret-at-least-16-chars-long',
|
||||
docmostWebhookSecret: undefined,
|
||||
bridgeApiTokens: undefined,
|
||||
authStrictMapping: true,
|
||||
rateLimitGlobalMax: opts.globalMax,
|
||||
rateLimitGlobalWindow: 60,
|
||||
rateLimitMutationMax: opts.mutationMax,
|
||||
rateLimitMutationWindow: 60,
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: stubs
|
||||
baserow: {} as any,
|
||||
redis: redis as unknown as RedisCache,
|
||||
repos,
|
||||
tokens: new Map([
|
||||
[READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:personnes'] }],
|
||||
[
|
||||
WRITE_TOKEN,
|
||||
{ token: WRITE_TOKEN, name: 'writer', scopes: ['write:attributions', 'admin:*'] },
|
||||
],
|
||||
]),
|
||||
tableIds: TABLE_IDS,
|
||||
oidc: null,
|
||||
groupsScopesMap: {},
|
||||
logger,
|
||||
});
|
||||
return repos;
|
||||
}
|
||||
|
||||
function buildAppWithRateLimit(redis: FakeRedis, opts: { globalMax: number; mutationMax: number }) {
|
||||
const app = new Hono<{ Variables: AuthVariables }>();
|
||||
app.onError(errorHandler);
|
||||
|
||||
app.get('/api/health', (c) => c.json({ status: 'ok' }));
|
||||
app.get('/api/ready', (c) => c.json({ status: 'ok' }));
|
||||
app.route('/api/webhooks', webhooksRoutes);
|
||||
|
||||
const v1 = new Hono<{ Variables: AuthVariables }>();
|
||||
v1.use(
|
||||
'*',
|
||||
authMiddleware({
|
||||
tokens: new Map([
|
||||
[READ_TOKEN, { token: READ_TOKEN, name: 'reader', scopes: ['read:personnes'] }],
|
||||
[
|
||||
WRITE_TOKEN,
|
||||
{ token: WRITE_TOKEN, name: 'writer', scopes: ['write:attributions', 'admin:*'] },
|
||||
],
|
||||
]),
|
||||
oidc: null,
|
||||
groupsScopesMap: {},
|
||||
strictMapping: true,
|
||||
cache: {
|
||||
get: async () => null,
|
||||
set: async () => {},
|
||||
},
|
||||
finder: { findByEmail: async (): Promise<Personne | null> => null },
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
v1.use('*', rateLimit(redis, { maxRequests: opts.globalMax, windowSeconds: 60 }));
|
||||
const mutLimiter = rateLimit(redis, {
|
||||
maxRequests: opts.mutationMax,
|
||||
windowSeconds: 60,
|
||||
keyFrom: (c) => `${defaultRateLimitKey(c)}:mut`,
|
||||
});
|
||||
v1.use('*', async (c, next) => {
|
||||
const m = c.req.method.toUpperCase();
|
||||
if (m === 'POST' || m === 'PATCH' || m === 'PUT' || m === 'DELETE') {
|
||||
return mutLimiter(c, next);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
v1.route('/personnes', personnesRoutes);
|
||||
v1.route('/modules', modulesRoutes);
|
||||
v1.route('/interventions', interventionsRoutes);
|
||||
v1.route('/attributions', attributionsRoutes);
|
||||
app.route('/api/v1', v1);
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(() => setContainer(null));
|
||||
|
||||
describe('Rate limit application sur /api/v1/*', () => {
|
||||
it('GET /api/health : pas de rate limit (route publique)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 1, mutationMax: 1 });
|
||||
const app = buildAppWithRateLimit(redis, { globalMax: 1, mutationMax: 1 });
|
||||
|
||||
const r1 = await app.request('/api/health');
|
||||
const r2 = await app.request('/api/health');
|
||||
const r3 = await app.request('/api/health');
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r3.status).toBe(200);
|
||||
expect(redis.rateChecks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('GET /api/ready : pas de rate limit', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 1, mutationMax: 1 });
|
||||
const app = buildAppWithRateLimit(redis, { globalMax: 1, mutationMax: 1 });
|
||||
|
||||
await app.request('/api/ready');
|
||||
await app.request('/api/ready');
|
||||
|
||||
expect(redis.rateChecks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('POST /api/webhooks/baserow : pas de rate limit (HMAC + idempotence couvrent)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 1, mutationMax: 1 });
|
||||
const app = buildAppWithRateLimit(redis, { globalMax: 1, mutationMax: 1 });
|
||||
|
||||
// Sans signature : 401, mais sans avoir consomme le rate limit.
|
||||
const r = await app.request('/api/webhooks/baserow', { method: 'POST', body: '{}' });
|
||||
expect(r.status).toBe(401);
|
||||
expect(redis.rateChecks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('GET /api/v1/personnes : rate limit consomme', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 100, mutationMax: 100 });
|
||||
const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 });
|
||||
|
||||
const r = await app.request('/api/v1/personnes', {
|
||||
headers: { Authorization: `Bearer ${READ_TOKEN}` },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
expect(redis.rateChecks).toHaveLength(1);
|
||||
expect(redis.rateChecks[0]?.key).toBe('token:reader');
|
||||
});
|
||||
|
||||
it('GET au-dela de globalMax -> 429', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 2, mutationMax: 100 });
|
||||
const app = buildAppWithRateLimit(redis, { globalMax: 2, mutationMax: 100 });
|
||||
|
||||
const headers = { Authorization: `Bearer ${READ_TOKEN}` };
|
||||
const r1 = await app.request('/api/v1/personnes', { headers });
|
||||
const r2 = await app.request('/api/v1/personnes', { headers });
|
||||
const r3 = await app.request('/api/v1/personnes', { headers });
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r3.status).toBe(429);
|
||||
});
|
||||
|
||||
it('PATCH attribution : applique mutation rate limit + invalide le cache', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 100, mutationMax: 100 });
|
||||
const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 100 });
|
||||
|
||||
const r = await app.request('/api/v1/attributions/500/heures-realisees', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${WRITE_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ heures_realisees: 4 }),
|
||||
});
|
||||
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
// Deux compteurs Redis distincts : token:writer et token:writer:mut.
|
||||
const keys = redis.rateChecks.map((c) => c.key);
|
||||
expect(keys).toContain('token:writer');
|
||||
expect(keys).toContain('token:writer:mut');
|
||||
|
||||
// Cache invalidation : attribution row + list + cascade module + personne.
|
||||
expect(redis.invalidations).toContain('bridge:attribution:list:*');
|
||||
expect(redis.invalidations).toContain('bridge:attribution:row:500');
|
||||
expect(redis.invalidations).toContain('bridge:module:list:*');
|
||||
expect(redis.invalidations).toContain('bridge:personne:list:*');
|
||||
});
|
||||
|
||||
it('mutation au-dela de mutationMax -> 429 meme si globalMax pas atteint', async () => {
|
||||
const redis = new FakeRedis();
|
||||
installContainer(redis, { globalMax: 100, mutationMax: 1 });
|
||||
const app = buildAppWithRateLimit(redis, { globalMax: 100, mutationMax: 1 });
|
||||
|
||||
const headers = { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json' };
|
||||
const body = JSON.stringify({ heures_realisees: new Decimal(1).toNumber() });
|
||||
|
||||
const r1 = await app.request('/api/v1/attributions/500/heures-realisees', {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const r2 = await app.request('/api/v1/attributions/500/heures-realisees', {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(429);
|
||||
});
|
||||
});
|
||||
286
bridge/tests/middleware/rate-limit.test.ts
Normal file
286
bridge/tests/middleware/rate-limit.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* Tests rate limit middleware — fakes Redis.checkRateLimit pour observabilite.
|
||||
*
|
||||
* Pour les fenetres temporelles : pas de fake timers Vitest car
|
||||
* RedisCache.checkRateLimit est mocke par fonction async ; on simule la reset
|
||||
* en remplacant le mock entre les requetes (equivalent fonctionnel).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { errorHandler } from '../../src/middleware/error-handler.js';
|
||||
import {
|
||||
type RateLimitDeps,
|
||||
defaultRateLimitKey,
|
||||
rateLimit,
|
||||
} from '../../src/middleware/rate-limit.js';
|
||||
|
||||
interface FakeUser {
|
||||
source: string;
|
||||
tokenId?: string;
|
||||
email?: string;
|
||||
sub?: string;
|
||||
roles: string[];
|
||||
groups: string[];
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
function buildApp(
|
||||
deps: RateLimitDeps,
|
||||
opts: { max: number; window: number; keyFrom?: Parameters<typeof rateLimit>[1]['keyFrom'] },
|
||||
presetUser?: FakeUser,
|
||||
) {
|
||||
const app = new Hono();
|
||||
app.onError(errorHandler);
|
||||
app.use('*', async (c, next) => {
|
||||
if (presetUser) c.set('user' as never, presetUser as never);
|
||||
await next();
|
||||
});
|
||||
app.use(
|
||||
'*',
|
||||
rateLimit(deps, {
|
||||
maxRequests: opts.max,
|
||||
windowSeconds: opts.window,
|
||||
keyFrom: opts.keyFrom,
|
||||
}),
|
||||
);
|
||||
app.get('/', (c) => c.text('ok'));
|
||||
app.post('/mut', (c) => c.text('mut'));
|
||||
return app;
|
||||
}
|
||||
|
||||
class FakeLimiter {
|
||||
public calls: Array<{ key: string; max: number; window: number }> = [];
|
||||
// Map de (key) -> count vu jusqu'a maintenant. La fenetre n'est pas simulee :
|
||||
// on remet a 0 manuellement entre les groupes de tests pour mimer l'expiry.
|
||||
public counts = new Map<string, number>();
|
||||
|
||||
checkRateLimit = async (key: string, max: number, window: number): Promise<boolean> => {
|
||||
this.calls.push({ key, max, window });
|
||||
const next = (this.counts.get(key) ?? 0) + 1;
|
||||
this.counts.set(key, next);
|
||||
return next <= max;
|
||||
};
|
||||
|
||||
reset(key: string) {
|
||||
this.counts.set(key, 0);
|
||||
}
|
||||
}
|
||||
|
||||
describe('rateLimit middleware', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('autorise sous la limite, refuse au-dela avec 429 RATE_LIMITED', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const app = buildApp(
|
||||
limiter,
|
||||
{ max: 2, window: 60 },
|
||||
{
|
||||
source: 'service-token',
|
||||
tokenId: 'svc-A',
|
||||
roles: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
);
|
||||
|
||||
const r1 = await app.request('/');
|
||||
const r2 = await app.request('/');
|
||||
const r3 = await app.request('/');
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
expect(r3.status).toBe(429);
|
||||
const body = (await r3.json()) as {
|
||||
error: { code: string; details?: { retry_after: number } };
|
||||
};
|
||||
expect(body.error.code).toBe('RATE_LIMITED');
|
||||
expect(body.error.details?.retry_after).toBe(60);
|
||||
expect(r3.headers.get('X-RateLimit-Limit')).toBe('2');
|
||||
expect(r3.headers.get('X-RateLimit-Remaining')).toBe('0');
|
||||
expect(r3.headers.get('X-RateLimit-Reset')).toBe('60');
|
||||
});
|
||||
|
||||
it('reset apres windowSeconds (simulee via reset du compteur fake)', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const app = buildApp(
|
||||
limiter,
|
||||
{ max: 1, window: 5 },
|
||||
{
|
||||
source: 'service-token',
|
||||
tokenId: 'svc-B',
|
||||
roles: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
);
|
||||
|
||||
const r1 = await app.request('/');
|
||||
const r2 = await app.request('/');
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(429);
|
||||
|
||||
// Simule l'expiry de la fenetre.
|
||||
limiter.reset('token:svc-B');
|
||||
|
||||
const r3 = await app.request('/');
|
||||
expect(r3.status).toBe(200);
|
||||
});
|
||||
|
||||
it('compteurs global et mutation sont independants (cles distinctes en Redis)', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const user: FakeUser = {
|
||||
source: 'service-token',
|
||||
tokenId: 'svc-C',
|
||||
roles: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
};
|
||||
|
||||
const app = new Hono();
|
||||
app.onError(errorHandler);
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('user' as never, user as never);
|
||||
await next();
|
||||
});
|
||||
app.use('*', rateLimit(limiter, { maxRequests: 5, windowSeconds: 60 }));
|
||||
app.use(
|
||||
'*',
|
||||
rateLimit(limiter, {
|
||||
maxRequests: 2,
|
||||
windowSeconds: 60,
|
||||
keyFrom: (c) => `${defaultRateLimitKey(c)}:mut`,
|
||||
}),
|
||||
);
|
||||
app.post('/mut', (c) => c.text('ok'));
|
||||
|
||||
const r1 = await app.request('/mut', { method: 'POST' });
|
||||
const r2 = await app.request('/mut', { method: 'POST' });
|
||||
const r3 = await app.request('/mut', { method: 'POST' });
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
// 3eme : global = 3/5 OK, mutation = 3/2 KO -> 429.
|
||||
expect(r3.status).toBe(429);
|
||||
|
||||
// Assertions sur les cles : deux compteurs distincts en Redis.
|
||||
const keys = limiter.calls.map((c) => c.key);
|
||||
expect(keys).toContain('token:svc-C');
|
||||
expect(keys).toContain('token:svc-C:mut');
|
||||
});
|
||||
|
||||
it('cle prioritaire : tokenId service-token avant tout', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const app = buildApp(
|
||||
limiter,
|
||||
{ max: 5, window: 60 },
|
||||
{
|
||||
source: 'service-token',
|
||||
tokenId: 'svc-D',
|
||||
email: 'should-not-be-used@test',
|
||||
sub: 'sub-x',
|
||||
roles: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
);
|
||||
|
||||
await app.request('/');
|
||||
expect(limiter.calls[0]?.key).toBe('token:svc-D');
|
||||
});
|
||||
|
||||
it('cle email OIDC quand pas de tokenId', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const app = buildApp(
|
||||
limiter,
|
||||
{ max: 5, window: 60 },
|
||||
{
|
||||
source: 'oidc-jwt',
|
||||
email: 'Foo@Bar.IO',
|
||||
sub: 'sub-y',
|
||||
roles: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
);
|
||||
|
||||
await app.request('/');
|
||||
// Email lower-case pour stabilite cross-casing.
|
||||
expect(limiter.calls[0]?.key).toBe('email:foo@bar.io');
|
||||
});
|
||||
|
||||
it('cle sub OIDC quand email absent', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const app = buildApp(
|
||||
limiter,
|
||||
{ max: 5, window: 60 },
|
||||
{
|
||||
source: 'oidc-jwt',
|
||||
sub: 'sub-z',
|
||||
roles: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
);
|
||||
|
||||
await app.request('/');
|
||||
expect(limiter.calls[0]?.key).toBe('sub:sub-z');
|
||||
});
|
||||
|
||||
it('fallback IP via x-forwarded-for + warning logge', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const app = new Hono();
|
||||
app.onError(errorHandler);
|
||||
app.use('*', rateLimit(limiter, { maxRequests: 5, windowSeconds: 60 }));
|
||||
app.get('/', (c) => c.text('ok'));
|
||||
|
||||
await app.request('/', { headers: { 'x-forwarded-for': '203.0.113.5, 10.0.0.1' } });
|
||||
|
||||
// Premier IP (client le plus eloigne) extrait correctement.
|
||||
expect(limiter.calls[0]?.key).toBe('ip:203.0.113.5');
|
||||
});
|
||||
|
||||
it('fallback anonymous quand ni user ni IP', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const app = new Hono();
|
||||
app.onError(errorHandler);
|
||||
app.use('*', rateLimit(limiter, { maxRequests: 5, windowSeconds: 60 }));
|
||||
app.get('/', (c) => c.text('ok'));
|
||||
|
||||
await app.request('/');
|
||||
expect(limiter.calls[0]?.key).toBe('anonymous');
|
||||
});
|
||||
|
||||
it('keyFrom custom override la priorite par defaut', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const app = buildApp(
|
||||
limiter,
|
||||
{ max: 5, window: 60, keyFrom: () => 'custom-key' },
|
||||
{ source: 'service-token', tokenId: 'svc-E', roles: [], groups: [], scopes: [] },
|
||||
);
|
||||
|
||||
await app.request('/');
|
||||
expect(limiter.calls[0]?.key).toBe('custom-key');
|
||||
});
|
||||
|
||||
it('appelle next() en cascade sans erreur quand sous la limite', async () => {
|
||||
const limiter = new FakeLimiter();
|
||||
const app = buildApp(
|
||||
limiter,
|
||||
{ max: 10, window: 60 },
|
||||
{
|
||||
source: 'service-token',
|
||||
tokenId: 'svc-F',
|
||||
roles: [],
|
||||
groups: [],
|
||||
scopes: [],
|
||||
},
|
||||
);
|
||||
|
||||
const res = await app.request('/');
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe('ok');
|
||||
});
|
||||
});
|
||||
134
bridge/tests/unit/cache.test.ts
Normal file
134
bridge/tests/unit/cache.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Tests unit pour invalidateEntity — verifie les patterns generes par entite,
|
||||
* la cascade rollups parent, et l'idempotence (deux invalidations meme key).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type CacheInvalidator, invalidateEntity } from '../../src/lib/cache.js';
|
||||
|
||||
class FakeRedis implements CacheInvalidator {
|
||||
public patterns: string[] = [];
|
||||
// Map pour simuler des keys persistees (incrementee a chaque set fictif).
|
||||
public callCount = 0;
|
||||
|
||||
async invalidatePattern(pattern: string): Promise<number> {
|
||||
this.patterns.push(pattern);
|
||||
this.callCount++;
|
||||
return 1; // un match fictif
|
||||
}
|
||||
}
|
||||
|
||||
describe('invalidateEntity', () => {
|
||||
it('attribution : cascade sur module + personne (rollups RG-01)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'attribution', 42);
|
||||
|
||||
expect(redis.patterns).toContain('bridge:attribution:list:*');
|
||||
expect(redis.patterns).toContain('bridge:attribution:row:42');
|
||||
expect(redis.patterns).toContain('bridge:module:row:*');
|
||||
expect(redis.patterns).toContain('bridge:module:list:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:row:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:list:*');
|
||||
});
|
||||
|
||||
it('intervention : cascade sur tache + personne', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'intervention', 100);
|
||||
|
||||
expect(redis.patterns).toContain('bridge:intervention:list:*');
|
||||
expect(redis.patterns).toContain('bridge:intervention:row:100');
|
||||
expect(redis.patterns).toContain('bridge:tache:row:*');
|
||||
expect(redis.patterns).toContain('bridge:tache:list:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:row:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:list:*');
|
||||
});
|
||||
|
||||
it('module : cascade sur bloc + formation', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'module', 7);
|
||||
expect(redis.patterns).toContain('bridge:module:list:*');
|
||||
expect(redis.patterns).toContain('bridge:module:row:7');
|
||||
expect(redis.patterns).toContain('bridge:bloc:row:*');
|
||||
expect(redis.patterns).toContain('bridge:bloc:list:*');
|
||||
expect(redis.patterns).toContain('bridge:formation:row:*');
|
||||
expect(redis.patterns).toContain('bridge:formation:list:*');
|
||||
});
|
||||
|
||||
it('bloc : cascade formation seulement', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'bloc', 3);
|
||||
expect(redis.patterns).toContain('bridge:bloc:list:*');
|
||||
expect(redis.patterns).toContain('bridge:bloc:row:3');
|
||||
expect(redis.patterns).toContain('bridge:formation:row:*');
|
||||
expect(redis.patterns).toContain('bridge:formation:list:*');
|
||||
// Pas de cascade modules au-dessus.
|
||||
expect(redis.patterns).not.toContain('bridge:module:list:*');
|
||||
});
|
||||
|
||||
it('tache : cascade projet', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'tache', 8);
|
||||
expect(redis.patterns).toContain('bridge:tache:list:*');
|
||||
expect(redis.patterns).toContain('bridge:tache:row:8');
|
||||
expect(redis.patterns).toContain('bridge:projet:row:*');
|
||||
expect(redis.patterns).toContain('bridge:projet:list:*');
|
||||
});
|
||||
|
||||
it('projet : cascade client', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'projet', 5);
|
||||
expect(redis.patterns).toContain('bridge:projet:list:*');
|
||||
expect(redis.patterns).toContain('bridge:projet:row:5');
|
||||
expect(redis.patterns).toContain('bridge:client:row:*');
|
||||
expect(redis.patterns).toContain('bridge:client:list:*');
|
||||
});
|
||||
|
||||
it('personne : pas de cascade parent (entite racine)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'personne', 1);
|
||||
expect(redis.patterns).toContain('bridge:personne:list:*');
|
||||
expect(redis.patterns).toContain('bridge:personne:row:1');
|
||||
expect(redis.patterns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('formation : pas de cascade parent (entite racine)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'formation', 9);
|
||||
expect(redis.patterns).toContain('bridge:formation:list:*');
|
||||
expect(redis.patterns).toContain('bridge:formation:row:9');
|
||||
expect(redis.patterns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('client : pas de cascade parent', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'client', 4);
|
||||
expect(redis.patterns).toContain('bridge:client:list:*');
|
||||
expect(redis.patterns).toContain('bridge:client:row:4');
|
||||
expect(redis.patterns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('sans id : invalide juste la liste + cascade (cas create avant id connu)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'attribution');
|
||||
|
||||
expect(redis.patterns).toContain('bridge:attribution:list:*');
|
||||
expect(redis.patterns).not.toContain('bridge:attribution:row:undefined');
|
||||
// Cascade toujours appliquee meme sans id.
|
||||
expect(redis.patterns).toContain('bridge:module:list:*');
|
||||
});
|
||||
|
||||
it('idempotent : deux invalidations meme key ne throw pas', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await invalidateEntity(redis, 'attribution', 42);
|
||||
await expect(invalidateEntity(redis, 'attribution', 42)).resolves.toBeGreaterThanOrEqual(0);
|
||||
// Les patterns sont appeles deux fois, c'est attendu.
|
||||
expect(redis.patterns.filter((p) => p === 'bridge:attribution:row:42')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('retourne le total des keys invalidees', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const total = await invalidateEntity(redis, 'attribution', 1);
|
||||
// FakeRedis retourne 1 par appel, 6 patterns -> 6.
|
||||
expect(total).toBe(6);
|
||||
});
|
||||
});
|
||||
|
|
@ -55,6 +55,11 @@ function installContainer(redis: FakeRedis, withDocmostSecret = true) {
|
|||
baserowWebhookSecret: BASEROW_SECRET,
|
||||
docmostWebhookSecret: withDocmostSecret ? DOCMOST_SECRET : undefined,
|
||||
bridgeApiTokens: undefined,
|
||||
authStrictMapping: true,
|
||||
rateLimitGlobalMax: 10000,
|
||||
rateLimitGlobalWindow: 60,
|
||||
rateLimitMutationMax: 10000,
|
||||
rateLimitMutationWindow: 60,
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: fake injection
|
||||
baserow: {} as any,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,19 @@ export default defineConfig({
|
|||
branches: 85,
|
||||
statements: 85,
|
||||
},
|
||||
// Bloc 5 : rate limit middleware + cache helper.
|
||||
'src/middleware/rate-limit.ts': {
|
||||
lines: 85,
|
||||
functions: 85,
|
||||
branches: 85,
|
||||
statements: 85,
|
||||
},
|
||||
'src/lib/cache.ts': {
|
||||
lines: 85,
|
||||
functions: 85,
|
||||
branches: 85,
|
||||
statements: 85,
|
||||
},
|
||||
},
|
||||
},
|
||||
passWithNoTests: true,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue