--- type: testing milestone: M7 date: "2026-05-14" project: Metamorph --- # Testing M7 — Red & blue execution on a mission test ## 1. Lancement de la stack ```bash make up make migrate # applies the M7 last_actor_id migration (91a4e7c6d2f3) ``` Le boot seede automatiquement les 4 detection_levels par défaut (`detected_blocked` / `detected_alert` / `logged_only` / `not_detected`) via `seed_detection_levels()`. Si tu pars d'un stack pré-existant, un `make restart` (down+up) suffit — le seed est idempotent. > L'admin stable `admin@metamorph.local / AdminPass1234!` est restauré par > le hook `afterAll` du spec e2e M7. La 1ʳᵉ fois, bootstrappe-le via `/setup`. ## 2. Tests automatisés ```bash make test-api # 131 tests pytest, dont 25 M7 (perm gating, state machine, evidence, activity) make e2e # 48 tests Playwright, dont 5 M7 (red/blue gating, 24/26 MB, SHA256, SPA) ``` Rapport HTML : `e2e/playwright-report/`. > **Reminder** : `make test-api` et `make e2e` partagent le Postgres dev. > Lancer en milieu de session **wipe** les données — l'`afterAll` re-bootstrap > l'admin stable, mais les missions/tests/uploads sur le disque créés à la > main sont perdus. ## 3. Smoke navigateur ### Pré-requis - Stack `make up` + admin loggé. - Une mission existante avec au moins **1 scenario** snapshotté contenant **≥ 1 test** (voir `testing-m6.md` pour le chemin de création). ### 3.1 Page de test (`/missions//tests/`) 1. Depuis `/missions/`, onglet **tests**, cliquer une ligne (ou le nom du test). Redirection vers la page dédiée. 2. **En-tête** : - `← Back to mission` (link `data-testid="back-to-mission"`). - Nom du test (snapshot). - Ligne *"Last touched Xs ago by Y"* — vide à la création, remplie dès qu'un champ est sauvé. - Status pill (`Pending` / `Executed` / `Reviewed` / `Skipped` / `Blocked`). - Boutons de transitions autorisés depuis l'état courant (voir matrice en §6). 3. **Card metadata** : MITRE chips, OPSEC tag, et 4 `
` pliés (Objective / Procedure / Expected red / Expected blue). ### 3.2 Zone Red (bordure rouge) - `Command` (mono, `data-testid="red-command"`). - `Output` (textarea mono multilign, `data-testid="red-output"`). - `Comment` (markdown, `data-testid="red-comment"`). - Toggle **Override executed-at** + input datetime-local — disabled tant que le test n'est pas `executed` / `reviewed_by_blue`. - Bouton **Save red fields** : - disabled si rien n'a changé ou si l'utilisateur n'a pas `mission.write_red_fields`. - Sur succès, le bandeau "Last touched" se met à jour (cache invalidé). ### 3.3 Zone Blue (bordure cyan) - Select `Detection level` (sourcé de `/detection-levels`). - `Comment` (markdown, `data-testid="blue-comment"`). - Bouton **Save blue fields** (analogue à la zone red). - **Evidence dropzone** : - Drag & drop ou bouton **Pick files** (multi-fichiers). - Sous la dropzone, la liste des extensions acceptées est affichée : `Accepted: .png · .jpg · .jpeg · .pdf · .txt · .log · .json · .csv · .evtx · .zip · max 25 MB / file` (testid `evidence-allowed-formats`). - Le sélecteur OS est pré-filtré via l'attribut `accept` — pas de "All files". - Un drag&drop d'une extension hors-liste est rejeté côté client avec le message d'erreur en rouge ; le serveur re-vérifie quoi qu'il arrive (`unsupported_extension` / `too_large`). - Limite : **25 MB / fichier** (côté client = garde-fou UX, côté serveur = stricte avec stream cap chunk-par-chunk). - Table récap : nom · taille · uploader · `sha256[:12]…` · link download + bouton soft-delete. ### 3.5 Override executed-at — pièges timezone - Le toggle **Override executed-at timestamp** affiche un input `datetime-local` qui parle en **heure locale du navigateur** (ex. Europe/Paris UTC+2). L'état local du composant garde la valeur sous forme `YYYY-MM-DDTHH:MM` ; la conversion en UTC ISO n'a lieu qu'au submit. Donc si tu tapes `10:30` à Paris, le serveur reçoit `08:30:00+00:00` — c'est attendu. - Le polling activity (15 s) ne re-sync l'état local **que** sur changement d'identité du test (`useEffect([test.id])`) — une frappe en cours n'est jamais écrasée par un refetch. ### 3.4 Indicateur d'activité - À l'arrivée sur la page, le polling `GET /missions//activity` démarre (toutes les 15 s, gated sur `document.visibilityState === 'visible'`). - Si un autre user édite le test, la query est invalidée → la page reload les champs (TanStack cache replaced). - Le `server_time` est passé en `?since=` à l'appel suivant pour ne recevoir que ce qui a bougé depuis. ## 4. Vérifications fonctionnelles (DoD) ### 4.1 Red écrit en parallèle de Blue, sans conflit 1. Sur un test `pending`, login en red dans 1 onglet, en blue dans un autre. 2. Red : remplit `red_command` + sauve. 3. Blue : sélectionne `detected_alert` + commentaire + sauve. 4. Les 2 saves passent en 200, aucun conflit. 5. Rafraîchir l'onglet red → les champs blue apparaissent (et réciproquement). ### 4.2 Perm gating field-level | User | red_command | red_comment | blue_comment | detection_level | upload | |--------------|------------:|------------:|-------------:|----------------:|-------:| | red | ✓ | ✓ | **403** | **403** | **403** | | blue | **403** | **403** | ✓ | ✓ | ✓ | | red + blue | ✓ | ✓ | ✓ | ✓ | ✓ | | admin | ✓ | ✓ | ✓ | ✓ | ✓ | ### 4.3 Evidence upload — limites 1. Upload un fichier `.evtx` de **24 MB** → 201, body inclut `sha256`, `size_bytes=25165824`, `mime=application/octet-stream`. 2. Vérif `sha256` côté client : `sha256sum file24.evtx` == `body.sha256`. 3. Upload un fichier `.evtx` de **26 MB** → 400 `{error:"too_large"}`. 4. Upload un fichier `.exe` (1 octet) → 400 `{error:"unsupported_extension"}`. 5. Download via le lien `download` → bytes byte-for-byte identiques. ### 4.4 Soft delete d'evidence 1. Upload un PDF, vérif qu'il apparaît dans la table. 2. Cliquer **delete** → confirmation → row disparaît. 3. `GET /evidence/` → 404 (le row reste en DB avec `deleted_at` set, mais le service l'occulte). 4. Sur disque, `/data/evidence///.pdf` est **conservé** (purge physique = M12). ## 5. Vérification du state machine | from | to | result | side requis | |--------------------|-------------------|--------|-------------| | pending | executed | 200 | red | | pending | skipped | 200 | any | | pending | blocked | 200 | any | | pending | reviewed_by_blue | **409** | — | | executed | reviewed_by_blue | 200 | blue | | executed | pending | 200 | red (reset) | | reviewed_by_blue | executed | 200 | blue | | reviewed_by_blue | pending | **409** | — | | skipped | pending | 200 | any | | blocked | pending | 200 | any | | any | (same state) | 200 | — (no-op) | ```bash curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \ -d '{"target_state":"executed"}' \ http://localhost:8080/api/v1/missions//tests//transition ``` Side-effect attendu : `target_state="executed"` stamp `executed_at=now()` et remet `executed_at_overridden=false`. Le retour à `pending` efface `executed_at`. ## 6. Vérification override executed_at 1. État `pending` → PUT `{"executed_at": "...", "executed_at_overridden": true}` → **400** (refusé tant que le test n'a pas été marqué executed). 2. Transition `pending → executed` → `executed_at` auto-stamp. 3. PUT `{"executed_at":"2026-05-14T10:00:00+00:00","executed_at_overridden":true}` → 200, body reflète la nouvelle date + override=true. 4. Blue user tente le même PUT → **403** (executed_at est red-side). ## 7. Vérification activity polling ```bash # Snapshot t0 curl -H "Authorization: Bearer $T" \ http://localhost:8080/api/v1/missions//activity \ | jq .server_time # Mutate curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \ -d '{"red_comment_md":"poke"}' \ http://localhost:8080/api/v1/missions//tests/ # Poll t1 (URL-encode the timestamp's `+`) SINCE=$(python -c "import urllib.parse;print(urllib.parse.quote('${T0}'))") curl -H "Authorization: Bearer $T" \ "http://localhost:8080/api/v1/missions//activity?since=${SINCE}" ``` Réponse attendue : 1 entrée pour le test mis à jour, avec `last_actor_email` peuplé. ## 8. Quick teardown ```bash make down # ou reset complet (test-only) : curl -X POST http://localhost:8080/api/v1/diag/reset ```