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:
MaksTinyWorkshop
2026-05-02 22:12:44 +02:00
parent 02ad0de258
commit b3417ad77b
31 changed files with 5370 additions and 12 deletions

View File

@@ -15,5 +15,6 @@ Avant toute proposition backend, identifie le fichier dont le nom et la descript
| `nestjs.md` | NestJS, guards, Redis, quotas | Guard global APP_GUARD, RedisHealthService cache court, quota INCR+EXPIREAT atomique |
| `multi-tenant.md` | Multi-tenant, isolation, feature flags | 403 vs 404, repository tenant-aware, tenantId dans updates, helper tenant partagé, feature flag tenant, EN enforcement |
| `nextjs.md` | Next.js App Router, Server Actions, isolation | Runtime-only logique pure, server-only isolation, utilitaires purs sans server-only, réutiliser champ V1, validation URL externe |
| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents |
| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable |
| `async.md` | Jobs async, webhooks sortants, queues | Exécution asynchrone outbox light, webhooks sortants HMAC + retries idempotents, hooks fire-and-forget après création DB, fanout notification avec filtre grade, auto-purge fenêtre temporelle SQL |
| `general.md` | Architecture générale, helpers, RBAC | Helper auth centralisé enrichissable, ordre canonique des gates HTTP, délégation agrégat → endpoint agrégé, anti-énumération DELETE 204, lazy init memoizé, cap LRU par-user, convention dot-notation audit, whitelist explicite audit, singleton DB config, invalidation cache avant mutation, pipeline CI/CD GitHub Actions → VPS |
| `tests.md` | Tests d'intégration DB, isolation, atomicité | `cleanup.track()` LIFO, `globalSetup` purge, template database Postgres, helper `waitForX()` polling-borné, test d'atomicité transaction, convention `describe()` 2 niveaux, refactor itératif d'un fichier monolithe |

View File

@@ -77,3 +77,177 @@
- Dead-letter ou statut FAILED visible
- Idempotence documentée
- Logs corrélés (requestId/traceId)
---
<a id="pattern-hooks-fire-and-forget-creation-db"></a>
## Pattern : Hooks fire-and-forget après création DB critique
- Objectif : déclencher des hooks secondaires (mail accusé réception, notification, invalidation cache) après une création DB sans bloquer la réponse HTTP au client.
- Contexte : endpoint POST qui crée une ressource en DB et déclenche en cascade des hooks impliquant des appels réseau (Resend, FCM, Redis cache).
- Quand l'utiliser : hooks **rapides** (< 1-2 s) qui peuvent vivre dans le même process que la requête HTTP.
- Quand l'éviter : tâches lourdes (génération PDF, batch envoi sur 100 destinataires) — utiliser un vrai job queue (BullMQ, pg-boss).
- Avantage :
- la 201 part dès la création DB (l'AC critique de la route)
- chaque hook logge ses propres échecs sans bloquer le caller
- `Promise.allSettled` détaché → robustesse même si un hook futur ajoute un comportement async
- Limites / vigilance :
- dans Next.js 15+, préférer `after()` (cf. `knowledge/backend/patterns/nextjs.md`) qui garantit l'exécution post-réponse même en serverless
- `Promise.all` reject au premier échec — `allSettled` attend toutes les promesses
- tests : poll DB borné (`waitForX`) plutôt que `setTimeout(50)` (cf. `knowledge/backend/patterns/tests.md`)
- Validé le : 30-04-2026
- Contexte technique : Node.js — RL799_V2
### Implémentation
```typescript
// ✅ La 201 part dès la création DB ; les hooks tournent en parallèle
const created = await prisma.registration.create({ data });
// Promise.allSettled détaché : ne reject jamais, on capture quand même
// au cas où le service de log lui-même bug
void Promise.allSettled([
sendAcknowledgmentMail(data.email),
notifyObservers(created.id),
invalidateCache(`stats:${data.scope}`),
]).catch((err) => {
logger.error({ type: 'hooks', event: 'unexpected_error', err: String(err) });
});
return jsonResponse(201, { data: created });
```
### Règles d'utilisation
1. **L'AC critique doit être atteint avant** : la création DB doit réussir (await) — c'est le seul résultat que le client attend.
2. **Chaque hook doit logger ses propres échecs** : le service mail doit avoir son propre `logger.error` sur status=failed. Le `.catch()` du `Promise.allSettled` est un filet, pas le canal d'audit primaire.
3. **`Promise.allSettled` (pas `Promise.all`)** : robuste si un hook futur ajoute un comportement asynchrone derrière.
4. **Côté tests** : helper `waitForX` polling-borné plutôt que `setTimeout(N)` arbitraire.
---
<a id="pattern-fanout-notification-grade-plancher"></a>
## Pattern : Notification fanout fire-and-forget avec filtre grade plancher
- Objectif : notifier N destinataires éligibles (filtrage par grade plancher) après une mutation, sans bloquer la réponse HTTP et sans rollback de la création principale si la notif échoue.
- Contexte : action métier qui crée une ressource + doit notifier les membres dont le grade ≥ grade plancher de la ressource (`SOIREE_CANCELLED` à tous les membres, `COMMUNICATION_PUBLISHED` aux membres de grade ≥ X, etc.).
- Quand l'utiliser : fanout multi-rôles avec filtrage métier sur le profil destinataire.
- Quand l'éviter : si la notif est critique (la ressource ne doit pas exister sans notif) — utiliser une transaction.
- Avantage :
- seuil monotone `gradeRank(member) >= gradeRank(resource)` aligné sur les filtres `list*` consommateurs
- exclusion du créateur via `id: { not: userId }` pour éviter de se notifier soi-même
- log explicite sur `catch` du fire-and-forget — pas de perte silencieuse
- Limites / vigilance :
- pas de transaction avec la création principale : best-effort, dégradation acceptable
- le `linkUrl` doit être rôle-aware (cf. `knowledge/backend/risques/general.md` risque-notif-linkurl-non-role-aware)
- Validé le : 23-04-2026
- Contexte technique : Prisma — RL799_V2
### Implémentation
```typescript
const createResourceNotifications = async (input: {
resourceId: string;
grade: string; // plancher (seuil monotone)
excludeUserId?: string;
}): Promise<void> => {
const thresholdRank = gradeRank(input.grade);
const recipients = await prisma.user.findMany({
where: {
isActive: true,
role: { in: [...ROLES_ALL_ACTIVE] },
id: input.excludeUserId ? { not: input.excludeUserId } : undefined,
profile: { is: {} },
},
select: {
id: true,
role: true, // pour linkUrl rôle-aware si multi-rôles
profile: { select: { grade: true } },
},
});
const eligibleIds = recipients
.filter((r) => {
const g = r.profile?.grade;
if (!g) return false;
return gradeRank(g) >= thresholdRank;
})
.map((r) => r.id);
if (eligibleIds.length === 0) return;
await prisma.notification.createMany({
data: eligibleIds.map((recipientId) => ({
type: NotificationType.RESOURCE_CREATED,
recipientId,
// …
linkUrl: ..., // rôle-aware si nécessaire
})),
});
};
// Côté handler
try {
const resource = await createResource({ ... });
logAction(userId, 'resource:create', ...);
// Fire-and-forget
void createResourceNotifications({
resourceId: resource.id,
...minimumDataForNotif,
}).catch((err) => {
console.error('[resource:create] notification fanout failed:', err);
});
return jsonResponse(201, { data: serialize(resource) });
} catch {
return errorResponse(500, ...);
}
```
### Pourquoi un seuil monotone
`gradeRank(member) >= gradeRank(resource)` = "à partir du grade X", aligné sur les filtres `list*` consommateurs. Évite les sélections non-contiguës (A+M sans C) qui sont pénibles à représenter.
---
<a id="pattern-auto-purge-fenetre-temporelle-sql"></a>
## Pattern : Auto-purge côté vue via fenêtre temporelle SQL
- Objectif : faire porter la rétention courte par le filtre de lecture plutôt que par un cron de purge réelle, quand une donnée a deux publics avec des besoins de rétention différents.
- Contexte : donnée consultée à long terme côté admin/historique mais utile uniquement sur fenêtre courte côté consommateur final (membre lambda).
- Quand l'utiliser : 2 publics, rétention courte côté consommateur, rétention longue côté admin, volumétrie raisonnable.
- Quand l'éviter :
- volumétrie très élevée (millions de rows) — finir par un vrai archivage si le volume explose
- RGPD / obligations légales de suppression — il faut **vraiment** supprimer la donnée, pas la masquer
- données avec coût de stockage significatif (PDF, blobs, logs verbeux) — purge réelle + archivage externe
- Avantage :
- pas de cron à écrire, déployer, monitorer
- zéro risque de purge destructive : la donnée reste en DB
- rétention courte est **déclarative** (paramètre de query), pas cachée dans un job planifié
- l'admin conserve l'accès complet via un autre endpoint
- Limites / vigilance :
- index sur `createdAt` indispensable dès que la table grossit
- Validé le : 23-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```typescript
export const listRecentXxxForMember = async (
...filters,
sinceDays = 30,
) => {
const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000);
return prisma.xxx.findMany({
where: {
...filters,
createdAt: { gte: since },
},
orderBy: { createdAt: 'desc' },
});
};
```
L'admin garde un endpoint distinct sans le filtre temporel pour l'accès historique complet.

View File

@@ -342,3 +342,338 @@ Le helper `requireRoleAccess` doit retourner :
- Contexte technique : auth / audit — RL799_V2
---
<a id="pattern-consume-single-use-pas-session-implicite"></a>
## Pattern : Consume single-use = pas de session implicite, force re-login
- Objectif : éviter qu'un endpoint qui consume un magic link (invitation, reset password) émette une session implicite — défense en profondeur contre l'interception d'email.
- Contexte : flows magic-link qui aboutissent à un `consume` (set du nouveau mot de passe ou activation du compte).
- Quand l'utiliser : tous les flows single-use qui touchent au cycle d'authentification.
- Quand l'éviter : magic-link "passwordless" pur (sans étape de saisie de mot de passe) où le link EST le facteur d'auth.
- Avantage :
- un attaquant qui intercepte le mail obtient un consume, pas une session
- cohérence cross-flow : un seul pattern post-consume, surface d'audit réduite
- factorisation page consume facilitée (les modes diffèrent par wording, pas par flow)
- Limites / vigilance :
- friction perçue d'un re-login → minime, l'utilisateur vient de saisir son mot de passe
- Validé le : 28-04-2026
- Contexte technique : auth / magic-link — RL799_V2
### Règle
Réponse minimale post-consume : `{ data: { ok: true, email } }`. Pas de cookie JWT, pas d'access token. Le frontend redirige vers `/login?email=…` avec bouton "Se connecter" bien placé sur la page de succès.
### Anti-pattern
- "Auto-login pour invitation, re-login pour reset" sous prétexte UX — divergence asymétrique = anti-pattern de sécurité
- Cookie JWT émis avant que l'utilisateur ait validé qu'il connaît son nouveau mot de passe
---
<a id="pattern-magic-link-consume-sans-fusion-table"></a>
## Pattern : Magic-link consume — mécanique partagée sans fusion table
- Objectif : factoriser la mécanique sécu d'un consume single-use (hash SHA-256, transaction atomique, révocation refresh tokens) sans fusionner les modèles DB des deux domaines.
- Contexte : projet avec deux flows partageant la même mécanique (invitation magic-link + reset password) mais des objets métier différents (invitation = audit riche, reset = purement technique).
- Quand l'utiliser : factorisation au niveau **service**, pas au niveau table.
- Quand l'éviter : un seul flow magic-link dans le projet — pas de factorisation prématurée.
- Avantage :
- audit historique riche pour les invitations (statut traçable)
- transaction atomique partagée (rotation/consume)
- rate limiter dédié par endpoint sans pollution cross-domaine
- Limites / vigilance :
- duplication contrôlée des modèles DB (acceptable — chaque domaine a son cycle de vie)
- Validé le : 28-04-2026
- Contexte technique : auth / magic-link / Prisma — RL799_V2
### Règles d'or
1. **Tokens stockés en hash SHA-256 uniquement** (`tokenHash @unique`). Le raw token n'est transmis que dans l'URL email, jamais persisté. Helper `hashXxxToken(raw: string): string` réutilisable côté service ET tests.
2. **PK technique `id String @id @default(uuid())`**, pas le token comme PK. Évite le couplage PK/sécurité, permet la rotation d'un token sans créer une nouvelle row.
3. **Transaction atomique consume** :
- Lock implicite via `findUnique({ where: { tokenHash }, include: { user } })`
- Check `status === 'active'`, `consumedAt === null`, `revokedAt === null`, `expiresAt > now`, `user.isActive === true`
- UPDATE token `status='consumed', consumedAt=now()`
- UPDATE user (password si applicable)
- UPDATE refresh_tokens `revokedAt=now()` (force re-login propre)
- UPDATE autres tokens actifs `status='revoked'` (anti-réutilisation)
- INSERT audit dans la transaction (atomicité stricte)
4. **Retour discriminé** :
```typescript
export type ConsumeResult =
| { ok: true; userId: string; email: string }
| { ok: false; reason: 'NOT_FOUND' | 'ALREADY_USED' | 'EXPIRED' | 'REVOKED' | 'USER_INACTIVE' };
```
Le frontend ne connaît qu'un code générique côté UX (`INVALID_X_TOKEN`) pour ne pas exposer d'oracle.
5. **Rate limiter dédié par endpoint** :
- `validate` : 30 req / 15 min / IP (oracle d'énumération)
- `consume` : 10 req / 15 min / IP (modifie le mot de passe)
- `resend` (admin) : 20 req / heure / admin
6. **Helpers transverses** : `revokeAndIssueXxx(input)` en transaction unique (couplé à un index unique partiel `WHERE status='active'`), `revokeXxxForUser(userId)` (appelé par `setUserActive(false)`), `getLatestXxxByUserIds(userIds[])` batch (anti N+1).
---
<a id="pattern-sentinelle-non-hashable-user-invite"></a>
## Pattern : Sentinelle non-hashable pour user en attente de mot de passe
- Objectif : éviter à la fois un `password: String?` nullable qui casse les chemins login/test/audit qui font tous des `select: { password }`, et un appel scrypt inutile (~100 ms par user invité).
- Contexte : user créé via invitation qui n'a pas encore défini son mot de passe.
- Quand l'utiliser : tout flow d'invitation où le user existe en base avant le set du password.
- Quand l'éviter : projet où le user n'est créé qu'au consume du magic link (pas de placeholder nécessaire).
- Avantage :
- le champ `password` reste `NOT NULL` en DB → aucun chemin code ne casse
- `verifyPassword` détecte le préfixe en early-return → 0 ms scrypt
- garantie cryptographique (pas conventionnelle) : le préfixe `!` est absent d'une sortie hex
- Limites / vigilance :
- la sentinelle est lisible en clair par un admin DB — acceptable car c'est un placeholder identifié, pas un secret
- Validé le : 28-04-2026
- Contexte technique : auth / scrypt — RL799_V2
### Implémentation
```typescript
// services/.../inviteService.ts
const buildInvitedPlaceholderPassword = (): string =>
`!INVITED_PENDING_${crypto.randomUUID()}`;
await prisma.user.create({
data: { email, password: buildInvitedPlaceholderPassword(), ... },
});
// lib/passwords.ts
export const verifyPassword = (password: string, stored: StoredPassword): boolean => {
if (typeof stored !== 'string' || typeof password !== 'string') return false;
if (stored.startsWith('!')) return false; // placeholder réservé, jamais matchable
// …logique scrypt habituelle
};
```
### Checklist
- [ ] Préfixe `!` (caractère **garanti** absent de l'alphabet hex `0-9a-f`)
- [ ] UUID embarqué pour l'unicité par user (utile pour debug audit, pas un secret)
- [ ] Test : `verifyPassword('anything', '!INVITED_PENDING_xxx') === false`
- [ ] AC dédié : `verifyPassword` rejette en 0 ms observable (pas d'appel scrypt)
---
<a id="pattern-ttl-court-bouton-resend"></a>
## Pattern : TTL court + bouton resend admin > TTL longue
- Objectif : minimiser la fenêtre d'exposition d'un token sensible sans dégrader l'UX dans les cas marginaux (user en vacances).
- Contexte : tokens d'authentification sensibles (invitation, reset password) où la tentation est d'allonger la TTL pour couvrir les cas de longue absence.
- Quand l'utiliser : tout token sensible avec un canal admin disponible pour relancer.
- Quand l'éviter : tokens où le resend est impossible (signed URLs publiques sans admin).
- Avantage :
- surface d'attaque réduite (token intercepté n'est utilisable que pendant la TTL courte)
- granularité opérationnelle : l'admin trace dans l'audit qui demande un resend
- invariant "1 active par user" reste applicable (le resend révoque l'ancien et émet un nouveau)
- Limites / vigilance :
- rate limiter sur le bouton resend (20/h/admin) pour éviter le spam involontaire
- pas d'extension d'`expiresAt` au resend — émettre un nouveau token, pas patcher l'ancien
- Validé le : 28-04-2026
- Contexte technique : auth / magic-link — RL799_V2
### Règles d'or
- TTL en constante explicite côté service (`7 * 24 * 60 * 60 * 1000`), pas en magic number éparpillé
- Fenêtres recommandées : invitation ≤ 7 jours, reset password ≤ 24 h
- Audit `xxx.resend` sur le bouton admin + audit `xxx.revoke` (cohérence avec resend qui révoque l'ancien)
- AC dédié : `expiresAt - createdAt ≈ N * 24h ± 1s` (tolérance fixture)
---
<a id="pattern-hook-setuseractive-revoke-side-tokens"></a>
## Pattern : Hook `setUserActive(false)` → revoke side-tokens
- Objectif : éviter qu'un user désactivé puisse continuer à consommer un magic link reçu juste avant la désactivation et reprendre la main.
- Contexte : opération admin de désactivation user dans un projet avec tokens secondaires actifs (refresh tokens, invitations, futurs reset password tokens).
- Quand l'utiliser : tout endpoint qui transitionne `user.isActive` de `true` à `false`.
- Quand l'éviter : si la désactivation est purement métier (suspension UI) sans portée auth.
- Avantage :
- défense en profondeur (le checker du consume vérifie aussi `user.isActive` — ceinture + bretelles)
- les tokens orphelins ne survivent pas à la désactivation
- Limites / vigilance :
- best-effort tracé : les revokes ne doivent pas bloquer la désactivation (l'admin attend un retour immédiat)
- Validé le : 28-04-2026
- Contexte technique : auth / lifecycle user — RL799_V2
### Implémentation
```typescript
if (!body.isActive) {
try {
await revokeAllRefreshTokensForUser(userId);
} catch (err) {
console.error('[admin.users] refresh token revoke failed', err);
refreshTokenWarning = '...';
}
try {
await revokeInvitationsForUser(userId);
} catch (err) {
console.error('[admin.users] invitation revoke failed', err);
}
// À ajouter quand pertinent : reset password tokens, OAuth states, etc.
}
```
### Règles d'or
- **Best-effort tracé**, pas silencieux : `try { ... } catch { console.error(...) }`
- Warning UX en cas d'échec partiel : la réponse JSON peut contenir un champ optionnel `warning` que le frontend affiche
- **Double protection consume** : le checker du consume vérifie également `user.isActive === true` — même si le revoke échoue, l'user désactivé ne peut pas consommer
- AC dédié : "user actif avec invitation pending → admin désactive → consume échoue avec INVALID_TOKEN, pas de session créée"
---
<a id="pattern-magic-link-url-clean"></a>
## Pattern : Magic link "URL clean" — token signé HMAC + `history.replaceState`
- Objectif : ouvrir une page web qui pré-charge un contexte serveur (soireeId, eventId, inviteId) sans que l'identifiant ne reste visible dans l'URL navigable.
- Contexte : mail (convocation, invitation, RSVP) avec un bouton qui mène vers une landing page PWA. On veut éviter forward, capture, indexation involontaire.
- Quand l'utiliser : magic links publics vers une page qui pré-charge un contexte sensible.
- Quand l'éviter : magic links d'authentification membre — utiliser les patterns auth classiques (refresh + access httpOnly).
- Avantage :
- URL visible côté utilisateur ne contient pas l'identifiant
- HMAC garantit l'intégrité (custom claim `purpose` pour rejeter un token signé pour un autre usage)
- `sessionStorage` plutôt que `localStorage` → contexte meurt avec l'onglet
- Limites / vigilance :
- `algorithms: ['HS256']` imposé côté `jwt.verify` pour bloquer les "alg: none" attacks
- sécurité dépendante de l'isolation cryptographique du secret (cf. pattern dérivation HMAC)
- Validé le : 30-04-2026
- Contexte technique : Node.js crypto / Vue / Vite PWA — RL799_V2
### Architecture
```
Mail HTML
↓ Bouton (GET https://app/visit?t=<token-signé>)
Landing PWA /visit
↓ JS lit le token, POST /api/.../redeem { token }
↓ Backend valide HMAC + extrait contextId, retourne metadata
↓ JS stocke contextId en sessionStorage
↓ history.replaceState(null, '', '/inscription')
↓ router.replace() pour synchroniser le router SPA
Page d'inscription (URL clean)
```
### Backend — signature
```typescript
import jwt from 'jsonwebtoken';
export function signAccessToken(contextId: string, expiresAt: Date): string {
const expSeconds = Math.floor(expiresAt.getTime() / 1000);
return jwt.sign(
{ sub: contextId, purpose: 'rsvp-v1', exp: expSeconds },
getDerivedSecret(),
{ algorithm: 'HS256' },
);
}
export function redeemAccessToken(token: string):
| { ok: true; contextId: string }
| { ok: false; reason: 'expired' | 'invalid' } {
try {
const decoded = jwt.verify(token, getDerivedSecret(), {
algorithms: ['HS256'],
}) as { sub?: string; purpose?: string };
if (decoded.purpose !== 'rsvp-v1') return { ok: false, reason: 'invalid' };
if (typeof decoded.sub !== 'string') return { ok: false, reason: 'invalid' };
return { ok: true, contextId: decoded.sub };
} catch (err) {
if (err instanceof jwt.TokenExpiredError) return { ok: false, reason: 'expired' };
return { ok: false, reason: 'invalid' };
}
}
```
### Frontend — landing minimaliste
```typescript
onMounted(async () => {
const token = route.query.t as string;
if (!token) return showError('Lien incomplet');
const result = await redeemToken(token);
saveSessionContext(result); // sessionStorage
if (typeof window !== 'undefined' && window.history?.replaceState) {
window.history.replaceState(null, '', '/inscription');
}
await router.replace({ name: 'inscription' });
});
```
### Choix d'expiration
- Magic link auth : court (15 min 24 h), token consommable une fois
- RSVP événement : long (jusqu'à la date)
- Invitation one-shot : moyen (7-30 jours), invalidable à l'usage
---
<a id="pattern-isolation-cryptographique-hmac"></a>
## Pattern : Isolation cryptographique — secret dérivé via HMAC
- Objectif : signer plusieurs types de tokens isolés cryptographiquement sans ajouter une nouvelle env var par usage.
- Contexte : projet avec un `JWT_SECRET` racine (auth membre) qui doit signer un autre type de token (magic link, RSVP, webhook) sans cross-domain attack possible.
- Quand l'utiliser : besoin d'un secret de signature isolé sans nouvelle clé à provisionner et à tourner.
- Quand l'éviter : si le projet supporte déjà un système de gestion de clés multiples (KMS, Vault) — utiliser le mécanisme natif.
- Avantage :
- une seule env var racine à provisionner et tourner
- HMAC est one-way : un attaquant qui obtient le secret dérivé ne peut pas remonter au racine
- reproductible : `(JWT_SECRET, purpose)` → même secret, pas d'état à persister
- versionnable via le purpose (`-v1`, `-v2`) : rotation possible en bumpant le purpose
- Limites / vigilance :
- n'est PAS un substitut à la rotation de clés : si `JWT_SECRET` est compromis, tous les secrets dérivés le sont aussi
- HKDF est plus rigoureux pour la dérivation formelle ; pour un simple isolement d'usage, `HMAC(secret, purpose)` suffit
- Validé le : 30-04-2026
- Contexte technique : Node.js crypto — RL799_V2
### Implémentation
```typescript
import { createHmac } from 'node:crypto';
const TOKEN_PURPOSE = 'magic-link-v1';
let cachedSecret: Buffer | null = null;
function getDerivedSecret(): Buffer {
if (cachedSecret) return cachedSecret;
const root = process.env.JWT_SECRET;
if (!root) throw new Error('JWT_SECRET requis');
cachedSecret = createHmac('sha256', root).update(TOKEN_PURPOSE).digest();
return cachedSecret;
}
```
### Test d'isolation
```typescript
test('rejette un token signé avec un autre purpose', async () => {
const tokenAsMember = jwt.sign(
{ sub: 'x' },
process.env.JWT_SECRET!,
{ algorithm: 'HS256', expiresIn: '15m' },
);
const result = redeemToken(tokenAsMember);
expect(result.ok).toBe(false);
});
```
### Applications
- Tokens magic link / RSVP / invitation
- Signature de webhooks sortants
- Tokens d'unsubscribe email (lien direct sans login)
- Tokens d'accès aux ressources publiques limitées dans le temps
---

View File

@@ -187,3 +187,268 @@ Quand une fonction crypto travaille en base64 pour la sérialisation, prévoir u
### Signal review
- `buffer.toString('base64')` suivi immédiatement de `decrypt(base64String)` qui fait `Buffer.from(str, 'base64')` → round-trip inutile
---
<a id="pattern-zod-strict-mutations"></a>
## Pattern : Zod `.strict()` systématique sur les schémas de mutation
- Objectif : bloquer la pollution de champs internes via PATCH/POST/PUT en rejetant tout champ supplémentaire non listé dans le schéma.
- Contexte : tout schéma Zod qui valide un payload de mutation côté API.
- Quand l'utiliser : systématiquement sur tous les schémas de mutation.
- Quand l'éviter : schémas de réponse (où l'API est l'émetteur) ou schémas d'enrichissement intentionnel.
- Avantage :
- première ligne de défense contre la pollution de payload (`uploadedBy`, `createdAt`, `isAdmin` injectés par un client malveillant)
- rejet à 400 avant d'atteindre Prisma → pas de risque de spread accidentel dans `data: parsed.data`
- Limites / vigilance :
- ne dispense pas de la deuxième ligne de défense : ne JAMAIS spread `parsed.data` directement dans `prisma.update`, construire `data` au champ près
- Validé le : 20-04-2026
- Contexte technique : TypeScript / Zod — RL799_V2
### Implémentation
```typescript
export const updateXxxSchema = z.object({
name: z.string().min(1).optional(),
status: z.enum(['active', 'inactive']).optional(),
}).strict();
```
### Combiné avec le repo
```typescript
const data: Partial<UpdateXxxData> = {};
if (parsed.data.name !== undefined) data.name = parsed.data.name;
if (parsed.data.status !== undefined) data.status = parsed.data.status;
// …jamais `data: parsed.data` brut
```
### Test à ajouter
```typescript
test('PATCH .strict() rejette les champs hors-whitelist', async () => {
const r = await PATCH(makeReq({ name: 'OK', uploadedBy: 'attacker' }));
expect(r.status).toBe(400);
});
```
---
<a id="pattern-rigidification-zod-2-phases"></a>
## Pattern : Rigidification Zod en 2 phases (données d'abord, schémas ensuite)
- Objectif : rigidifier un schéma Zod artificiellement laxiste sans casser la suite de tests en cascade.
- Contexte : schéma qui accepte une forme large (`z.string().min(1).max(128)`) pour compenser une donnée hétérogène en base (slugs + UUIDs cohabitent), avant d'avoir uniformisé la donnée.
- Quand l'utiliser : tout chantier de rigidification (`.uuid()`, `.email()`, `.enum()`) sur un champ dont la base contient encore l'ancien format.
- Quand l'éviter : si la donnée est déjà uniforme — rigidifier directement.
- Avantage :
- diagnostic séparé : si le commit 2 casse un test, on sait que c'est la rigidification, pas la migration
- rollback granulaire : on peut rollback la rigidification sans reperdre la migration
- revue plus lisible : un reviewer valide indépendamment "migration correcte" puis "rigidification sûre"
- Limites / vigilance :
- tentation de tout faire d'un coup → écarter
- Validé le : 24-04-2026
- Contexte technique : Zod / Prisma — RL799_V2
### Séquence obligatoire (2 commits séparés)
**Phase 1 — Normalisation des données** :
- Migrer la base (seed, fixtures, lignes legacy via `prisma migrate`)
- Adapter tous les consommateurs qui référencent l'ancien format (tests, helpers E2E, scripts admin)
- Le schéma Zod reste laxiste à ce stade — il accepte les deux formats pendant la transition
- Ajouter un test d'invariant qui valide que la base ne contient plus que le format cible
- Commit : `feat(<domaine>): migration <X> + adaptation tests`
**Phase 2 — Rigidification du schéma** :
- Remplacer `z.string()` par `z.uuid()` / `z.email()` / `z.enum()` sur les champs concernés
- Adapter les quelques tests qui reposaient sur l'ancienne sémantique laxiste
- Vérifier par grep final qu'aucun autre schéma n'a le même pattern laxiste oublié
- Commit : `feat(<domaine>): rigidification Zod sur <X>`
### Signaux de dérive
- Schéma avec un commentaire "accepte toute chaîne pour compatibilité avec X" → dette à rigidifier dès que X est migré
- `.min(1).max(128)` sur un champ conceptuellement UUID/email/enum → forme laxiste en attente de rigidification
---
<a id="pattern-enum-canonique-sous-ensembles-nommes"></a>
## Pattern : Enum canonique + sous-ensembles nommés (vs flags par usage)
- Objectif : factoriser les règles métier sur une enum partagée par plusieurs domaines fonctionnels sans alourdir l'enum elle-même de flags.
- Contexte : enum (rôles, statuts, types) qui sert plusieurs usages avec des règles différentes (annuaire, pointage rituel, mandats administratifs).
- Quand l'utiliser : dès qu'un même `enum.filter(r => …)` apparaît à plusieurs endroits avec une règle métier explicite.
- Quand l'éviter : si le filtre n'apparaît qu'une fois — laisser inline, l'extraction est prématurée.
- Avantage :
- chaque sous-ensemble a un nom métier explicite — le lecteur comprend sans chercher
- les règles sont localisées au point de définition, pas éparpillées en flags
- ajouter un usage = ajouter un sous-ensemble, pas modifier la structure de l'enum
- Limites / vigilance :
- les sous-ensembles doivent être typés `readonly Role[]` pour bénéficier du narrowing
- propagation côté front ET côté Zod backend (defense-in-depth)
- Validé le : 21-04-2026
- Contexte technique : TypeScript / Zod — RL799_V2
### Anti-pattern
```typescript
// ❌ Flag par usage, multipliable, illisible
export const OFFICER_ROLES = [
{ code: 'venerable', label: '...', isRitual: true, isAdmin: true },
{ code: 'archiviste', label: '...', isRitual: false, isAdmin: true },
// … 12 rôles × 3-4 flags
];
```
### Pattern correct
```typescript
export const OFFICER_ROLES = [
'venerable', 'premier-surveillant', /* … */ 'archiviste',
] as const;
type OfficerRole = (typeof OFFICER_ROLES)[number];
/** Officiers avec fonction rituelle pendant la tenue (pointage). */
export const RITUAL_OFFICER_ROLES: readonly OfficerRole[] =
OFFICER_ROLES.filter((role) => role !== 'archiviste');
/** Officiers éligibles à un mandat administratif. */
export const MANDATABLE_OFFICER_ROLES = OFFICER_ROLES;
```
### Propagation Zod backend
```typescript
// Le sous-ensemble est utilisé côté front ET côté Zod
export const tenueOfficerAssignmentSchema = z.object({
role: z.enum(RITUAL_OFFICER_ROLES as readonly [OfficerRole, ...OfficerRole[]]),
});
// → POST avec role: 'archiviste' = 400, sans duplication de la règle
```
---
<a id="pattern-constantes-variant-fige-selecteur-strict"></a>
## Pattern : Constantes par variant figé + sélecteur enum strict
- Objectif : figer dans le code des règles ou textes versionnés via Git tout en sélectionnant l'implémentation à l'exécution via un champ DB (tenant, pays, juridiction).
- Contexte : règles métier figées (CGV par juridiction, formats de facture par pays, libellés réglementaires par régulateur) qui doivent rester typées strictement et versionnées via Git, mais sélectionnées au runtime.
- Quand l'utiliser : préparation multi-variant **avant** d'avoir réellement plusieurs implémentations, OU cas où on veut des diffs visibles dans la PR à chaque modification (texte à autorité).
- Quand l'éviter : règles métier admin-éditables runtime — ces données appartiennent à la DB, pas au code.
- Avantage :
- une seule source de vérité par variant, typée strictement
- étendre l'union à `'A' | 'B'` propage automatiquement la nouvelle option (Zod, UI, tests)
- diff visible dans la PR à chaque modification — review éclate sur un mot changé
- Limites / vigilance :
- throw explicite dans le sélecteur (pas de fallback silencieux) — un drift DB doit échouer fort
- pour du texte à autorité, préférer `expect(X).toBe(...)` à `toMatchSnapshot` — diff visible vs snapshot file rarement lu
- Validé le : 28-04-2026
- Contexte technique : TypeScript / Zod — RL799_V2
### Structure type
```
packages/shared/src/<domain>/
types.ts ← SupportedXCode union fermée + SUPPORTED_X_CODES tuple runtime
<variantA>.ts ← Constantes du variant A (typées <Constants>)
index.ts ← getXConstants(code) + isSupportedXCode + UnsupportedXError
```
### Source de vérité unique pour le code
```typescript
// types.ts
export type SupportedRiteCode = 'REAA';
export const SUPPORTED_RITE_CODES = ['REAA'] as const
satisfies readonly SupportedRiteCode[];
```
`SUPPORTED_RITE_CODES` est consommé partout :
- `z.enum([...SUPPORTED_RITE_CODES] as [...])` côté validation
- `<select v-for="code in SUPPORTED_RITE_CODES">` côté UI
- `switch` exhaustif dans `getXConstants`
### Sélecteur avec narrowing runtime
```typescript
export class UnsupportedRiteError extends Error { /* … */ }
export const isSupportedRiteCode = (v: string): v is SupportedRiteCode =>
(SUPPORTED_RITE_CODES as readonly string[]).includes(v);
export const getRitualConstants = (code: SupportedRiteCode): RitualConstants => {
switch (code) {
case 'REAA': return REAA_RITUAL;
}
throw new UnsupportedRiteError(code); // garde-fou DB drift
};
```
Côté service backend, `assertSupportedX(record.code)` AVANT d'exposer dans le DTO public — protège contre une row DB qui aurait drift.
### Tests : assertions explicites pour texte à autorité
```typescript
expect(X.formule).toBe('chaîne exacte'); // diff visible en review
// Avec glyphes Unicode à risque de swap (ex : ' U+0027 vs U+2019)
expect([...X.formule].map(c => c.codePointAt(0)!)).toEqual([0x41, 0x2234, /* … */]);
expect(X.formule).not.toMatch(/'/); // anti-régression typographique
```
### Anti-patterns
- Stocker le texte figé en DB "pour pouvoir l'éditer plus tard" — si le texte est versionné, il appartient au code
- Hardcoder le code variant dans la validation UI (`if (code === 'REAA')`) — toujours dériver de `SUPPORTED_X_CODES` runtime
- Fallback silencieux dans le sélecteur (`switch (code) { default: return DEFAULT }`) — throw explicite
---
<a id="pattern-regex-critique-partagee-anti-divergence"></a>
## Pattern : Regex critique partagée serveur ↔ client (anti-divergence)
- Objectif : éviter qu'une règle de validation critique (regex anti open-redirect, format de slug) ne dérive entre serveur (Zod) et client (composant, store, Service Worker).
- Contexte : règle de sécurité ou d'intégrité qui doit s'appliquer identiquement des deux côtés.
- Quand l'utiliser : règle où une divergence côté un seul des deux mène à un trou (anti open-redirect, anti SQL injection visible client-side, format de path/URL).
- Quand l'éviter : règle UX uniquement (pattern d'email pour autocomplétion live).
- Avantage :
- une seule source de vérité — `packages/shared` ou équivalent
- dérive impossible (ou détectée au build TS) si l'import partagé est possible
- Limites / vigilance :
- si le client ne peut PAS importer le package partagé (cas Service Worker en mode `injectManifest`), DUPLIQUER avec un commentaire `⚠️ DOIT correspondre à <chemin>` + un test croisé qui vérifie l'alignement string-wise
- Validé le : 28-04-2026
- Contexte technique : monorepo TypeScript — RL799_V2
### Implémentation (cas idéal — import partagé)
```typescript
// packages/shared/src/dto/push.ts (source de vérité)
/**
* Regex unique anti open-redirect : démarre par '/' simple (pas '//'),
* caractères alphanum + '/_-?&=%.', pas de ':' (bloque 'javascript:').
*/
export const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
export const pushPayloadSchema = z.object({
linkUrl: z.string().regex(INTERNAL_PATH_REGEX).optional(),
});
```
### Cas duplication contrôlée (SW mode `injectManifest`)
```typescript
// apps/frontend/src/sw-helpers.ts
/**
* ⚠️ DOIT correspondre à INTERNAL_PATH_REGEX de packages/shared/src/dto/push.ts.
* Le SW (mode injectManifest) ne peut pas importer le package partagé directement.
* Test croisé : apps/frontend/src/__tests__/regex-alignment.test.ts
*/
const INTERNAL_PATH_REGEX = /^\/(?!\/)[a-zA-Z0-9/_\-?&=%.]*$/;
```
### Checklist
- [ ] Une seule source de vérité, idéalement dans `packages/shared`
- [ ] Si duplication forcée : commentaire `⚠️ DOIT correspondre à <chemin>` des deux côtés
- [ ] Test croisé qui assert l'alignement string-wise des deux regex
- [ ] JSDoc qui rappelle que c'est un contrat de cohérence (revue obligatoire si modif)

View File

@@ -59,3 +59,541 @@ Préférer étendre le service avec un nouveau type (enum/Set) et ajuster les re
### Signal review
- Nouveau service qui réplique le CRUD d'un service existant avec un filtre additionnel → candidat à la fusion par type
---
<a id="pattern-build-packages-workspace-amont-tests-ci"></a>
## Pattern : Builder les packages workspace en amont des tests CI (monorepo pnpm)
- Objectif : garantir qu'un package workspace compilé (TypeScript → `dist/`) est construit avant que les apps consommatrices lancent leurs tests dans le CI.
- Contexte : monorepo pnpm où `apps/*` consomme `packages/<lib>` via `"workspace:*"`, et où le `package.json` du package pointe sur un build artifact (`"main": "dist/index.js"`, `"types": "dist/index.d.ts"`).
- Quand l'utiliser : dans tout pipeline CI/CD qui lance des tests sur les apps consommatrices d'un package compilé.
- Quand l'éviter : si tous les packages workspace sont consommés en source directement (TS sans build, ex. via `tsx`, `vite-node`, `@swc-node`).
- Validé le : 30-04-2026
- Contexte technique : pnpm monorepo / GitHub Actions / Node 22 / TypeScript — RL799_V2
### Règle
Ajouter une étape `Build workspace packages` entre `pnpm install` et le premier `pnpm run test:*` du pipeline. Préférer un build complet du workspace plutôt qu'un build ciblé : `pnpm -r --filter '<scope>/*' build` (ou `pnpm run build` si un script racine équivalent existe). `pnpm -r` respecte le graphe de dépendances et bâtit les libs avant les apps.
### Anti-pattern à éviter
Ne pas placer le `build` du package partagé **dans** la commande de tests de ce package (`"test:shared": "pnpm -C packages/shared build && pnpm -C packages/shared test"`). Si le script `test:shared` tourne après `test:api` dans la séquence CI, le build arrive trop tard pour les tests qui consomment `dist/`. Garder le build comme étape CI explicite et amont, et garder `test:<package>` minimaliste.
### Signal review
- Pipeline CI qui enchaîne `pnpm install` puis directement `pnpm run test:api` (ou équivalent) sans étape de build intermédiaire, alors que le package workspace consommé pointe sur un `dist/` compilé.
- Bug type : `Cannot find module '<scope>/<package>'` ou `Cannot find module '<scope>/<package>/dist/...'` au démarrage des tests CI, alors que les tests passent en local.
### Pourquoi ce bug est silencieux en local
En local, `dist/` existe presque toujours (build précédent) — donc les tests passent. Le pipeline CI part d'un environnement vierge et casse. Test de fumée local avant un push CI sensible : `rm -rf packages/<lib>/dist && pnpm run build && pnpm run test:<app>`.
---
<a id="pattern-ordre-canonique-gates-http"></a>
## Pattern : Ordre canonique des gates dans un handler HTTP
- Objectif : éviter les fuites d'information via les codes HTTP — un user non authentifié ne doit jamais distinguer "ressource n'existe pas" de "ressource existe mais non autorisée".
- Contexte : tout handler HTTP qui combine validation de payload, authentification, autorisation, vérification d'existence de ressource et gates métier.
- Quand l'utiliser : systématique sur tout handler HTTP exposé.
- Quand l'éviter : jamais.
- Avantage :
- la sémantique HTTP reste cohérente entre endpoints
- aucune énumération possible via les codes 4xx
- Limites / vigilance :
- test "non auth + payload problématique → 401, pas 400" obligatoire pour verrouiller l'ordre
- Validé le : 27-04-2026
- Contexte technique : Next.js / NestJS / API HTTP — RL799_V2
### Ordre canonique (du plus permissif au plus restrictif)
1. **Parsing du body** (400 VALIDATION_ERROR si malformé)
2. **Validation du schéma** (400 VALIDATION_ERROR si payload invalide)
3. **Auth** (401 si non authentifié)
4. **Autz** (403 si rôle insuffisant)
5. **Existence ressource** (404 si l'id n'existe pas)
6. **Gates métier** (400/403 si règle business violée)
7. **Mutation**
### Anti-pattern
```typescript
// ❌ Gate métier AVANT auth — leak l'existence de la route + de la règle
if (payload.contains_locked_field) return 400 LOCKED;
const auth = requireAuth(...); // jamais atteint si payload contient le champ
// ✅ Auth AVANT gate métier
const auth = requireAuth(...);
if (auth instanceof Response) return auth;
if (payload.contains_locked_field) return 400 LOCKED;
```
### Test associé
Ajouter systématiquement un test `non auth + payload problématique → 401, pas 400`. Sans ce test, la régression passe.
---
<a id="pattern-delegation-endpoint-agrege"></a>
## Pattern : Délégation au niveau d'un agrégat → endpoint agrégé serveur
- Objectif : éviter les cascades de 403 silencieux côté client quand un user "délégué" doit accéder à des entités liées hors de l'agrégat parent.
- Contexte : user avec un rôle "délégué" rattaché à un agrégat parent (`Soiree.secretaireDeSeanceId`), qui doit pouvoir lire/écrire sur des entités liées au-delà de l'agrégat (tenues précédentes du même grade).
- Quand l'utiliser : la délégation ouvre l'accès à plusieurs entités liées qui sont rendues ensemble dans une vue unique.
- Quand l'éviter : si le frontend a vraiment besoin des entités séparément (rare).
- Avantage :
- une seule réponse hydrate toute la vue → pas de cascade de 403
- guard centrale au niveau du parent réutilisée en lecture ET en écriture
- source de vérité unique de la "fenêtre légitime" (un repo helper)
- Limites / vigilance :
- les codes d'erreur restent standards (`403 FORBIDDEN`), pas de codes ad hoc `FORBIDDEN_OUT_OF_DELEGATION_SCOPE`
- Validé le : 27-04-2026
- Contexte technique : Next.js / API HTTP — RL799_V2
### Le pattern
1. **Endpoint agrégé côté serveur** : `GET /api/<parent>/[id]/<vue-agrégée>` qui hydrate en une seule réponse toutes les entités liées dont la vue a besoin.
2. **Guard centrale au niveau du parent** : `requireAccessForParent(request, parentId, { roleSet })` retourne `{ userId, role, viaDelegation, delegatedParentId? }` ou `Response 403/404`.
3. **Si la guard sur l'entité enfant doit aussi reconnaître la délégation** (cas d'écriture) : étendre avec un slow-path qui appelle la résolution serveur — *« cet enfant fait-il partie de la fenêtre légitime ouverte par la délégation ? »*. Pas de re-vérification dispersée dans chaque service.
4. **Single source of truth de la "fenêtre légitime"** : la même fonction de résolution est utilisée par l'endpoint agrégé (lecture) ET par la guard d'écriture.
### Ce qu'on évite
- Cascade de N requêtes côté client → autant de chances de 403/erreurs silencieuses
- Logique métier dupliquée dans la guard et dans le service de lecture (dérive garantie)
---
<a id="pattern-anti-enumeration-delete-204"></a>
## Pattern : Anti-énumération sur DELETE — 204 systématique
- Objectif : empêcher un user authentifié d'énumérer les ressources d'autres users via les codes HTTP du DELETE (204 vs 404 fuite l'existence).
- Contexte : endpoint DELETE qui révoque/supprime une ressource identifiée par un identifiant connu uniquement de son propriétaire (push endpoint, refresh token, magic link, OAuth state).
- Quand l'utiliser : la ressource est identifiée par un secret/identifiant non énumérable.
- Quand l'éviter : ressource identifiée par un ID séquentiel public — fixer d'abord l'autorisation.
- Avantage :
- aucune information ne fuit (inconnue / à un autre / déjà révoquée → indistinguables)
- simplifie le code : pas de branchement 404 vs 204
- idempotence naturelle (rejouer un DELETE n'a aucun effet observable)
- Limites / vigilance :
- garder une validation de format en amont (400 si body malformé) — c'est le format qui ne fuit rien, pas l'existence
- le filtre SQL DOIT inclure `userId = currentUser` — sinon on révoque la ressource d'un autre user
- Validé le : 28-04-2026
- Contexte technique : API HTTP / Prisma — RL799_V2
### Implémentation
```typescript
export const handleDelete = async (request: Request): Promise<Response> => {
const auth = requireAuthenticatedUser(request, ROLES);
if ('status' in auth) return auth;
const parsed = bodySchema.safeParse(await request.json());
if (!parsed.success) {
return errorResponse(400, 'VALIDATION_ERROR', 'Body invalide');
}
try {
await revokeByOwner({ userId: auth.userId, ...parsed.data });
} catch {
/* logger côté serveur, retourner 204 quand même */
}
// 204 systématique : inconnu / autre user / déjà révoqué → indistinguables
return new Response(null, { status: 204 });
};
// Repository
export const revokeByOwner = async (input: {
userId: string;
endpoint: string;
}): Promise<void> => {
await prisma.subscription.updateMany({
where: {
userId: input.userId, // filtre propriétaire OBLIGATOIRE
endpoint: input.endpoint,
revokedAt: null, // idempotent
},
data: { revokedAt: new Date() },
});
};
```
### Anti-patterns
- `findUnique` + `if (!sub) return 404` : fuit l'existence
- `findUnique` + check `userId === auth.userId` + 403 : fuit l'existence (la 403 vs 404 vs 204 distingue)
- `prisma.delete({ where: { endpoint } })` sans filtre `userId` : un user peut révoquer la sub d'un autre
### Tests minimaux
DELETE inconnu / DELETE autre user / DELETE déjà révoqué → tous 204, état inchangé pour les autres users.
---
<a id="pattern-lazy-init-memoize-libs-config-globale"></a>
## Pattern : Lazy init memoizé pour libs avec config globale
- Objectif : initialiser une lib qui exige une config globale (clés API, credentials) seulement au premier appel utile pour permettre feature flag, tests isolés et démarrage gracieux sans env complet.
- Contexte : libs externes type `web-push.setVapidDetails`, `Stripe(secret)`, `S3Client({ region })`, `Resend(apiKey)`.
- Quand l'utiliser : lib avec init globale + feature flag possible + tests qui doivent reset l'état.
- Quand l'éviter : lib qui réclame son init au boot (ex : connexion persistante TCP), libs trivialement instanciables par appel.
- Avantage :
- le service ne crash pas au boot si l'env n'est pas encore configuré
- feature flag respecté de bout en bout (`isEnabled()` court-circuite avant init)
- tests parallèles peuvent reset l'état via `__resetForTests()`
- `setX` n'est appelé qu'une fois par process (memoization)
- Limites / vigilance :
- state module-level partagé entre tous les imports — bien isoler la lib derrière un service
- `__resetForTests` doit être gardé par `NODE_ENV === 'test'` pour éviter l'usage en prod
- Validé le : 28-04-2026
- Contexte technique : Node.js / SDK avec config globale — RL799_V2
### Implémentation
```typescript
let initialized = false;
const isEnabled = (): boolean =>
process.env.FEATURE_FLAG === 'true' &&
Boolean(process.env.API_KEY && process.env.SECRET);
const ensureInit = (): boolean => {
if (initialized) return true;
if (!isEnabled()) return false;
externalLib.configure({
apiKey: process.env.API_KEY!,
secret: process.env.SECRET!,
});
initialized = true;
return true;
};
export const callExternal = async (input: Input): Promise<void> => {
if (!ensureInit()) return; // no-op silencieux si flag off ou env manquant
await externalLib.send(input);
};
/** Reset interne — usage tests uniquement. */
export const __resetInitForTests = (): void => {
if (process.env.NODE_ENV !== 'test') return;
initialized = false;
};
```
### Checklist
- `isEnabled()` vérifie flag ET présence env complète
- `ensureInit()` retourne booléen, no-op silencieux si non activé
- `__resetForTests` gardé par `NODE_ENV === 'test'`
- Pas de log "skipped" en cas de flag off (silencieux par design — c'est le contrat du flag)
---
<a id="pattern-cap-lru-ressources-par-user"></a>
## Pattern : Cap LRU sur ressources par-user avec contrainte d'unicité externe
- Objectif : empêcher un user (malveillant ou bugué) de générer un nombre illimité de ressources par-user en exploitant l'unicité d'un identifiant externe.
- Contexte : ressources où chaque insert produit une row unique côté DB (push subscription endpoint, refresh token, device fingerprint, OAuth state).
- Quand l'utiliser : ressource sans limite naturelle côté usage, où chaque action utilisateur peut créer une nouvelle row.
- Quand l'éviter : ressource intrinsèquement bornée (1 par user — utiliser une PK composite), ou ressource où l'historique compte (audit, logs).
- Avantage :
- cap dur prévisible (10 actives max, p.ex.) — borne supérieure de coût stockage connue
- LRU eviction naturelle : les anciennes subs (devices oubliés, browsers réinstallés) sont nettoyées automatiquement
- pas besoin de TTL global, le user peut garder ses N appareils légitimes
- Limites / vigilance :
- faire le check + révocation **avant** l'insert, pas après (sinon `unique constraint` violation possible si race)
- choisir entre `revoked` (soft delete) et `delete` selon les besoins audit
- le cap doit être au-dessus de l'usage légitime max (10 pour push = laptop + perso + pro + mobile + tablette + marges)
- Validé le : 28-04-2026
- Contexte technique : Prisma — RL799_V2
### Implémentation
```typescript
const MAX_ACTIVE_PER_USER = 10;
export const handleCreate = async (userId: string, input: CreateInput) => {
const active = await prisma.resource.count({
where: { userId, revokedAt: null },
});
if (active >= MAX_ACTIVE_PER_USER) {
const oldest = await prisma.resource.findFirst({
where: { userId, revokedAt: null },
orderBy: { lastSeenAt: 'asc' },
select: { id: true },
});
if (oldest) {
await prisma.resource.update({
where: { id: oldest.id },
data: { revokedAt: new Date() },
});
}
}
return prisma.resource.upsert({
where: { externalKey: input.externalKey },
create: { userId, ...input },
update: { userId, revokedAt: null, lastSeenAt: new Date() },
});
};
```
### Checklist
- [ ] Cap (constante) défini + commenté avec justification du chiffre
- [ ] Eviction LRU faite **avant** l'insert
- [ ] `lastSeenAt` bumpé à chaque usage légitime, pas juste à la création
- [ ] Test : seed cap-1 actives + 1 nouvel insert → cap respecté, plus ancienne révoquée
---
<a id="pattern-convention-dot-notation-audit"></a>
## Pattern : Convention dot-notation pour audit events
- Objectif : aligner le nommage des audit events avec la convention des outils observables (segment.io, datadog, posthog) qui utilisent tous la dot notation.
- Contexte : projet avec un `AUDIT_ACTION_CATALOG` ou équivalent listant les actions auditées.
- Quand l'utiliser : tout nouvel audit event, et migration progressive des events legacy en colon (`document:delete`).
- Quand l'éviter : projets dont l'outil d'observabilité impose un autre séparateur.
- Avantage :
- cohérence inter-outils (segment.io, datadog, posthog)
- lecture humaine plus fluide (`document.soft_delete` > `document:soft-delete`)
- multi-niveaux possible (`planche.tronc.admin_override`) sans confusion avec un séparateur de namespace JS
- Limites / vigilance :
- migration en PR atomique (call sites + catalog ensemble) pour éviter les events orphelins en filtrage UI
- Validé le : 20-04-2026
- Contexte technique : audit / observabilité — RL799_V2
### Convention
- **Préférer la dot notation** : `<entity>.<action_detail>` (ex : `document.update_metadata`, `document.soft_delete`, `cotisation_payment.created`)
- **Legacy colon** (`document:delete`) toléré pour rétrocompatibilité — migration encouragée lors du prochain touchement du module concerné
### Comment migrer
1. Vérifier qu'il n'y a plus de call site (`grep -rn 'document:delete'`)
2. Retirer l'entrée du `AUDIT_ACTION_CATALOG`
3. Si des call sites existent, les migrer en même temps que le retrait (PR atomique)
---
<a id="pattern-whitelist-explicite-audit-fields"></a>
## Pattern : Whitelist explicite pour audit metadata fields
- Objectif : empêcher qu'un futur dev ajoutant un champ secret au schema (`backupPassword`, `apiToken`) ne le voie automatiquement loggé dans le journal d'audit.
- Contexte : PATCH d'admin qui logge `metadata: { fields: Object.keys(payload) }` pour tracer ce qui a changé.
- Quand l'utiliser : tout audit qui veut tracer "quels champs ont été modifiés".
- Quand l'éviter : audit qui ne logge que l'action et l'id cible (pas les champs).
- Avantage :
- pas de fuite silencieuse dans le journal d'audit
- `satisfies readonly (keyof typeof baseShape)[]` garantit que la whitelist ne peut pas contenir de champ inexistant (typo-safe)
- Limites / vigilance :
- test "PATCH multi-champs valides → tous présents dans `metadata.fields`" pour vérifier que la whitelist couvre 100 % des champs PATCH-ables légitimes (silence par omission, pas de fuite)
- Validé le : 27-04-2026
- Contexte technique : audit / observabilité — RL799_V2
### Implémentation
```typescript
// packages/shared/src/validation/<entity>Schemas.ts
export const AUDITABLE_LODGE_SETTINGS_FIELDS = [
'nameLong',
'nameShort',
// … uniquement les champs SAFE à logger
] as const satisfies readonly (keyof typeof baseShape)[];
// Côté service
const fields = AUDITABLE_LODGE_SETTINGS_FIELDS.filter((k) => k in payload);
await logActionSync(tx, userId, '<entity>.update', '<entity>', id, { fields });
```
### Test obligatoire
```typescript
test('AUDITABLE_<X>_FIELDS couvre tous les champs PATCH-ables légitimes', () => {
const allPatchableFields = Object.keys(updateXxxSchema.shape);
const sensitiveFields = ['secretToken', 'backupPassword'];
const expected = allPatchableFields.filter((k) => !sensitiveFields.includes(k));
expect([...AUDITABLE_X_FIELDS]).toEqual(expected);
});
```
---
<a id="pattern-singleton-db-config-globale"></a>
## Pattern : Singleton DB pour config globale d'instance
- Objectif : stocker une configuration applicative qui doit être éditable runtime, unique pour l'instance, et protégée contre toute création accidentelle de doublon.
- Contexte : application mono-tenant déployable où chaque instance a sa propre DB et expose une config globale (paramètres de l'instance, branding, identité).
- Quand l'utiliser : config (a) en DB pour être éditable runtime sans rebuild, (b) unique pour l'instance, (c) protégée par contrainte DB.
- Quand l'éviter : SaaS multi-tenant — chaque tenant a sa propre row.
- Avantage :
- le `CHECK` SQL est la dernière ligne de défense même si un dev contourne le repo
- le repo centralise le `where: { id: 'singleton' }` — première ligne d'abstraction
- Limites / vigilance :
- `@default("singleton")` seul ne suffit pas — un dev peut créer une row avec `id: 'other'`
- cache mémoire à invalider AVANT mutation (cf. pattern `pattern-invalidation-cache-avant-mutation`)
- Validé le : 27-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```prisma
model LodgeSettings {
id String @id @default("singleton")
// …champs
@@map("lodge_settings")
}
```
Garde-fou SQL au niveau migration (édition manuelle du `.sql` après `prisma migrate dev --create-only`) :
```sql
ALTER TABLE "lodge_settings"
ADD CONSTRAINT "lodge_settings_singleton_check"
CHECK (id = 'singleton');
```
Repository centralisé : tout accès passe par `getXxx()` / `updateXxx()` qui prennent un client transactionnel optionnel pour permettre l'atomicité mutation + audit. Jamais de `prisma.xxx.create()` direct depuis ailleurs.
### Tests d'intégration
```typescript
test('rejette la création d\'une 2e row', async () => {
await expect(
prisma.lodgeSettings.create({ data: { id: 'other', ... } }),
).rejects.toThrow('lodge_settings_singleton_check');
});
```
---
<a id="pattern-invalidation-cache-avant-mutation"></a>
## Pattern : Invalidation cache mémoire AVANT mutation atomique
- Objectif : éviter qu'un GET parallèle pendant la transaction re-cache l'ancienne valeur jusqu'à expiration TTL.
- Contexte : config en cache mémoire (settings, feature flags, catalog) modifiée par une mutation atomique.
- Quand l'utiliser : tout flow `cache + mutation + audit` où le cache vit dans le même process que la mutation.
- Quand l'éviter : cache distribué Redis avec `SETEX` — la TTL gère naturellement.
- Avantage :
- cohérence forte (au pire, GET parallèle bloque sur fetch DB → trade-off latence acceptable)
- pas de fenêtre où le client a vu une valeur déjà obsolète après ACK serveur
- Limites / vigilance :
- garde-fou `cacheVersion` (compteur incrémenté à chaque `invalidate()`) recommandé : tout fetch en vol capture la version au démarrage et n'écrit le cache que si la version est inchangée au retour
- Validé le : 27-04-2026
- Contexte technique : Node.js / cache mémoire — RL799_V2
### Séquence sûre
```
1. invalidateCache() // AVANT toute mutation
2. transaction { // Prisma $transaction
update(...)
logActionSync(tx, ...)
}
3. return // Le prochain GET re-fetch fresh DB
```
### Pourquoi avant et pas après
Si on invalide après mutation :
1. GET parallèle pendant la transaction hit le cache (ancienne valeur) → return cachedValue
2. Si la transaction commit, le cache contient l'ancienne valeur jusqu'à TTL
3. Le client a vu une valeur déjà obsolète après ACK serveur → race condition
Si on invalide avant mutation :
1. Cache vide pendant la transaction
2. GET parallèle fait un fetch DB (peut renvoyer l'ancienne ou la nouvelle selon timing)
3. Au pire, GET parallèle bloque sur fetch DB → cohérence forte
### Anti-pattern à éviter
- Auto-chaînage entre invalidations couplées de caches dont les timings doivent diverger (ex : cache settings DTO à invalider AVANT la mutation, cache logo filesystem à invalider APRÈS pour éviter un re-cache d'un fichier supprimé). Chaque cache expose son `invalidate()` propre, le caller décide explicitement du moment.
### TTL recommandé
TTL court (60 s) plutôt que long (5 min) : fenêtre de stale plus courte si plusieurs admins éditent.
---
<a id="pattern-pipeline-cicd-github-actions-vps"></a>
## Pattern : Pipeline CI/CD GitHub Actions → VPS (compose externe + GHCR + SSH)
- Objectif : déployer automatiquement un monorepo Node + Postgres + Docker à chaque merge sur main vers un VPS hébergeant déjà des stacks globales (Traefik + Postgres).
- Contexte : VPS multi-apps avec Traefik global + Postgres global sur réseaux Docker externes (`traefik`, `stack`). Repo GitHub privé, image GHCR privée, user SSH dédié `deploy` (pas superuser).
- Quand l'utiliser : projet ≥ 1 app + ≥ 1 service partagé sur un VPS, sans Kubernetes ni service externe (pas de Vercel/Render/Railway).
- Quand l'éviter : déploiement sur un seul app/VPS dédié (compose simple suffit), ou besoin de blue/green strict (k8s ou Nomad plus adaptés).
- Avantage :
- réutilisation des stacks Traefik + Postgres existantes via réseaux externes
- pas de superuser, juste membre du groupe `docker`
- migrations exécutées AVANT le redémarrage applicatif (séquence `pull → migrate → up -d`)
- Limites / vigilance :
- plan GitHub gratuit pour repos privés : pas de gating manuel possible — compenser par `workflow_dispatch` only sur le job deploy
- `pnpm run <script>` dans le service `migrate` peut casser si le script dépend de `scripts/` non embarqué dans l'image — appeler les binaires directement
- Validé le : 02-05-2026
- Contexte technique : pnpm monorepo / Next.js / Vue / Prisma / Postgres / Docker — RL799_V2
### `compose.vps.yml` — compose dédié CI/CD
```yaml
services:
api:
image: ${API_IMAGE:?API_IMAGE is required}
env_file: [.env]
networks: [app_net, stack_net, traefik_net]
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${APP_DOMAIN}`) && PathPrefix(`/api`)
frontend:
image: ${FRONTEND_IMAGE:?FRONTEND_IMAGE is required}
networks: [app_net, traefik_net]
migrate:
image: ${API_IMAGE:?API_IMAGE is required}
profiles: [ops]
# Appel DIRECT du binaire Prisma — éviter `pnpm run prisma:migrate` qui
# exécute scripts/check-node-version.mjs absent de l'image API
command: ['pnpm', '-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy']
networks: [stack_net]
networks:
traefik_net:
external: true
name: traefik
stack_net:
external: true
name: stack
```
### Workflow CI/CD en 2 jobs
- `build-and-push` : `docker buildx` → push image sur GHCR (tag = SHA court)
- `deploy` : SSH au VPS, `scp compose.vps.yml`, `pull` + `migrate` + `up -d` + healthcheck retries
Trigger : `workflow_run` du workflow `Tests` quand vert sur main, OU `workflow_dispatch` manuel.
### Secrets GitHub à configurer
| Secret | Rôle | Exemple |
|---|---|---|
| `VPS_HOST` | hostname réel (pas alias `~/.ssh/config`) | `82.x.x.x` |
| `VPS_USER` | `deploy` |
| `VPS_SSH_PORT` | port custom éventuel | `2287` |
| `VPS_SSH_PRIVATE_KEY` | clé privée multi-lignes | `-----BEGIN OPENSSH...` |
| `VPS_SSH_KNOWN_HOSTS` | `ssh-keyscan -p <port> <host>` | 3 lignes |
| `VPS_APP_DIR` | chemin app sur VPS | `/srv/sites/<app>` |
### Pièges anticipés
1. **`pnpm run <script>` dans le service `migrate`** : si le script appelle un wrapper (`pnpm run env:node`) qui exécute `scripts/check-node-version.mjs`, et que `scripts/` n'est pas embarqué dans l'image API → 500 au boot du job migrate. **Toujours appeler les binaires directement** dans les services Docker.
2. **Image GHCR privée + `docker login` côté VPS** : credentials par-user (`~/.docker/config.json`). Faire `docker login` **en tant que `deploy`** via SSH, pas via `sudo -u deploy`.
3. **Hostname réel vs alias `~/.ssh/config`** : `VPS_HOST` doit être l'hostname résolu (`ssh -G <alias> | grep ^hostname`), pas l'alias.
4. **Permissions dossier `/srv/sites/<app>/`** : si owner historique = autre user, `deploy` ne peut pas `scp`. Solution propre = groupe partagé avec `setgid`.
5. **Healthcheck timeout** : si l'API met longtemps à boot (Chromium/Puppeteer lazy load, migrations longues), augmenter au-delà du défaut 60 s.

View File

@@ -164,3 +164,124 @@ if (mediaUrl) {
- [ ] Validation format (`new URL()`) + protocole + longueur max
- [ ] Retourner `null` si invalide, jamais passer la string brute
- [ ] Composant UI reçoit `string | null`, jamais une string non vérifiée
---
<a id="pattern-nextjs-after-fanout-post-reponse"></a>
## Pattern : Next.js `after()` pour fanout post-réponse
- Objectif : exécuter du travail accessoire (notif push, audit, webhook) APRÈS avoir renvoyé la réponse HTTP, sans bloquer la latence métier ni risquer la fermeture lambda du fire-and-forget naïf.
- Contexte : route Next.js 15+ (App Router) qui termine une mutation métier (ex : `prisma.$transaction`) et veut déclencher un effet de bord non critique.
- Quand l'utiliser : effet de bord best-effort qui ne doit JAMAIS bloquer la réponse principale (push, log audit asynchrone, webhook sortant).
- Quand l'éviter : effet de bord qui DOIT réussir avant de répondre 200 (validation transactionnelle, écriture critique).
- Avantage :
- réponse HTTP immédiate (latence métier préservée)
- le runtime Node attend la fin du callback avant de fermer le process — pas de fire-and-forget orphelin
- les exceptions du callback sont attrapables localement, ne propagent pas au caller
- Limites / vigilance :
- **JAMAIS placer `after()` à l'intérieur d'une `prisma.$transaction(async tx => { ... after(...) })`** : si la transaction rollback, le callback `after()` reste planifié et s'exécute sur des données qui n'existent pas en DB
- construire le payload SYNCHRONE avant `after()` et attraper les erreurs là, sinon une exception dans le callback async devient silencieuse
- comportement en serverless (Vercel, Lambda) vs Node standalone peut différer — tester en cible
- Validé le : 28-04-2026
- Contexte technique : Next.js 15+ App Router — RL799_V2
### Implémentation
```typescript
import { after } from 'next/server';
export const notifyConvocationPublished = async (
tenueId: string,
recipientIds: string[],
): Promise<void> => {
// 1. Mutation métier (transactionnelle)
await prisma.$transaction(async (tx) => {
await tx.notification.createMany({ data: ... });
});
// 2. Construction payload SYNCHRONE — toute exception attrapée ici
let payload: PushPayload;
try {
payload = buildPushPayload(tenueId);
} catch (err) {
log.warn('payload build failed', { err });
return;
}
// 3. Hook après la transaction — JAMAIS dedans
after(async () => {
try {
await pushService.sendPushToUsers(recipientIds, payload);
} catch (err) {
log.warn('fanout failed', { err }); // jamais throw vers le caller
}
});
};
```
### Anti-patterns
-`await pushService.sendPushToUsers(...)` dans le service métier (bloque la latence + propage les erreurs)
-`after(() => { ... })` à l'intérieur d'une `prisma.$transaction(async tx => { ... after(...); })`
- ❌ Construire le payload DANS le callback `after()` async — une exception y devient silencieuse
### Checklist
- [ ] `after()` appelé APRÈS le `await prisma.$transaction(...)`, jamais à l'intérieur
- [ ] Payload construit synchrone avant `after()`, exceptions attrapées localement
- [ ] Le callback `after()` n'a pas le droit de throw (best-effort wrapping)
- [ ] La route métier renvoie 2xx même si le fanout échoue
---
<a id="pattern-gate-agir-au-nom-de"></a>
## Pattern : Gate "agir au nom de X" (3 étages : rôle → type/scope → cible actif)
- Objectif : valider correctement une autorisation d'override d'attribution (`uploadedByOverride`, `createdByOverride`) pour qu'un rôle élevé puisse agir "au nom de" un autre user, sans laisser de faille.
- Contexte : endpoint API qui accepte un override d'attribution (archiviste upload pour un Frère, admin crée une entité pour X).
- Quand l'utiliser : tout endpoint avec un override d'attribution sensible.
- Quand l'éviter : si la délégation est implicite et déjà couverte par un guard centralisé.
- Avantage :
- validation strictement séquentielle et défensive — chaque étage a son code HTTP propre
- le check "actif" combine toutes les dimensions disponibles (pas un seul flag)
- Limites / vigilance :
- ordre impératif : rôle EN PREMIER (plus sensible). Un membre lambda envoyant un payload avec override doit recevoir 403 même si la cible est valide
- Validé le : 20-04-2026
- Contexte technique : Next.js / API HTTP — RL799_V2
### Les 3 étages
1. **Rôle** : le user courant a-t-il la capacité ? Sinon **403 FORBIDDEN**.
2. **Type/scope** : l'override est-il pertinent pour ce type d'entité ? Sinon **400 VALIDATION_ERROR**.
3. **Cible** : la cible existe-t-elle ET est-elle active ? Sinon **400 VALIDATION_ERROR** si introuvable OU inactive OU démissionnée OU décédée.
### Implémentation
```typescript
let effectiveUploadedBy = currentUser.id;
if (metadata.uploadedByOverride) {
// Étage 1 : rôle
if (userRole !== 'archiviste' && userRole !== 'admin') {
return errorResponse(403, 'FORBIDDEN', '...');
}
// Étage 2 : type/scope
if (metadata.type !== 'planche') {
return errorResponse(400, 'VALIDATION_ERROR', '...');
}
// Étage 3 : cible actif (toutes les dimensions disponibles)
const targetUser = await getUserById(metadata.uploadedByOverride);
const isInactive =
!targetUser
|| !targetUser.isActive
|| !!targetUser.profile?.resignedAt
|| !!targetUser.profile?.deceasedAt;
if (isInactive) {
return errorResponse(400, 'VALIDATION_ERROR', '...');
}
effectiveUploadedBy = metadata.uploadedByOverride;
}
```
### Pourquoi vérifier toutes les dimensions
Un user peut être `isActive: true` dans le système mais avoir une `resignedAt` antérieure (désactivation non-synchrone). Le check "actif" doit combiner **toutes** les dimensions disponibles du modèle, pas un seul flag.

View File

@@ -42,6 +42,29 @@ source_projects: [app-template-resto, app-alexandrie]
- Index DB tenant compte du soft delete
```
### Piège — `include` ne filtre pas `deletedAt` automatiquement
`include: { related: true }` n'applique pas le filtre soft delete sur la relation. Si la relation pointe vers une entité elle-même soft-deletable, le doc caché reste exposé via la relation → fuite systématique.
Mitigations :
- relations to-many : `include: { related: { where: { deletedAt: null } } }`
- relations to-one (Prisma ne supporte pas `where` dans un `include` to-one) : `include: { related: { select: { deletedAt: true, ... } } }` puis filtrer post-query côté repo (`if (entity.related?.deletedAt) entity.related = null`)
Toujours `grep -rn "include.*<relationName>"` après l'ajout d'un soft delete pour identifier les sites à fixer.
### Pattern atomique anti-race delete/restore
```typescript
const result = await prisma.<model>.updateMany({
where: { id, deletedAt: null }, // ou { not: null } pour restore
data: { deletedAt: new Date(), deletedById: actorId },
});
if (result.count === 0) return notFound(); // idempotent, pas de double-audit
```
`updateMany` + `where: { id, deletedAt: null }` permet de transformer un check-then-update non atomique en un update atomique conditionnel — le `count === 0` distingue "déjà supprimé" de "introuvable" sans risque de double effet de bord.
### Checklist
- Filtrage soft delete par défaut
@@ -49,6 +72,7 @@ source_projects: [app-template-resto, app-alexandrie]
- Purge maîtrisée (cron / job)
- Index DB adaptés
- Tests sur cas supprimé / restauré
- Audit des `include` sur les relations soft-deletables
---
@@ -256,3 +280,387 @@ return raw.filter(c => c.isVisible).map(toPublicDto);
// Admin : même repo, filtre différent dans le service admin
return raw.map(toAdminDto); // retourne tout, visible ou non
```
---
<a id="pattern-audit-transactionnel-atomique"></a>
## Pattern : Audit transactionnel — mutation et log dans la même `$transaction`
- Objectif : garantir l'invariant `mutation persistée ⇔ audit log existe` quand l'audit est un livrable métier (pas un simple effet de bord informatif).
- Contexte : opérations sensibles (correction par un délégué hors périmètre habituel, opérations admin, opérations soumises à conformité).
- Quand l'utiliser : tout flux où une mutation sans trace serait inacceptable.
- Quand l'éviter : audits purement informatifs (statistiques d'usage, debug) — fire-and-forget acceptable.
- Avantage :
- rollback automatique si l'audit échoue → pas de mutation orpheline
- aucune divergence possible entre l'état persisté et la trace
- Limites / vigilance :
- une mutation peut désormais échouer pour cause "audit indisponible" → 5xx renvoyé au client (cohérent : on préfère refuser la mutation que la passer sans trace)
- Validé le : 27-04-2026
- Contexte technique : Prisma / NestJS — RL799_V2
### Implémentation
```typescript
type AuditClient = Prisma.TransactionClient | typeof prisma;
export const logActionSync = async (
client: AuditClient,
userId: string,
action: string,
targetType?: string,
targetId?: string,
metadata?: Record<string, unknown>,
) => {
await client.auditLog.create({ data: { userId, action, targetType, targetId, metadata } });
};
await prisma.$transaction(async (tx) => {
await tx.<entity>.update({ where: { id }, data: { ... } });
await logActionSync(tx, userId, '<entity>.<action>', '<entity>', id, { ... });
});
```
### Anti-patterns
- `logAction(...)` (fire-and-forget) après le persist quand l'audit est requis métier
- `logActionSync(prisma, ...)` (hors transaction) après le persist : synchrone mais pas atomique avec la mutation
- `.catch(() => {})` autour de l'audit "pour ne pas casser la mutation"
### Checklist
- [ ] Le helper d'audit accepte un `client: AuditClient` (transaction ou prisma)
- [ ] Mutation et audit dans la même `$transaction`
- [ ] Test d'atomicité : mock `createAuditLog` qui throw → assert rollback (cf. `knowledge/backend/patterns/tests.md`)
---
<a id="pattern-index-unique-partiel-actif"></a>
## Pattern : Index unique partiel Postgres pour invariant "≤ 1 active par X"
- Objectif : enforcer l'invariant "au plus une row active par scope" au niveau base de données plutôt que via un check applicatif vulnérable aux races.
- Contexte : ressources avec un cycle de vie `active → revoked/closed` où l'invariant métier impose une seule active par user/contexte (invitation, mandat d'officier, lock éditeur).
- Quand l'utiliser : dès qu'un check applicatif "≤ 1 active" est nécessaire et que la concurrence est possible.
- Quand l'éviter : si la table n'a pas de colonne `status` discriminante ou si plusieurs rows actives sont métier-acceptables.
- Avantage :
- 2e INSERT concurrente échoue avec contrainte unique violée (P2002) plutôt que de créer un doublon
- défense en profondeur : le check applicatif reste, mais la DB est la dernière ligne
- Limites / vigilance :
- Prisma ne supporte pas les unique partials en `schema.prisma` → ajouter dans la migration SQL brute
- documenter dans la migration : un `prisma format` accidentel pourrait droper l'index
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```sql
-- prisma/migrations/<TS>_xxx/migration.sql
CREATE UNIQUE INDEX invitations_one_active_per_user
ON invitations(user_id) WHERE status = 'active';
```
```typescript
export const revokeAndIssueInvitation = async (input) => {
try {
return await prisma.$transaction(async (tx) => {
await tx.invitation.updateMany({
where: { userId: input.userId, status: 'active' },
data: { status: 'revoked', revokedAt: new Date() },
});
return tx.invitation.create({
data: { userId: input.userId, ..., status: 'active' },
});
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
return { ok: false, reason: 'RACE_CONFLICT' };
}
throw err;
}
};
```
### Checklist
- [ ] Index partiel ajouté dans le SQL brut de la migration
- [ ] Handler `P2002` traduit en code métier (`RACE_CONFLICT`, 409)
- [ ] Test de race : `Promise.all([resend(), resend()])` puis `count({ status: 'active' }) === 1`
- [ ] Commentaire dans la migration : "Prisma ne supporte pas les unique partials en schema, ne pas droper sur `prisma format`"
---
<a id="pattern-uuid-v5-deterministe-seed"></a>
## Pattern : UUID v5 déterministe pour ids de seed
- Objectif : permettre d'écrire `seedUserId('venerable')` côté tests/code tout en garantissant que `User.id` reste un UUID RFC 4122 en base — débloque la rigidification Zod `.uuid()` en aval.
- Contexte : seed Prisma qui crée des entités référencées par slug lisible dans les tests, et qui doivent rester typées strictement côté API.
- Quand l'utiliser : nouveaux seeds OU migration d'un seed historique avec slugs littéraux comme PK.
- Quand l'éviter : si le seed est purement aléatoire (`@default(uuid())`) et qu'aucun test ne référence un user particulier par identifiant.
- Avantage :
- déterminisme : `seedUserId('venerable')` donne toujours le même UUID v5
- type uniforme : tous les `User.id` sont des UUID RFC 4122 → `.uuid()` activable
- lisibilité préservée : le code de tests reste sémantique
- Limites / vigilance :
- le slug ne doit JAMAIS être persisté en clair (mapping explicite `{ id: seedUserId(slug), ...rest }`)
- migration depuis un seed slug existant = chantier en 2 commits (cf. pattern rigidification Zod 2 phases dans `contracts.md`)
- Validé le : 24-04-2026
- Contexte technique : Prisma / uuid v5 — RL799_V2
### Implémentation
```typescript
// packages/shared/src/utils/seedIdentity.ts
import { v5 as uuidv5 } from 'uuid';
// Namespace stable du projet (généré une fois, committé ensuite)
export const SEED_USER_NAMESPACE = '2cd71e75-dd5e-42cc-b9fa-52888c42cc3d';
export const seedUserId = (slug: string): string =>
uuidv5(slug, SEED_USER_NAMESPACE);
```
Côté tests :
- helpers (`TEST_SECRETARY`, `TEST_VENERABLE`) exposent l'UUID résolu : les tests écrivent `TEST_SECRETARY.id`, pas `'secretaire'`
- les users ad-hoc éphémères (créés/supprimés dans le scope d'un test) utilisent `randomUUID()`, pas `seedUserId()` — réservé aux entités seed durables
### Pourquoi pas UUID v4 aléatoire
Le déterminisme est essentiel : il permet aux fixtures E2E de pointer un user précis (`const TRESORIER_ID = seedUserId('tresorier')`) sans lire la base, et garantit la reproductibilité du seed en CI.
---
<a id="pattern-test-invariant-post-seed"></a>
## Pattern : Test d'invariant post-seed
- Objectif : transformer la liste canonique des entités seed en contrat exécutable, détecter immédiatement un drift (slug ajouté hors helper, user oublié, format incohérent).
- Contexte : projet avec un seed structurant (users, configurations système) référencé par les fixtures de tests et les flux E2E.
- Quand l'utiliser : à chaque migration qui modifie la forme d'une entité seed (UUID, format d'id, contraintes).
- Quand l'éviter : seed purement aléatoire et jetable (pas de référence stable depuis les tests).
- Avantage :
- le test devient un contrat lisible du seed, pas une abstraction
- détecte un futur dev qui ajouterait un user via un slug littéral sans `seedUserId()`
- détecte un user oublié ou dupliqué
- Limites / vigilance :
- **nombre exact**, pas "au moins N" : si le seed tronque à 29 au lieu de 30, le test doit échouer
- filtrer explicitement par la liste des slugs connus — ne pas valider "tous les users en base" (résidus possibles)
- Validé le : 24-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation
```typescript
// __tests__/seedInvariants.test.ts
const SEED_SLUGS: readonly string[] = [
'venerable', 'secretaire', /* … 30 slugs … */
];
const EXPECTED_SEED_USER_COUNT = 31;
test('seed invariant: users seed possèdent un UUID déterministe', async () => {
assert.equal(SEED_SLUGS.length, EXPECTED_SEED_USER_COUNT, 'liste figée');
const expectedIds = SEED_SLUGS.map(seedUserId);
const users = await prisma.user.findMany({
where: { id: { in: expectedIds } },
select: { id: true },
});
assert.equal(users.length, EXPECTED_SEED_USER_COUNT);
assert.ok(users.every((u) => isValidUuid(u.id)));
});
```
### Checklist
- [ ] Liste figée des slugs en constante
- [ ] Compte exact (`.equal`, pas `.gte`)
- [ ] Filtrage explicite par la liste (pas de `findMany()` global)
- [ ] Vérification du format de l'id
---
<a id="pattern-check-fail-loud-conditionnee"></a>
## Pattern : Check `RAISE EXCEPTION` conditionnée à la présence de données
- Objectif : préserver la rejouabilité de la migration sur une DB vide (dev `prisma migrate reset`) tout en gardant le fail-loud sur DB peuplée.
- Contexte : migration qui fait un backfill de données existantes et veut échouer si l'admin/owner cible est absent.
- Quand l'utiliser : toute check "fail if missing X" qui protège un backfill, jamais le schéma lui-même.
- Quand l'éviter : check de schéma purement structurel (`NOT NULL`, FK) — ces contraintes appartiennent au DDL, pas à un `RAISE`.
- Avantage :
- DB vide (dev reset) : 0 row à backfiller → check skip propre, migration passe
- DB prod/staging avec données : check conservée, fail-loud comme prévu
- Limites / vigilance :
- une migration doit rester rejouable sur une DB vide ET une DB peuplée — c'est le contrat de `prisma migrate reset`
- Validé le : 21-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Anti-pattern
```sql
-- ❌ Bloque tout migrate reset sur dev (DB vide)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true) THEN
RAISE EXCEPTION 'Migration X requires an admin user.';
END IF;
END $$;
```
### Pattern correct
```sql
-- ✅ Exige admin uniquement s'il y a des données à backfiller
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM cotisation_entries WHERE status = 'paid')
AND NOT EXISTS (SELECT 1 FROM users WHERE role = 'admin' AND is_active = true)
THEN
RAISE EXCEPTION 'Migration X requires an admin user to backfill existing paid entries.';
END IF;
END $$;
```
---
<a id="pattern-revocation-atomique-etat-transversal"></a>
## Pattern : Révocation atomique d'un état transversal lors d'une transition de cycle
- Objectif : éteindre les champs d'état transversaux (délégation, lock, ownership) dans la **même transaction** que la transition de cycle de vie de l'entité parente.
- Contexte : transitions `close / archive / cancel / soft-delete / lock définitif` d'une entité qui porte un ou plusieurs champs transversaux n'ayant plus de sens dans le nouveau cycle.
- Quand l'utiliser : à chaque transition de cycle où un état transversal devient un "zombie" potentiel (délégation qui survit à la clôture, lock qui survit à l'archivage).
- Quand l'éviter : transitions sans état transversal pertinent (archivage simple).
- Avantage :
- aucun état zombie possible
- la valeur précédente est capturée sous lock → audit et notif fiables (pas de race entre lecture et écriture)
- Limites / vigilance :
- les effets de bord (audit, notif) DOIVENT sortir de la transaction (best-effort, fire-and-forget)
- l'idempotence est gérée par le `WHERE` du `updateMany` (`closedAt: null`) — la 2e tentative ne re-déclenche pas les effets de bord
- Validé le : 27-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Implémentation
```typescript
let previousDelegateeId: string | null = null;
let updateCount = 0;
await prisma.$transaction(async (tx) => {
const lockResult = await tx.$queryRaw<Array<{ delegatee_id: string | null }>>`
SELECT delegatee_id FROM "entities"
WHERE id = ${id} AND closed_at IS NULL
FOR UPDATE
`;
if (lockResult.length === 0) return; // idempotence
previousDelegateeId = lockResult[0].delegatee_id;
const updated = await tx.entity.updateMany({
where: { id, closedAt: null },
data: {
closedAt: now,
closedBy: userId,
delegateeId: null, // ← révocation atomique dans la même tx
},
});
updateCount = updated.count;
});
// Effets de bord HORS de la transaction
if (updateCount > 0 && previousDelegateeId !== null) {
logAction(userId, 'entity:delegation_revoked_on_close', ...);
void notifyDelegatee(previousDelegateeId, ...);
}
```
### Les 4 invariants
1. La révocation vit dans le **même `updateMany`** que la transition principale.
2. La capture de la valeur précédente est sous **`SELECT FOR UPDATE`** dans la transaction.
3. Les effets de bord (audit, notif) **sortent de la transaction**.
4. L'idempotence est gérée par le `WHERE` (`closedAt: null`) — la 2e tentative est un no-op observable.
### Tests minimaux
- Happy path : transition avec valeur transversale présente → champ nullé + audit + notif au bon target
- Sans valeur transversale : pas d'effet de bord (pas d'audit révocation, pas de notif)
- Idempotence : 2e transition retombe en already_closed sans double effet
---
<a id="pattern-migration-destructive-4-phases"></a>
## Pattern : Migration destructive en 4 phases avec sentinelle d'archive
- Objectif : refondre une table avec PK changée ou colonnes incompatibles sans perdre l'audit historique des rows métier importantes.
- Contexte : table dont la PK ou la forme évolue de façon non-rétrocompatible (ex : token en clair → hash SHA-256 stocké, slug → UUID).
- Quand l'utiliser : refonte structurelle où un `ALTER TABLE` patchwork serait fragile (FK multiples, index, contraintes).
- Quand l'éviter : ajout simple de colonne nullable, refactor cosmétique d'index.
- Avantage :
- DROP + CREATE plus sûr qu'un patchwork ALTER quand la PK change
- les rows historiques (`status = 'consumed'`) sont conservées pour audit
- sentinelle d'archive non-collisionnable garantit qu'aucun login ne peut matcher une row archivée
- Limites / vigilance :
- Phase 1 (DELETE des rows non-migrables) impose une communication aux admins pré-deploy
- inspection manuelle obligatoire du SQL généré par `prisma migrate dev --create-only`
- Validé le : 28-04-2026
- Contexte technique : Prisma / Postgres — RL799_V2
### Recette
```sql
-- Phase 1 : invalidation propre des données non-migrables
-- Les tokens en clair ne peuvent pas être convertis en SHA-256 (one-way).
-- Les rows 'consumed' sont conservées pour audit historique.
DELETE FROM invitations WHERE status != 'consumed';
-- Phase 2 : refonte de la table
CREATE TEMP TABLE invitations_archive AS
SELECT email, status, consumed_at FROM invitations WHERE status = 'consumed';
DROP TABLE invitations CASCADE;
CREATE TABLE invitations (
id TEXT NOT NULL DEFAULT gen_random_uuid()::text PRIMARY KEY,
user_id TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
...
);
CREATE UNIQUE INDEX invitations_one_active_per_user
ON invitations(user_id) WHERE status = 'active';
-- Phase 3 : restauration des rows consommées avec sentinelle non-collisionnable
-- '_legacy_' n'est jamais produit par crypto.randomBytes(32).toString('hex')
INSERT INTO invitations (id, user_id, token_hash, email, status, consumed_at)
SELECT
gen_random_uuid()::text,
u.id,
'_legacy_' || gen_random_uuid()::text,
a.email,
'consumed',
a.consumed_at
FROM invitations_archive a
JOIN users u ON u.email = a.email;
DROP TABLE invitations_archive;
-- Phase 4 : drop des colonnes obsolètes sur d'autres tables
ALTER TABLE users DROP COLUMN IF EXISTS must_change_password;
```
Côté repository, filtrer la sentinelle :
```typescript
const LEGACY_TOKEN_HASH_PREFIX = '_legacy_';
export const findInvitationByTokenHash = async (tokenHash: string) => {
if (tokenHash.startsWith(LEGACY_TOKEN_HASH_PREFIX)) return null;
// … lookup normal
};
```
### Checklist
- [ ] Phase 1 communiquée aux admins pré-deploy si tokens actifs en cours
- [ ] Phase 2 préfère `DROP + CREATE` quand la PK change
- [ ] Phase 3 utilise un préfixe **garanti non-collisionnable** par construction cryptographique
- [ ] Idempotence (`IF EXISTS` / `IF NOT EXISTS`) sur les changements réversibles
- [ ] Procédure rollback documentée (`pg_dump` avant migration)
- [ ] Smoke test post-deploy (login, création, magic link)

View File

@@ -0,0 +1,378 @@
---
title: Backend — Patterns : Tests
domain: backend
bucket: patterns
tags: [tests, vitest, prisma, integration, isolation]
applies_to: [implementation, review, debug]
severity: high
validated_on: 2026-05-02
source_projects: [RL799_V2]
---
# Backend — Patterns : Tests
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/patterns/README.md` pour l'index complet.
---
<a id="pattern-cleanup-track-tests-integration-db"></a>
## Pattern : `cleanup.track()` LIFO pour tests d'intégration DB
- Objectif : nettoyer proprement les artefacts créés par chaque test sans recourir à un `TRUNCATE CASCADE` qui casserait les transactions du SUT.
- Contexte : tests qui écrivent dans une vraie base (pas mockée), en cohabitation avec un seed durable et des tables FK-liées.
- Quand l'utiliser : dès qu'un test crée des rows à la volée et qu'on veut éviter la pollution inter-tests.
- Quand l'éviter : si le projet utilise `transactions + rollback par test` (déjà isolé), ou si le SUT n'écrit jamais en DB.
- Avantage :
- ordre LIFO = ordre FK-safe (les enfants tombent avant les parents)
- résistant aux throws intermédiaires (le `afterEach` tourne quand même)
- pas de `TRUNCATE` qui casserait les transactions ouvertes par le SUT
- Limites / vigilance :
- convention "100 % via `cleanup.track`" : ne pas mélanger avec des `prisma.*.deleteMany` directs
- la queue est pour le teardown, pas pour le setup : ne jamais tracker une création
- Validé le : 24-04-2026
- Contexte technique : Vitest / Prisma / Postgres — RL799_V2
### Implémentation (helper minimal)
```typescript
// __tests__/helpers/db.ts
type CleanupFn = () => Promise<void> | void;
export function createCleanup() {
const queue: CleanupFn[] = [];
return {
track(fn: CleanupFn) { queue.push(fn); },
async run() {
while (queue.length > 0) {
const fn = queue.pop()!;
try { await fn(); }
catch (err) { console.warn('cleanup.run error:', err); }
}
},
};
}
```
### Usage typique
```typescript
const cleanup = createCleanup();
afterEach(async () => { await cleanup.run(); });
test('crée une tenue', async () => {
const tenue = await prisma.tenue.create({ data: { /* … */ } });
cleanup.track(async () => {
await prisma.tenue.deleteMany({ where: { id: tenue.id } });
});
// … assertions
});
```
### Checklist
- [ ] Ordre LIFO respecté (FK-safe)
- [ ] Aucun `prisma.*.deleteMany` direct hors `cleanup.track` dans les fichiers concernés
- [ ] Cleanup défensif (`try/catch + warn`) — pas de partial leak
- [ ] Aucune création (seulement des suppressions/restores) dans la queue
---
<a id="pattern-globalsetup-vitest-purge-residus"></a>
## Pattern : `globalSetup` vitest pour purger les résidus DB inter-runs
- Objectif : repartir d'une DB propre en début de suite quand des cleanups intra-test sont incomplets et que les artefacts s'accumulent entre sessions.
- Contexte : projet vitest avec `maxWorkers: 1` (DB partagée), tables 100 % alimentées par les tests (notifications, audit logs, payments éphémères).
- Quand l'utiliser : symptômes de "tests verts en isolation, rouges en suite complète après plusieurs jours" liés à des `findFirst({ type })` qui tombent sur des résidus.
- Quand l'éviter : si la suite tourne déjà avec une stratégie `transactions + rollback` ou DB-per-worker.
- Avantage :
- hook standard vitest, pas de cron externe ni de `prisma:reset` manuel entre sessions
- 1 seule passe au démarrage de la suite, coût négligeable
- Limites / vigilance :
- **NE PAS purger les tables seed** (users, soirees seed, profiles, etc.) — réservé aux tables 100 % "test-only"
- ne corrige pas la flakiness intra-run entre fichiers consécutifs
- Validé le : 25-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation
```typescript
// vitest.config.ts
test: {
maxWorkers: 1,
globalSetup: ['./src/__tests__/globalSetup.ts'],
}
// src/__tests__/globalSetup.ts
export default async function globalSetup() {
await prisma.notification.deleteMany();
await prisma.auditLog.deleteMany();
await prisma.cotisationPayment.deleteMany();
await prisma.$disconnect();
}
```
### Vérification avant d'ajouter une table
```bash
# Tables référencées par un snapshot/seed → NE PAS purger
grep -rln "prisma.<entity>.findMany\|seedSnapshot" src/__tests__/
```
### Checklist
- [ ] Aucune table seed dans la liste des `deleteMany`
- [ ] Hook référencé dans `vitest.config.ts`
- [ ] Justifier en commentaire pourquoi chaque table listée est test-only
---
<a id="pattern-template-database-isolation-fichiers"></a>
## Pattern : Template database Postgres pour isoler les fichiers de tests
- Objectif : éliminer la flakiness inter-fichiers en garantissant que chaque fichier de tests démarre avec une DB seedée pristine.
- Contexte : suite vitest qui partage une DB Postgres unique (cas typique : `maxWorkers: 1` ou DB unique côté CI).
- Quand l'utiliser : projet avec ≥50 fichiers de tests partageant une DB, présence de tests qui mutent les données seed sans restaurer systématiquement.
- Quand l'éviter : si les tests utilisent transactions+rollback (déjà isolés), DB-per-worker, ou si le rôle Postgres n'a pas le droit `CREATEDB`.
- Avantage :
- flakiness inter-fichiers éliminée par construction
- les tests ne polluent plus la DB de dev partagée
- réutilisation entre sessions : ~27 s au 1er run, < 1 s aux suivants
- Limites / vigilance :
- surcoût ~50 s sur 134 fichiers (cycle `drop + create FROM TEMPLATE` ~380 ms par fichier)
- les sub-processes (`prisma db seed`) doivent recevoir les envs critiques explicitement (`APP_BASE_URL`, `JWT_SECRET`, `ENCRYPTION_KEY`)
- Validé le : 01-05-2026
- Contexte technique : Prisma 7 / pg 8 / Postgres 17 / vitest 4 — RL799_V2
### Mécanique en 3 lots
**Lot 1 — DSN + primitives admin SQL** (`dbUrls.ts`, `dbAdmin.ts`) : `getAdminDsn()`, `getTemplateDsn()`, `getTestDsn()`, `databaseExists`, `dropDatabase` (`pg_terminate_backend` + `DROP IF EXISTS`), `createDatabase(name, { template })`. Whitelist stricte des noms de DB (les SQL admin ne supportent pas `$1`).
**Lot 2 — Bootstrap idempotent du template** (`bootstrapTemplate.ts`) :
```typescript
export async function ensureTemplateReady() {
if (await databaseExists(TEMPLATE)) return; // no-op si déjà là
await createDatabase(TEMPLATE);
spawnSync('pnpm', ['-C', 'apps/api', 'exec', 'prisma', 'migrate', 'deploy'], {
env: { ...process.env, DB_URL: getTemplateDsn() },
});
spawnSync('pnpm', ['-C', 'apps/api', 'exec', 'prisma', 'db', 'seed'], {
env: { ...process.env, DB_URL: getTemplateDsn() },
});
}
```
**Lot 3 — Câblage vitest** :
```typescript
// globalSetup.ts
export default async function globalSetup() {
await ensureTemplateReady();
if (await databaseExists(TEST)) await dropDatabase(TEST);
return async () => {
if (await databaseExists(TEST)) await dropDatabase(TEST);
};
}
// setupFile.ts (avant chaque fichier)
if (await databaseExists(TEST)) await dropDatabase(TEST);
await createDatabase(TEST, { template: TEMPLATE });
```
### Pièges anticipés
- **`resolveDbUrl()` qui force le pathname `/test`** : doit préserver le DSN s'il pointe déjà sur la template (sinon le seed sub-process écrit dans la mauvaise DB).
- **`prisma db seed` sub-process** : les envs critiques chargées par `lib/prisma.ts` côté app doivent être propagées au sub-process (`loadEnv.ts` importé en tête de `globalSetup.ts`).
- **`globalForPrisma` cache un PrismaClient** : c'est le **contenu** de la DB qui change (drop+create), pas le DSN — pas de `$disconnect()` nécessaire grâce à `pg_terminate_backend`.
### Checklist
- [ ] Whitelist stricte des noms de DB autorisés (anti-injection)
- [ ] Bootstrap idempotent (réutilise la template entre sessions de dev)
- [ ] Sub-processes du bootstrap reçoivent les envs critiques
- [ ] Drop + create FROM TEMPLATE au début de chaque fichier
- [ ] Test de flakiness avant/après pour valider le gain
---
<a id="pattern-helper-waitfor-fire-and-forget"></a>
## Pattern : Helper `waitForX()` polling-borné pour les attentes fire-and-forget
- Objectif : remplacer les `setTimeout(50) + findFirst` dispersés par un helper centralisé robuste à la charge CPU et auto-documenté.
- Contexte : tests qui valident un side-effect async (audit log, notification, mail log) déclenché par `setImmediate` / `Promise.resolve().then(...)` côté SUT.
- Quand l'utiliser : tout test qui attend qu'un side-effect non-awaited apparaisse en DB.
- Quand l'éviter : pour vérifier l'**absence** d'un event — là il faut un délai fixe puis assertion d'absence (le polling jusqu'au timeout ne prouve rien).
- Avantage :
- robuste aux variations de charge (pas de durée arbitraire)
- fail rapide si l'event n'arrive pas (timeout 1500 ms par défaut)
- lisibilité : intention claire (`waitForX` vs `setTimeout` + `findFirst`)
- Limites / vigilance :
- le polling consomme des requêtes DB (1 toutes les 50 ms) — négligeable en `maxWorkers: 1`
- **NE PAS** utiliser pour vérifier l'absence d'un event
- Validé le : 25-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation
```typescript
// __tests__/helpers/asyncWait.ts
export const waitForAudit = async (
query: { action: string; targetId?: string; userId?: string },
options: { timeoutMs?: number; intervalMs?: number } = {},
) => {
const timeout = options.timeoutMs ?? 1500;
const interval = options.intervalMs ?? 50;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const audit = await prisma.auditLog.findFirst({ where: query });
if (audit) return audit;
await new Promise((r) => setTimeout(r, interval));
}
return null;
};
```
### Anti-pattern
```typescript
// ❌ délai arbitraire et fragile
await new Promise((r) => setTimeout(r, 50));
const notif = await prisma.notification.findFirst({ where: { ... } });
// ✅ polling borné, intention claire
const notif = await waitForNotification({ type: 'X', recipientId: userId });
```
### Checklist
- [ ] Filtre exhaustif (au minimum `recipientId` ou `targetId` pour éviter de matcher les artefacts d'un autre test)
- [ ] Timeout par défaut court (1500 ms)
- [ ] Migration progressive — pas tous les tests d'un coup
---
<a id="pattern-test-atomicite-transaction"></a>
## Pattern : Tester l'atomicité d'une transaction (audit + mutation)
- Objectif : prouver par un test que la promesse "X est garanti par une transaction" tient — sans ce test, un refactor "fire-and-forget" passe en review sans alarme.
- Contexte : code de service qui revendique `mutation persistée ⇔ audit log existe` via `prisma.$transaction(async tx => { await tx.X.update(...); await logActionSync(tx, ...); })`.
- Quand l'utiliser : sur tout chemin critique où l'audit ou un side-effect transactionnel est un livrable métier.
- Quand l'éviter : pour les audits purement informatifs (statistiques d'usage) où le fire-and-forget est acceptable.
- Avantage :
- le test fait office de contrat exécutable du pattern transactionnel
- une régression silencieuse (passer `prisma` au lieu de `tx`) casse le test immédiatement
- Limites / vigilance :
- le mock doit cibler le seul appel ciblé (pas un mock global qui fait tout throw)
- Validé le : 27-04-2026
- Contexte technique : Vitest / Prisma — RL799_V2
### Implémentation (recipe vitest + spyOn)
```typescript
import * as auditRepository from '../repositories/admin/auditRepository';
test('mutation rollback si audit throw', async () => {
vi.spyOn(auditRepository, 'createAuditLog').mockImplementation(async (data) => {
if (data.action === 'X.cross_soiree_update') {
throw new Error('audit-failure-simulee');
}
return null as never;
});
const res = await POST_HANDLER(...);
assert.ok(res.status >= 500);
assert.equal(await prisma.X.findFirst(...), null); // rollback
assert.equal(await prisma.auditLog.count(...), 0);
});
```
### Checklist
- [ ] Mock chirurgical sur l'action ciblée, pas un mock global
- [ ] Assertions sur **trois** invariants : status HTTP 5xx, donnée non persistée, audit non écrit
- [ ] Restaurer le mock après le test (`vi.restoreAllMocks()` en `afterEach`)
---
<a id="pattern-describe-convention-tests-integration"></a>
## Pattern : Convention `describe()` minimale pour tests d'intégration
- Objectif : rendre les fichiers de tests > 150 LOC navigables, regroupables par cause d'échec et lisibles dans le reporter.
- Contexte : tests d'intégration API où les assertions s'enchaînent sans regroupement, ou fichiers historiquement plats.
- Quand l'utiliser : tout fichier > 150 LOC ou > 5 tests.
- Quand l'éviter : fichier court (< 50 LOC, < 3 tests) où le `describe` ajoute du bruit.
- Avantage :
- le reporter vitest groupe les échecs logiquement (toutes les erreurs d'auth ensemble)
- navigation facilitée par recherche (`describe('Auth'`)
- un groupe devient candidat naturel à l'extraction en fichier dédié
- Limites / vigilance :
- 2 niveaux maximum — au-delà ça devient illisible
- Validé le : 24-04-2026
- Contexte technique : Vitest — RL799_V2
### Convention recommandée (2 niveaux)
```typescript
describe('POST /api/tenues', () => {
describe('Auth', () => {
test('refuse un user non-admin → 403', async () => { /* … */ });
test('refuse un token invalide → 401', async () => { /* … */ });
});
describe('Validation', () => {
test('refuse un body sans date → 400', async () => { /* … */ });
});
describe('Happy path', () => {
test('crée une tenue valide → 201', async () => { /* … */ });
});
});
```
### Seuils
- < 50 LOC et < 3 tests : optionnel
- 50150 LOC : 1 niveau de contexte
- \> 150 LOC ou > 5 tests : 2 niveaux obligatoires
### Niveau 2 — vocabulaire stable
`Auth`, `Validation`, `Happy path`, `Edge cases`, `Error handling`, `Integration`. Ne pas inventer un nom local — la cohérence inter-fichiers vaut plus que l'expressivité ponctuelle.
---
<a id="pattern-refactor-iteratif-tests-monolithe"></a>
## Pattern : Refactor itératif d'un fichier de tests monolithe
- Objectif : découper un fichier > 1000 LOC mêlant plusieurs domaines métier en fichiers thématiques sans introduire de régression.
- Contexte : fichier de tests historique qui partage un `beforeEach` lourd, n'a pas de `describe`, et accueille tous les nouveaux tests par défaut.
- Quand l'utiliser : > 1000 LOC, plusieurs domaines mélangés, navigation pénible.
- Quand l'éviter : fichier ciblé, déjà cohérent même volumineux.
- Avantage :
- 1 commit par domaine extrait → review possible commit par commit
- helpers réutilisables émergent naturellement
- reporter vitest groupe les échecs par domaine
- Limites / vigilance :
- flakiness inter-fichiers possible pendant la transition (cleanups incomplets temporaires)
- tolérer 1-2 fails au 1er run pendant le chantier, investiguer après
- Validé le : 25-04-2026
- Contexte technique : Vitest — RL799_V2
### Étapes (ordre obligatoire)
1. **Analyse** : `grep -nE "^test|^// ---" fichier.test.ts` → carte des sections + repérage des dépendances closure.
2. **Extraction des helpers d'abord** : `helpers/<domaine>.ts` avec `setup<Domaine>Fixtures(cleanup)` qui retourne explicitement les IDs (pas de closure module-level).
3. **POC sur le domaine le plus simple** (4-7 tests, peu de dépendances). Commit + push dès que vert.
4. **Itération domaine par domaine** par ordre croissant de complexité. Une seule règle : ne jamais commencer un nouveau domaine sans avoir commit le précédent.
5. **Suppression finale du fichier monolithe** quand le dernier domaine est extrait.
### Pièges à éviter
- **Ne jamais dupliquer le fichier** pour le scinder : toujours **déplacer** (extraction → suppression dans l'original → commit).
- **Closure variables partagées** : si les tests utilisent `let X = ''` au niveau module, les ids doivent passer par le retour de `setup<Domaine>`.
- **Snapshot/restore seed** : si le fichier original prenait un snapshot d'entries seed, **chaque** nouveau fichier doit le faire (sinon le 1er fichier qui tourne snapshot un état déjà pollué).
### Checklist
- [ ] Helpers extraits **avant** le 1er domaine
- [ ] 1 commit par domaine
- [ ] `wc -l` du fichier original baisse à chaque commit (preuve de progrès)
- [ ] Suite verte à chaque commit