mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-05-18 08:18:15 +02:00
capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) : - 60 entrées intégrées dans knowledge/ (backend, frontend, workflow) - 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md, frontend/patterns/general.md, workflow/patterns/general.md - 6 doublons rejetés - Mise à jour des READMEs index pour refléter les nouvelles entrées - 95_a_capitaliser.md restauré à sa structure initiale - 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant - 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI, prisma migrate diffs cosmétiques Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,5 +14,6 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
|
||||
| `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 |
|
||||
| `redis.md` | Redis, cache, quotas, TTL | Thrash connexion sous charge, entitlements TTL > SLA, compteurs in-memory, TTL heure locale ±12h |
|
||||
| `nextjs.md` | Next.js, build, routing | Prisma init au chargement module, server-only dans repositories, redirect boucle infinie feature flags |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
@@ -365,4 +365,97 @@ it('retourne 403 si subscription inactive', async () => {
|
||||
|
||||
- Contexte technique : auth / cycle de vie compte — RL799_V2 17-04-2026
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
<a id="risque-helpers-x-actif-derivants"></a>
|
||||
## Helpers "X actif" qui dérivent silencieusement
|
||||
|
||||
### Risques
|
||||
|
||||
- Plusieurs helpers répondent à la même question — *"l'entité X est-elle active / opérante ?"* — avec des filtres légèrement différents
|
||||
- Un user passe la guard A mais pas la guard B sur la même ressource (ou inversement). Bugs silencieux, pas d'erreur, juste une asymétrie de comportement
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Délégation `secretaireDeSeance` "active" filtrée sur `status: 'published', closedAt: null, cancelledAt: null` dans un helper, juste `cancelledAt: null` dans l'autre
|
||||
- Un ex-délégué d'une soirée clôturée garde l'autorité cross-soirée indéfiniment
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
1. **Un seul helper canonique** par notion d'activité (ex : `isDelegationActive`, `isSoireeOpenForRappel`). Les autres l'appellent
|
||||
2. Si la centralisation n'est pas faisable immédiatement (ex : helper appelé en N+1 query, perf), au moins un test qui compare leur output sur des fixtures partagées et casse à la moindre divergence
|
||||
3. Au minimum : un commentaire en tête du helper "secondaire" qui pointe vers le canonique et liste explicitement les filtres à maintenir synchronisés
|
||||
|
||||
- Contexte technique : auth / RBAC — RL799_V2 27-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-guard-charge-objets-riches"></a>
|
||||
## Guard d'autorisation qui charge des objets riches
|
||||
|
||||
### Risques
|
||||
|
||||
- Une guard d'autorisation s'exécute à CHAQUE requête sur une route protégée
|
||||
- Si la guard a besoin de "trouver une candidate" (ex : "cette tenue est-elle dans les 'dernières rappelables' du grade pour une de mes délégations ?"), le repo helper utilisé doit avoir un select **minimal**, PAS le select complet utilisé par les services métier
|
||||
- Pour un user avec N délégations actives, on charge N agrégats volumineux à chaque requête
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Même fonction repo appelée par (1) un service qui a besoin de toutes les relations (rendu UI) et (2) une guard qui n'a besoin que de l'id
|
||||
- La guard paie le coût du fetch riche inutilement
|
||||
- Latence guard qui croît avec le nombre de relations chargées
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
Exposer **deux variantes** du repo helper :
|
||||
|
||||
- `findX(...)` — select riche, utilisé par les services métier
|
||||
- `findXIdOnly(...)` — select `{ id: true }`, utilisé par les guards
|
||||
|
||||
```typescript
|
||||
// Guard
|
||||
export const requireXAccess = async (request, id, { roleSet }) => {
|
||||
// utilise findXIdOnly (select minimal) — pas findX
|
||||
const candidate = await repo.findXIdOnly({ ... });
|
||||
if (!candidate || candidate.id !== id) return forbidden();
|
||||
};
|
||||
|
||||
// Service métier
|
||||
export const getXFullDetails = async (id) => {
|
||||
return repo.findX({ ... }); // include riche
|
||||
};
|
||||
```
|
||||
|
||||
Coût : duplication de la clause `where` (acceptable, factorisable en constante). Bénéfice : la guard reste O(1) en payload même quand les relations grossissent.
|
||||
|
||||
- Contexte technique : auth / performance — RL799_V2 27-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-suppression-flag-auth-global"></a>
|
||||
## Suppression d'un flag auth global (DB + DTO + tests) — cleanup atomique obligatoire
|
||||
|
||||
### Risques
|
||||
|
||||
- Un flag profondément câblé dans Prisma (ex : `mustChangePassword`, `isVerified`) ne peut pas être supprimé incrémentalement : chaque cleanup partiel produit un état non-compilable
|
||||
- Les fixtures de tests qui posent `mustChangePassword: false` cassent à la compilation TS au moment du drop — bloque tout commit séparé
|
||||
- Les helpers `helpers/db.ts` et les DTO partagés (`packages/shared`) sont prioritaires, sinon les imports cross-package échouent en cascade
|
||||
|
||||
### Symptômes
|
||||
|
||||
- `Property 'mustChangePassword' does not exist on type 'User'` après un drop partiel
|
||||
- Tentative de découpage en sous-lots qui échoue au typecheck
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
Quand on prévoit de supprimer un flag auth profondément câblé :
|
||||
|
||||
1. **Le cleanup ne peut pas être incrémental** — soit on supprime tout dans un chantier, soit on garde le flag avec un nullable de transition
|
||||
2. **Les fixtures de tests doivent être nettoyées dans le même PR** — grep systématique avant de démarrer (`grep -rn "mustChangePassword" apps/`) pour estimer l'ampleur
|
||||
3. **Les helpers `helpers/db.ts`** sont prioritaires — un seul fichier touché casse tous les tests qui l'importent
|
||||
4. **Les DTO partagés (`packages/shared`)** doivent être alignés en premier
|
||||
5. Considérer un sous-lot dédié au cleanup si le flag est transverse — éviter de l'inclure dans un sous-lot fonctionnel
|
||||
|
||||
**Anti-pattern** : déprécier en douceur en gardant le flag avec un commentaire `// @deprecated` sans supprimer les usages. Le code mort s'accumule, les futurs devs hésitent à le nettoyer ("pourquoi c'est encore là ?"), la dépréciation ne se finit jamais.
|
||||
|
||||
- Contexte technique : auth / refactor schema — RL799_V2 28-04-2026
|
||||
@@ -222,3 +222,40 @@ Quand un repository ou service crée une nouvelle valeur pour un champ enum-like
|
||||
3. Vérifier que les endpoints de lecture qui parsent ces données acceptent la nouvelle valeur
|
||||
|
||||
- Contexte technique : Zod / contrats partagés — RL799_V2 03-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-zod-email-tolowercase-trim"></a>
|
||||
## Bug Zod 4 — `z.string().email().toLowerCase().trim()` rejette les emails à trim
|
||||
|
||||
### Risques
|
||||
|
||||
- Le pattern `z.string().email().toLowerCase().trim()` ne fait **pas** ce qu'il prétend en Zod 4 : `.email()` est une assertion qui valide le format **brut**, **avant** que les transforms `.toLowerCase()` / `.trim()` s'appliquent
|
||||
- Un email avec espace trailing (`"BOB@X.FR "`) est rejeté `Invalid email` au lieu d'être trim+lower
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Test fixture `BOB@X.FR ` (trailing space) → 400 alors que l'intention est `bob@x.fr`
|
||||
- Pattern présent dans plusieurs schémas du projet (`visitorProfileLookupSchema`, `tenueVisitorCreateSchema`, etc.)
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
// ❌ Pattern legacy faux (Zod 4) — assertion AVANT transforms
|
||||
const emailSchema = z.string().email().max(254).toLowerCase().trim();
|
||||
|
||||
// ✅ Pattern correct : trim/lower AVANT email assertion via pipe
|
||||
const emailSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.pipe(z.string().email().max(254));
|
||||
```
|
||||
|
||||
`.pipe()` chaîne deux schémas — le premier transforme (trim+lower), le second valide (email+max). L'ordre devient explicite et l'assertion est appliquée après normalisation.
|
||||
|
||||
**Tests à ajouter** : `BOB@X.FR ` (trailing space) → `bob@x.fr`, ` ALICE@TEST.FR` (leading + casse) → `alice@test.fr`. Si le schéma rejette `Invalid email`, le bug est présent.
|
||||
|
||||
**À 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
|
||||
|
||||
@@ -868,3 +868,280 @@ try {
|
||||
- Définir une timezone métier unique pour les communications utilisateur.
|
||||
|
||||
- Contexte technique : dates / formatage serveur — RL799_V2 15-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-derive-dto-liste-vs-detail"></a>
|
||||
## Dérive silencieuse DTO liste vs DTO détail
|
||||
|
||||
### Risques
|
||||
|
||||
- Un DTO "détail" expose un ensemble complet de champs métier, pendant qu'un DTO "liste" ne propage qu'un sous-ensemble jugé "suffisant" au moment où il est créé
|
||||
- Au fil du temps, le front a besoin de plus de champs et découvre que les DTOs de liste sont **amputés** — workarounds ad-hoc, champs morts produits jamais consommés, helpers partagés impossibles à appeler sur les listes sans cast
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Un consommateur front appelle l'endpoint détail juste pour obtenir un champ qui existe côté détail mais pas liste (N+1 réseau déguisé)
|
||||
- Workarounds ad-hoc (`soireeClosedAt: Date | null` dans un mapper TenueSummary, copie partielle de champs) parce que le champ racine manque
|
||||
- Helper partagé (`getSoireeLifecycle(input)`) qui accepte un `SoireeLifecycleInput` qu'**aucun** DTO de liste n'implémente réellement
|
||||
- Type "sous-ensemble" (`SoireeCalendarStatus = 'draft' | 'pending_vm_approval' | 'published'`) aligné sur un filtre SQL transitoire plutôt que sur la sémantique du domaine
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- **Règle par défaut** : DTO liste = sous-ensemble de DTO détail, pas un type parallèle. Extraire une base commune si besoin (`SoireeCore`).
|
||||
- Pour chaque champ scalaire ajouté au DTO détail, se poser la question : doit-il aussi être dans les DTOs de liste ? Si oui, le propager sur-le-champ
|
||||
- **Typage fort sur les sous-ensembles** : `SoireeCalendarStatus = SoireeStatus` (alias) plutôt qu'une union locale qui reflète un filtre SQL
|
||||
- Test de coverage statique qui vérifie, pour chaque DTO ciblé, que tous ses mappers exposent les champs requis
|
||||
- Audit périodique après une livraison qui ajoute des champs (ex : `openedAt` persistant) : lister les DTOs de liste et vérifier qu'aucun n'est amputé
|
||||
|
||||
- Contexte technique : DTO / contrats partagés — RL799_V2 23-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-notif-linkurl-non-role-aware"></a>
|
||||
## Notification `linkUrl` non rôle-aware → page vide / 403 silencieux
|
||||
|
||||
### Risques
|
||||
|
||||
- Une notification envoyée à N destinataires multi-rôles avec un `linkUrl` constant route certains utilisateurs vers une page à laquelle ils n'ont pas accès
|
||||
- Symptôme côté membre : "la notif m'envoie sur une page vide" — UX cassée sans message d'erreur explicite
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Code de création de notif qui fait `recipients.map((r) => ({ linkUrl: 'constant' }))` sans lire `r.role`
|
||||
- Notif qui cible plusieurs rôles (ex : "tous les membres") mais utilise un linkUrl pointant vers un module à accès restreint
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
// Toujours sélectionner role dans le select des recipients
|
||||
const recipients = await prisma.user.findMany({
|
||||
where: { isActive: true, role: { in: [...ROLES_ALL_ACTIVE] } },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
|
||||
// Brancher le linkUrl par rôle
|
||||
const secretariatRoles = new Set(['secretaire', 'venerable', 'admin']);
|
||||
linkUrl: secretariatRoles.has(recipient.role)
|
||||
? `/secretariat?soireeId=${id}`
|
||||
: `/tenues?tab=calendrier`;
|
||||
```
|
||||
|
||||
**Règle d'or** : le `linkUrl` d'une notif doit ouvrir une page **que l'utilisateur a le droit de voir ET où le contexte de la notif est visible**. Un membre qui reçoit "Soirée annulée" doit atterrir sur le calendrier (carte rouge), pas sur un module secrétariat qu'il ne peut pas consulter.
|
||||
|
||||
**Test E2E suggéré** : publier une notif multi-rôles, se connecter avec chaque rôle, cliquer, vérifier que chacun arrive sur une page accessible et pertinente.
|
||||
|
||||
- Contexte technique : notifications / RBAC — RL799_V2 23-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-matrice-documentee-vs-code"></a>
|
||||
## Matrice documentée ≠ code — dérive silencieuse
|
||||
|
||||
### Risques
|
||||
|
||||
- Une matrice de permissions / contrats publiée dans une story (markdown) diverge discrètement de l'implémentation
|
||||
- La doc dit "X peut Y", le code refuse Y à X (ou inversement). Aucun test ne couvre la combinaison rare
|
||||
- La divergence se paye au prochain audit RBAC ou au touchement suivant du module — souvent par surprise
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Story d'origine qui annonce une perm que le code ne grant pas (ou inversement)
|
||||
- Un nouvel agent lit la story et la matrice, pense que la perm est active, et écrit du code qui repose dessus → faux positif aval
|
||||
- Bug détecté plusieurs cycles après publication, par hasard
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
1. **Audit pré-flight systématique** avant tout PATCH d'un module RBAC : `grep -rn '<helper-perm>' apps/` pour confirmer les call sites, comparer avec la matrice de la story d'origine
|
||||
2. **Réconciliation atomique** : si on touche un helper de permission, mettre à jour les **deux couches** (granulaire `permissions.ts` + fonctionnelle `documentPermissions.ts`) dans la même PR
|
||||
3. **Test de matrice dédié** : un test unitaire qui itère la matrice de la story et vérifie chaque cellule. Casse à la première dérive
|
||||
4. Préférer **un seul source of truth** (le code) et générer la doc automatiquement (markdown depuis tests, ou inverse)
|
||||
|
||||
- Contexte technique : RBAC / documentation — RL799_V2 20-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-format-user-id-mixte"></a>
|
||||
## Format `User.id` : UUID OU slug, jamais les deux
|
||||
|
||||
### Risques
|
||||
|
||||
- Un schéma où `User.id` est un `String` libre finit par mélanger deux formats : IDs lisibles du seed (`admin`, `membre-m05`) et vrais UUIDs générés à l'invitation
|
||||
- Conséquence : impossible de mettre `z.string().uuid()` dans les DTOs qui prennent un `userId` sans casser la prod
|
||||
- Surface d'injection grande (payloads de 100 caractères acceptés au lieu de UUID stricts)
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Schéma Zod avec `z.string().min(1).max(128)` là où on voudrait `z.string().uuid()`
|
||||
- Commentaire "l'ID n'est pas forcément un UUID, on accepte toute chaîne"
|
||||
- Deux populations d'ids coexistantes en base (seed slug + invitations UUID)
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Décider tôt : soit `@default(uuid())` côté Prisma partout, soit IDs structurés documentés avec une regex stricte (`^[a-z]+-[a-z0-9]+$`) publiée dans un helper shared (`isValidUserId`)
|
||||
- **Ne jamais mélanger**
|
||||
- Ajouter un test d'invariant : à la fin du seed, assert que tous les `users.id` matchent le format choisi
|
||||
- Si migration vers UUID 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`, et tout `@relation` vers `User`)
|
||||
- Pattern de migration : UUID v5 déterministe via `seedUserId(slug)` (cf. `pattern-uuid-v5-deterministe-seed` dans `patterns/prisma.md`)
|
||||
|
||||
- Contexte technique : Prisma / Zod — RL799_V2 22-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-web-push-topic-32-chars"></a>
|
||||
## Web Push `topic` header > 32 chars rejeté/tronqué (RFC 8030)
|
||||
|
||||
### Risques
|
||||
|
||||
- La [RFC 8030 §5.4](https://datatracker.ietf.org/doc/html/rfc8030#section-5.4) limite le header `Topic` à 32 caractères URL-safe
|
||||
- FCM tronque silencieusement (topics distincts pour deux notifs censées dédupliquer), Apple Push rejette la requête, Mozilla autopush comportement variable
|
||||
- Symptôme : déduplication absente → avalanche de notifs au reconnect d'un device offline
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Push provider qui retourne 4xx sur des `topic` longs
|
||||
- Plusieurs notifs reçues là où une seule devrait l'être
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const hashTopic = (seed: string): string =>
|
||||
crypto.createHash('sha256').update(seed).digest('base64url').slice(0, 32);
|
||||
|
||||
await webpush.sendNotification(sub, body, {
|
||||
TTL: 86_400,
|
||||
urgency: 'high',
|
||||
topic: hashTopic(`${type}-${contextId}`), // toujours ≤ 32 chars URL-safe
|
||||
});
|
||||
```
|
||||
|
||||
**Notes** :
|
||||
- `base64url` (Node `crypto` natif depuis 16.x) produit un encoding URL-safe (`A-Za-z0-9_-`)
|
||||
- Tronquer à 32 chars **après** encoding base64url, pas avant le hash
|
||||
- Test unitaire : assert `topic.length <= 32` ET `topic.match(/^[A-Za-z0-9_-]+$/)` pour toutes les seeds réalistes
|
||||
|
||||
- Contexte technique : Web Push / RFC 8030 — RL799_V2 28-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-lib-npm-types-non-embarques"></a>
|
||||
## Lib npm avec types annoncés mais non embarqués
|
||||
|
||||
### Risques
|
||||
|
||||
- Certaines libs Node prétendent embarquer leurs types TS depuis une version donnée mais le package npm publié ne les contient pas
|
||||
- `@types/<lib>` DefinitelyTyped existe mais peut être legacy, non maintenu, ou en conflit avec les exports réels du package
|
||||
|
||||
### Symptômes
|
||||
|
||||
- `Could not find a declaration file for module '<lib>'. … Try \`npm i --save-dev @types/<lib>\``
|
||||
- TS7016 après `pnpm add <lib>` alors que la doc annonce que les types sont embarqués
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
Créer une déclaration TS locale minimaliste qui couvre uniquement la surface consommée par le projet :
|
||||
|
||||
```typescript
|
||||
// apps/api/src/types/web-push.d.ts (exemple)
|
||||
declare module 'web-push' {
|
||||
export interface PushSubscriptionLike {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
}
|
||||
export interface RequestOptions {
|
||||
TTL?: number;
|
||||
urgency?: 'very-low' | 'low' | 'normal' | 'high';
|
||||
topic?: string;
|
||||
}
|
||||
export function setVapidDetails(subject: string, publicKey: string, privateKey: string): void;
|
||||
export function sendNotification(
|
||||
sub: PushSubscriptionLike,
|
||||
payload?: string | Buffer | null,
|
||||
options?: RequestOptions,
|
||||
): Promise<{ statusCode: number; body: string; headers: Record<string, string> }>;
|
||||
const _default: { setVapidDetails: typeof setVapidDetails; sendNotification: typeof sendNotification };
|
||||
export default _default;
|
||||
}
|
||||
```
|
||||
|
||||
**Bénéfices** :
|
||||
- on est maître du contrat utilisé (si la lib évolue, on étend volontairement)
|
||||
- pas de dépendance `@types/*` legacy
|
||||
- documentable : commentaire JSDoc en tête `Pourquoi pas @types/<lib>`
|
||||
|
||||
**Préventif** :
|
||||
- `tsconfig.json` doit `include` le dossier `src/types/**/*.d.ts`
|
||||
- documenter en commentaire en tête du `.d.ts` POURQUOI on a écrit ça soi-même
|
||||
|
||||
- Contexte technique : TypeScript / npm — RL799_V2 28-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-form-html-post-mail"></a>
|
||||
## Form HTML POST dans un mail = neutralisé par tous les clients
|
||||
|
||||
### Risques
|
||||
|
||||
- Un `<form method="POST" action="...">` placé dans le corps HTML d'un mail transactionnel est **neutralisé par tous les clients mail majeurs** — c'est une mesure anti-phishing universelle, pas un bug
|
||||
- Toute donnée structurée doit transiter par **l'URL** d'un GET (query string ou path), donc visible côté visiteur
|
||||
|
||||
### Symptômes
|
||||
|
||||
| Client | Comportement réel sur `<form method="POST">` |
|
||||
| --- | --- |
|
||||
| Gmail web | Rewrite l'action en GET, body en query string |
|
||||
| Gmail iOS/Android | Bouton inactif ou ouvre en GET |
|
||||
| Outlook web | Strip le `<form>` complètement |
|
||||
| Apple Mail (macOS/iOS) | Désactive le submit, bouton no-op |
|
||||
| Thunderbird | Bloqué par sécurité |
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
Mitigations pour ne pas exposer la donnée dans l'URL navigable :
|
||||
|
||||
1. **Pattern token signé court** (HMAC ou JWT) : encode la donnée dans un token opaque dans la query string, échangé immédiatement côté client contre un état serveur, puis `history.replaceState()` pour nettoyer l'URL (cf. `pattern-magic-link-url-clean` dans `patterns/auth.md`)
|
||||
2. **Token one-shot DB** : génère un token aléatoire stocké en DB, consommé à la 1ʳᵉ requête, expire ensuite
|
||||
3. **Cookie de session courte** : le 1ᵉʳ hit set un cookie httpOnly puis redirige vers une URL clean
|
||||
|
||||
À documenter dans toute spec de magic link / RSVP / one-shot URL pour éviter qu'un dev parte sur un POST mail.
|
||||
|
||||
- Contexte technique : email transactionnel — RL799_V2 30-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-env-vars-frontend-facing-fail-fast"></a>
|
||||
## env vars frontend-facing — fail-fast strict hors dev (pas de fallback `localhost`)
|
||||
|
||||
### Risques
|
||||
|
||||
- Un mail prod qui contient un lien `http://localhost:3000/foo` parce que `APP_URL` n'a pas été défini sur l'instance prod
|
||||
- Aucun signal serveur, aucune erreur au déploiement, aucune trace en logs. L'utilisateur final clique → page introuvable
|
||||
- Le fallback dev-friendly (`process.env.APP_URL ?? 'http://localhost:3000'`) cache l'erreur de config en non-dev
|
||||
|
||||
### Symptômes
|
||||
|
||||
- URL `localhost` dans des emails reçus par des utilisateurs réels
|
||||
- Détection uniquement par un humain qui reçoit le mail, pas par le serveur
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
export const getBaseUrl = (): string => {
|
||||
const raw = process.env.APP_URL;
|
||||
if (raw !== undefined && raw !== '') return raw.replace(/\/+$/, '');
|
||||
if (process.env.NODE_ENV === 'development') return 'http://localhost:3000';
|
||||
throw new Error('APP_URL non configuré (requis hors dev). Le bouton ... pointerait vers un host invalide.');
|
||||
};
|
||||
```
|
||||
|
||||
- Dev local : fallback silencieux (workflow attendu)
|
||||
- Prod / staging / test : throw au premier appel → erreur visible dans les logs du dispatch
|
||||
- Le throw au boot du dispatch est préférable à un mail dégradé silencieux
|
||||
|
||||
**Variantes à étendre** : tout helper qui construit une URL frontend depuis le backend (reset password, invitation, convocation, notification mail) doit utiliser le même helper centralisé. Une seule source de vérité par projet — éviter le doublon `APP_URL` + `APP_BASE_URL`.
|
||||
|
||||
**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
|
||||
|
||||
@@ -164,3 +164,28 @@ return buildLocalizedPath(locale, "home");
|
||||
- **Signal review** : logique dupliquée dans `middleware.ts` avec un commentaire "Edge incompatible"
|
||||
|
||||
- Contexte technique : Next.js / middleware — RL799_V2 08-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-app-router-private-folders"></a>
|
||||
## App Router — dossiers `_*` exclus silencieusement du routing
|
||||
|
||||
### Risques
|
||||
|
||||
- Tout segment d'URL préfixé par `_` (ex : `_e2e`, `_helpers`, `__internal`) est traité par Next.js App Router comme un *private folder* et **exclu silencieusement du routing**
|
||||
- Le `route.ts` ou `page.tsx` existe sur le filesystem, le typecheck passe, mais l'URL retourne 404
|
||||
- Aucune erreur au boot, aucun warning
|
||||
|
||||
### Symptômes
|
||||
|
||||
- "Ma route existe pourtant je vois le fichier" — `apps/api/src/app/api/__e2e/visitor-token/route.ts` qui retourne 404
|
||||
- Temps de debug perdu à chercher une cause obscure
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Ne **jamais** préfixer un segment de route par `_` ou `__` même pour signaler une intention "interne / e2e / debug"
|
||||
- Utiliser des noms explicites : `/api/e2e/`, `/api/internal/`, `/api/dev/` (sans underscore initial)
|
||||
- Pour gater l'accès en prod : check `process.env` au début du handler (`if (process.env.E2E !== '1') return 404`)
|
||||
- Référence : Next.js docs — Project Structure → Private folders. Convention héritée de l'écosystème React/Webpack pour exclure les dossiers de la résolution
|
||||
|
||||
- Contexte technique : Next.js App Router — RL799_V2 30-04-2026
|
||||
|
||||
@@ -441,3 +441,181 @@ Checklist minimale après `prisma migrate resolve --applied` :
|
||||
- Ajouter des tests ciblés sur payload partiel et concurrence logique.
|
||||
|
||||
- Contexte technique : Prisma / partition logique — RL799_V2 09-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-capture-pre-updatemany-race-window"></a>
|
||||
## Capture pré-`updateMany` sans transaction — race window silencieuse
|
||||
|
||||
### Risques
|
||||
|
||||
- `findUnique` + `updateMany` non atomiques : entre les deux, un autre process peut modifier le champ capturé. L'audit log ment (enregistre une `previousValue` qui n'était plus la valeur courante au moment de l'écriture). Notif envoyée au mauvais target.
|
||||
- Si l'`updateMany` ne filtre que sur `status` sans inclure la valeur attendue, il peut écraser une nouvelle valeur sans erreur
|
||||
|
||||
### Symptômes
|
||||
|
||||
```typescript
|
||||
// ❌ Race window entre les deux requêtes
|
||||
const before = await prisma.entity.findUnique({ where: { id }, select: { x: true } });
|
||||
// … un autre process modifie entity.x ici …
|
||||
const after = await prisma.entity.updateMany({
|
||||
where: { id, status: 'X' },
|
||||
data: { status: 'Y', x: null },
|
||||
});
|
||||
audit.log({ previousX: before.x }); // ← MENT
|
||||
notify(before.x); // ← mauvais target
|
||||
```
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
**Solution 1 — `SELECT ... FOR UPDATE` dans une transaction** (cf. `pattern-revocation-atomique-etat-transversal` dans `patterns/prisma.md`) :
|
||||
|
||||
```typescript
|
||||
let previousValue: T | null = null;
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const locked = await tx.$queryRaw<Array<{ x: T }>>`
|
||||
SELECT x FROM "entities" WHERE id = ${id} FOR UPDATE
|
||||
`;
|
||||
if (locked.length === 0) return;
|
||||
previousValue = locked[0].x;
|
||||
await tx.entity.updateMany({ where: { id, ... }, data: { ... } });
|
||||
});
|
||||
```
|
||||
|
||||
**Solution 2 — `WHERE` qui inclut la valeur attendue** (CAS-light, sans transaction) :
|
||||
|
||||
```typescript
|
||||
const updated = await prisma.entity.updateMany({
|
||||
where: { id, status: 'X', x: expectedX }, // ← guard sur la valeur
|
||||
data: { ... },
|
||||
});
|
||||
if (updated.count === 0) {
|
||||
// soit déjà transitionné, soit x a changé — relire et décider
|
||||
}
|
||||
```
|
||||
|
||||
### Détecteur mental
|
||||
|
||||
Si tu écris :
|
||||
|
||||
```typescript
|
||||
const before = await prisma.X.findUnique(...);
|
||||
await prisma.X.updateMany(...);
|
||||
// … tu utilises before.<champ> dans l'audit ou la notif
|
||||
```
|
||||
|
||||
→ **Stop**. Tu as une race. Soit `before.<champ>` n'a pas changé entre les deux (et alors pourquoi le capturer ?), soit il a pu changer (et tu mens).
|
||||
|
||||
- Contexte technique : Prisma / concurrence — RL799_V2 27-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-slugs-metier-user-id"></a>
|
||||
## Slugs métier comme `User.id` — schémas Zod laxistes obligés
|
||||
|
||||
### Risques
|
||||
|
||||
- Un `User.id` en `String` libre (slug lisible côté seed, UUID `@default(uuid())` côté invitations) empêche toute rigidification Zod `.uuid()` sur les champs `userId` côté API
|
||||
- Couplage tests/seed invisible : des dizaines de tests hardcodent `'membre-m05'` côté input, sans contrat explicite. Tout renommage du seed casse la suite en cascade sans warning compilateur
|
||||
- Drift silencieux : deux populations d'ids coexistent en base, validation impossible à uniformiser
|
||||
|
||||
### Symptômes
|
||||
|
||||
- `prisma.user.create({ data: { id: '<texte-lisible>', ... } })` dans un fichier de seed
|
||||
- Schéma Zod avec `z.string().min(1).max(128)` là où on voudrait `z.string().uuid()`
|
||||
- Test qui référence `userId: 'membre-m05'` en argument d'une requête API
|
||||
- Commentaire "l'ID n'est pas forcément un UUID, on accepte toute chaîne" → dette déguisée
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Décider tôt : soit `@default(uuid())` côté Prisma partout, soit IDs structurés documentés avec une regex stricte (`^[a-z]+-[a-z0-9]+$`) publiée dans un helper shared (`isValidUserId`)
|
||||
- **Ne jamais mélanger** : si le seed utilise des slugs et les comptes produits utilisent des UUIDs, les schémas Zod sont condamnés à être laxistes
|
||||
- Migration : utiliser un UUID v5 déterministe (`seedUserId(slug)`) — cf. `pattern-uuid-v5-deterministe-seed` dans `patterns/prisma.md`
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-prisma-where-relation-every-vide"></a>
|
||||
## `where: { relation: { every: ... } }` trivialement vrai sur relation vide
|
||||
|
||||
### Risques
|
||||
|
||||
- La clause `every` sur une relation est **trivialement vraie** quand la relation est vide. Sans coupler avec `some: {}`, on capture aussi les rows qui n'ont aucune entrée liée — risque de purge à tort sur les nouvelles entités
|
||||
|
||||
### Symptômes
|
||||
|
||||
```typescript
|
||||
// ❌ Faux : capture aussi les profiles SANS aucune VR liée
|
||||
const orphans = await prisma.visitorProfile.findMany({
|
||||
where: {
|
||||
lastSeenAt: { lt: cutoff },
|
||||
registrations: { every: { status: 'rejected' } }, // vacuously true si pas de VR
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- Test "purge orphelins après 30 j" qui supprime un profile fraîchement créé
|
||||
- Tests qui passent sur des fixtures avec relations existantes mais cassent dès qu'une entité sans relation est créée
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
// ✅ Correct : exige au moins une VR liée ET toutes rejected
|
||||
where: {
|
||||
lastSeenAt: { lt: cutoff },
|
||||
registrations: {
|
||||
some: {}, // au moins une VR existe
|
||||
every: { status: 'rejected' }, // toutes rejected
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
**Règle générale** : à chaque fois qu'on cherche « toutes les X de Y sont Z », vérifier si Y peut avoir 0 X. Si oui, ajouter `some: {}` pour exclure le cas vide.
|
||||
|
||||
- Contexte technique : Prisma — RL799_V2 01-05-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-resolvedburl-template-based-testing"></a>
|
||||
## `resolveDbUrl()` testing template-based — préserver le DSN explicite vers la template
|
||||
|
||||
### Risques
|
||||
|
||||
- Un helper `resolveDbUrl()` qui force `pathname='/<projet>_test'` quand `NODE_ENV=test` écrase un DSN appelant qui pointe explicitement vers `<projet>_test_template`
|
||||
- Le bootstrap template (`runPrisma(['db', 'seed'])` en sub-process avec `DB_URL=...test_template + NODE_ENV=test`) écrit dans la mauvaise DB ou échoue avec "database does not exist"
|
||||
|
||||
### Symptômes
|
||||
|
||||
- `bootstrapTemplate échec "pnpm prisma db seed" (exit 1)`
|
||||
- Tests vitest échouent ensuite avec `Database <projet>_test does not exist on the database server`
|
||||
- Sub-process de seed qui logge un DSN différent de celui passé en `env`
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
const resolveDbUrl = (): string | undefined => {
|
||||
const url = process.env.DB_URL;
|
||||
if (!url) return url;
|
||||
if (process.env.NODE_ENV !== 'test') return url;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
// Exception : préserver le DSN si déjà sur la template
|
||||
// (cas bootstrap migrate/seed, sinon le seed pointe sur <projet>_test inexistante)
|
||||
if (parsed.pathname === '/<projet>_test_template') {
|
||||
return url;
|
||||
}
|
||||
parsed.pathname = '/<projet>_test';
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
123
knowledge/backend/risques/tests.md
Normal file
123
knowledge/backend/risques/tests.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Backend — Risques & vigilance : Tests
|
||||
domain: backend
|
||||
bucket: risques
|
||||
tags: [tests, vitest, isolation, env-vars, flakiness]
|
||||
applies_to: [analysis, implementation, review, debug]
|
||||
severity: high
|
||||
validated_on: 2026-05-02
|
||||
source_projects: [RL799_V2]
|
||||
---
|
||||
|
||||
# Backend — Risques & vigilance : Tests
|
||||
|
||||
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/risques/README.md` pour l'index complet.
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-vi-stubenv-sans-restauration"></a>
|
||||
## `vi.stubEnv` sans restauration — fuite env vars inter-fichiers
|
||||
|
||||
### Risques
|
||||
|
||||
- Un test qui stub une env var dans `beforeAll` sans `vi.unstubAllEnvs()` en `afterAll` affecte silencieusement tous les fichiers exécutés après lui dans le même process
|
||||
- En séquentiel (`maxWorkers: 1`), l'ordre est déterministe et la fuite est invisible — la suite passe au vert
|
||||
- En passant à `maxWorkers > 1`, les env vars stubbées sont partagées entre workers → tests imprévisibles
|
||||
|
||||
### Symptômes
|
||||
|
||||
- Tests qui passent en isolation mais échouent dans la suite complète, ou inversement
|
||||
- Comportement d'un endpoint qui dépend d'une env définie dans un fichier qui n'a rien à voir
|
||||
- Migration de `maxWorkers: 1` vers `maxWorkers: 4` qui rouge la suite d'un coup
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
```typescript
|
||||
beforeAll(() => {
|
||||
vi.stubEnv('RESEND_API_KEY', 'test-key');
|
||||
});
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
// Variante encore plus robuste (isolation parfaite par test) :
|
||||
beforeEach(() => { vi.stubEnv('X', 'y'); });
|
||||
afterEach(() => { vi.unstubAllEnvs(); });
|
||||
```
|
||||
|
||||
- Détection : `rg "vi\.stubEnv\(" __tests__ | wc -l` doit être ≤ `rg "vi\.unstubAllEnvs\(\)" __tests__ | wc -l` regroupé par fichier
|
||||
- Avant toute migration vers `maxWorkers > 1` : sweep complet `stubEnv` / `unstubAllEnvs`
|
||||
|
||||
- Contexte technique : Vitest — RL799_V2 24-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-maxworkers-1-masque-isolation"></a>
|
||||
## `maxWorkers: 1` masque les problèmes d'isolation
|
||||
|
||||
### Risques
|
||||
|
||||
- L'exécution séquentielle cache systématiquement tous les bugs d'isolation : `vi.stubEnv` non restaurée, mutations de seed non restaurées, `deleteMany` direct, compteurs globaux non resets
|
||||
- La CI actuelle ne peut pas détecter ces problèmes → faux sentiment de sécurité
|
||||
- Le jour où on veut paralléliser pour gagner du temps, on découvre une dizaine de bugs d'isolation simultanément
|
||||
|
||||
### Symptômes
|
||||
|
||||
- CI verte en `maxWorkers: 1`, rouge dès `maxWorkers > 1`
|
||||
- Tests "verts depuis 6 mois" qui rougent soudainement après un changement de config
|
||||
- Pas de détection possible avant le passage en parallèle
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
- Tester la parallélisation tôt, même si la suite est petite — passer `maxWorkers: 2` force l'équipe à écrire des tests isolés
|
||||
- Si on hérite d'un projet en `maxWorkers: 1`, ne pas migrer d'un coup. Audit ciblé d'abord :
|
||||
- `grep "vi\.stubEnv\(" / "vi\.unstubAllEnvs\(" / "deleteMany\(" / "TEST_USER\."` pour repérer les patterns suspects
|
||||
- Ajouter un audit "hidden_by_serial_execution" en review de tests : lister les patterns qui marcheraient aujourd'hui mais casseraient en parallèle
|
||||
- Heuristique : projet > 200 tests → impératif de passer en parallèle (sinon CI > 15 min = friction dev majeure)
|
||||
|
||||
- Contexte technique : Vitest — RL799_V2 24-04-2026
|
||||
|
||||
---
|
||||
|
||||
<a id="risque-flakiness-inter-fichiers-db-partagee"></a>
|
||||
## Flakiness inter-fichiers vitest avec DB partagée
|
||||
|
||||
### Risques
|
||||
|
||||
- Un fichier de tests laisse des artefacts résiduels en DB que le fichier suivant ne s'attend pas à voir : audits orphelins, notifications, entries seed mutées, rate-limiters non resets
|
||||
- Le pattern se "résout" au 2e run par chance (le `beforeEach` finit par nettoyer par effet de bord), donnant une fausse confiance
|
||||
- En CI, un retry automatique masque la vraie cause
|
||||
|
||||
### Symptômes
|
||||
|
||||
- 2-4 tests rouges au 1er run, vert au 2e run sans aucune modification
|
||||
- `vitest run <fichier-isolé>` vert, suite complète rouge
|
||||
- Compteurs `count({ type: 'X' })` qui tombent sur des résidus d'anciens tests
|
||||
|
||||
### Bonnes pratiques / mitigations
|
||||
|
||||
**Diagnostic** :
|
||||
```bash
|
||||
# 1. Run isolé sur le fichier suspect
|
||||
pnpm -C apps/api test mon-fichier
|
||||
# 2. 2 runs consécutifs de la suite complète
|
||||
pnpm -C apps/api test && pnpm -C apps/api test
|
||||
# Si 1er rouge / 2e vert → flakiness inter-fichiers
|
||||
```
|
||||
|
||||
**Stratégies par horizon** :
|
||||
- **Court terme** : accepter comme dette connue si le 2e run est stable, documenter dans le commit message
|
||||
- **Moyen terme** : identifier le fichier qui pollue, ajouter le cleanup manquant dans son `afterEach`
|
||||
- **Long terme** : DB-per-worker ou `transactions + rollback` (chantier d'infra dédié, voir `knowledge/backend/patterns/tests.md` pattern template database)
|
||||
|
||||
**À ne PAS faire** :
|
||||
- Ajouter des `setTimeout` pour "attendre que ça se stabilise"
|
||||
- Wrapper les assertions dans des try/catch silencieux
|
||||
- Marquer les tests `.skip`
|
||||
|
||||
**Heuristique gravité** :
|
||||
- 1 fail intermittent toutes les 5 runs : acceptable temporairement
|
||||
- 1+ fail systématique au 1er run, vert au 2e : à diagnostiquer mais pas urgent
|
||||
- Fails aléatoires différents à chaque run : urgent (state corruption)
|
||||
|
||||
- Contexte technique : Vitest / Prisma — RL799_V2 25-04-2026
|
||||
Reference in New Issue
Block a user