docs(knowledge): capitalisation backend — intégration du triage local (mai-juin 2026)

Triage et intégration des propositions backend du buffer 95_a_capitaliser.md
(lot local RL799_V2 + app-alexandrie, mai-juin 2026), distinct de la capitalisation
remote antérieure (triage 2026-05-02).

~73 entrées intégrées sur knowledge/backend/, dont :
- patterns/auth.md : série "membrane d'auth fédérée BFF/OIDC" (9 patterns) + jose algo whitelist
- patterns/prisma.md : recette fusionnée "Migration String/Int → enum" (backfill + Cas A/B/C),
  row réactivable, endpoint replace atomique, updateMany conditionnel, etc.
- risques/general.md : 19 risques (epoch s vs ms, keepAliveTimeout=0, upsert+filtre liste,
  fail-safe catch-all, retrait asymétrique front/back, anti-énumération rate-limit, etc.)
- patterns/general, async, nestjs, contracts, tests + risques/auth, contracts, prisma, redis, stripe, tests
- compléments d'entrées existantes (authorize-after-fetch, P3014, cursor opaque, DI swc, Stripe v20...)
- README patterns/risques mis à jour

Doublons internes corrigés en relecture (suppression-champ .map() → general seul ;
e2e DB-based → tests.md seul). Doublons hors backend / entrées projet / rejets non intégrés.
Source 95_a_capitaliser.md non purgée à ce stade (purge en fin de capitalisation complète).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MaksTinyWorkshop
2026-06-25 11:25:02 +02:00
parent ef24d85d57
commit f1b783407a
18 changed files with 2896 additions and 24 deletions
+8 -8
View File
@@ -8,12 +8,12 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
| Fichier | Domaine | Entrées clés |
|---------|---------|--------------|
| `auth.md` | Auth, sessions, guards, accès | AuthN/AuthZ dispersée, guard global manquant, null-check request.user, AdminRoleGuard sans @RequireAdminRole, GET sans contrôle accès, cookie après révocation, mock session sans expiresAt, buildApp partagé e2e, champ absent JWT, email login vs contact, disclosure comptes soft-deleted dans login() |
| `contracts.md` | Contrats, validation, codes erreur | Contrats implicites, erreurs non standardisées, duplication constantes, schema orphelin, code erreur générique 409, ForbiddenException pour validation, process.env direct, statut métier non propagé |
| `prisma.md` | Prisma, DB, transactions, migrations | @unique nullable, TOCTOU transaction, OR tenantId null, nextOrder race condition, tenantId sans FK, schema divergence spec, getter manquant, init module build, clearAllMocks imbriqué, cursor non validé, enum-like String, migration manuelle hors git, relation 1:1 sans unique, index partial soft-delete (perf) |
| `stripe.md` | Stripe, paiements, webhooks, subscriptions | billing_cycle_anchor vs current_period_end, list() sans has_more, concurrence trial→payant, non-idempotence, 200 pendant processing |
| `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète, guard multi-statut READ_METHODS, bootstrap OK mais injection cassée (tsx watch), guard écriture mode dégradé bloque le support |
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h, compensation incrBy non-atomique (quota fantôme) |
| `auth.md` | Auth, sessions, guards, accès | AuthN/AuthZ dispersée, guard global manquant, null-check request.user, AdminRoleGuard sans @RequireAdminRole, GET sans contrôle accès, cookie après révocation, mock session sans expiresAt, buildApp partagé e2e, champ absent JWT, email login vs contact, disclosure comptes soft-deleted dans login(), guard d'abonnement global vs droits acquis permanents, validité du jeton d'octroi ≠ durée de l'accès, cohérence des filtres d'authz entre chemins, rotation refresh token IdP en BFF (cookie non réécrit) |
| `contracts.md` | Contrats, validation, codes erreur | Contrats implicites, erreurs non standardisées, duplication constantes, schema orphelin, code erreur générique 409, ForbiddenException pour validation, process.env direct, statut métier non propagé, AC d'affichage vert mais champ absent du contract, schéma par audience pas par entité |
| `prisma.md` | Prisma, DB, transactions, migrations | @unique nullable, TOCTOU transaction, OR tenantId null, nextOrder race condition, tenantId sans FK (relation des deux côtés), schema divergence spec, getter manquant, init module build, clearAllMocks imbriqué, cursor non validé (champs typés), enum-like String, migration manuelle hors git, relation 1:1 sans unique, index partial soft-delete (perf), index partiels littéraux text bloquent ALTER enum, colonnes Prisma jamais écrites, read-then-write/transition one-shot race, @@unique + @@index redondant, suppression champ DB invisible via .map(), DELETE row en transaction d'anonymisation, slug User.id (id auto-généré + validation Zod), template DB de test à droper après migration |
| `stripe.md` | Stripe, paiements, webhooks, subscriptions | billing_cycle_anchor vs current_period_end (+ SDK v20 par item), list() sans has_more, concurrence trial→payant, non-idempotence, 200 pendant processing, remboursement lié à la transaction (PaymentIntent), refund éligibilité mesurée sur visionnage réel |
| `nestjs.md` | NestJS, controllers, providers | TooManyRequestsException NestJS 11, controller corrompu insertions, repository dead layer, interface provider incomplète, guard multi-statut READ_METHODS, bootstrap OK mais injection cassée (tsx watch → fix swc/.swcrc), guard écriture mode dégradé bloque le support |
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h, compensation incrBy non-atomique (quota fantôme + échec transaction DB), rate-limit à compteur partagé entre endpoints jumeaux |
| `nextjs.md` | Next.js, build, routing | Prisma init au chargement module, server-only dans repositories, redirect boucle infinie feature flags, dossiers `_*` exclus du routing App Router |
| `general.md` | Observabilité, migrations, performance, architecture | Observabilité insuffisante, migrations non reproductibles, upsert N+1, authorize-after-fetch, valeur sentinelle DTO, idempotence endpoint, fichier orphelin, mélange Date UTC/locale, champ fantôme Zod, catch vide, params non validés, cast TS brut, chevauchement temporel, TOCTOU, biais agrégation, couplage types erreur, service HTTP-aware, count sans filtre, env top-level, dérive DTO liste vs détail, notification linkUrl rôle-aware, matrice documentée vs code, format `User.id` mixte, Web Push topic > 32 chars, lib npm types non embarqués, form HTML POST dans un mail, env vars frontend-facing fail-fast |
| `tests.md` | Isolation des tests d'intégration | `vi.stubEnv` sans restauration, `maxWorkers: 1` masque l'isolation, flakiness inter-fichiers DB partagée |
| `general.md` | Observabilité, migrations, performance, architecture | Observabilité insuffisante, migrations non reproductibles, upsert N+1, authorize-after-fetch (+ RBAC-before-parse), valeur sentinelle DTO, idempotence endpoint, fichier orphelin, mélange Date UTC/locale, champ fantôme Zod, catch vide, params non validés, cast TS brut, chevauchement temporel, TOCTOU, biais agrégation, couplage types erreur, service HTTP-aware, count sans filtre, env top-level, dérive DTO liste vs détail, notification linkUrl rôle-aware, matrice documentée vs code, format `User.id` mixte, Web Push topic > 32 chars, lib npm types non embarqués, form HTML POST dans un mail, env vars frontend-facing fail-fast, `deleteOlderThan` sans cron caller, couplage framework dans shared/utils, cache in-process stale en test, AuditLog.userId NOT NULL vs action publique, keepAliveTimeout=0 ne désactive pas, upsert + filtre liste (pollution/désync), suppression champ DB via .map(), gate de seuil sur valeur entrante, flag capacité global non réconcilié, garde-fou seed TRUNCATE, migration flag → dérivé, bypass authz sur liste (lookup batché), epoch secondes vs ms, wrapper fail-safe catch-all, retrait asymétrique front/back, Keycloak start --optimized vs theme/provider monté, comparaison de dates vs NaN, entité active via status, anti-énumération codes différenciés rate-limitée |
| `tests.md` | Isolation des tests d'intégration | `vi.stubEnv` sans restauration, `maxWorkers: 1` masque l'isolation, flakiness inter-fichiers DB partagée, test RBAC qui ré-encode la table de rôles au lieu d'invoquer les guards réels, préfixe de fixture partagé entre fichiers, test qui écrit/supprime un fichier versionné, singleton module-level dépendant de l'env, rate-limit qui hardcode le rang exact |
+76 -1
View File
@@ -307,6 +307,21 @@ it('retourne 403 si subscription inactive', async () => {
- Contexte technique : auth / refresh token — RL799_V2 08-04-2026
### Complément — rotation du refresh token IdP en BFF : cookie rafraîchi non réécrit → déconnexions erratiques
Angle distinct mais lié à la rotation : en archi BFF, si le cookie de session rafraîchi (`sessionCookieToApply`) n'est réécrit que par UN seul handler (typiquement `/me`), la rotation du refresh token côté IdP déconnecte les utilisateurs de façon erratique.
- Les N autres call-sites d'auth déclenchent bien le refresh (access token rafraîchi en mémoire → requête autorisée → 200) mais JETTENT le nouveau cookie.
- Tant que l'IdP n'a PAS la rotation activée, c'est inoffensif (l'ancien refresh token reste valide). MAIS si le realm a `Revoke Refresh Token` / rotation activée (durcissement prod COURANT chez Keycloak/Auth0/etc.), chaque refresh INVALIDE l'ancien refresh token côté IdP : le cookie non réécrit garde un refresh token révoqué → la requête suivante échoue → `SESSION_EXPIRED` → re-login forcé.
- **Piège** : INVISIBLE en dev (rotation souvent off par défaut), il n'apparaît qu'au déploiement quand un ops active la rotation pour durcir.
Règles :
1. Si la réécriture du cookie n'est pas généralisée à TOUS les handlers (via un wrapper qui attache `sessionCookieToApply` systématiquement), alors NE PAS activer la rotation du refresh token côté realm — et le documenter comme garde-fou de déploiement explicite.
2. Inversement, si on veut la rotation (recommandé en sécurité), généraliser la réécriture du cookie AVANT.
3. Ne jamais traiter « le refresh marche en dev » comme preuve que la rotation marchera en prod — tester avec la rotation activée.
- Cas vécu : RL799 K1.5, seul `/me` consomme `sessionCookieToApply`, ~202 autres call-sites l'ignorent ; garde-fou « pas de rotation realm avant généralisation » renvoyé au Lot 6 déploiement — 15-06-2026.
---
<a id="risque-drift-auth-copier-coller"></a>
@@ -331,6 +346,16 @@ it('retourne 403 si subscription inactive', async () => {
- Contexte technique : auth / architecture — RL799_V2 08-04-2026
### Complément — cohérence des filtres d'autorisation entre TOUS les chemins ciblant la même population
Le drift ne touche pas que les codes d'erreur : il touche aussi les FILTRES appliqués sur la même population résolue à plusieurs endroits.
- Quand une même population (ex. « les membres actifs d'un grade ») est résolue à plusieurs endroits — un chemin de NOTIFICATION qui filtre `isActive: true` et un chemin d'AUTORISATION qui fait `getUserByEmail` sans filtre `isActive` — la divergence crée une faille : un compte désactivé/démissionnaire avec un JWT encore valide (fenêtre ≤ TTL) n'est pas notifié MAIS peut encore agir.
- **Règle** : tout contrôle d'autorisation basé sur un fetch user doit re-vérifier `isActive` à chaque requête (le JWT ne reflète pas une désactivation survenue après émission).
- **Audit** : grep des `getUserByEmail` / `findUser*` dans les services, vérifier que chaque usage en contexte d'autorisation filtre/contrôle `isActive`.
- **Symptôme de l'incohérence** : « la liste des destinataires d'un effet et la liste des autorisés à le déclencher ne coïncident pas ».
- Cas vécu : isolation de réponse aux instructions RL799 — le fetch DB avait été ajouté EXPRÈS pour capter les changements d'état à chaque requête, mais ignorait `isActive`, annulant le bénéfice.
---
<a id="risque-auth-acl-unique-champ-sensible"></a>
@@ -494,4 +519,54 @@ if (user.deletedAt !== null) {
- **Règle** : dans `login()`, toujours répondre `INVALID_CREDENTIALS` pour un compte soft-deleted — jamais un code spécifique.
- **Nuance** : un code `ACCOUNT_DELETED` reste acceptable dans un flux `exchange()` OAuth, où le provider a déjà confirmé l'identité (pas d'énumération possible côté attaquant).
- Contexte technique : auth / soft-delete / anti-énumération — app-alexandrie 13-04-2026
- Contexte technique : auth / soft-delete / anti-énumération — app-alexandrie 13-04-2026
---
<a id="risque-guard-abonnement-vs-droit-acquis"></a>
## Guard d'abonnement global vs droits acquis permanents
### Risques
- Un guard de gating « abonnement actif » (ex. `RequireSubscriptionActive` / `RequireAccessLevel(FULL)`) posé uniformément sur TOUTES les routes d'un domaine coupe l'accès à un contenu déjà payé en one-shot (« possession à vie ») dès que l'abonnement est résilié
- Violation silencieuse d'un invariant métier : « je garde ce que j'ai payé même sans abo »
### Symptômes
- Couper l'abonnement rend inaccessible un contenu acheté de façon permanente
- Aucun test ne couvre le cas « droit permanent + abo coupé » → régression non détectée
### Bonnes pratiques / mitigations
- Avant d'appliquer un guard « abonnement actif » uniformément, distinguer deux natures de droit :
- **droit RÉCURRENT** (lié à l'abo : feed, communauté, contenu inclus)
- **droit ACQUIS/permanent** (achat one-shot, possession « à vie »)
- **Règle** : gater la LECTURE d'un bien acquis par la POSSESSION (helper `canAccess…`), pas par l'abonnement. Réserver le guard abo aux routes d'écriture/progression et aux contenus récurrents.
- TOUJOURS écrire un test « bien possédé + abo coupé → lisible » : c'est l'angle mort classique qui laisse passer ce type de régression.
- Contexte technique : auth / gating abonnement — app-alexandrie 02-06-2026
---
<a id="risque-validite-jeton-vs-duree-acces"></a>
## Confondre la validité du JETON d'octroi avec la durée de l'ACCÈS octroyé
### Risques
- Un helper d'accès lit le `expiresAt` d'un jeton d'octroi (code de déblocage, lien/token d'invitation) comme SOURCE D'ACCÈS directe
- Mais `expiresAt` borne la fenêtre d'ACTIVATION du jeton (ex. 72 h), pas la durée de l'accès octroyé (censé être permanent) → l'accès expire en même temps que le jeton
### Symptômes
- L'accès « à vie » expire 72 h après l'émission du code
- Bug non détecté par les tests (qui valident le helper tel qu'écrit, pas l'intention)
### Bonnes pratiques / mitigations
- Ne JAMAIS faire dépendre la vérification d'accès du `expiresAt` du jeton.
- À l'activation, **matérialiser l'accès dans son entité propre** (ex. `UserPack`/possession) et vérifier l'accès via CETTE entité — pas via le jeton.
- **Règle** : « le jeton expire, le droit qu'il a créé persiste. »
- Test obligatoire : « jeton activé puis expiré → l'accès reste valide ».
- **Corollaire** : un helper d'accès ne doit pas « anticiper » un mécanisme pas encore implémenté en lisant un état intermédiaire — il introduit un modèle d'accès parallèle qui diverge du modèle cible (la branche aurait dû passer par `UserPack` dès le départ).
- Contexte technique : auth / activation vs possession — app-alexandrie 02-06-2026
+52
View File
@@ -259,3 +259,55 @@ const emailSchema = z
**À auditer projet-wide** : grep tous les schémas avec ce pattern (`.email().toLowerCase().trim()`) et migrer en `.pipe()`.
- Contexte technique : Zod 4 — RL799_V2 01-05-2026
---
<a id="risque-ac-affichage-champ-contract-zod"></a>
## AC d'affichage livré « vert » mais champ absent du contract Zod
### Risques
- Un AC métier dit « afficher / truncate / preview de [champ] dans [carte/écran] » mais le champ n'est jamais ajouté au schéma Zod public correspondant → le user ne le voit jamais
- Le service backend peut même charger le champ depuis la DB (`select: { bio: true }`) puis le jeter au mapping de réponse → invisible
- Ni le typage ni les tests unit ne détectent l'absence : la code review est le seul filet
### Symptômes
- AC d'affichage livré « tout vert » (tests/typecheck/lint passent) mais l'écran ne montre rien
- Variante : champ rendu côté UI mais jamais transmis par l'API → `undefined`/`null` silencieux à l'écran
### Bonnes pratiques / mitigations
Quand un AC mentionne « afficher / truncate / preview de [champ] dans [carte/écran] », vérifier la chaîne complète :
1. **Le champ existe dans le schéma Zod public** (ex : AC « carte annuaire affiche bio truncate 80 char » → `DirectoryUserSchema` doit avoir `bio: z.string().nullable()`).
2. **Le service backend l'expose dans le mapping de réponse** (pas seulement dans le `select` Prisma).
3. **Le composant UI lit le champ.**
Le contrat est la barrière minimale : si le champ n'y est pas, l'AC ne peut pas être satisfait.
- Contexte technique : NestJS / Zod / contracts-first — app-alexandrie 28-05-2026
---
<a id="risque-schema-par-audience-pas-par-entite"></a>
## Schéma par audience, pas par entité : vue admin réutilise le schéma public
### Risques
- Une vue ADMIN/back-office réutilise (ou `extend`) le schéma de la vue PUBLIQUE/utilisateur de la même entité → elle hérite d'un schéma qui masque délibérément les champs de gestion
- L'admin ne peut pas distinguer les états (ex : leçons DRAFT vs PUBLISHED) car le contrat ampute l'info structurante (`status`, `body`, timestamps, flags internes)
- Le service inclut correctement les données (pas de filtre status) mais le CONTRAT les supprime — bug invisible au typecheck et au test
### Symptômes
- Un détail admin `extend`/réutilise un schéma existant qui est en réalité la variante de rendu front
- Un test qui ne peut asserter que la **présence** d'un élément, jamais son **état** (le champ d'état n'existe pas dans le schéma)
- AC « prévisualiser avant publication » / « back-office » non satisfait alors que tout est vert
### Bonnes pratiques / mitigations
- Une vue admin/back-office et une vue publique de la MÊME entité ne partagent PAS le schéma de réponse par défaut : l'admin a besoin des champs de gestion (status, body brut, timestamps, flags) que la vue publique masque.
- **Réflexe de revue** : quand un détail admin réutilise un schéma, vérifier que c'est bien la variante ADMIN (porte le statut/les champs éditoriaux), pas la variante de rendu front.
- Contexte technique : NestJS / Zod / contracts-first — app-alexandrie 04-06-2026
+526 -1
View File
@@ -90,7 +90,15 @@
- Pour les endpoints détail sensibles, filtrer l'accès dans la requête DB (`where` + scope grade/tenant) ou faire un pré-check minimal avant de charger les relations
- Les accès non autorisés ne doivent pas déclencher un fetch complet des données métier
- Contexte technique : backend général — RL799_V2 02-04-2026
### RBAC-before-parse — autoriser avant de parser le body
- Le guard d'authentification/autorisation doit TOUJOURS être évalué AVANT tout accès au body de la requête. Un attaquant non authentifié peut sinon sonder les erreurs de validation Zod (codes, messages, structure du schéma) avant d'être rejeté, ce qui expose la surface d'attaque.
- Ordre obligatoire dans chaque handler HTTP : (1) `requireAuth` / `requireRoleAccess`, (2) validation du path/query params, (3) `request.json()` + validation Zod du body. Ne jamais inverser les étapes 1 et 3.
- Ne pas parser un payload pour un appel qui sera de toute façon refusé : c'est à la fois une fuite d'info et un coût inutile.
- **Signal review** : `request.json()` ou `schema.parse(body)` qui précède l'appel au guard d'autorisation dans un handler.
- Cas vécu : `handleUpdateReportManualSections` dans `seasonReportService.ts` (RBAC après parse), corrigé en revue adversariale v2-3-1.
- Contexte technique : backend général — RL799_V2 02-04-2026 (RBAC-before-parse ajouté 19-06-2026)
---
@@ -1145,3 +1153,520 @@ export const getBaseUrl = (): string => {
**Test** : couvrir les 4 cas (env défini avec slash, env défini sans slash, env undefined NODE_ENV=dev → fallback, env undefined NODE_ENV=prod → throw).
- Contexte technique : config / mails transactionnels — RL799_V2 29-04-2026
---
<a id="risque-fonction-purge-sans-cron-caller"></a>
## Fonction `deleteOlderThan` exposée sans cron caller — dette silencieuse
### Risques
- Une fonction de purge existe dans un repository (`deleteOlderThan(days)`, `purgeOlderThan(date)`) avec un commentaire du type "préparation pour future politique de rétention" mais aucun caller ne l'invoque jamais en prod
- À faible volume c'est invisible ; à mesure que la table grossit, l'index sur `(userId, createdAt)` ou `(createdAt)` se dégrade linéairement et ralentit les queries les plus chaudes (dashboard)
### Symptômes
- Fonction de purge avec JSDoc "non exposé via API", aucun caller documenté
- `EXPLAIN ANALYZE` révèle un scan d'index dégradé sur une table jamais purgée — diagnostic difficile car la cause est invisible côté code applicatif
### Bonnes pratiques / mitigations
À l'ouverture de toute fonction `delete*OlderThan`, vérifier :
1. Caller documenté dans le JSDoc (route admin ? cron ? script manuel ?)
2. Cron actif (crontab, scheduler in-process, job BullMQ) qui l'invoque réellement
3. Audit log écrit à chaque exécution (sinon impossible de savoir si la purge tourne en prod)
4. Endpoint admin `GET /maintenance/stats` exposant `total` + `oldestRow` pour observer la croissance
Si la rétention est métier (RGPD 12/24 mois), exposer un endpoint admin `POST /maintenance/purges` avec un mode `dryRun` (retourne les compteurs sans supprimer pour valider la politique avant de tirer).
- Contexte technique : backend / rétention — RL799_V2 05-05-2026
---
<a id="risque-couplage-framework-shared-utils"></a>
## Couplage framework (NestJS) dans `shared/utils/`
### Risques
- Un helper utilitaire dans `shared/utils/` jette directement une `HttpException` NestJS (ou un autre objet framework)
- L'util devient non réutilisable hors contexte HTTP (workers, jobs cron, CLI), force les tests à mocker le framework, et l'appelant perd la possibilité de différencier les causes d'échec (ex: `degraded` Redis down vs `exceeded` vraie limite)
### Symptômes
- `import { HttpException } from '@nestjs/common'` dans un fichier `shared/utils/`
- Test d'un util obligé d'instancier ou mocker un contexte HTTP
### Bonnes pratiques / mitigations
Le helper retourne un union discriminé framework-agnostic ; le service Nest traduit en exception :
```typescript
// shared/utils/daily-quota.ts (zéro import @nestjs/common)
export type DailyQuotaResult =
| { status: 'ok'; count: number }
| { status: 'degraded' }
| { status: 'exceeded'; count: number };
// modules/community/community.service.ts
const result = await consumeDailyQuota({ ... });
if (result.status === 'exceeded') {
throw new HttpException({ error: { code: 'QUOTA_EXCEEDED' } }, HttpStatus.TOO_MANY_REQUESTS);
}
```
- Règle : `shared/utils/` reste framework-agnostic. Seul `Logger` est toléré comme dépendance framework (instrumentation transverse).
- **Signal review** : import d'un type de transport (`HttpException`, `Response`) dans un fichier `utils/`.
- Contexte technique : architecture en couches — app-alexandrie 13-05-2026
---
<a id="risque-cache-in-process-stale-en-test"></a>
## Cache in-process stale dans les tests qui mutent la DB directement
### Risques
- Un test mute un modèle via `prisma.<model>.update()` direct en fixture, mais les lectures applicatives passent par un cache in-process (TTL, Map module-level, getter memoizé)
- Le cache stale fait que le test échoue, ou pire passe pour la mauvaise raison : un test "no-op si pas de X" peut passer parce que le cache stale ne voit jamais le X posé en fixture, masquant un vrai bug
### Symptômes
- Test sur un service consommant un cache qui échoue sur une assertion d'effet de bord (mail envoyé, status changé) au lieu d'une assertion logique (test attend `false`, reçoit `false`)
- Mutation Prisma directe en fixture sans invalidation du cache correspondant
### Bonnes pratiques / mitigations
- Identifier les caches in-process du projet (chercher `cached`, `invalidate*Cache`, getters memoizés, Map module-level)
- Exporter l'invalidator de chaque cache (`invalidate<X>Cache()`) — utile aux tests ET au code applicatif pour les writes hors handlers normaux
- Appeler les invalidators nécessaires dans le `beforeEach` (pas `beforeAll` : la suite peut faire plusieurs mutations) ET immédiatement après chaque mutation directe
- Documenter explicitement dans le setup pourquoi l'invalidation est nécessaire
- **Pattern de détection** : si un test échoue sur l'effet de bord alors que la logique semble correcte, suspecter le cache stale en premier
- Contexte technique : tests / cache in-process — RL799_V2 13-05-2026
---
<a id="risque-auditlog-userid-not-null-action-publique"></a>
## `AuditLog.userId NOT NULL` incompatible avec les actions publiques sans auth
### Risques
- Un endpoint public (sans auth) déclenche une mutation auditable (ex: désabonnement public via token), le réflexe est d'écrire dans `AuditLog`
- Mais si `AuditLog.userId` a une FK `NOT NULL` sur `User`, on ne peut pas créer une ligne audit pour un acteur anonyme — la FK lève
### Symptômes
- Erreur de contrainte FK lors d'un `auditLog.create` dans un handler public sans `userId` authentifié
- Action anonyme auditable bloquée par le modèle
### Bonnes pratiques / mitigations
Trois options selon le contexte métier :
1. **Logger structuré** (pipeline externe ELK/Sentry, pas la DB) : `logger.info({ type, event, profileId, outcome })`. Zéro friction, mais rétention dépendante des pipelines logs. Choix par défaut RL799.
2. **User système** (`id: 'system'`, un seul row réservé) utilisé comme `userId` des actions anonymes. Audit DB cohérent, mais pollue la table User et complique les queries.
3. **Relâcher la FK** en `userId String?`. Modèle propre, mais tous les call sites doivent gérer le nullable + retro-compat.
- Documenter explicitement le choix (JSDoc du handler + catalogue audit : "action publique non auditée en DB — tracée via `logger.info`")
- À évaluer avant prod : si une obligation réglementaire impose l'audit DB strict, l'option 1 ne suffit pas → basculer en 2 ou 3
- Les actions admin équivalentes (avec `userId` authentifié) restent dans `AuditLog`
- Contexte technique : audit / actions publiques — RL799_V2 13-05-2026
---
<a id="risque-keepalivetimeout-zero"></a>
## `http.Server.keepAliveTimeout = 0` ne désactive PAS le keep-alive
### Risques
- `keepAliveTimeout = 0` en Node.js signifie "pas de timer de fermeture" : la connexion keep-alive est gardée indéfiniment, pas fermée. Le serveur continue de répondre `Connection: keep-alive`
- Utilisé en croyant "couper le keep-alive", `= 0` fait le CONTRAIRE de l'intention courante
### Symptômes
- Code de test/prod posant `server.keepAliveTimeout = 0` comme "kill switch" du keep-alive — probablement du code mort qui ne fait rien
- Le header `Connection` reste `keep-alive` malgré le réglage
### Bonnes pratiques / mitigations
- Pour réellement répondre `Connection: close`, poser l'en-tête via middleware ou fermer explicitement les sockets — pas via `keepAliveTimeout = 0`
- Ne jamais utiliser `= 0` comme désactivation du keep-alive ; vérifier empiriquement avant de s'y fier :
```js
const s = require('http').createServer((q, r) => r.end('ok'));
s.listen(0, () => {
s.keepAliveTimeout = 0;
require('http').get({ port: s.address().port }, res =>
console.log(res.headers.connection)); // => "keep-alive", PAS "close"
});
```
- Contexte technique : Node.js / HTTP — app-alexandrie 21-05-2026
---
<a id="risque-upsert-filtre-liste-desync"></a>
## Upsert idempotent + filtre de liste sur attribut d'activité = pollution DB / désync client
### Risques
- Un endpoint upsert crée une ressource composite (DM, follow, room) sans attribut d'activité (`lastMessageAt`, `participantCount`), et l'endpoint de liste filtre sur cet attribut (`lastMessageAt: { not: null }`)
- Deux problèmes : (1) pollution DB silencieuse — un attaquant crée N ressources vides invisibles dans son UI ; (2) désynchronisation client → état illégal si le mobile dépend du store de liste pour des métadonnées (ex: `peerUserId`) et ne peut donc pas opérer sur la ressource fraîchement créée
### Symptômes
- Ressources vides accumulées en DB, jamais visibles côté client (filtre activité)
- Client incapable d'envoyer le 1er message / d'agir sur une ressource créée mais sans activité
### Bonnes pratiques / mitigations
- **Garbage collect** côté backend : job périodique supprimant les ressources vides depuis > X minutes (le plus propre)
- Ou retirer le filtre activité côté liste (exposer aussi les ressources vides — impact UI à arbitrer)
- Ou rendre l'écran de détail auto-suffisant : passer les métadonnées critiques en query param (`/messages/[id]?peerUserId=X`) ou exposer un `GET /resource/:id` qui retourne tout le contexte indépendamment du store de liste
- **Garde-fou de review** : à chaque ajout d'un endpoint upsert (`POST /resource`), auditer l'endpoint `GET /list` correspondant — si la liste a un filtre activité, l'écran de détail DOIT pouvoir s'auto-suffire
- Contexte technique : backend / upsert + REST — app-alexandrie 27-05-2026
---
<a id="risque-suppression-champ-typecheck-map"></a>
## Suppression de champ DB : le typecheck ne couvre PAS les objets construits via `.map()`
### Risques
- Après le retrait d'un champ d'un modèle (Prisma ou autre), `tsc` vert ne prouve PAS que tous les call-sites sont nettoyés
- L'excess-property-check de TypeScript ne s'applique qu'aux LITTÉRAUX d'objet directs, pas aux objets renvoyés par un callback `.map()`/`.reduce()` (typés "assignable", propriété en trop tolérée)
- Un `createMany({ data: items.map(i => ({ champRetiré: i.x })) })` compile et casse au runtime (Prisma : "Unknown argument")
### Symptômes
- Typecheck vert (seeds inclus) mais erreur runtime "Unknown argument 'X'" sur un seed/fixture utilisant `.map()`
- Champ retiré du modèle mais encore présent dans un callback de construction d'objet
### Bonnes pratiques / mitigations
- À chaque retrait de champ, faire un GREP textuel du nom du champ sur tout le repo (seeds, fixtures, scripts inclus) — ne pas se fier au seul typecheck
- Lancer le lint/tests sur les seeds et scripts, pas seulement sur les fichiers de la story (ces fichiers accumulent de la dette non vérifiée)
- Distinct de « Divergence schéma Prisma / spec story » (champ déclaré dans une story mais absent du schema) : ici le champ existait, a été retiré, et reste référencé via `.map()`
- Contexte technique : TypeScript / Prisma — app-alexandrie 02-06-2026
---
<a id="risque-gate-valeur-entrante-vs-cumulee"></a>
## Gate de seuil sur la valeur entrante au lieu de l'état cumulé
### Risques
- Quand un compteur de progression est "non-régressif" (on garde le max), un gate basé sur ce compteur qui lit la valeur du PAYLOAD courant au lieu de la valeur CUMULÉE refuse à tort une action déjà débloquée
- Un renvoi d'une valeur plus basse (autre device, rejeu, reset client) bloque une action légitime
### Symptômes
- Gate "≥ seuil" qui échoue alors que l'utilisateur a déjà dépassé le seuil sur un autre device
- Calcul du `merged = max(persisté, payload)` situé APRÈS le gate au lieu d'avant
### Bonnes pratiques / mitigations
- Tout gate basé sur un compteur non-régressif doit porter sur la valeur CUMULÉE (`max(persisté, payload)`), pas sur le seul payload
- Calculer le `merged` AVANT le gate, pas après
- Contexte technique : backend / progression — app-alexandrie 02-06-2026
---
<a id="risque-flag-capacite-non-reconcilie-transfert"></a>
## Flag de capacité global non réconcilié lors d'un transfert/réassignation
### Risques
- Une capacité utilisateur (`isPractitioner`, `isModerator`) est un BOOLÉEN global dérivé de relations N..1 (anime un pack, modère un forum)
- Lors d'un transfert de la relation, le code pose le flag sur le nouveau titulaire mais ne le retire jamais de l'ancien → le flag "colle" et reste à `true` pour d'anciens titulaires, état incohérent qui s'accumule
### Symptômes
- Ex-titulaire qui conserve une capacité globale sans plus rien animer/modérer
- Réassignation qui ne touche qu'un seul côté de la relation
### Bonnes pratiques / mitigations
- Tout transfert de relation doit RÉCONCILIER les deux côtés : poser le flag sur le nouveau ET le retirer de l'ancien s'il ne détient plus aucune relation qui le justifie
- Calculer la rétrogradation APRÈS le transfert (la relation courante ne compte plus), dans la même transaction :
```ts
if (previous && previous !== next) {
await transfer(next);
const stillJustified =
(await count({ packs: { practitioner: previous } })) > 0 ||
(await count({ forums: { moderator: previous } })) > 0;
if (!stillJustified) await demote(previous);
}
```
- **Test obligatoire** : réassigner → l'ancien perd le flag s'il n'a plus rien, le garde s'il anime encore autre chose
- Contexte technique : backend / capacités RBAC — app-alexandrie 03-06-2026
---
<a id="risque-seed-destructif-garde-fou-fail-safe"></a>
## Garde-fou d'un seed destructif (TRUNCATE) — fail-safe obligatoire
### Risques
- Un seed qui TRUNCATE toute la base est destructif : exécuté par erreur sur une prod ou une DB distante = perte de données
### Symptômes
- Seed avec `TRUNCATE`/`deleteMany` global sans garde-fou, ou garde-fou exécuté après la connexion DB
- Garde-fou fail-open (accepte par défaut, refuse sur liste noire)
### Bonnes pratiques / mitigations
Règles non négociables pour un seed destructif :
1. Le garde-fou s'exécute AVANT toute connexion DB et AVANT le truncate (sinon il truncate puis refuse)
2. Liste BLANCHE d'hôtes locaux (`localhost`/`127.0.0.1`/`::1`/`db`/`postgres`) ; tout host non listé → REFUS (fail-safe, jamais fail-open)
3. `DATABASE_URL` absente/malformée → REFUS (pas de crash, pas d'accept)
4. Refus aussi si `NODE_ENV=production`
5. Bypass uniquement par flag explicite (`--force`/`SEED_FORCE=1`), jamais activable par accident
6. Tester le garde-fou : prod→refus, DB distante→refus, URL absente→refus, locale→accept, `--force`→accept
- Extraire le garde-fou en fonction PURE (`evaluateSeedGuard`) testable sans I/O
- Contexte technique : seed / sécurité données — app-alexandrie 03-06-2026
---
<a id="risque-migration-flag-stocke-vers-derive"></a>
## Migration flag stocké → valeur dérivée : retirer l'ancien flag, pas le laisser mort
### Risques
- On remplace un flag booléen stocké (`User.isPractitioner` écrit à chaque assignation) par un calcul dérivé (`count(packs animés) > 0`) exposé via les entitlements
- Si l'ancien flag stocké reste écrit sans être lu, c'est du code mort trompeur + une fausse source de vérité concurrente qui peut diverger du calcul dérivé
### Symptômes
- Colonne/flag encore écrit dans le code mais lu par personne (dette invisible)
- Deux sources de vérité concurrentes pour la même information
### Bonnes pratiques / mitigations
- À la bascule, soit supprimer la colonne/le flag stocké et tout code qui l'écrit, soit documenter explicitement pourquoi il survit
- Vérifier par grep que plus AUCUNE logique d'accès ne lit l'ancien flag avant de considérer la migration terminée
- Le calcul dérivé (source de vérité = relations) est plus robuste car il ne diverge jamais
- Contexte technique : backend / source de vérité — app-alexandrie 04-06-2026
---
<a id="risque-bypass-sur-liste-lookup-batche"></a>
## Bypass d'autorisation sur une liste = lookup batché (éviter le N+1)
### Risques
- Ajouter un "bypass admin" (rôle court-circuitant une garde) sur un chemin traitant une LISTE d'utilisateurs invite le réflexe `ids.map(id => isAdmin(id))`, qui réintroduit un N+1 silencieux (un `findUnique` par élément), précisément là où le code avait factorisé en `findMany`
### Symptômes
- `Promise.all(ids.map(() => fetchUnitaire()))` dans un helper d'autorisation appelé sur une collection
- Un appel DB par élément de liste pour une vérification de rôle/flag
### Bonnes pratiques / mitigations
- Tout helper d'autorisation dérivé du rôle/d'un flag, appelé sur une collection (interlocuteurs, membres, destinataires), doit exposer une variante BATCH :
```ts
const getAdminIdSet = async (ids: string[]): Promise<Set<string>> => {
const rows = await prisma.user.findMany({
where: { id: { in: ids }, role: 'ADMIN' },
select: { id: true },
});
return new Set(rows.map(r => r.id));
};
```
- **Garde-fou de review** : si un nouveau `Promise.all(ids.map(() => fetchUnitaire()))` apparaît, exiger la version batch
- Contexte technique : backend / N+1 autorisation — app-alexandrie 04-06-2026
---
<a id="risque-epoch-secondes-vs-millisecondes"></a>
## `expiresAt`/`exp` : epoch en secondes (OIDC/JWT) vs millisecondes (`Date.now()`)
### Risques
- Les standards OIDC/JWT (`exp`, `iat`, `expires_in`) sont en SECONDES ; `Date.now()`, `new Date().getTime()` et la plupart des APIs JS sont en MILLISECONDES
- Comparer les deux sans conversion ne lève AUCUNE erreur (deux `number`) mais donne un résultat absurde : un `expiresAt` en secondes (~1.7e9) est TOUJOURS `<= Date.now()` en ms (~1.7e12) → tout est jugé "expiré"
- Le bug est aggravé par le découpage en lots (un lot écrit le champ, un autre le lit avec la mauvaise unité) et reste invisible tant que le chemin est dormant (derrière un flag off)
### Symptômes
- `expiresAt <= Date.now()` ou `< Date.now()` sur un champ issu d'un token/claim OIDC
- Tout est jugé expiré dès l'activation du flag ; aucun test ne couvre le chemin dormant
### Bonnes pratiques / mitigations
1. **Comparer en secondes** : `expiresAt <= Math.floor(Date.now() / 1000)`, jamais `<= Date.now()`
2. **Documenter l'unité** dans le type/JSDoc au point d'écriture ET de lecture (`/** epoch en SECONDES */`)
3. **Vérifier la cohérence end-to-end** quand écriture et lecture sont dans des lots/PR différents : tracer qui écrit, dans quelle unité, qui lit
4. **Signal review** : tout `<= Date.now()` / `< Date.now()` sur un champ issu d'un token/claim OIDC est suspect par défaut
- Contexte technique : auth / OIDC / unités de temps — RL799_V2 14-06-2026
---
<a id="risque-wrapper-fail-safe-catch-all"></a>
## Wrapper fail-safe catch-all qui noie les pannes anormales sous les échecs attendus
### Risques
- Un repo/service rendu non-bloquant par un `try/catch → return { ok: false }` global traite à l'identique deux causes opposées : l'échec ATTENDU/bénin (collision `@unique` P2002 sur un rejeu) et la panne INATTENDUE (connexion DB perdue, timeout, deadlock — anormal, mérite alerte ops)
- Une vraie panne devient indiscernable d'un cas nominal dans les logs, le diagnostic est noyé
### Symptômes
- Wrapper qui retourne un booléen `ok` opaque alors que plusieurs causes d'échec ont des implications ops différentes
- Logs uniformes pour une collision attendue et une perte de connexion DB
### Bonnes pratiques / mitigations
Qualifier l'échec avant de l'avaler :
1. Le wrapper bas-niveau remonte un discriminant SANS rethrow (il reste non-bloquant) : `{ ok: false, collision: code === 'P2002', errorCode: code }`
2. L'appelant module le NIVEAU de log selon le discriminant : `warn` pour l'attendu/bénin, `error` pour la panne inattendue (qui doit remonter au monitoring)
3. Ne jamais se contenter d'un booléen `ok` opaque — un champ de plus dans le type de retour garde le fail-safe ET la visibilité
- Cas vécu : `setKeycloakSubForUser` (RL799 K1.4) — catch-all renvoyant `collision` pour toute erreur, corrigé en remontant `errorCode` + log modulé.
- Contexte technique : observabilité / fail-safe — RL799_V2 15-06-2026
---
<a id="risque-retrait-route-asymetrique-front-back"></a>
## Retrait asymétrique front/back — route backend supprimée, call-sites frontend orphelins
### Risques
- Supprimer une route backend (cutover, dépréciation, refonte) sans retirer ses call-sites frontend produit des 404 silencieux
- Un frontend qui appelle la route via une URL en STRING (`fetch('/api/x')`) continue de COMPILER (pas d'import cassé) mais tape dans le vide → 404 runtime
- Si aucun test ne couvre ce parcours bout-en-bout, la suite reste 100% verte malgré le bug
### Symptômes
- 404 runtime sur un parcours utilisateur alors que `tsc`/`vue-tsc` et la suite de tests sont verts
- Route supprimée côté backend mais référencée par un service/composant frontend
### Bonnes pratiques / mitigations
1. **Retrait SYMÉTRIQUE** : retirer le handler backend ET le service/composant/route frontend dans le même lot. Grep `'/api/<route-retirée>'` côté frontend AVANT de considérer le retrait fait
2. **Préférer un 410 Gone à une suppression pure** quand le frontend ne peut pas être nettoyé immédiatement : un 410 avec message clair est une transition lisible (toast affiché), un 404 est opaque. Traiter TOUTES les routes du même retrait de façon homogène
3. **La couverture verte ne prouve PAS le retrait complet** : ajouter/garder un test asserant que le parcours client est soit retiré (route absente, bouton absent), soit géré (410 + message)
4. Attention aux composants PARTAGÉS : une page servant deux modes (reset-password ET invitation) ne doit pas être supprimée si un seul mode meurt — découper finement
- Contexte technique : retrait d'API / front-back — RL799_V2 15-06-2026
---
<a id="risque-keycloak-optimized-theme-volume"></a>
## Keycloak `start --optimized` incompatible avec un theme/provider monté en volume runtime
### Risques
- `--optimized` indique à Keycloak de démarrer SANS re-évaluer les options build-time, en supposant un `kc.sh build` préalable ayant FIGÉ la config dans l'image (theme/provider inclus au build)
- Monter un theme en volume runtime (`./themes/x:/opt/keycloak/themes/x:ro`) avec une image non-buildée et `--optimized` provoque soit un refus de démarrer ("The build time option … was changed, please rebuild"), soit un démarrage qui IGNORE le theme (liste figée au build)
### Symptômes
- Keycloak refuse de démarrer après ajout d'un theme/provider en volume
- Theme monté en volume mais non pris en compte (liste des thèmes figée)
### Bonnes pratiques / mitigations
- **Theme/provider en VOLUME runtime → `command: start`** (sans `--optimized`) : Keycloak lit la config et scanne les volumes au démarrage
- **Theme/provider DANS l'image → `kc.sh build` puis `start --optimized`** (boot plus rapide, mais rebuild d'image à chaque changement)
- Plus largement, pour toute appliance mêlant options build-time et runtime : ne pas combiner `--optimized` (qui présuppose un build) avec une config injectée au runtime. Vérifier au déploiement réel, pas seulement à la lecture du compose
- Bonus sécurité : épingler la version d'image et confirmer qu'elle matche la version installée avant un `up` — un up/downgrade Keycloak déclenche une migration de schéma potentiellement destructive
- Contexte technique : Keycloak / Docker Compose — RL799_V2 16-06-2026
---
<a id="risque-helper-comparaison-date-nan"></a>
## Helpers de comparaison de dates — garder contre `NaN` explicitement
### Risques
- Un helper qui accepte `Date | string` et convertit via `new Date(str)` peut recevoir une date invalide (`new Date('invalid')`)
- `NaN > x` et `NaN < x` sont TOUJOURS `false` en JS : une date invalide passée à un filtre de fenêtre temporelle produit un `false` silencieux (événement ignoré) au lieu d'une erreur explicite
### Symptômes
- Helper de fenêtre temporelle qui retourne `false` sans raison apparente pour certaines entrées
- `localDayKey`/comparaison qui produit `NaN` non détecté
### Bonnes pratiques / mitigations
- Tester `isNaN(d.getTime())` avant toute comparaison numérique sur une date convertie :
```ts
if (isNaN(event.getTime())) throw new Error('Date invalide passée à isEventInSeason');
```
- Placer le garde en tête du helper, avant toute comparaison `>`/`<`
- Cas vécu : `isEventInSeason` dans `packages/shared/src/utils/season.ts`, corrigé en revue v2-3-1.
- Contexte technique : dates / validation — RL799_V2 19-06-2026
---
<a id="risque-entite-active-via-status-pas-bornes"></a>
## Pattern "entité active" : utiliser le champ `status`, jamais reconstruire depuis les bornes temporelles
### Risques
- Quand un modèle a un champ `status` (enum `active/archived`, `active/ended`), recoder la requête "trouve l'entité active" via `{ startDate: { lte: now }, endDate: { gte: now } }` au lieu de `{ status: 'active' }` crée une divergence
- Ce critère daté : (1) casse avec les données de test où les bornes sont incomplètes/null, (2) diverge silencieusement du service existant si on oublie de les synchroniser, (3) échoue quand la logique "qui est active" change (ex: suspension manuelle)
### Symptômes
- Deux services pour la même sémantique avec des critères différents (`status` vs bornes datées)
- Query datée qui ne trouve jamais l'entité en test (seed avec `endDate: null`)
### Bonnes pratiques / mitigations
- La source de vérité de l'activité est le champ `status`, pas les bornes de dates : `where: { status: 'active' }`
- Les bornes restent utiles pour des requêtes analytiques ("quelles saisons couvraient cette date ?") mais pas pour "trouve l'entité courante"
- **Signal review** : `{ startDate: { lte: now }, endDate: { gte: now } }` dans un repo dont le modèle possède un champ `status`
- Cas vécu : `hospitalierVeilleRepository.getActiveSeason` (bornes datées) divergeait de `seasonRepository.getActiveSeason` (`status`).
- Contexte technique : Prisma / source de vérité — RL799_V2 20-06-2026
---
<a id="risque-anti-enumeration-codes-differencies-rate-limit"></a>
## Anti-énumération : endpoint à codes différenciés sur un userId cible doit être rate-limité
### Risques
- Un endpoint authentifié qui accepte un `:targetUserId` (ou équivalent) et renvoie des codes d'erreur DISTINCTS selon l'état du target (existence `404 USER_NOT_FOUND` vs `403` access denied, abonnement, relation sociale) permet l'énumération
- Un attaquant peut spammer le endpoint sur 10 000 userIds différents pour reconstituer le graphe social, les entitlements, ou la présence (user existe / supprimé) — même sans écriture
### Symptômes
- Endpoint authentifié sans rate-limit qui expose des relations (follow, blocages, packs partagés), un état calculé (entitlements, scores), ou un signal de présence
- Rate-limit présent sur le `GET /eligibility/:targetUserId` mais absent sur le `POST /.../messages` jumeau qui renvoie les mêmes bits d'info via ses codes d'erreur
### Bonnes pratiques / mitigations
- **Heuristique d'audit** pour tout nouvel endpoint authentifié : "que peut faire un attaquant qui spam ce endpoint sur 10 000 userIds ?". Si la réponse révèle une information dérivée par accumulation (relation, état calculé, présence), rate-limit obligatoire. Le critère n'est pas "écriture vs lecture" mais "exposition d'information dérivée"
- Cibler en particulier : `POST /<feature>/with/:targetUserId/...`, `GET /<feature>/eligibility/:targetUserId`, tout endpoint distinguant `404 USER_NOT_FOUND` d'un `403 access denied` selon l'existence du user
- Limite type : 60 req/min/user via Redis `incrWithExpireAt`, dégradation permissive si Redis KO
- **Clé Redis COMMUNE entre endpoints jumeaux** (`<service>-rate:<userId>:<window>`) : sinon l'attaquant multiplie sa surface en alternant entre `getEligibility` et le `POST` jumeau
- Implémentation type : méthode `assertXxxRateLimit(userId, now)` appelée en première instruction du handler ; constante de limite dans `packages/contracts/.../<domain>.schemas.ts` (réutilisable côté mobile pour hint UX)
- Contexte technique : sécurité / anti-énumération — app-alexandrie 13-05-2026
+21 -1
View File
@@ -167,7 +167,27 @@ if (user.sessionStatus === 'BLOCKED') throw new HttpException(...);
- Tester immédiatement `GET /`, l'endpoint OpenAPI et une route publique métier, puis lire la stack runtime **après le premier hit** (pas seulement les logs de bootstrap).
- Si un lanceur `tsx watch` est utilisé avec NestJS, vérifier explicitement la compatibilité avec l'injection runtime ; en cas de doute, expliciter les injections critiques avec `@Inject(...)` sur guards et services exposés dès le premier hit.
- Contexte technique : NestJS / tsx watch / injection — app-alexandrie 01-04-2026
#### Cause racine : esbuild n'émet pas `emitDecoratorMetadata`
Tout runner basé sur esbuild (`tsx watch`, `tsup --watch`, `esbuild-node-runner`...) **n'implémente pas** `emitDecoratorMetadata`. Sans cette metadata, Nest ne sait plus quels types injecter dans les constructeurs `@Injectable()` et tombe dans un fallback **silencieux** : `new Service(undefined, undefined, …)`. Aucun crash au boot ; tous les services à >1 dépendance deviennent inopérants au runtime, le bug n'apparaît qu'au **premier appel** d'une méthode touchant une dépendance injectée.
Le piège est durable : la CI passe (elle utilise `nest build` = tsc), les e2e passent (AppModule allégé). Le bug ne se voit qu'au navigateur/mobile, sur les endpoints non couverts par e2e. Cas vécu : introduit par bascule `nest start --watch``tsx watch src/main.ts` (gain de boot / contournement conflit Prisma v7 ESM/CJS), non détecté 2 mois et demi.
**Détection en 30 s** : `console.log({ deps: typeof someDep })` dans le constructor du service qui crashe. Si `typeof === 'undefined'` au boot alors que le module l'importe → c'est le bug metadata.
#### Fix recommandé (Nest 11) : `nest start --watch --builder swc`
- `pnpm add -D @swc/cli @swc/core`
- `.swcrc` avec `jsc.transform.legacyDecorator: true`, `jsc.transform.decoratorMetadata: true`, `module.type: "commonjs"` (préserve decorators + metadata)
- `nest-cli.json` : `"compilerOptions": { "builder": "swc", "typeCheck": true }` (le `typeCheck: true` relance `tsc --noEmit` en parallèle pour garder la sécurité types)
- `package.json` : conserver `"start:dev": "nest start --watch"` (le `nest-cli.json` prend le relais pour le builder)
- Si pnpm bloque le binaire natif SWC : `onlyBuiltDependencies: ['@swc/core']` dans `pnpm-workspace.yaml`
Bénéfices : DI nominale, type-check préservé, build ~10× plus rapide que tsc seul (≈190 ms pour 153 fichiers), résout au passage le conflit Prisma v7 ESM/CJS.
**Anti-pattern à proscrire** : plugin esbuild qui prétend réimplémenter `emitDecoratorMetadata` (ex : `@anatine/esbuild-decorators`) — couverture partielle, casse silencieusement sur les types génériques. Sa présence est un signal qu'il faut passer à SWC.
- Contexte technique : NestJS / tsx watch / esbuild / SWC / injection — app-alexandrie 01-04-2026, complété 26-05-2026
---
+253 -4
View File
@@ -177,7 +177,34 @@ Tout modèle tenant-scoped doit avoir les trois :
- **Checklist review** : vérifier systématiquement que les nouveaux modèles respectent ce guardrail
- Contexte technique : Prisma / multi-tenant — app-template-resto 17-03-2026
### Règle générale — toute FK doit déclarer sa relation Prisma des DEUX côtés
Le piège n'est pas spécifique à `tenantId`. Tout `xxxId String @map(...)` sans `@relation` correspondante (côté table cible **et** côté table référencée) ne génère **aucune FK SQL**. Prisma ne le détecte pas — il faut un check humain à la review de schéma.
```prisma
// ❌ pas de @relation → pas de FK générée → orphelins possibles
model DmConversation {
userAId String @map("user_a_id")
}
model User { /* pas de field DmConversation[] */ }
// ✅ relation déclarée des deux côtés → FK générée
model User {
dmConversationsAsA DmConversation[] @relation("DmConversationUserA")
dmConversationsAsB DmConversation[] @relation("DmConversationUserB")
}
model DmConversation {
userAId String @map("user_a_id")
userBId String @map("user_b_id")
userA User @relation("DmConversationUserA", fields: [userAId], references: [id], onDelete: Cascade)
userB User @relation("DmConversationUserB", fields: [userBId], references: [id], onDelete: Cascade)
}
```
- **Critère review** : tout `xxxId String @map(...)` (y compris les paires de tables de jointure `userAId`/`userBId`) DOIT avoir sa `@relation` paire des deux côtés.
- **Bonus index** : Postgres n'indexe pas automatiquement la colonne porteuse de la FK. Dès qu'on filtre dessus (`updateMany({ where: { senderId } })`, purges admin, dashboards), ajouter un `@@index` dédié sur cette colonne — un index composite `(conversation_id, created_at, id)` ne couvre pas un filtre par `sender_id` seul (seq scan sinon).
- Contexte technique : Prisma / multi-tenant — app-template-resto 17-03-2026 ; app-alexandrie 13-05-2026
---
@@ -314,7 +341,29 @@ if (cursor) {
- **Règle** : ajouter un test unitaire "cursor invalide → 400" sur tout endpoint paginé par cursor
- Contexte technique : NestJS / pagination — app-alexandrie 24-03-2026
### Valider chaque champ typé du cursor décodé, pas seulement sa structure
Le décodage JSON valide la **structure** (présence des clés) mais pas le **format** des champs typés. Un attaquant peut forger `{"createdAt":"garbage","id":"x"}` : `JSON.parse` réussit → `new Date('garbage') = Invalid Date` → Prisma renvoie un 500 au lieu d'un 400 propre.
```ts
let decoded: { createdAt: string; id: string } | null = null;
if (cursor) {
try {
decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString());
if (!decoded.createdAt || !decoded.id) throw new Error('Champs manquants');
// ✅ valider la convertibilité de chaque champ consommé par Prisma
if (Number.isNaN(new Date(decoded.createdAt).getTime()))
throw new Error('createdAt invalide');
// (id UUID : check regex si requis)
} catch {
throw new BadRequestException({ error: { code: 'INVALID_CURSOR', message: '…' } });
}
}
```
- **Règle** : pour chaque champ du cursor décodé consommé par Prisma (`new Date()`, `BigInt()`, etc.), valider explicitement la convertibilité avant la query, sinon l'erreur fuit en 500.
- Contexte technique : NestJS / pagination — app-alexandrie 24-03-2026 ; app-alexandrie 28-05-2026
---
@@ -534,7 +583,17 @@ await prisma.X.updateMany(...);
- Test d'invariant post-seed obligatoire (cf. pattern dédié)
- Si migration en cours de route : prévoir un script qui propage sur **toutes** les FKs (`audit_logs.user_id`, `notifications.recipient_id`, `refresh_tokens.user_id`, etc.)
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026
### Sous-règle — seed Prisma + contracts Zod : `id` auto-généré + validation Zod sortante
Tout modèle Prisma référencé dans un schéma Zod par `z.string().uuid()` (ex: `DmConversation.id`, `User.id` exposé en `peerUserId`) doit avoir un `id` **auto-généré** par Prisma (`@id @default(uuid())`) dans le seed. **Jamais d'ID lisible** type `seed-user-alice` sur ces modèles : la validation Zod **sortante** les rejette.
Le piège est silencieux : `prisma.user.create({ data: { id: 'seed-alice' } })` ne plante pas (Postgres ne valide pas le format UUID sur une colonne `text`/`varchar`), mais l'endpoint de listing renvoie un HTTP 400 (`fieldErrors.items: ["Invalid UUID"]`) après `ZodValidationPipe` sur la **response**. Invisible si les e2e mockent `PrismaClient` (les UUID auto-générés sont remplacés par des stubs) — visible uniquement à l'usage réel (mobile/curl).
- Fixtures : référencer les entités par une **`key` logique stable** (`alice`, `alice-bob`), pas par `id` ; construire un `Map<key, uuid>` après insertion et le propager aux fixtures dépendantes.
- Les modèles dont le contract n'exige pas un UUID (Thread, Comment, Mention) peuvent garder des IDs lisibles si utile à la lisibilité des fixtures.
- **Test de non-régression** : tout endpoint de listing qui validate sa response via `ZodValidationPipe` + fixture seed doit être testé via un e2e **DB-based** (non mocké) qui hit l'endpoint réel.
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026 ; app-alexandrie 26-05-2026
---
@@ -618,7 +677,15 @@ const resolveDbUrl = (): string | undefined => {
**Règle générale** : toute stratégie template-based doit auditer le chemin du `DB_URL` à travers les sub-processes de bootstrap. Le bootstrap ouvre une connexion sur la template, mais le seed transitif exécuté via un sub-process peut être sujet à des transformations agressives du DSN qui le redirigent ailleurs.
- Contexte technique : Prisma / template database / Vitest — RL799_V2 01-05-2026
### Après TOUTE nouvelle migration : droper le template DB de test avant de re-run
Un template DB construit une fois (`migrate + seed`) puis cloné par worker (`bootstrapTemplate.ensureTemplateReady` / `globalSetup`) est **réutilisé tel quel s'il existe** — il ne détecte PAS qu'une nouvelle migration est apparue. Symptôme trompeur : juste après avoir ajouté une migration `ADD COLUMN`, les tests échouent en `column "x" does not exist` (`PrismaClientKnownRequestError`) alors que `prisma migrate status` dit « up to date » sur la DB dev et que le schema est correct. Cause : le template de test est resté sur l'ancien schéma.
- **Fix** : droper le template avant la 1ʳᵉ exécution → le `globalSetup` le recrée from scratch (migrate deploy + seed) avec la colonne. Si `psql` indisponible, via client `pg` : `DROP DATABASE <template> WITH (FORCE)`.
- **À automatiser idéalement** : faire dépendre la validité du template d'un **hash du dossier `migrations/`** (re-build si le hash change).
- Note Prisma 7.x : garde-fou anti-IA sur les actions destructives (`prisma migrate reset` exige un consentement explicite). La cohabitation migration+seed se prouve via le rebuild du template de test, pas besoin de reset la DB dev.
- Contexte technique : Prisma / template database / Vitest — RL799_V2 01-05-2026 ; RL799_V2 14-06-2026
---
@@ -649,3 +716,185 @@ CREATE INDEX "users_deleted_at_idx" ON "users"("deleted_at")
- **Règle** : pour une colonne soft-delete nullable à majorité `NULL`, préférer un index partial `WHERE deleted_at IS NOT NULL`.
- Contexte technique : Prisma / PostgreSQL / index partial — app-alexandrie 13-04-2026
---
<a id="risque-index-partiel-text-alter-enum"></a>
## Index partiels avec littéraux text — rejettent `ALTER COLUMN String → enum`
### Risques
- Une migration de conversion `String → enum` plante au `ALTER TABLE ... ALTER COLUMN ... TYPE "<Enum>" USING ...` à cause d'un index partiel **historique** dont la clause `WHERE` contient un littéral text.
- **Le pré-scan des valeurs DB ne détecte PAS ce piège** : une migration peut passer le pré-scan des orphelins et planter quand même. Coût d'oubli : rollback en urgence + reset du template DB de tests.
### Symptômes
```
ERROR: operator does not exist: "<EnumName>" = text
HINT: No operator matches the given name and argument types.
```
Apparaît au moment de l'`ALTER COLUMN ... TYPE`. La migration est rollée back atomiquement par Postgres (pas d'état hybride), mais bloque le déploiement.
### Cause racine
Une migration historique a créé un index partiel avec un **littéral text** dans le `WHERE` :
```sql
CREATE UNIQUE INDEX my_index ON table(col) WHERE status = 'active';
```
Quand `status` passe de `text` à `enum`, le littéral `'active'` reste **typé text** → Postgres refuse la conversion car l'opérateur `enum = text` n'est pas défini.
### Bonnes pratiques / mitigations
Encadrer la conversion par DROP/CREATE de l'index (le `CREATE` post-conversion typera automatiquement le littéral en enum) :
```sql
DROP INDEX IF EXISTS "my_index";
ALTER TABLE "table" ALTER COLUMN "status" TYPE "MyEnum" USING "status"::"MyEnum";
CREATE UNIQUE INDEX "my_index" ON "table"("col") WHERE "status" = 'active';
```
**Détection préventive** — avant toute migration enum, grep les index partiels littéraux :
```bash
grep -rn "WHERE.*=.*'" prisma/migrations --include="*.sql" | grep -v "DELETE\|UPDATE"
```
Tout `WHERE col = 'literal'` touchant une colonne candidate à conversion doit être ajouté au DROP/CREATE de la migration. Risque compagnon du pattern `pattern-migration-string-int-enum-sans-downtime` dans `patterns/prisma.md`.
- Contexte technique : Prisma / PostgreSQL — RL799_V2 05-05-2026
---
<a id="risque-colonne-prisma-jamais-ecrite"></a>
## Colonnes Prisma jamais écrites (placeholder / i18n côté contracts)
### Risques
- Un champ ajouté à un modèle Prisma "au cas où" mais que le code projette systématiquement depuis une constante en mémoire (côté contracts/schemas) → colonne morte : dette schéma silencieuse, writes ralentis par un index inutile, confusion future ("à quoi sert-elle ?").
### Symptômes
- Migration `ADD COLUMN "placeholder_label" TEXT;`
- Service : `placeholderLabel: isAutoHidden ? AUTO_HIDE_PLACEHOLDER_LABEL : null` (constante de contracts)
- Aucun `update({ data: { placeholderLabel: ... } })` dans le codebase ; colonne toujours NULL en pratique
### Bonnes pratiques / mitigations
Avant d'ajouter une colonne destinée à porter un libellé ou un texte localisable :
1. Besoin réel `(global, immuable)`**constante côté contracts**, pas de colonne.
2. Besoin `(par-row, configurable plus tard)` → colonne **+** endpoint admin pour la peupler **dès la story** qui l'introduit.
3. Jamais "j'ajoute la colonne au cas où une story future en aurait besoin" → YAGNI.
- **Signal review** : si une colonne du schema n'apparaît dans **aucun** `.create` / `.update` / `.upsert` du codebase, c'est probablement une colonne morte.
- Contexte technique : Prisma / schema — app-alexandrie 05-05-2026
---
<a id="risque-read-then-write-transition-one-shot"></a>
## Read-then-write sur invariant d'unicité / transition one-shot — race condition
### Risques
- Vérifier une condition par un `findUnique`/`findFirst`/`SELECT` puis agir par un `update` séparé n'est **pas atomique** sous concurrence. Sous `READ COMMITTED` (défaut Prisma/Postgres), deux requêtes concurrentes passent toutes deux la garde en mémoire avant tout update → double consommation (usage-unique) ou double transition (machine à états "irréversible" devenue ré-écrasable).
- Le check applicatif ne protège PAS contre la concurrence (double-clic, retry, multi-onglets).
### Symptômes
- Deux ressources créées pour un seul code/ticket usage-unique (2 `UserPack` pour 1 code).
- Une transition `open → settled` (ou `draft → published`, `pending → approved`) appliquée deux fois : test `Promise.all([settle(), settle()])` prouve `[200, 200]` au lieu de `[200, 409]`.
### Bonnes pratiques / mitigations
Porter la garde **dans l'écriture** : `updateMany` conditionnel atomique + test de `count`. Le verrou de ligne sérialise les transactions concurrentes ; le perdant voit `count === 0`.
```typescript
// ✅ Consommation usage-unique
const { count } = await tx.code.updateMany({
where: { id, consumedBy: null }, // garde DANS le WHERE
data: { consumedBy: userId },
});
if (count === 0) throw new ConflictException('ALREADY_USED');
// ✅ Transition one-shot
const { count } = await prisma.proposal.updateMany({
where: { id, status: 'open' },
data: { status: 'settled', ... },
});
if (count === 0) /* perdant de la course → 409 */;
```
- La garde en mémoire reste utile en **fail-fast** (évite un round-trip si déjà transité à la lecture), mais ce n'est plus elle qui garantit l'unicité.
- Alternative : `isolationLevel: 'Serializable'` + retry, mais l'`updateMany` gardé est plus simple.
- **Test obligatoire** : deux opérations concurrentes (`Promise.all`) → exactement une réussit.
- Contexte technique : Prisma / Postgres / concurrence — app-alexandrie 02-06-2026 ; RL799_V2 (settle proposition d'instruction)
---
<a id="risque-unique-plus-index-redondant"></a>
## `@@unique` + `@@index` sur la même colonne — index redondant en Postgres
### Risques
- Déclarer `@@unique([col])` ET `@@index([col])` (ou `@unique` + `@@index`) sur la même colonne génère **deux** construits SQL : un `CREATE UNIQUE INDEX` et un `CREATE INDEX` normal. Postgres utilise l'index unique pour les lookups — le second ne sert à rien, consomme de l'espace et ralentit toutes les écritures (chaque write tient les deux index à jour).
### Symptômes
- Migration générée avec un `CREATE UNIQUE INDEX` et un `CREATE INDEX` sur la même colonne (ex: `season_reports.season_id`).
### Bonnes pratiques / mitigations
- N'ajouter `@@index` que sur des colonnes qui **ne sont pas déjà couvertes** par `@@unique`/`@unique`.
- Correction : supprimer `@@index([col])` et générer une migration `DROP INDEX IF EXISTS`.
- Contexte technique : Prisma / PostgreSQL — RL799_V2 14-06-2026
---
<a id="risque-delete-row-fin-transaction-anonymisation"></a>
## DELETE row à la fin d'une transaction d'anonymisation
### Risques
- Dans une transaction qui clôture un cycle métier sensible (admission, archivage, anonymisation RGPD) et DELETE une row "pivot" pour purger ses dépendances en cascade (`ON DELETE CASCADE`), placer le DELETE **en milieu** de transaction casse les opérations suivantes :
- audit log, projection DTO de retour, side-effects référencent un id qui n'existe plus → `RecordNotFound` ou retour `null` ;
- les requêtes post-DELETE peuvent retomber sur un état pré-CASCADE ou retourner des rows liées zombies.
### Symptômes
```typescript
await prisma.$transaction(async (tx) => {
const row = await tx.profane.findUnique({ where: { id } });
await tx.profane.delete({ where: { id } }); // ← TROP TÔT
await logActionSync(tx, 'enquete:admitted', 'Profane', id, { ... }); // référence un id supprimé
return tx.user.create({ data: { ... } });
});
```
### Bonnes pratiques / mitigations
DELETE = **dernière opération** de la transaction. Tout ce qui doit lire ou auditer la row se fait avant. Les side-effects post-commit (notifs, `fs.rm`) utilisent des données **capturées avant** le DELETE.
```typescript
await prisma.$transaction(async (tx) => {
const row = await tx.profane.findUnique({ where: { id }, include: { rapports: true } });
// 1. Lectures, audits, créations dérivées
await logActionSync(tx, 'enquete:admitted_purge', 'Profane', id, { profaneId: id, nbRapports: row.rapports.length });
const newUser = await tx.user.create({ data: { email: row.email, ... } });
// 2. DELETE EN DERNIER (CASCADE balaie Enquete + Rapports + …)
await tx.profane.delete({ where: { id } });
return newUser;
});
// Post-commit (hors tx) : fs.rm uploads/enquetes/{enqueteId} en best-effort, sur les paths capturés avant
```
- **DELETE vs SET NULL** : DELETE si la row n'a plus aucune valeur métier post-cycle ; SET NULL/anonymize si la row doit rester pour des liens entrants (ex. `RapportEnquete.enqueteurId = null` quand un enquêteur est remplacé — le rapport reste consultable, le lien à l'auteur est anonymisé).
- Toujours capturer les `fileUrl`/`path` **avant** le DELETE pour permettre un `fs.rm` post-commit.
- Audit log **avant** DELETE — sinon le `targetId` référence une row inexistante.
- Contexte technique : Prisma / transactions — RL799_V2 05-05-2026
+45 -1
View File
@@ -140,4 +140,48 @@ if (compensated === null) {
- **Règle** : toujours vérifier le retour du décrément de compensation et loguer explicitement si `null`. Documenter ce choix dans les Dev Notes de la story (comportement intentionnel vs bug).
- **Solution robuste** : encapsuler incrément + compensation conditionnelle dans le même pipeline `MULTI/EXEC` ou un script Lua (atomicité garantie), au prix d'une complexité plus élevée.
- Contexte technique : Redis / NestJS — app-alexandrie 01-04-2026
#### Compenser AUSSI quand la transaction DB échoue (pas seulement au dépassement)
Le même compteur doit être compensé quand l'écriture DB qui suit le quota échoue. Pattern récurrent : `consumeDailyQuota` (incrément Redis) est appelé **avant** une transaction DB. Si la transaction throw, le compteur du user est consommé sans avoir produit le side-effect attendu → quota fantôme. Le piège : la compensation `incrBy(-1)` existante n'est souvent déclenchée **que** sur dépassement de quota, pas sur exception de la transaction.
```typescript
// ❌ si $transaction throw, le compteur reste incrémenté → quota fantôme
await consumeDailyQuota({ ..., action: 'dm-message' });
const message = await prisma.$transaction(async (tx) => { /* INSERT, peut échouer */ });
// ✅ compensation systématique sur exception de la transaction
await consumeDailyQuota({ ... });
try {
const message = await prisma.$transaction(...);
} catch (err) {
await redis.incrBy(quotaKey, -1).catch(() => {});
throw err;
}
```
- **Trade-off** : garder l'ordre `quota → tx → compensation` (et non « tx puis quota ») garantit qu'on ne dépasse pas la limite sous charge concurrente (deux requêtes simultanées qui passeraient toutes deux le check). La compensation sur catch est donc **obligatoire**.
- **Règle** : tout flow `consumeDailyQuota` puis écriture DB doit compenser sur exception. Vérifier aussi les autres actions partageant ce flow (`comment`, `post`, `support_ticket`).
- Contexte technique : Redis / NestJS / Prisma — app-alexandrie 01-04-2026, complété 13-05-2026 (story 10.2)
---
<a id="risque-rate-limit-compteur-partage"></a>
## Rate-limit à compteur partagé entre endpoints jumeaux
### Risques
- Un helper de rate-limit dont la clé Redis omet un discriminant d'endpoint (`quota:${action}:${userId}:${jour}`) fait partager **un unique compteur** à plusieurs endpoints réutilisant le même `action` et le même discriminant (ex : l'IP).
- Les seuils respectifs des endpoints perdent tout sens : un excès sur l'un consomme le quota de l'autre. Ex : un excès de `login` bloque un `reset` de mot de passe légitime.
### Symptômes
- Un endpoint renvoie 429 alors que son propre seuil n'est pas atteint, à cause du trafic sur un endpoint jumeau.
- Le bug est invisible en test : chaque e2e exerce un seul endpoint à la fois, donc le compteur n'est jamais partagé pendant un test.
### Bonnes pratiques / mitigations
- **Règle de clé** : `clé = quota:<action>:<endpoint>:<identité>:<fenêtre>`. La clé DOIT inclure un discriminant d'endpoint, pas seulement l'identité de l'appelant.
- **Test de non-régression** : exercer DEUX endpoints jumeaux jusqu'au seuil dans le **même** test — un test mono-endpoint ne révèle jamais ce bug.
- Contexte technique : Redis / NestJS — app-alexandrie 22-05-2026
+51
View File
@@ -23,6 +23,10 @@
- Utiliser `subscription.current_period_end` (timestamp) pour la fin de période courante
- Ajouter un test sur un événement webhook/Subscription qui vérifie la date persistée
### Nuance SDK v20 (API 2025-03-31.basil+) : `current_period_end` est par ITEM, plus à la racine
Depuis le SDK Stripe v20, `Subscription.current_period_end` n'existe **plus** au niveau racine : lire `subscription.items.data[i].current_period_end` (prendre le **max** des items pour borner au plus tard). Symptôme : `currentPeriodEnd` revient systématiquement `null` → un abo « ACTIVE » sans borne de période reste ouvert indéfiniment (abo « zombie ») si un event de renouvellement est manqué. Garde-fou complémentaire : `isActive = status === 'ACTIVE' && currentPeriodEnd != null && currentPeriodEnd > now`. Valider sur un event Stripe réel (l'API effective dépend de la clé/compte).
---
<a id="risque-stripe-list-has-more"></a>
@@ -114,3 +118,50 @@
- Si `processing` détecté (concurrent) : attendre brièvement la transition `processed`, sinon répondre **non-2xx** (force retry provider)
- Ne jamais passer à `processed` sans preuve d'un traitement effectif
- Contexte technique : Stripe / NestJS — 09-03-2026
---
<a id="risque-refund-lie-user-produit"></a>
## Remboursement lié à (user, produit) au lieu de la TRANSACTION (PaymentIntent)
### Risques
- Un garde-fou « ne pas ré-accorder l'accès si déjà remboursé » identifié par `(userId, productId)` casse le cas « rembourser puis racheter » : l'ancien refund contamine le nouvel achat
- Un RACHAT légitime (nouveau paiement, nouveau PaymentIntent) du même produit par le même user est bloqué → client qui re-paie sans accès (perte de revenu + incident)
### Symptômes
- Garde-fou d'idempotence/révocation indexé sur `(userId, packId)` plutôt que sur le `paymentIntentId`
- Un `RefundRecord` orphelin (refund arrivé avant `completed`) qui bloque tout achat futur du même produit
### Bonnes pratiques / mitigations
- Un remboursement concerne UNE transaction précise, pas une relation `(user, produit)` durable. La clé d'un refund et de son garde-fou d'idempotence/ordre = le `paymentIntentId` (ou l'id de charge), **jamais** `(user, produit)`.
- Propager le `paymentIntentId` depuis `checkout.session.completed` (`session.payment_intent`) jusqu'au garde-fou.
- Corollaire (arrivée désordonnée refund-avant-completed via un `RefundRecord`) : matcher ce record par `paymentIntentId` à la création du `UserPack`, sinon il bloque tout achat futur du même produit.
- **Test obligatoire** : achat → refund → rachat (nouveau PI) → accès accordé.
- Contexte technique : Stripe / refund / webhooks — app-alexandrie 02-06-2026
---
<a id="risque-refund-consommation-visionnage-reel"></a>
## Éligibilité « refund si peu consommé » mesurée sur la validation, pas le visionnage réel
### Risques
- Une politique de remboursement bornée par la consommation (ex : « < 20 % consommé ») qui mesure un drapeau de VALIDATION EXPLICITE (clic « terminer » → state `COMPLETED`) est contournable
- L'utilisateur regarde 100 % du contenu sans cliquer « valider » → `completionPct = 0` → remboursable malgré tout le contenu consommé (open-bar)
### Symptômes
- AC qui cite `maxWatchedPct` mais implémentation qui compte `state === 'COMPLETED'`
- Divergence d'oracle entre le badge UI « remboursable » et ce que le serveur accepte réellement
### Bonnes pratiques / mitigations
- Mesurer la consommation EFFECTIVE (progression vidéo `maxWatchedPct >= seuil`, lecture réelle), JAMAIS un drapeau de validation cliqué par l'utilisateur (`COMPLETED`).
- Règle de seuil : leçon vidéo consommée dès `maxWatchedPct >= 90` (seuil de complétion vidéo), leçon texte dès `COMPLETED`.
- Garder UNE seule source de mesure partagée par la décision serveur ET le badge UI « remboursable » (sinon divergence d'oracle).
- Contexte technique : Stripe / refund / consommation contenu — app-alexandrie 04-06-2026
+120
View File
@@ -121,3 +121,123 @@ pnpm -C apps/api test && pnpm -C apps/api test
- Fails aléatoires différents à chaque run : urgent (state corruption)
- Contexte technique : Vitest / Prisma — RL799_V2 25-04-2026
---
<a id="risque-test-non-regression-rbac-guards-reels"></a>
## Test de non-régression d'accès (RBAC) qui ré-encode la table de rôles au lieu d'invoquer les guards réels
### Risques
- Un test "filet anti-régression" donne un faux sentiment de sécurité s'il teste une projection inerte de la règle au lieu de la règle appliquée
- Cas vécu RL799 : un test "snapshot d'accès" conçu comme "pour chaque rôle, quels sets de rôles le couvrent" ne dépendait QUE du fichier de définition des sets — il restait vert même si un guard ou un call-site changeait (le vrai risque d'une refonte RBAC). La revue adversariale l'a qualifié de "faux filet"
### Symptômes
- Le test ne peut PAS échouer si la régression qu'il prétend protéger se produit
- Question de détection : "ce test peut-il échouer si la régression que je crains arrive ?" — si non, c'est un faux filet
### Bonnes pratiques / mitigations
- Un test censé protéger contre un changement X doit exercer le chemin où X s'applique RÉELLEMENT, pas une reformulation parallèle de la règle
- Correctif type : matrice (rôle représentatif × handler représentatif), token forgé par rôle, **appel du handler/guard réel**, assertion `200`/`403`
- Ne jamais ré-encoder la table de rôles dans le test : invoquer les guards/handlers de prod
- Contexte technique : RBAC / API HTTP — RL799_V2
---
<a id="risque-prefixe-fixture-unique-par-fichier"></a>
## Préfixe de fixture de test partagé entre fichiers — cleanup qui efface la fixture d'un voisin
### Risques
- Deux fichiers de tests écrivant dans la même table avec le MÊME préfixe (`RI-TEST-`), dont l'un fait un filet `deleteMany({ ref: { startsWith: 'RI-TEST-' } })` en `afterEach`
- Vitest pouvant entrelacer deux fichiers sur un même worker, ce filet peut effacer la fixture VIVANTE de l'autre fichier → flakiness intermittente non déterministe
- Bug LATENT de profil "iceberg" : les deux fichiers passent en isolation et même ensemble la plupart du temps, fail sporadique en CI (ex. 404 sur le merge) difficile à diagnostiquer
### Symptômes
- 404 / "introuvable" sporadique sur une fixture que le test croyait avoir créée
- Deux fichiers `grep`-ables sur le même littéral de préfixe
### Bonnes pratiques / mitigations
- **Préfixe de fixture = UNIQUE par fichier, jamais par domaine/table** : `RI-CRUD-` vs `RI-MERGE-`, pas `RI-TEST-` partagé
- Règle générale : un cleanup de test ne doit JAMAIS supprimer au-delà de ce que CE fichier a créé — ni `rm` une racine disque partagée, ni `deleteMany` un pattern qu'un autre fichier peut matcher, ni réutiliser un id du seed
- Détection : `grep -rln "<PREFIX>" <test-dir>` doit retourner UN seul fichier par préfixe
- Contexte technique : Vitest / Prisma — RL799_V2 22-06-2026
---
<a id="risque-test-collision-fichier-versionne"></a>
## Test qui écrit/supprime un fichier collisionnant avec un artefact versionné
### Risques
- Un test pose une fixture via `writeFile` puis la supprime dans son `finally`, mais en ciblant le nom d'un fichier SEED VERSIONNÉ dans git (ex. `seed-planches-architecture-apprenti.pdf`)
- À chaque run de la suite, le fichier seed disparaît du working tree (`git status``D`), polluant tous les diffs et risquant d'être commité par erreur
### Symptômes
- Un fichier suivi par git réapparaît en `D` (supprimé) dans `git status` après l'exécution d'une suite de tests, sans qu'aucun code applicatif ne le touche
- Déroutant : le coupable est un test, pas le code en cours de dev
### Bonnes pratiques / mitigations
- Les fixtures posées sur disque portent un nom JETABLE non versionné (préfixe `_fixture-`, `tmp-`, ou sous-dossier `__fixtures__/` git-ignoré), JAMAIS le nom d'un fichier seed/asset commité
- Vérifier : `git ls-files <dir>` liste les fichiers versionnés — aucun nom de fixture de test ne doit y figurer
- Détection rapide : si `git status` montre une suppression `D` inattendue d'un seed/asset après un run de tests, `grep` le nom exact dans `__tests__/` → le test qui le référence avec un `rm`/`removeFixture` en `finally` est le coupable
- Contexte technique : Vitest / filesystem — RL799_V2 23-06-2026
---
<a id="risque-test-singleton-module-level-env"></a>
## Test consommant un singleton module-level dépendant de l'env — passe "par accident" selon l'ordre des fichiers
### Risques
- Un test qui consomme un singleton module-level configuré par l'environnement (transport SMTP, client API, pool DB mémoïsé) sans stubber son propre env ET reset le singleton passe "par accident" selon l'ordre d'exécution des fichiers
- Le singleton (`let transport = null` mémoïsé au 1er `getTransport()`) est partagé entre fichiers d'un même worker : un fichier A pose `SMTP_HOST` + construit le transport, un fichier B qui ne configure RIEN réutilise le transport de A → B "marche" tant que A tourne avant lui
- Corollaire : un reset de singleton (`__resetXxxForTests`) placé dans le SETUP GLOBAL via import statique court-circuite les `vi.mock` des autres fichiers — l'`import` charge le module (et sa chaîne, ex. `smtpTransport → nodemailer`) AVANT que les `vi.mock` propres à chaque fichier soient hoistés → module figé sur la vraie dépendance
### Symptômes
- Test vert en local, rouge en CI (ou l'inverse) sans changement de code ; `sentCount === 0` au lieu de N, ou appel réseau réel malgré un mock
- Ajouter un reset "utile" au setup global casse des dizaines de tests sans rapport (`'failed' !== 'sent'`)
### Bonnes pratiques / mitigations
- Tout fichier qui exerce le singleton doit être AUTO-SUFFISANT : stubber l'env requis dans `beforeAll` (`vi.stubEnv('SMTP_HOST', …)` + `vi.unstubAllEnvs()` en `afterAll`) ET reset le singleton dans `beforeEach` (`__resetXxxForTests()`), au POINT D'USAGE (où son propre `vi.mock` est actif), jamais dans le setup global
- Ne jamais s'appuyer sur l'état laissé par un fichier voisin
- Un reset de singleton va dans le `beforeEach` du/des fichier(s) qui en ont besoin, PAS dans le setup global ; si vraiment transverse, l'importer en différé (`await import('@/lib/...')`) au point d'usage
- Le `dryRun`/mode test d'un service ne dérive PAS forcément de `NODE_ENV === 'test'` — vérifier la VRAIE condition (souvent une var dédiée, ex. `MAIL_DRY_RUN === 'true'`)
- Après tout changement touchant le fichier de setup, rejouer la suite COMPLÈTE (effet iceberg)
- Contexte technique : Vitest — RL799_V2 23-06-2026
---
<a id="risque-test-rate-limit-rang-hardcode"></a>
## Test de rate-limit qui hardcode le rang exact de la requête bloquée
### Risques
- Un test qui hardcode "la Ne requête déclenche le 429" casse silencieusement quand un flag d'environnement (E2E) relève la limite
- La limite effective dépend d'un flag (`process.env.E2E === '1' ? 1000 : 20`), mais le test fige le nombre d'itérations
### Symptômes
- `for (i=0;i<21;i++) ...; expect(last.status).toBe(429)` passe en local (limite=20) mais casse sous E2E=1 (limite relevée à 1000) — jamais de 429 atteint, rouge en CI E2E
- À l'inverse, un test calibré sur la limite E2E serait trop lent/inutile en local
### Bonnes pratiques / mitigations
- Boucler jusqu'au PREMIER 429 avec un plafond de sécurité couvrant le régime le plus permissif (`SAFETY_CAP > limite E2E`)
- Asserter (a) qu'un 429 a bien été atteint avant le plafond, (b) qu'au moins une requête est passée avant le blocage
- Le test reste correct quelle que soit la limite effective et survit à un ajustement de capacité
- Alternative : exposer la limite (getter) et dériver le nombre d'itérations — mais boucler-jusqu'au-429 évite de changer l'API de prod pour un test
- Contexte technique : rate-limit / API HTTP — RL799_V2 23-06-2026