mirror of
https://github.com/MaksTinyWorkshop/_Assistant_Lead_Tech
synced 2026-04-06 21:41:42 +02:00
307 lines
11 KiB
Markdown
307 lines
11 KiB
Markdown
# Backend — Risques & vigilance : Prisma
|
|
|
|
> Extrait de la base de connaissance Lead_tech. Voir `knowledge/backend/risques/README.md` pour l'index complet.
|
|
|
|
---
|
|
|
|
<a id="risque-prisma-unique-nullable"></a>
|
|
## PostgreSQL / Prisma : `@unique` sur champ nullable (idempotence cassée)
|
|
|
|
### Risques
|
|
|
|
- Doublons en base malgré un "unique" attendu (PostgreSQL autorise plusieurs `NULL` dans un index UNIQUE)
|
|
- Upserts non idempotents si la clé peut être `null` (`where: { externalId: null }` crée plusieurs lignes)
|
|
|
|
### Symptômes
|
|
|
|
- Plusieurs enregistrements "équivalents" avec `externalId = NULL`
|
|
- Rejouer un webhook / retry réseau crée une nouvelle ligne au lieu d'upsert
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
- Toute clé utilisée dans un `where` d'`upsert` doit être **non-nullable**
|
|
- Si un identifiant externe peut légitimement être `null`, ne pas l'utiliser comme clé d'idempotence : choisir une autre clé unique non-nullable
|
|
|
|
---
|
|
|
|
<a id="risque-prisma-transaction-toctou-tenantid"></a>
|
|
## Prisma `$transaction` : fenêtres TOCTOU (check hors transaction)
|
|
|
|
### Risques
|
|
|
|
- Un pre-check + une `$transaction` avec un `update` non sécurisé crée une fenêtre TOCTOU
|
|
- Deux appels concurrents peuvent tous deux passer le check et agir simultanément
|
|
- En multi-tenant : un bug upstream peut permettre une écriture cross-tenant malgré le guard applicatif
|
|
|
|
### Symptômes
|
|
|
|
- Double action sur un état booléen (ex : double mise en vitrine) si le check n'est pas dans la transaction
|
|
- Écriture sur une ressource d'un autre tenant possible en race condition
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
**Cas 1 — Multi-tenant : inclure `tenantId` dans chaque écriture**
|
|
|
|
```typescript
|
|
// ❌ Anti-pattern — check OK mais écriture sans tenantId
|
|
const existing = await prisma.item.findMany({ where: { id: { in: ids }, tenantId } });
|
|
await prisma.$transaction(
|
|
ids.map((id, idx) => prisma.item.update({ where: { id }, data: { sortOrder: idx + 1 } }))
|
|
);
|
|
|
|
// ✅ Défense en profondeur — tenantId dans chaque écriture
|
|
await prisma.$transaction(
|
|
ids.map((id, idx) => prisma.item.updateMany({ where: { id, tenantId }, data: { sortOrder: idx + 1 } }))
|
|
);
|
|
```
|
|
|
|
- Règle : toute écriture Prisma sur une ressource tenant-aware doit inclure `tenantId` dans le WHERE, même dans une transaction précédée d'un check
|
|
- Utiliser `updateMany`/`deleteMany` pour inclure `tenantId` sans exception si 0 lignes
|
|
|
|
**Cas 2 — Idempotence / plafond : re-check d'état à l'intérieur de la transaction**
|
|
|
|
```typescript
|
|
// ❌ Anti-pattern : check d'état hors transaction
|
|
if (resource.isActive) throw ...;
|
|
await prisma.$transaction(async (tx) => {
|
|
// resource.isActive a pu changer entre-temps
|
|
return tx.resource.update(...);
|
|
});
|
|
|
|
// ✅ Pattern correct : check ET update dans la transaction
|
|
await prisma.$transaction(async (tx) => {
|
|
const current = await tx.resource.findUnique({ where: { id } });
|
|
if (current?.isActive) throw ...; // re-check atomique
|
|
const count = await tx.resource.count(...);
|
|
if (count >= LIMIT) throw ...;
|
|
return tx.resource.update(...);
|
|
});
|
|
```
|
|
|
|
- Règle : tout guard métier de type "déjà fait / plafond atteint" doit être vérifié à l'intérieur de la transaction, pas avant
|
|
|
|
- Contexte technique : Prisma / multi-tenant — app-template-resto 21-03-2026 ; NestJS / Prisma — app-alexandrie 23-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-prisma-or-tenantid-null"></a>
|
|
## Prisma OR multi-tenant : `tenantId: null` manquant sur la branche système
|
|
|
|
### Risques
|
|
|
|
- Sur un modèle à `tenantId` nullable distinguant ressources "système" et "tenant", un filtre `{ isSystem: true }` sans `tenantId: null` expose des ressources corrompues à tous les tenants
|
|
|
|
### Symptômes
|
|
|
|
- Un tag `isSystem: true` avec `tenantId` non-null est exposé à tous les tenants
|
|
- Bug de sécurité difficile à détecter car le comportement nominal semble correct
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```typescript
|
|
// ❌ Trop permissif
|
|
OR: [{ isSystem: true }, { tenantId, isSystem: false }]
|
|
|
|
// ✅ Défense en profondeur — double condition sur la branche système
|
|
OR: [{ isSystem: true, tenantId: null }, { tenantId, isSystem: false }]
|
|
```
|
|
|
|
- Règle : sur tout modèle `tenantId?` (nullable) + flag `isSystem`/`isGlobal`/`isPublic`, la branche "ressource publique" du filtre OR doit toujours inclure `tenantId: null`
|
|
|
|
- Contexte technique : Prisma / multi-tenant — app-template-resto 21-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-nextorder-hors-transaction"></a>
|
|
## Calcul de `nextOrder` hors transaction (race condition `sortOrder`)
|
|
|
|
### Risques
|
|
|
|
- Deux requêtes concurrentes obtiennent le même `MAX(sortOrder)` et créent deux entités avec le même `sortOrder`
|
|
|
|
### Symptômes
|
|
|
|
- Deux items avec le même `sortOrder` dans la même catégorie/scope
|
|
- Bug aléatoire selon la charge — invisible en dev, présent en prod
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```typescript
|
|
// ✅ Calcul dans la transaction interactive
|
|
return prisma.$transaction(async (tx) => {
|
|
const maxOrder = await tx.entity.aggregate({
|
|
where: { tenantId, scopeId },
|
|
_max: { sortOrder: true },
|
|
});
|
|
const nextOrder = (maxOrder._max.sortOrder ?? 0) + 1;
|
|
return tx.entity.create({ data: { ..., sortOrder: nextOrder } });
|
|
});
|
|
```
|
|
|
|
- Règle : ne jamais calculer `maxOrder` hors de la transaction qui crée l'entité
|
|
|
|
- Contexte technique : Prisma / transactions — app-template-resto 21-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-tenantid-sans-fk-relation"></a>
|
|
## Champ `tenantId` sans FK ni relation Prisma vers `Tenant`
|
|
|
|
### Risques
|
|
|
|
- Un `tenantId TEXT NOT NULL` sans relation Prisma ne génère aucune FK en DB
|
|
- L'isolation multi-tenant n'est pas enforced au niveau base de données
|
|
|
|
### Symptômes
|
|
|
|
- Migration SQL sans `ALTER TABLE ... ADD CONSTRAINT ... REFERENCES "tenants"`
|
|
- Prisma ne génère pas de FK automatiquement sans `@relation` déclarée
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
Tout modèle tenant-scoped doit avoir les trois :
|
|
1. `tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)` dans le modèle Prisma
|
|
2. La relation inverse dans `Tenant` (ex: `menuCategories MenuCategory[]`)
|
|
3. La FK correspondante dans la migration SQL
|
|
|
|
- **Checklist review** : vérifier systématiquement que les nouveaux modèles respectent ce guardrail
|
|
|
|
- Contexte technique : Prisma / multi-tenant — app-template-resto 17-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-schema-divergence-spec-story"></a>
|
|
## Divergence schéma Prisma / spec story (champ déclaré ✅ mais absent)
|
|
|
|
### Risques
|
|
|
|
- Une tâche de story cochée ✅ implique un champ (ex: `consumedAt`, `tokenHash`) qui n'existe pas dans `schema.prisma`
|
|
- Le code compile ou passe en review sans que le champ soit réellement présent en DB
|
|
|
|
### Symptômes
|
|
|
|
- Erreur à l'exécution sur un champ inexistant malgré une story marquée "done"
|
|
- `schema.prisma` ne contient pas le champ mentionné dans les tâches
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
- Avant de marquer une tâche ✅, croiser avec `schema.prisma` pour confirmer que le champ existe réellement
|
|
- Une story peut décrire un champ comme stratégie de conception sans l'avoir intégré — toujours vérifier
|
|
|
|
- Contexte technique : Prisma / app-template-resto — 16-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-prismaservice-getter-manquant"></a>
|
|
## PrismaService — getter explicite manquant sur nouveau modèle
|
|
|
|
### Risques
|
|
|
|
- L'ajout d'un modèle dans `schema.prisma` sans son getter dans `PrismaService` casse le typecheck
|
|
- Erreur silencieuse si les modules sont peu typés
|
|
|
|
### Symptômes
|
|
|
|
- `Property 'forum' does not exist on type 'PrismaService'` à la compilation
|
|
- Module fonctionnel sur le `PrismaClient` direct mais cassé via `PrismaService`
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
Tout ajout de modèle Prisma = **deux actions** :
|
|
|
|
1. Ajouter le modèle dans `schema.prisma`
|
|
2. Ajouter le getter dans `prisma.service.ts`
|
|
|
|
```typescript
|
|
// apps/api/src/infra/prisma/prisma.service.ts
|
|
get forum() {
|
|
return this.client.forum;
|
|
}
|
|
```
|
|
|
|
- **Checklist review** : à chaque nouvelle migration Prisma, vérifier que `prisma.service.ts` est mis à jour.
|
|
- Contexte technique : NestJS / PrismaService encapsulé — app-alexandrie 20-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-prisma-init-module-build"></a>
|
|
## Prisma initialisé au chargement de module — casse le build Next.js
|
|
|
|
### Risques
|
|
|
|
- Un import global qui initialise Prisma immédiatement peut faire échouer la collecte de pages/routes au build si `DATABASE_URL` n'est pas disponible dans l'environnement de build
|
|
|
|
### Symptômes
|
|
|
|
- `PrismaClientInitializationError` ou `Error: Environment variable not found: DATABASE_URL` au `next build`
|
|
- L'app tourne en dev mais le build CI échoue
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
- Préférer une initialisation lazy-safe : retarder l'accès DB au moment de l'appel métier
|
|
- Retourner un proxy qui lève une erreur claire uniquement lors du premier accès réel à la DB
|
|
- Ne jamais instancier `new PrismaClient()` au top-level d'un module importé par Next.js
|
|
|
|
- Contexte technique : Next.js App Router / Prisma — app-template-resto 16-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-jest-clearallmocks-imbrique"></a>
|
|
## `jest.clearAllMocks()` dans des `beforeEach` imbriqués avec mocks Prisma
|
|
|
|
### Risques
|
|
|
|
- Remise à zéro d'un setup attendu par un scope de test plus profond
|
|
- Tests verts ou rouges pour de mauvaises raisons
|
|
- Forte difficulté à comprendre l'état réel des mocks
|
|
|
|
### Symptômes
|
|
|
|
- Comportement différent selon l'ordre ou le niveau d'imbrication des `describe`
|
|
- Mocks Prisma "perdus" entre deux tests
|
|
- Corrections locales qui cassent d'autres blocs de tests
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
- Centraliser la stratégie de reset des mocks
|
|
- Éviter les `clearAllMocks()` concurrents à plusieurs niveaux de nesting
|
|
- Préférer un setup explicite et local par scénario quand les mocks Prisma sont structurants
|
|
- Contexte technique : Jest / Prisma / tests NestJS — 10-03-2026
|
|
|
|
---
|
|
|
|
<a id="risque-cursor-pagination-opaque"></a>
|
|
## Cursor de pagination opaque — validation manquante (500 au lieu de 400)
|
|
|
|
### Risques
|
|
|
|
- Un cursor base64url+JSON non validé crash en HTTP 500 si malformé ou corrompu
|
|
- Exposé à des attaques par input malveillant sur les endpoints paginés publics ou semi-publics
|
|
|
|
### Symptômes
|
|
|
|
- `JSON.parse` ou décodage base64 lève une exception non catchée → 500 en prod
|
|
- Les logs montrent une stack trace sur un endpoint paginé avec un cursor externe
|
|
|
|
### Bonnes pratiques / mitigations
|
|
|
|
```typescript
|
|
// ❌ DANGEREUX — crash 500 si cursor corrompu
|
|
const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString());
|
|
|
|
// ✅ CORRECT — validation avec code d'erreur sémantique
|
|
let decoded = null;
|
|
if (cursor) {
|
|
try {
|
|
decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString());
|
|
if (!decoded.createdAt || !decoded.id) throw new Error('Champs manquants');
|
|
} catch {
|
|
throw new BadRequestException({ error: { code: 'INVALID_CURSOR', message: 'Cursor de pagination invalide.' } });
|
|
}
|
|
}
|
|
```
|
|
|
|
- **Règle** : ajouter un test unitaire "cursor invalide → 400" sur tout endpoint paginé par cursor
|
|
|
|
- Contexte technique : NestJS / pagination — app-alexandrie 24-03-2026
|