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

This commit is contained in:
Corentin JOGUET 2026-05-07 21:44:33 +02:00
parent 571f5c3426
commit 0cf6533885
15 changed files with 987 additions and 8 deletions

View file

@ -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)** : - **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). - 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 | | 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% | | 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 | | 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 | | 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 | | 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 | | 7b — Webhook Docmost (stub) | STUB | log-only, handlers metier en Bloc 8 |

View file

@ -37,7 +37,13 @@ BRIDGE_API_TOKENS=
# AUTH_GROUPS_SCOPES_MAP={"formation-hub-formateurs":["formation:read","intervention:write"],"formation-hub-admins":["admin:*"]} # 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) # AUTH_STRICT_MAPPING=true # false -> autorise les emails OIDC sans Personne (scopes des groups uniquement)
# Rate limiting (par token + endpoint) # Rate limiting (Bloc 5) — sliding window Redis sur /api/v1/*
RATE_LIMIT_READ_PER_MIN=600 # (hors /api/health, /api/ready, /api/webhooks/* qui ont leur propre defense).
RATE_LIMIT_WRITE_PER_MIN=60 # Global s'applique sur tous les verbes ; Mutation s'ajoute sur POST/PATCH/PUT/DELETE
RATE_LIMIT_WEBHOOK_PER_MIN=1000 # 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

View file

@ -13,6 +13,7 @@ import { getContainer, initContainer } from './lib/container.js';
import { logger } from './lib/logger.js'; import { logger } from './lib/logger.js';
import { type AuthVariables, authMiddleware } from './middleware/auth.js'; import { type AuthVariables, authMiddleware } from './middleware/auth.js';
import { errorHandler } from './middleware/error-handler.js'; import { errorHandler } from './middleware/error-handler.js';
import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js';
import { attributionsRoutes } from './routes/attributions.js'; import { attributionsRoutes } from './routes/attributions.js';
import { formationsRoutes } from './routes/formations.js'; import { formationsRoutes } from './routes/formations.js';
import { interventionsRoutes } from './routes/interventions.js'; import { interventionsRoutes } from './routes/interventions.js';
@ -57,6 +58,30 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
logger: ctn.logger, 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('/personnes', personnesRoutes);
v1.route('/formations', formationsRoutes); v1.route('/formations', formationsRoutes);
v1.route('/projets', projetsRoutes); v1.route('/projets', projetsRoutes);

73
bridge/src/lib/cache.ts Normal file
View 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;
}

View file

@ -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 // 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. // (scopes derives uniquement des groups Authentik). Defaut strict.
authStrictMapping: z.coerce.boolean().default(true), 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>; export type Config = z.infer<typeof ConfigSchema>;
@ -46,6 +53,10 @@ export function loadConfig(): Config {
authentikAudience: process.env.AUTHENTIK_AUDIENCE, authentikAudience: process.env.AUTHENTIK_AUDIENCE,
authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP, authGroupsScopesMap: process.env.AUTH_GROUPS_SCOPES_MAP,
authStrictMapping: process.env.AUTH_STRICT_MAPPING, 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) { if (!parsed.success) {

View 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();
};
}

View file

@ -6,6 +6,7 @@
import { Decimal } from 'decimal.js'; import { Decimal } from 'decimal.js';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import { invalidateEntity } from '../lib/cache.js';
import { getContainer } from '../lib/container.js'; import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js'; import { errors } from '../lib/errors.js';
import { dec, parseBody } from '../lib/http.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); 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({ return c.json({
data: { data: {
attribution_id: id, attribution_id: id,

View file

@ -6,6 +6,7 @@
import { Decimal } from 'decimal.js'; import { Decimal } from 'decimal.js';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import { invalidateEntity } from '../lib/cache.js';
import { getContainer } from '../lib/container.js'; import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js'; import { errors } from '../lib/errors.js';
import { dec, parseBody } from '../lib/http.js'; import { dec, parseBody } from '../lib/http.js';
@ -61,6 +62,10 @@ interventionsRoutes.post('/', requireScope('write:interventions'), async (c) =>
throw err; throw err;
} }
// Invalidation cache locale apres create — cascade tache + personne (rollups).
const { redis } = getContainer();
await invalidateEntity(redis, 'intervention', createdId);
return c.json( return c.json(
{ {
data: { data: {

View file

@ -10,6 +10,7 @@
import { Decimal } from 'decimal.js'; import { Decimal } from 'decimal.js';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import { invalidateEntity } from '../lib/cache.js';
import { getContainer } from '../lib/container.js'; import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js'; import { errors } from '../lib/errors.js';
import { dec, parseBody } from '../lib/http.js'; import { dec, parseBody } from '../lib/http.js';
@ -86,6 +87,10 @@ modulesRoutes.post('/:id/attribuer', requireScope('write:attributions'), async (
throw err; throw err;
} }
// Invalidation cache locale apres create — cascade module + personne (rollups).
const { redis } = getContainer();
await invalidateEntity(redis, 'attribution', createdId);
return c.json( return c.json(
{ {
data: { data: {

View file

@ -63,12 +63,23 @@ export interface TestContainerOverrides {
tokens?: ApiTokenRecord[]; 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 { export function installTestContainer(over: TestContainerOverrides): Container {
const tokensMap = new Map<string, ApiTokenRecord>(); const tokensMap = new Map<string, ApiTokenRecord>();
for (const t of over.tokens ?? TEST_TOKENS) tokensMap.set(t.token, t); for (const t of over.tokens ?? TEST_TOKENS) tokensMap.set(t.token, t);
const fakeBaserow = over.baserow ?? ({} as BaserowClient); const fakeBaserow = over.baserow ?? ({} as BaserowClient);
const fakeRedis = over.redis ?? ({} as RedisCache); const fakeRedis = over.redis ?? buildNoopRedis();
const container: Container = { const container: Container = {
config: { config: {
@ -82,6 +93,10 @@ export function installTestContainer(over: TestContainerOverrides): Container {
docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars', docmostWebhookSecret: 'fake_docmost_secret_at_least_16_chars',
bridgeApiTokens: undefined, bridgeApiTokens: undefined,
authStrictMapping: true, authStrictMapping: true,
rateLimitGlobalMax: 10000,
rateLimitGlobalWindow: 60,
rateLimitMutationMax: 10000,
rateLimitMutationWindow: 60,
}, },
baserow: fakeBaserow, baserow: fakeBaserow,
redis: fakeRedis, redis: fakeRedis,

View 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);
});
});

View 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');
});
});

View 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);
});
});

View file

@ -55,6 +55,11 @@ function installContainer(redis: FakeRedis, withDocmostSecret = true) {
baserowWebhookSecret: BASEROW_SECRET, baserowWebhookSecret: BASEROW_SECRET,
docmostWebhookSecret: withDocmostSecret ? DOCMOST_SECRET : undefined, docmostWebhookSecret: withDocmostSecret ? DOCMOST_SECRET : undefined,
bridgeApiTokens: undefined, bridgeApiTokens: undefined,
authStrictMapping: true,
rateLimitGlobalMax: 10000,
rateLimitGlobalWindow: 60,
rateLimitMutationMax: 10000,
rateLimitMutationWindow: 60,
}, },
// biome-ignore lint/suspicious/noExplicitAny: fake injection // biome-ignore lint/suspicious/noExplicitAny: fake injection
baserow: {} as any, baserow: {} as any,

View file

@ -37,6 +37,19 @@ export default defineConfig({
branches: 85, branches: 85,
statements: 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, passWithNoTests: true,