Files
Metamorph/tasks/testing-m7.md
Knacky 9fc78e0832 feat(m7-amend): full-bleed scenario table with inline edit + docs
Frontend half of the 2026-05-15 amendment (backend shipped in 447f152).

- `MissionScenarioTable` component: per-scenario <table> with 7 cols
  (Test | Procédure | Exécution | Source de log | Commentaires | Logs
  SIEM | Cyber Incident) + Actions cell. Read mode truncates; double-
  click toggles a row into edit mode where each cell becomes the right
  control. detection_level lives inside the Commentaires cell as a
  pill + select (no 8th column).
- MissionDetailPage Tests tab uses the new component, lifts
  `editingTestId` so only one row across the whole mission is editable
  at a time. Esc reverts (prompt if dirty), double-click on a different
  row with a dirty draft also prompts.
- Full-bleed escape via `calc(50% - 50vw)` (same recipe as the M4 MITRE
  picker). 7 dense columns breathe on wide screens, no horizontal scroll.
- `draftDiff(test, draft)` returns `null` when nothing changed → no PUT
  on a no-op save. The diff carries only touched fields so the server's
  per-field perm gate stays clean.
- Datetime semantics: both datetime-local inputs reuse the M7 verbatim
  recipe (`iso.slice(0, 16)` + `${local}:00Z`), zero TZ shift.

Docs
- tasks/testing-m7.md §3.0 documents the column matrix + edit workflow.
- tasks/lessons.md captures the Pydantic ctx-serialisation pitfall, the
  naïve-datetime guard, the table-edit pattern.
- CHANGELOG section moves "Frontend (in progress)" → "Frontend (shipped)"
  and details the diff.

49 Playwright tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:51:28 +02:00

11 KiB

type, milestone, date, project
type milestone date project
testing M7 2026-05-14 Metamorph

Testing M7 — Red & blue execution on a mission test

1. Lancement de la stack

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

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.0 Vue tabulaire (/missions/<id> — onglet tests, amendement 2026-05-15)

L'onglet tests rend désormais un tableau plein écran par scénario (un row par test). Largeur pleine viewport (max-w-page échappé via la recette calc(50% - 50vw) — même mécanisme que le picker MITRE).

Colonnes :

Colonne Read mode Edit mode Perm requise
Test nom + chips MITRE (read-only)
Procédure snapshot_objective / description tronqués 180 chars (read-only)
Exécution executed_at + red_command tronqué datetime-local + textarea command mission.write_red_fields
Source de log blue_log_source input texte (placeholder EDR / Firewall / NDR …) mission.write_blue_fields
Commentaires pill state + pill detection_level + commentaire select detection_level + textarea commentaire mission.write_blue_fields
Logs SIEM blue_siem_logs tronqué 240 chars textarea 6 lignes mission.write_blue_fields
Cyber Incident incident_at / incident_number / incident_recipient_email empilés 3 inputs (datetime-local / texte / email) mission.write_blue_fields
Actions lien open ↗ vers /missions/<id>/tests/<test_id> (full detail + evidence) boutons Save / Cancel

Workflow d'édition :

  1. Double-clic sur une ligne → la ligne entre en mode édition (les cellules deviennent des inputs). Une seule ligne en édition à la fois — un double-clic sur une autre ligne propose Discard unsaved changes? si la précédente est dirty.
  2. Esc = cancel (prompt si dirty).
  3. Save = PUT /missions/{id}/tests/{test_id} avec uniquement les champs modifiés. Les cellules qu'un user ne peut pas écrire restent disabled ; le serveur revalide quoi qu'il arrive (defense in depth).
  4. Pour l'upload de preuves : cliquer open ↗ qui ouvre la page détail (zone Red / zone Blue + dropzone evidence).

3.1 Page de test (/missions/<id>/tests/<test_id>)

  1. Depuis /missions/<id>, 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 <details> 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/<id>/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/<id> → 404 (le row reste en DB avec deleted_at set, mais le service l'occulte).
  4. Sur disque, /data/evidence/<mission_id>/<test_id>/<sha256>.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)
curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
     -d '{"target_state":"executed"}' \
     http://localhost:8080/api/v1/missions/<mid>/tests/<tid>/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 → executedexecuted_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

# Snapshot t0
curl -H "Authorization: Bearer $T" \
     http://localhost:8080/api/v1/missions/<mid>/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/<mid>/tests/<tid>
# 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/<mid>/activity?since=${SINCE}"

Réponse attendue : 1 entrée pour le test mis à jour, avec last_actor_email peuplé.

8. Quick teardown

make down
# ou reset complet (test-only) :
curl -X POST http://localhost:8080/api/v1/diag/reset