test(m5): playwright spec + docs (CHANGELOG, README, lessons, testing-m5)
- 4 Playwright tests: API CRUD round-trip, scenario reorder via PUT, SPA list + opsec filter, SPA scenario list rendering with ordered tests. - afterAll restores the stable admin (admin@metamorph.local) per the test_admin memory rule. - CHANGELOG M5 section + Fixed subsections for the LogRecord 'name' collision and the React `currentTarget` vs `target` quirk. - README status bumps to M0-M5. - tasks/lessons.md captures the new patterns (sentinel pattern for partial-update, FK ordering in /diag/reset, dnd-kit stable IDs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,18 @@ project: Metamorph
|
||||
- **`podman compose stop api` puis `up -d api` casse les dépendances** entre containers (`db` healthy → `api` depends on it) : podman-compose ne résout pas la chaîne de deps quand on cible un seul service. Pour un override d'env, mieux vaut `make down && APP_ENV=test make up`.
|
||||
- **`/diag/reset` test-only** : exposer un endpoint qui truncate la DB est tentant pour les e2e mais ouvre une grosse surface en cas de fuite. Compromise actuel : autorisé en `dev` ET `test` (pas en prod), avec un log `WARNING` à chaque appel. Si jamais on déploie une stack dev publique, **désactiver** l'endpoint via env var.
|
||||
|
||||
## 2026-05-12 — M5 templates + scenarios
|
||||
|
||||
- **`extra={"name": ...}` dans `log.info()` crash silencieusement** — Python's `logging.LogRecord` réserve `name` (le logger name). Coût : 500 sur le POST, message peu parlant (`KeyError: "Attempt to overwrite 'name' in LogRecord"`). Fix : renommer la clé (`template_name`). Liste réservée à éviter : `name`, `msg`, `args`, `levelname`, `levelno`, `pathname`, `filename`, `module`, `funcName`, `created`, `msecs`, `lineno`, `thread`, `threadName`, `process`. Pattern : préfixer les clés extra par l'entité (`template_name`, `group_id`, `user_id` est OK mais `id` aussi est piégeux dans certains setups).
|
||||
- **React 18 + `setX((prev) => ({...prev, val: e.currentTarget.value }))` → page blanche au 1er input.** `e.currentTarget` est cleared après la fin du bubble, AVANT que l'updater fonctionnel exécute. Le synthetic event survit (pas de pooling depuis React 17), mais `currentTarget` est setté/cleared par le dispatcher. Fix : `e.target.value` (qui persiste sur le synthetic event), ou capturer `const v = e.currentTarget.value;` avant le `setX`. À garder en tête : tout `onChange` qui passe par un updater fonctionnel doit lire `e.target`, pas `e.currentTarget`.
|
||||
- **Sentinel `Any = object()` plutôt que `... (Ellipsis)`** pour les "field unset" optional en service Python. Pyright voit `... = object()` correctement comme `Any`, alors que `description: str | None | object = ...` rend `description.strip()` invalide. Pattern : `_UNSET: Any = object()` au top du module + `description: Any = _UNSET` dans la signature + `if description is not _UNSET: ...`. Net + typecheck-friendly.
|
||||
- **Postgres UNIQUE(scenario_id, position) + position-swap = ON CONFLICT pendant l'UPDATE.** Pour réordonner, le pattern naïf (UPDATE position) viole la contrainte sur le 1er swap. Trois options : (a) full delete + re-insert dans la même tx [retenu, atomique + lisible], (b) shift d'offset (UPDATE position = position + 1000 puis renumérotation), (c) deferred constraint. (a) gagne en simplicité — la liste rarement >50 éléments, le coût est négligeable.
|
||||
- **`@dnd-kit/sortable` requires `useSortable({ id })` IDs to be unique and stable across renders.** Si on utilise un index numérique comme id, drag-and-drop ne réagit pas. Utiliser `test_template_id` (UUID stable) marche directement.
|
||||
- **Frontend deps ajoutés à `package.json` sans `package-lock.json`** : le Dockerfile fait `npm install --no-audit --no-fund` sur fallback. OK pour M5 (3 deps `@dnd-kit/*`). À l'avenir, freeze un lockfile avant M14 pour build reproductibles.
|
||||
- **Playwright `getByTestId` est défini par `testIdAttributeName: 'data-testid'`** dans `playwright.config.ts`. Pour qu'un test-id descende sur l'input via TextField, il faut que `...rest` soit spread sur l'input (déjà OK dans `TextField.tsx`). Mais avec un wrapper `<div><label/><input/></div>`, `getByTestId` matche le DIV si le test-id est dessus. Bien le mettre sur l'élément interactif (input/button), pas sur le container.
|
||||
- **`/diag/reset` truncate order matters** : `scenario_template_tests.test_template_id` est FK `ON DELETE RESTRICT`, donc il faut truncate `scenario_template_tests` AVANT `test_templates`. Hierarchy : `scenario_template_tests → scenario_templates → test_template_mitre_tags → test_templates → mitre_*`. Maintenant inscrite dans `diag.py`.
|
||||
- **Modal embarquant le `MitreTagPicker` complet (15 cols × 50 techniques)** : le picker se charge via `/mitre/matrix` (~94 KB). Affichage instantané, OK. Pour de futurs modals lourds, considérer le lazy-render derrière un toggle ou tab.
|
||||
|
||||
<!--
|
||||
Template for future entries:
|
||||
|
||||
|
||||
131
tasks/testing-m5.md
Normal file
131
tasks/testing-m5.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
type: testing
|
||||
milestone: M5
|
||||
date: "2026-05-12"
|
||||
project: Metamorph
|
||||
---
|
||||
|
||||
# Testing M5 — Templates : tests unitaires & scénarios
|
||||
|
||||
## 1. Lancement de la stack
|
||||
|
||||
```bash
|
||||
make clean
|
||||
make up
|
||||
make migrate
|
||||
make seed-mitre # tag picker needs the catalogue
|
||||
```
|
||||
|
||||
> L'admin stable `admin@metamorph.local / AdminPass1234!` est restauré
|
||||
> automatiquement par le hook `afterAll` du spec e2e M5 — mais la 1ʳᵉ fois,
|
||||
> bootstrappe-le via `/setup` ou laisse les tests faire le travail.
|
||||
|
||||
## 2. Tests automatisés
|
||||
|
||||
```bash
|
||||
make test-api # 77 tests pytest dont 19 M5 (CRUD, perm, mitre tags, reorder)
|
||||
make e2e # 38 tests Playwright dont 4 M5 (API CRUD + scenario reorder + SPA list/filter)
|
||||
```
|
||||
|
||||
Rapport HTML : `e2e/playwright-report/`. JUnit : `e2e/playwright-report/junit.xml`.
|
||||
|
||||
## 3. Smoke navigateur
|
||||
|
||||
### Pré-requis
|
||||
- Stack `make up` + admin loggé.
|
||||
- MITRE seedé (vérifier via `/mitre`).
|
||||
|
||||
### 3.1 Catalogue de tests (`/admin/tests`)
|
||||
1. Cliquer **Tests** dans la nav admin → page chargée.
|
||||
2. Cliquer **+ New test** → modal s'ouvre avec :
|
||||
- Champs : Name, Description, Objective, Procedure (markdown), Prerequisites, Red expected, Blue expected, OPSEC, Free tags, Expected IOCs.
|
||||
- Sous-section **MITRE ATT&CK tags** : matrice complète, mêmes interactions que `/mitre`.
|
||||
3. Remplir au minimum `Name=phish-link`, OPSEC=`low`, ajouter 2 tags MITRE (ex. `TA0001 + T1566`) → **Create** → carte apparaît dans la liste avec chips OPSEC + MITRE.
|
||||
4. Cliquer **Edit** sur la carte → modal pré-remplie, modifier OPSEC à `high` → **Save** → la card est repeinte avec l'accent rouge OPSEC.
|
||||
5. Filtres en haut :
|
||||
- `Search` (full-text q sur nom/description)
|
||||
- `Tactic external_id` (ex. `TA0001`)
|
||||
- `OPSEC` (select : —all— / low / medium / high)
|
||||
- `Free tag` (mot-clé libre)
|
||||
6. Cliquer **Delete** sur une carte → confirm popup → la card disparaît (soft-delete : visible via `?include_deleted=true` côté API).
|
||||
|
||||
### 3.2 Catalogue de scénarios (`/admin/scenarios`)
|
||||
1. Cliquer **Scenarios** dans la nav admin.
|
||||
2. **+ New scenario** → modal.
|
||||
- Champs Name + Description.
|
||||
- Catalogue picker en bas : champ de recherche + liste des tests dispos (max 50).
|
||||
3. Cliquer 3 tests dans le catalogue → ils s'empilent dans la liste ordonnée avec leurs indices `01/02/03`.
|
||||
4. **Drag-and-drop** : empoigner la poignée `☰` à gauche d'une ligne et glisser vers le haut/bas → la liste se réordonne. La grille met à jour les indices au relâchement.
|
||||
5. **Save** → carte apparaît avec un Tag « N tests » + l'aperçu des 4 premiers tests dans l'ordre choisi.
|
||||
6. Re-ouvrir Edit → l'ordre est persisté côté serveur (vérifie le numéro 01, 02, 03 dans la modal).
|
||||
7. Supprimer un `test_template` dont un scénario dépend (via `/admin/tests`) → la card scénario marque le test en rose dans le résumé (`test_template_deleted: true`).
|
||||
|
||||
### 3.3 Permissions
|
||||
1. Inviter Bob via Admin > Invitations sans groupe → Bob peut se logger mais reçoit `403` sur `/api/v1/test-templates`.
|
||||
2. Lui attacher un groupe avec seulement `test_template.read` → Bob voit `/admin/tests`... non, l'UI gate sur `is_admin`. La perm seule donne l'accès API ; l'UI ne l'expose pas pour les non-admins (par design M5).
|
||||
3. Bob tente `POST /api/v1/test-templates` → `403` (manque `test_template.create`).
|
||||
|
||||
## 4. Smoke API
|
||||
|
||||
### 4.1 Login admin
|
||||
|
||||
```bash
|
||||
ACCESS=$(curl -sX POST http://localhost:8080/api/v1/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"admin@metamorph.local","password":"AdminPass1234!"}' | jq -r .access_token)
|
||||
```
|
||||
|
||||
### 4.2 Créer un test taggué MITRE
|
||||
|
||||
```bash
|
||||
curl -sX POST http://localhost:8080/api/v1/test-templates \
|
||||
-H "Authorization: Bearer $ACCESS" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "lsass-dump",
|
||||
"opsec_level": "high",
|
||||
"tags": ["creds"],
|
||||
"mitre_tags": [
|
||||
{"kind":"technique","external_id":"T1003"},
|
||||
{"kind":"subtechnique","external_id":"T1003.001"}
|
||||
]
|
||||
}' | jq
|
||||
```
|
||||
|
||||
### 4.3 Créer un scénario ordonné
|
||||
|
||||
```bash
|
||||
# Suppose 3 ids: $A $B $C
|
||||
curl -sX POST http://localhost:8080/api/v1/scenario-templates \
|
||||
-H "Authorization: Bearer $ACCESS" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"name\":\"chained\",\"test_template_ids\":[\"$A\",\"$B\",\"$C\"]}" | jq
|
||||
|
||||
# Reorder (full replace)
|
||||
curl -sX PUT http://localhost:8080/api/v1/scenario-templates/<scn_id>/tests \
|
||||
-H "Authorization: Bearer $ACCESS" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"test_template_ids\":[\"$C\",\"$A\",\"$B\"]}" | jq
|
||||
```
|
||||
|
||||
### 4.4 Filtre par tactic
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:8080/api/v1/test-templates?tactic=TA0006" \
|
||||
-H "Authorization: Bearer $ACCESS" | jq '.items[].name'
|
||||
```
|
||||
|
||||
## 5. Points de contrôle critiques
|
||||
|
||||
- [x] `POST /test-templates` rejette MITRE inconnu avec `400 unknown_mitre_tag`.
|
||||
- [x] `POST /test-templates` rejette opsec hors `low/medium/high`.
|
||||
- [x] `PUT /test-templates/{id}` partial keeps unset fields.
|
||||
- [x] `PUT /test-templates/{id}` avec `mitre_tags` **remplace** la collection (pas d'append).
|
||||
- [x] `DELETE /test-templates/{id}` soft-delete (visible avec `?include_deleted=true`).
|
||||
- [x] `POST /scenario-templates` rejette test_template inconnu ou soft-deleted.
|
||||
- [x] `PUT /scenario-templates/{id}/tests` rewrite atomique (delete + re-insert, contrainte UNIQUE(position) honorée).
|
||||
- [x] Un test soft-deleted **après** linking reste référencé : `test_template_deleted: true` sur le scénario.
|
||||
- [x] Filtres list: `q`, `tactic`, `technique`, `subtechnique`, `opsec`, `tag` cumulatifs.
|
||||
- [x] Perm gating : `test_template.{read,create,update,delete}` + `scenario_template.{read,create,update,delete}`.
|
||||
- [x] `/diag/reset` truncate les 4 nouvelles tables (`scenario_template_tests`, `scenario_templates`, `test_template_mitre_tags`, `test_templates`) avant les tables MITRE.
|
||||
- [x] UI : drag-and-drop @dnd-kit/sortable réordonne la liste, save persistant.
|
||||
@@ -117,7 +117,7 @@ spec: tasks/spec.md
|
||||
|
||||
---
|
||||
|
||||
## M5 — Templates : tests unitaires & scénarios ☐
|
||||
## M5 — Templates : tests unitaires & scénarios ☑
|
||||
|
||||
**But** : admin peut bâtir le catalogue réutilisable.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user