From ba313a38809560dd42efa513a39beb9a6e5f2125 Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 27 May 2026 19:14:25 +0200 Subject: [PATCH 01/15] docs(spec): carry over sprint 3 SPEC update missed in PR #6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sprint 3 plan §0 updated SPEC.md § Simulation to reflect multi-techniques (plural + autocomplete + matrix modal + sub-techniques). That edit sat in the sprint 3 worktree but was never committed, so PR #6 merged the multi-tech code without the corresponding spec text. Applying it here at the start of sprint 4 so SPEC and main are aligned again. Lesson captured in tasks/lessons.md for sprint 4 wrap-up: always git status before declaring sprint complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SPEC.md b/SPEC.md index af566d0..240f515 100644 --- a/SPEC.md +++ b/SPEC.md @@ -8,7 +8,7 @@ Mimic est une application WebUI de type BAS (Breach and Attack Simulation), se b Une simulation est composée des champs suivants : * Partie RedTeam : - Nom du test - - Type d'attaque MITRE correspondant (peut être une liste de référence) + - Types d'attaque MITRE correspondants (multi-techniques — une simulation peut couvrir plusieurs TTPs) sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale. Sub-techniques (ex : T1059.001) supportées. - Description - Commandes exécutés (liste) - Pré-requis (champs texte) From 89eccad1ebfa1e1a196979946f0443ec89cb57a4 Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 27 May 2026 19:41:16 +0200 Subject: [PATCH 02/15] docs(sprint-4): plan + SPEC updates (Done terminal, engagement auto, UI/UX, workflows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tasks/todo.md: sprint 4 plan with 9 user stories (US-17 → US-25), 9 décisions arrêtées - SPEC.md § Fonctionnement: Done is terminal, Reopen returns to review_required (open to all roles); engagement auto-flips planned → active when any simulation hits in_progress, no auto-rollback - SPEC.md § Référentiel MITRE: sprint 3 multi-tech + sprint 4 tactic_ids separated field - SPEC.md § UI/UX (new): theming light/dark/system with system default, button convention (icon + ≤8-char label), modal focus trap V1 - SPEC.md § Workflows: design-reviewer inserted between frontend-builder and code-reviewer; PR via make open-pr Co-Authored-By: Claude Opus 4.7 (1M context) --- SPEC.md | 19 ++- tasks/todo.md | 442 +++++++++++++++++++++++++------------------------- 2 files changed, 240 insertions(+), 221 deletions(-) diff --git a/SPEC.md b/SPEC.md index 240f515..5937c99 100644 --- a/SPEC.md +++ b/SPEC.md @@ -25,11 +25,12 @@ La redteam peut modifier l'ensemble des champs d'une simulation, tandis que l'an Un workflow de simulation doit être mis en place : Pending, In progress, Review required, Done. Le workflow se mettra à jour de la manière suivante : - Création de la simulation : pending - - La redteam saisit des informations dans la simulation : in progress + - La redteam saisit des informations dans la simulation : in progress (auto) - La redteam décide par une action manuelle de passer la simulation en status "review required" ce qui offre à la possibilité au SOC de remplir les informations nécessaire. - - Le SOC (ou la redteam) décide par une action manuelle de passe la simulation en status "Done". + - Le SOC (ou la redteam) décide par une action manuelle de passer la simulation en status "Done". + - **Done est terminal** : aucune édition n'est possible sur une simulation Done. Pour reprendre, n'importe lequel des trois rôles (admin / redteam / soc) peut déclencher une action "Reopen" qui ramène la simulation à "review required" — les champs redeviennent éditables selon les règles habituelles. Cette transition est la seule autorisée à quitter Done. -Un engagement correspond à une mission redteam. Il est possible d'ajouter plusieurs test dans un engagement. +Un engagement correspond à une mission redteam. Il est possible d'ajouter plusieurs test dans un engagement. **Le statut de l'engagement progresse automatiquement** : créer un engagement le met à "planned" ; dès qu'une simulation de cet engagement passe en "in progress" (auto-transition par la redteam ou manuelle), l'engagement passe à "active" — pas de retour arrière automatique. La transition vers "closed" reste manuelle. Prévoir un module d'authentification : dans un premier temps local à la bdd. @@ -51,8 +52,11 @@ Dans un second temps, après que la V1 soit terminée, nous ajouterons une couch ## Workflows * Découpage en sprint * Chaque sprint doit apporter une nouvelle fonctionnalité à tester sur l'UI -* A chaque sprint : Code + Test (Test unitaire python + Test pywright front) + Reviews (spec + code). Une fois le review OK, PR que je valide après test. +* A chaque sprint : Code + Test (Test unitaire python + Test pywright front) + Reviews (spec + design + code). Une fois les reviews OK, PR que je valide après test. +* **Séquence sprint (depuis sprint 4)** : spec-reviewer → backend-builder → frontend-builder (livre des screenshots obligatoires) → **design-reviewer (NOUVEAU sprint 4)** → code-reviewer → test-verifier → team-lead PR. +* **design-reviewer** revoit le diff frontend + screenshots du sprint courant, audit alignement / hiérarchie typo / usage tokens DESIGN.md / cohérence visuelle / responsive. Read-only, ne modifie rien. * A chaque fin de sprint, avant mes tests, le team lead doit me faire un récapitulatif synthétique de ce qui a été fait et ce qui doit être testé (et comment le tester). +* **Création de PR** : à la fin du sprint, le team-lead ouvre la PR via `make open-pr SPRINT=N TITLE="..." BODY=path/to/body.md` (depuis sprint 4). Le script `scripts/open-pr.sh` parse `~/.git-credentials` et POST sur l'API Gitea. # Team @@ -157,6 +161,13 @@ Conforme à la spec (partie RedTeam + partie SOC). Workflow Pending → In progr * **Bundle local** : JSON officiel STIX 2.1 MITRE Enterprise embarqué dans l'image (`backend/data/mitre/enterprise-attack.json`). * Pas d'appel réseau au runtime. Seed/refresh manuel via `make update-mitre`. * Utilisé au Sprint 2+ pour l'autocomplete des TTPs (T-id + nom + tactique). +* **Sprint 3+** : multi-techniques par simulation, sélectionnables via autocomplete OU matrice cliquable. +* **Sprint 4+** : sélection de tactiques (TA-id, ex `TA0007 Discovery`) en plus des techniques. Stockées dans un champ `tactic_ids` distinct, séparé sémantiquement de `technique_ids`. + +## UI/UX +* **Theming** : light + dark + system (suit `prefers-color-scheme`). Toggle dans la topbar. Persistance localStorage clé `mimic-theme`. Défaut : `system`. +* **Boutons d'action** : icône (lucide-react ou unicode) + label court (≤ 8 chars) préférés aux phrases. Exceptions justifiées pour des libellés workflow-critiques sans icône évidente (ex : "Mark for review", "Clear all"). +* **Modals** : focus trap V1 minimal (focus initial sur le champ principal, Tab cycle, Escape + backdrop click = Cancel). Full WAI-ARIA conformance reportée à un sprint a11y dédié. ## Stack technique précisée * **Backend** : Python 3.12, Flask, SQLAlchemy, Alembic, pytest, ruff, mypy. Auth via `PyJWT` + middleware decorator. diff --git a/tasks/todo.md b/tasks/todo.md index 0c5260e..14b1e7a 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,290 +1,298 @@ -# Sprint 3 — MITRE matrix modal + multi-technique simulations +# Sprint 4 — UI polish + workflow tightening + dark mode + process hygiene -**Branche** : `sprint/3-mitre-matrix` -**Statut** : 🟢 SPRINT COMPLET — 105/105 sprint 3 e2e verts, code-review traité, PR prête -**Base** : `main` @ `e1d9738` -**Objectif** : remplacer la sélection MITRE mono-technique de sprint 2 par une sélection multi-techniques avec deux modes complémentaires : autocomplete (rapide) et matrice cliquable (exploration). Les techniques choisies s'affichent comme tags sur la simulation. +**Branche** : `sprint/4-ui-polish` +**Statut** : 🟡 DRAFT — 9 décisions arrêtées (3 nouvelles 2026-05-27 + 5 sprint 4 mémoire + 1 du PR helper), spec-reviewer en validation +**Base** : `main` @ `27573f5` (sprint 3 mergé via PR #6) + `ba313a3` (carry-over SPEC sprint 3) +**Objectif** : absorber les 7 retours QA sprint 3 (UI/UX, workflow, alignement) + livrer le dark mode + durcir le process UI (design-reviewer agent + screenshots mandatory) + automatiser l'ouverture de PR. Pas de hotfix sprint 3 séparé — tout dans sprint 4 (décision user 2026-05-27). --- -## 0. Évolution SPEC.md à acter en début de sprint +## 0. SPEC.md updates -SPEC.md § Simulation dit aujourd'hui "Type d'attaque MITRE correspondant (peut être une liste de référence)" au singulier. Le team-lead met à jour cette ligne en début de sprint (pas de PR séparée) pour refléter le scope multi-techniques. Texte cible : +- ✅ `ba313a3` — § Simulation : "Type d'attaque MITRE correspondant (peut être une liste de référence)" → "Types d'attaque MITRE correspondants (multi-techniques) ..." (carry-over manquant de sprint 3 §0). +- 🟡 § Fonctionnement à enrichir en début de sprint 4 : + - Préciser que "Done" est terminal : aucune édition possible sans Reopen explicite. + - Préciser que la transition Reopen `Done → Review required` est ouverte à admin/redteam/soc. + - Préciser que la création/avancement d'une simu fait avancer l'engagement de `planned` à `active` automatiquement (jamais l'inverse). +- 🟡 § Décisions techniques à enrichir : + - Section "UI/UX" : convention boutons (icônes / symboles préférés aux longs libellés). + - Section "Theming" : dark mode supporté, toggle topbar, défaut = `prefers-color-scheme` du système, persistance `localStorage`. -> Types d'attaque MITRE correspondants (multi-techniques) — sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale. - -L'évolution est tracée dans CHANGELOG.md § Changed du sprint 3. +L'évolution est tracée dans CHANGELOG.md § Changed sprint 4. --- ## 1. User stories -### US-13 — En tant que redteam, je sélectionne plusieurs techniques MITRE par simulation -**Pourquoi** : un test couvre souvent plusieurs TTPs (ex : Initial Access → Discovery → Execution). Mono-technique limite la description réelle d'un test. +### US-17 — UI polish : dédoublonnage boutons + alignement + icônes +**Pourquoi** : QA sprint 3 — `EngagementsListPage` montre 2 boutons "New engagement" + "Create engagement" qui font la même chose ; le bouton Create de `UsersAdminPage` reste mal aligné malgré le fix sprint 2. **Critères d'acceptation** -- [ ] AC-13.1 : modèle `Simulation` n'a plus `mitre_technique_id` ni `mitre_technique_name` (scalaires). Remplacés par `techniques` (colonne JSON, liste d'objets `{id: str, name: str}`, défaut `[]`). -- [ ] AC-13.2 : migration Alembic `0003_simulation_techniques_array.py` : - - ajoute la colonne `techniques` (JSON) - - backfill les simulations existantes : si `mitre_technique_id` non null → `techniques = [{id, name}]`, sinon `techniques = []` - - drop les deux anciennes colonnes - - migration réversible (downgrade : prendre le premier élément, ré-injecter dans les scalaires, drop `techniques`) -- [ ] AC-13.3 : sérialisation simulation expose `techniques: [{id, name, tactics: [...]}]` — le backend enrichit chaque entrée avec ses `tactics` depuis le service MITRE au moment du serialize (snapshot d'`id`+`name` en DB, tactics dérivées au runtime depuis le bundle). -- [ ] AC-13.4 : `PATCH /api/simulations/` accepte `{technique_ids: ["T1059", "T1078"]}` (liste d'IDs string). Backend valide chaque ID contre le bundle MITRE, résout `name`, écrit `[{id, name}]` en DB. ID inconnu → 400 `{error: "unknown technique id: T9999"}`. -- [ ] AC-13.5 : la règle d'auto-transition `pending → in_progress` s'applique aussi à `technique_ids` quand la liste reçue est non vide. +- [ ] AC-17.1 : `EngagementsListPage` n'affiche qu'UN SEUL bouton "New engagement". Le doublon "Create engagement" est supprimé. +- [ ] AC-17.2 : convention nouveaux boutons d'action (Create / Add / Save / Delete) : icône lucide-react ou unicode + label court (≤ 8 chars), pas de phrases. Audit des boutons existants : ne refactoriser que ceux qui dépassent ce seuil, garder les "Mark for review" / "Clear all" qui sont déjà courts ou ont une sémantique sans icône évidente. Boutons à passer en icône+label : "Save Red Team" → "Save" + icône, "Save SOC" → "Save SOC" + icône, "ADD TECHNIQUE" → "+" + "Add", "QUICK SEARCH" → "🔍" + "Search". +- [ ] AC-17.3 : `UsersAdminPage` formulaire "Create account" — les 3 FormField (Username, Password, Role) ont leurs labels alignés sur la même baseline ET leurs inputs alignés sur la même baseline. Le bouton Create est aligné horizontalement avec la rangée des inputs. Pixel-perfect au niveau visuel à 1280×720. -### US-14 — En tant que redteam, je vois et retire les techniques d'une simulation sous forme de tags -**Pourquoi** : visualiser rapidement la couverture TTP d'un test. +### US-18 — Simulation `done` = read-only + Reopen +**Pourquoi** : QA sprint 3 — actuellement une simu `done` peut toujours être PATCHée, ce qui contredit le statut terminal. **Critères d'acceptation** -- [ ] AC-14.1 : sur `SimulationFormPage`, à la place du seul `MitreTechniquePicker` du sprint 2, un composant `MitreTechniquesField` affiche : - - Liste des techniques sélectionnées sous forme de chips/tags (id + name, ex : `T1059 — Command and Scripting Interpreter`), avec un `×` cliquable pour retirer chaque technique. - - Bouton "Add technique" qui ouvre la modale matrice (US-15). - - Bouton "Quick search" qui ouvre l'autocomplete existant (réutilisation du `MitreTechniquePicker`) en mode "ajoute à la liste" (sélection = append, pas replace). - - État vide : message "No techniques selected — use the matrix or the quick search to add." -- [ ] AC-14.2 : retirer un tag (× sur le chip) déclenche un PATCH immédiat (auto-save) avec la liste mise à jour. La modale matrice (US-15) auto-save aussi via "Apply". Le picker Quick Search auto-save chaque sélection. Toast `'Techniques updated'` sur succès, toast erreur sinon. Pas de bouton Save manuel pour les techniques. -- [ ] AC-14.3 : sur `SimulationList` (table dans EngagementDetailPage), la colonne "MITRE" affiche un compteur + premier tag (ex : `T1059 +2` si 3 techniques sélectionnées). Si la liste est vide, afficher `—`. -- [ ] AC-14.4 : ordre des tags dans la simulation préservé entre lecture et écriture (pas de tri imposé côté serveur). -- [ ] AC-14.5 : tags affichés avec les couleurs/spacing DESIGN.md (`bg-primary-soft`, `text-primary-deep`, `rounded-full`, `px-md py-xxs`). +- [ ] AC-18.1 : `PATCH /api/simulations/` avec status courant `done` retourne **409** `{error: "simulation is done — reopen first"}` quel que soit le rôle. +- [ ] AC-18.2 : nouvelle transition `POST /api/simulations//transition {to: "review_required"}` quand status courant == `done` → 200, autorisée admin + redteam + soc. Met à jour `updated_at`. +- [ ] AC-18.3 : la transition `→ review_required` depuis `pending`/`in_progress` garde le comportement sprint 2 (admin/redteam only). La nouvelle règle s'ajoute SEULEMENT pour le cas `done`. +- [ ] AC-18.4 : sur `SimulationFormPage`, quand status == `done` : + - Tous les champs (RT + SOC) sont disabled. + - `MitreTechniquesField` en read-only (chips sans ×, input + icône matrice masqués). + - L'action bar affiche UNIQUEMENT un bouton "Reopen" (visible admin/redteam/soc). + - Save RT, Save SOC, Mark for review, Close, Delete sont masqués. +- [ ] AC-18.5 : click Reopen → POST transition, toast `'Simulation reopened'`, badge se met à jour, les champs redeviennent éditables selon le rôle. -### US-15 — En tant que redteam, j'ouvre la matrice MITRE ATT&CK pour explorer et sélectionner des techniques -**Pourquoi** : l'autocomplete est efficace si on sait ce qu'on cherche ; la matrice est nécessaire pour "voir ce qui existe" et combiner par tactique. +### US-19 — Engagement auto-status `planned → active` +**Pourquoi** : QA sprint 3 — un engagement reste `planned` même quand ses simulations sont in_progress. **Critères d'acceptation** -- [ ] AC-15.1 : nouvel endpoint `GET /api/mitre/matrix` (auth, tous rôles) → tree `[{tactic_id, tactic_name, techniques: [{id, name, subtechniques: [{id, name}]}]}]`. Chaque technique top-level embarque ses sub-techniques (`T1059` → `[T1059.001, T1059.002, ...]`). Ordre des tactiques = ordre canonique MITRE Enterprise (Initial Access → Execution → Persistence → ... → Exfiltration → Impact). 503 si bundle non chargé. -- [ ] AC-15.2 : composant `MitreMatrixModal` : - - Modal large (≥ 1100px), scroll vertical interne. - - Layout horizontal en colonnes : 1 colonne par tactique. Header de colonne = nom de la tactique + compteur de techniques sélectionnées dans cette tactique (sub-techniques incluses). - - Chaque technique top-level = bouton/cellule cliquable. État sélectionné visible (`bg-primary` + texte blanc). - - Si la technique a des sub-techniques (`subtechniques.length > 0`), un chevron (▸/▾) précède le nom. Click sur le chevron = expand/collapse (n'affecte PAS la sélection). Click sur le label = toggle sélection de la technique top-level. - - Sub-techniques affichées en cascade indentée sous leur parent quand expand. Cliquables individuellement (toggle de la sub). État visuel distinct : `bg-primary-soft` quand sélectionnée, indent `pl-md`, font-size légèrement plus petit. - - Sélectionner une sub-technique ne sélectionne PAS le parent (les deux sont indépendants côté data). Mais le compteur de tactique somme parent + subs sélectionnées. - - Champ de recherche en haut du modal qui filtre les techniques affichées (case-insensitive sur id ET name). Quand le filtre matche une sub-technique, son parent est automatiquement expand pour la rendre visible. - - Boutons en footer : "Cancel" (ferme sans appliquer), "Apply N techniques" (compteur = total parents + subs sélectionnés). -- [ ] AC-15.3 : la modale est ouverte depuis le bouton "Add technique" de US-14. Elle reçoit en input la liste actuelle de techniques sélectionnées et travaille sur une copie locale ; "Apply" déclenche directement le PATCH (auto-save, cf AC-14.2) et ferme la modale ; "Cancel" jette le diff local. -- [ ] AC-15.4 : Escape ferme la modale (= Cancel). Click sur le backdrop = Cancel. -- [ ] AC-15.5 : a11y V1 — **scope minimal explicite** : (1) focus initial sur le champ recherche à l'ouverture, (2) Tab cycle entre les éléments focusables de la modale (wrap : dernier élément → premier), (3) Escape ferme = onCancel, (4) ARIA `role="dialog"` + `aria-labelledby` sur le titre. Full WAI-ARIA dialog conformance (live regions, focus restoration au close, screen reader announcements détaillés) **out of scope V1** — c'est une dette assumée à reprendre dans un sprint a11y dédié. +- [ ] AC-19.1 : quand une simulation transitionne vers `in_progress` (auto-transition via PATCH RT-field non vide), si son engagement parent est `planned`, l'engagement passe à `active` dans la même unité de travail DB. +- [ ] AC-19.2 : si l'engagement est déjà `active` ou `closed`, pas de changement. +- [ ] AC-19.3 : aucun retour arrière auto. La transition `closed` reste manuelle. +- [ ] AC-19.4 : le frontend invalide `["engagement", eid]` et `["engagements"]` après chaque PATCH/transition simulation pour récupérer le statut à jour. -### US-16 — En tant que user (tous rôles), j'utilise les autres fonctionnalités sans régression -**Critères d'acceptation** (régression) -- [ ] AC-16.1 : workflow sprint 2 (auto-transition, transitions manuelles, RBAC SOC) inchangé — tous les ACs sprint 2 (US-7 → US-12) continuent de passer. -- [ ] AC-16.2 : l'ancien `MitreTechniquePicker` est conservé dans la base de code MAIS sa signature passe en clean rewrite (`onSelect({id, name})` au lieu de `onChange(id, name)`), wrappé par `MitreTechniquesField` en mode append. -- [ ] AC-16.3 : aucune e2e sprint 1/sprint 2 ne casse. Quelques assertions sprint 2 (US-8 et US-10) qui validaient le mono-technique sont mises à jour pour refléter la liste. +### US-20 — Matrice MITRE : look attack.mitre.org + pas de scroll horizontal +**Pourquoi** : QA sprint 3 — la matrice actuelle a un scroll horizontal et un layout maison. + +**Critères d'acceptation** +- [ ] AC-20.1 : `MitreMatrixModal` est élargi à `max-w-[98vw]`. +- [ ] AC-20.2 : layout 12 colonnes (12 tactiques Enterprise) qui tiennent SANS scroll horizontal à 1280×720 min. Largeur cellule technique ~95-110px (vs 220px actuel), font `text-[12px]`. +- [ ] AC-20.3 : couleurs cohérentes DESIGN.md ET visuellement proches de attack.mitre.org : header tactic avec fond contrasté + label uppercase tracking, techniques en cellules `bg-canvas` avec hairline border, hover `bg-fog`, sélectionnée `bg-primary` texte blanc. +- [ ] AC-20.4 : scroll vertical autorisé (`max-h-[80vh] overflow-y-auto`). Jamais de scroll horizontal. +- [ ] AC-20.5 : sub-techniques expand/collapse PRÉSERVÉ — pas de régression sprint 3 AC-15.2. Compteur "N selected" par tactique reste lisible. +- [ ] AC-20.6 : screenshot comparaison Mimic matrix vs attack.mitre.org joint au summary frontend-builder. + +### US-21 — Sélection de tactique en plus des techniques +**Pourquoi** : QA sprint 3 — l'utilisateur veut tagger une simulation par TACTIQUE (ex : `TA0007 Discovery`) sans devoir choisir une technique précise. + +**Critères d'acceptation** +- [ ] AC-21.1 : modèle `Simulation` gagne un champ `tactic_ids` (colonne JSON, liste de strings TA-id, défaut `[]`). Séparé de `techniques`. +- [ ] AC-21.2 : migration Alembic `0004_simulation_tactic_ids.py` — ADD COLUMN `tactic_ids` (JSON, NOT NULL, default `[]`). Pas besoin de batch pour ADD COLUMN (SQLite natif). Aucun backfill (default suffit). +- [ ] AC-21.3 : sérialisation Simulation expose `tactics: [{id, name}]` enrichi à partir de `tactic_ids` (id snapshot + name dérivé du bundle MITRE au runtime, comme pour `techniques`). +- [ ] AC-21.4 : `PATCH /api/simulations/` accepte `{tactic_ids: ["TA0007", ...]}`. Validation : chaque ID doit exister (préfixe `TA`, présent dans `_TACTIC_ORDER`). Dedup serveur. ID inconnu → 400. Bundle non chargé → 503. +- [ ] AC-21.5 : `tactic_ids` est ajouté au gate SOC : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`. SOC envoie → 403. Auto-transition se déclenche aussi si `tactic_ids` non vide. +- [ ] AC-21.6 : `MitreMatrixModal` — le header de chaque colonne tactique devient cliquable (toggle de la tactique elle-même). État visuel distinct des techniques sélectionnées. Compteur passe à `N+M selected` (techniques + tactique). +- [ ] AC-21.7 : `MitreTechniquesField` — tactiques sélectionnées affichées comme chips distincts (style différencié : `bg-primary text-canvas` au lieu de `bg-primary-soft text-primary-deep`). × pour retirer. Auto-save sur add/remove. + +### US-22 — Refonte input MITRE dans le form +**Pourquoi** : QA sprint 3 — pattern actuel (2 boutons textuels) trop verbeux. + +**Critères d'acceptation** +- [ ] AC-22.1 : sous le label "MITRE Techniques", le composant affiche : + - Une rangée de chips (techniques + tactiques sélectionnées). + - En dessous, une rangée `[input texte autocomplete] [icône matrice]`. + - L'input fait l'autocomplete inline (debounce 200ms, dropdown ↑↓Enter, comme sprint 2 mais EMBARQUÉ). + - L'icône matrice à droite ouvre `MitreMatrixModal`. + - Aucun bouton textuel "Add Technique" ni "Quick Search". +- [ ] AC-22.2 : les chips affichent UNIQUEMENT la référence (T-id ou TA-id, ex : `T1059.001` ou `TA0007`). Le nom apparaît au survol via `title=` attribute. +- [ ] AC-22.3 : `MitreTechniquePicker` existant est intégré dans le nouveau layout comme l'autocomplete inline. Garde la signature `onSelect`. +- [ ] AC-22.4 : empty state : message court ("No techniques selected") dans la zone des chips. L'input et l'icône matrice restent visibles. +- [ ] AC-22.5 : mode read-only (SOC sur simu non-done, ou tous sur simu done) : chips sans ×, input + icône cachés. + +### US-23 — Dark mode +**Pourquoi** : ergonomie demandée. Sprint 4 framing acté. + +**Critères d'acceptation** +- [ ] AC-23.1 : un toggle theme dans la topbar (`Layout.tsx`), à droite du nom user. Icône lucide-react `Sun` / `Moon` / `Monitor`. +- [ ] AC-23.2 : 3 états : `light`, `dark`, `system` (auto = suit `prefers-color-scheme`). Toggle cycle entre les 3. +- [ ] AC-23.3 : persistance via `localStorage` (clé `mimic-theme`, valeur `'light'|'dark'|'system'`, défaut `'system'`). +- [ ] AC-23.4 : Tailwind `darkMode: 'class'` activé. Classe `dark` appliquée sur `` selon le résolu. Tokens DESIGN.md étendus avec variantes dark (canvas, paper, ink, graphite, charcoal, etc.). Primary HP Electric Blue garde sa teinte. +- [ ] AC-23.5 : tous les composants principaux audités et utilisent les classes Tailwind `dark:bg-...` / `dark:text-...`. Pas de couleur hardcodée. +- [ ] AC-23.6 : screenshots light + dark de `EngagementsListPage`, `SimulationFormPage`, `MitreMatrixModal` ouverte. Joints au summary. + +### US-24 — Process hygiene : design-reviewer agent + screenshots mandatory +**Pourquoi** : sprint 4 framing acté. Sprint 2/3 avait laissé passer des bugs visuels faute de pass design dédié. + +**Critères d'acceptation** +- [ ] AC-24.1 : nouveau fichier `.claude/agents/design-reviewer.md`. Brief : revoit le diff frontend + les screenshots fournis par le frontend-builder, audit alignement / hiérarchie typo / DESIGN.md token usage / responsive sanity / cohérence visuelle. Read-only. Lance après frontend-builder, avant code-reviewer. +- [ ] AC-24.2 : `.claude/agents/frontend-builder.md` mis à jour pour rendre EXPLICITE que screenshots sont MANDATORY avant de marquer la tâche terminée (au moins 1 par feature visible / état modifié). Liste explicite des screenshots attendus dans le summary. +- [ ] AC-24.3 : workflow sprint mis à jour dans SPEC.md § Workflows : ajouter design-reviewer entre frontend-builder et code-reviewer. + +### US-25 — Infra : PR helper script + Makefile target +**Pourquoi** : capitaliser le pattern Gitea API curl utilisé en sprint 3 pour automatiser les PRs. + +**Critères d'acceptation** +- [ ] AC-25.1 : `scripts/open-pr.sh` (executable, `set -euo pipefail`). Lit `~/.git-credentials`. Args : `--sprint=N`, `--title="..."`, `--body=path`. Détecte la branche courante + owner/repo depuis `git remote get-url origin`. POST `/api/v1/repos/{owner}/{repo}/pulls`. Imprime PR URL. +- [ ] AC-25.2 : target Makefile `open-pr SPRINT=N TITLE="..." BODY=path` wrap le script. +- [ ] AC-25.3 : documenté dans README.md (1 paragraphe). +- [ ] AC-25.4 : team-lead utilise ce target pour ouvrir la PR sprint 4 (dogfooding). --- ## 2. Brief technique — Backend Builder -**Scope strict** : `backend/`. Pas de touche au frontend, e2e, docs (team-lead). +**Scope strict** : `backend/`. Pas de touche au frontend, e2e, `.claude/agents/`, `scripts/`, `Makefile`, docs. ### Livrables -**Modèle `Simulation`** (`backend/app/models/simulation.py`) -- Remplacer `mitre_technique_id`, `mitre_technique_name` (str nullable) par : - ```python - techniques: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list) - ``` -- Stockage : `[{"id": "T1059", "name": "Command and Scripting Interpreter"}, ...]`. Pas de `tactics` en DB (dérivé au serialize). +**Modèle `Simulation`** — ajout uniquement : +```python +tactic_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) +``` -**Service workflow** (`backend/app/services/simulation_workflow.py`) — **mise à jour RBAC field-level OBLIGATOIRE** -- Dans le `REDTEAM_FIELDS` frozenset existant : **retirer** `"mitre_technique_id"` et `"mitre_technique_name"`, **ajouter** `"technique_ids"`. -- Sans ce changement : un user soc qui PATCH avec `{technique_ids: [...]}` reçoit un silent no-op (champ ignoré) au lieu du 403 attendu. La gate field-level RBAC pour `technique_ids` repose intégralement sur ce frozenset. -- Le `SOC_FIELDS` frozenset reste inchangé. -- Tester explicitement : `test_simulations_techniques.py` doit inclure "SOC PATCH technique_ids → 403" (cf. liste de tests plus bas). +**Migration Alembic `0004_simulation_tactic_ids.py`** : +- Upgrade : `op.add_column('simulations', sa.Column('tactic_ids', sa.JSON(), nullable=False, server_default=sa.text("'[]'")))`. ADD COLUMN OK sans batch sur SQLite. `server_default` règle le NOT NULL pour les lignes existantes. +- Downgrade : `with op.batch_alter_table('simulations') as batch_op: batch_op.drop_column('tactic_ids')`. +- Test : schéma post-upgrade a `tactic_ids` NOT NULL avec default `[]`. -**Migration Alembic `0003_simulation_techniques_array.py`** -- Upgrade : - 1. Ajouter colonne `techniques` (JSON, nullable=True temporaire, default `'[]'`) — `op.add_column` direct OK. - 2. Data migration : pour chaque ligne, si `mitre_technique_id` IS NOT NULL → set `techniques = '[{"id":"","name":""}]'`, sinon `'[]'`. - 3. ALTER column `techniques` → nullable=False — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** car SQLite ne supporte pas ALTER COLUMN nativement. - 4. Drop columns `mitre_technique_id`, `mitre_technique_name` — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** (même raison : SQLite ne supporte pas DROP COLUMN hors batch mode). -- Downgrade : symétrique avec les MÊMES guards batch_alter_table pour les étapes ALTER/DROP. Recrée les 2 colonnes, prend le premier élément de `techniques` si non vide, drop `techniques`. -- Pattern à suivre : la migration `0002_add_simulations.py` (sprint 2) — vérifier le style batch_alter_table déjà en place. +**Serializer** : `serialize_simulation(sim)` ajoute `tactics: [{id, name}]` enrichi runtime. -**Serializer** (`backend/app/serializers.py`) -- `serialize_simulation(sim)` : - - Avant retour, enrichir chaque tag avec `tactics` depuis `mitre_svc.get_tactics(id)`. Si la technique a été retirée du bundle MITRE entre-temps, `tactics = []` (gracieux). - - `commands` reste tel quel (text brut, inchangé sprint 2). +**Service MITRE** : +- Nouvelle fonction `lookup_tactic(tactic_id)` → `{id, name}` ou None. +- Nouvelle fonction `get_tactic_name(tactic_id)` → name string ou fallback id. +- `_TACTIC_ORDER` / `_TACTIC_NAMES` réutilisés. -**Service MITRE** (`backend/app/services/mitre.py`) -- Étendre l'index avec un dict `tactics_by_technique: dict[str, list[str]]` pour lookup O(1) au serialize. -- Nouvelle fonction `get_tactics(technique_id: str) -> list[str]`. -- Nouvelle fonction `lookup_name(technique_id: str) -> str | None` — utilisée par l'endpoint PATCH pour résoudre le name côté serveur (le client n'envoie que les IDs). -- Nouvelle fonction `get_matrix() -> list[dict]` : - ```json - [ - {"tactic_id": "TA0001", "tactic_name": "Initial Access", - "techniques": [ - {"id": "T1078", "name": "Valid Accounts", - "subtechniques": [{"id": "T1078.001", "name": "Default Accounts"}, ...]}, - ... - ]}, - ... - ] - ``` - Sub-techniques embarquées sous chaque parent (relation STIX `subtechnique-of` dans le bundle). Si la technique n'en a pas, `subtechniques: []`. - Ordre des tactiques : canonical MITRE Enterprise order (12 tactics). Lecture depuis les objets STIX `x-mitre-tactic` ordonnés par `x_mitre_shortname` natif OU constante module-level hardcodée si plus simple. - Ordre des techniques au sein d'une tactique : alphabétique par `name` (déterministe, lisible). +**Service workflow `simulation_workflow.py`** — modifications : +1. **Guard `done` (AC-18.1)** : tout en haut de `apply_patch`, AVANT le check RBAC, si `simulation.status == "done"` → 409 `{error: "simulation is done — reopen first"}`. +2. **SOC gate étendu** : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`. +3. **Validation `tactic_ids`** upfront (similaire à `technique_ids`) : tous les IDs validés contre le bundle, dedup `dict.fromkeys`. Bundle non chargé → 503. +4. **Auto-transition** : ajouter le check `len(payload["tactic_ids"]) > 0` au calcul `auto_trigger`. +5. **Transition `done → review_required` (AC-18.2)** : ajouter ce cas au state machine. Autorisé admin + redteam + soc. Update `updated_at`. Autres transitions depuis `done` → 409. +6. **Hook engagement auto-status (AC-19.1)** : après une transition de simu vers `in_progress` (auto OU manual), appeler une fonction `_maybe_activate_engagement(simulation)` qui, si `simulation.engagement.status == "planned"`, set `engagement.status = "active"` et add à la session (commit en même temps que la simu). -**API** (`backend/app/api/simulations.py`) -- `GET /api/mitre/matrix` — nouvel endpoint, 200 + tree, 503 si bundle absent. -- `PATCH /api/simulations/` : le payload accepte maintenant `technique_ids: list[str]` à la place de `mitre_technique_id` + `mitre_technique_name`. Validation : tous les IDs doivent exister dans le bundle (400 sinon), `name` snapshot servi par `lookup_name`. Pas de rétrocompat avec les anciens champs scalaires (clean break — pas d'utilisateur externe). -- **Dedup serveur** : avant écriture en DB, dédupliquer la liste `technique_ids` en préservant l'ordre (`list(dict.fromkeys(technique_ids))`). Le client peut envoyer accidentellement des doublons (race UI ou bug), le serveur ne doit jamais persister deux fois la même technique. -- Auto-transition (AC-13.5) : un `technique_ids` non vide (≥1 élément) compte comme redteam-side filled, déclenche `pending → in_progress`. Liste vide = pas de trigger. +**API `simulations.py`** : +- PATCH : le check status==done est fait dans `apply_patch` (voir au-dessus). +- Transition : accepter le nouveau cas done → review_required pour admin/redteam/soc. **Tests pytest** -- `test_simulations_techniques.py` (nouveau) : - - Création + PATCH `technique_ids` → simulation a la bonne liste, sérialisation expose `techniques` avec `tactics`. - - PATCH avec ID inconnu → 400. - - Auto-transition sur `technique_ids` non vide. - - Retirer toutes les techniques (`technique_ids: []`) → pas de trigger d'auto-transition (cohérent avec règle "valeur vide"). - - **Dedup** : PATCH avec `technique_ids: ["T1059", "T1078", "T1059"]` → DB ne stocke que 2 entrées, ordre préservé (`T1059` en premier). -- `test_mitre.py` (existant) — ajouter : - - `get_matrix()` renvoie les bonnes tactiques dans le bon ordre. - - `lookup_name(unknown)` → None. - - `get_tactics(known)` → liste correcte (≥1 tactique). -- `test_simulations_crud.py` + `test_simulations_patch.py` + `test_simulations_workflow.py` (existants) — adapter toute assertion qui touchait `mitre_technique_id` / `mitre_technique_name`. -- Migration : test que les anciennes simulations en `pending` avec un id mono-tech sont upgradées en `techniques: [{id, name}]` (fixture inline ou test direct sur Alembic). +- `test_simulations_tactics.py` (nouveau) : PATCH valide, ID inconnu → 400, bundle absent → 503, dedup, auto-transition, SOC → 403. +- `test_simulations_done_readonly.py` (nouveau) : PATCH simu done → 409 (admin/redteam/soc). Reopen via transition → 200. Autres transitions depuis done → 409. Après reopen, PATCH OK. +- `test_engagement_lifecycle.py` (nouveau) : création simu → engagement reste `planned`. PATCH simu → simu in_progress + engagement active. Engagement déjà active → pas de changement. Engagement closed → pas de changement. +- Migration test : `tactic_ids` column NOT NULL après upgrade 0004 (similaire au pattern Alembic round-trip sprint 3). +- Adapter `test_simulations_crud.py`, `test_simulations_patch.py`, `test_simulations_workflow.py` si nécessaire pour les assertions sur `tactics` et la garde done. **Quality bar** : ruff + mypy clean, tous les tests existants + nouveaux verts. +### Règles +- Pas de touche au frontend, `.claude/agents/`, `scripts/`, `Makefile`. +- Renvoyer le summary attendu (cf `.claude/agents/backend-builder.md`). + --- ## 3. Brief technique — Frontend Builder -**Scope strict** : `frontend/` uniquement. +**Scope strict** : `frontend/` UNIQUEMENT. -**Note process (lesson learned sprint 2)** : avant de marquer la tâche terminée, lance le dev server et screenshot (a) la matrice modale ouverte avec ≥3 techniques sélectionnées et (b) la simulation form avec ≥2 tags affichés. Joins-les à ton summary final. +**SCREENSHOTS MANDATORY** (lesson sprint 2/3) : à la fin de ton travail, lance le dev server et fournis ≥ 5 screenshots : +1. `EngagementsListPage` light + dark +2. `SimulationFormPage` avec ≥ 2 chips technique + ≥ 1 chip tactique light + dark +3. `MitreMatrixModal` ouverte avec sélections light + dark +4. `UsersAdminPage` form "Create account" (alignement vérifié) light + dark +5. `SimulationFormPage` status `done` (read-only + Reopen visible) light + +Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le EXPLICITEMENT avec les raisons techniques précises. ### Livrables -**Types** (`frontend/src/api/types.ts`) -- `MitreTechnique`: `{id: string, name: string, tactics: string[]}` (déjà existant pour le picker — réutiliser, ajouter `tactics` si manquant). -- Ajouter `MitreTactic`: `{tactic_id: string, tactic_name: string, techniques: MitreMatrixTechnique[]}` avec `MitreMatrixTechnique = {id: string, name: string, subtechniques: {id: string, name: string}[]}`. -- `Simulation.techniques: MitreTechnique[]` à la place de `mitre_technique_id` + `mitre_technique_name`. PATCH payload : `{technique_ids: string[]}`. +**US-17 — UI polish** +- `EngagementsListPage.tsx` : supprimer le doublon "Create engagement". Garder un seul CTA "New" + icône `+` (selon convention AC-17.2). +- `UsersAdminPage.tsx` : retravailler la grille pour pixel-perfect alignment. Choix laissé au builder (align-items: stretch + align-self, ou restructurer en 2 rangées). +- Audit boutons : refactoriser ceux qui dépassent ≤ 8 chars. Garder "Mark for review" / "Clear all" / "Reopen" sans icône si pas d'icône évidente. Boutons à passer en icône+label : "Save Red Team" → icône + "Save", "Save SOC" → icône + "Save SOC", "ADD TECHNIQUE" → "+" + "Add" (rendu obsolète par US-22), "QUICK SEARCH" → "🔍" + "Search" (rendu obsolète par US-22). -**API client** (`frontend/src/api/mitre.ts`) -- `searchMitreTechniques(q)` — existant, garder. -- `getMitreMatrix()` — nouveau, GET `/api/mitre/matrix`. +**US-18 — Done read-only + Reopen** +- `SimulationFormPage.tsx` : + - Quand `simulation.status === 'done'` : tous champs disabled, `MitreTechniquesField disabled`, action bar montre UNIQUEMENT "Reopen" + icône (`↻`). + - Bouton Reopen : visible admin/redteam/soc, click → `useTransitionSimulation` to `review_required`, toast. -**Hooks** (`frontend/src/hooks/useMitre.ts`) -- `useMitreSearch(q, enabled)` — existant, garder. -- `useMitreMatrix(enabled)` — nouveau hook TanStack Query, `staleTime: Infinity` (la matrice ne change qu'avec `make update-mitre` + redémarrage). +**US-19 — Engagement auto-status (côté UI)** +- `useUpdateSimulation` et `useTransitionSimulation` : ajouter `["engagement", eid]` et `["engagements"]` aux invalidations après mutation réussie. Pas d'autre changement visuel. -**Composants** +**US-20 — Matrice MITRE attack.mitre.org look** +- `MitreMatrixModal.tsx` overhaul : + - `max-w-[98vw]`, `max-h-[80vh] overflow-y-auto`, JAMAIS de scroll horizontal. + - `display: grid; grid-template-columns: repeat(12, minmax(0, 1fr))` pour répartir équitablement. + - Cellule technique : `text-[12px]`, padding minimal, hairline border. + - Header tactique : sticky top, fond contrasté, uppercase tracking, badge compteur à droite. + - Sub-techniques indent `pl-[8px]`, fond `bg-cloud`. + - Search input top inchangé. -- **`MitreTechniqueTag.tsx`** (nouveau) : chip affichant `{id} — {name}` avec un bouton `×`. Props : `technique: MitreTechnique`, `onRemove: () => void`, `disabled?: boolean`. +**US-21 — Tactic selection** +- `MitreMatrixModal.tsx` : header de tactique cliquable (toggle). État visuel distinct. +- Apply renvoie `{techniques, tactics}` au parent. +- `MitreTechniquesField.tsx` : tactic chips style différencié `bg-primary text-canvas`. Auto-save tactic_ids. -- **`MitreTechniquesField.tsx`** (nouveau, dans `frontend/src/components/`) : conteneur qui orchestre la sélection multi-tech avec **auto-save** (PATCH déclenché par chaque add/remove/Apply). - - Props : `value: MitreTechnique[]`, `simulationId: number`, `disabled?: boolean`. (Pas de `onChange` du parent — le composant fait son propre PATCH via `useUpdateSimulation`.) - - UI : liste de `` + 2 boutons "Add technique" (ouvre matrix) et "Quick search" (ouvre/toggle picker autocomplete inline). - - Dédup : si l'utilisateur essaye d'ajouter une technique déjà présente, no-op silencieux (pas de PATCH non plus). - - Auto-save : chaque mutation (× sur tag, Apply matrice, sélection Quick Search) déclenche `useUpdateSimulation` avec `{technique_ids: [...]}`. Toast succès `'Techniques updated'`, toast erreur sinon. Pendant le PATCH : disable l'interaction (les × deviennent grisés, les boutons disabled). +**US-22 — Refonte input MITRE** +- `MitreTechniquesField.tsx` : + - Layout : chips area | input autocomplete inline + icône matrice button. + - Plus de boutons textuels "Add Technique" / "Quick Search". + - Chips compacts (T-id ou TA-id seul, name en `title=`). + - Empty state minimal. -- **`MitreMatrixModal.tsx`** (nouveau) : modale matrice avec sub-techniques expand/collapse. - - Props : `isOpen: boolean`, `initialSelection: MitreTechnique[]`, `onApply: (selection: MitreTechnique[]) => void`, `onCancel: () => void`. - - État local : (a) copie de `initialSelection` mutée par les toggles, (b) `expandedTechniques: Set` pour les IDs parents dépliés. - - Layout : flex horizontal scrollable, 1 colonne par tactique. Largeur fixe 220px par colonne pour cohérence visuelle. - - Chevron `▸/▾` à gauche du nom des techniques qui ont des sub-techniques (`subtechniques.length > 0`). Click chevron = toggle expand (mute le set `expandedTechniques`), ne modifie PAS la sélection. - - Click sur le label d'une technique top-level = toggle sa sélection (le chevron ne se déclenche pas dans ce cas — séparer les zones cliquables). - - Sub-techniques rendues en cascade indentée sous leur parent quand expand : `pl-md text-[12px] bg-cloud rounded` (vs parent `text-[14px]`). Cliquables individuellement, sélection indépendante du parent. - - Compteur header de tactique = nombre de techniques **parents + subs** sélectionnées dans cette tactique. - - Champ recherche en haut : filtre case-insensitive sur id ET name. Une sub-technique matchée force l'expand de son parent (modifie automatiquement `expandedTechniques`). - - Modale : `position: fixed`, backdrop `bg-ink/60`, container `bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden`. - - Footer : "Cancel" (jette les changements locaux + ferme), "Apply N techniques" (compteur total ; click → onApply renvoie la liste complète, parent fait le PATCH auto-save US-14.2). - - Focus trap (scope minimal V1, cf AC-15.5) : - - `useEffect` au mount → `searchInputRef.current?.focus()`. - - `onKeyDown` au niveau du container modale : - - `Tab` sans shift sur le dernier élément focusable → `preventDefault()` + focus le premier. - - `Shift+Tab` sur le premier → `preventDefault()` + focus le dernier. - - Récupérer la liste des focusables via `container.querySelectorAll('a, button, input, [tabindex]:not([tabindex="-1"])')`, ignorer ceux `disabled` ou `hidden`. - - Pas de focus restoration ni de live region — out of scope V1. - - Pas de dépendance npm. - - Escape → onCancel. Click backdrop → onCancel. - -- **`MitreTechniquePicker.tsx`** (existant, sprint 2) : clean rewrite de la signature. - - Avant : `onChange(id: string | null, name: string | null)` qui remplaçait la valeur. - - Après : `onSelect({id, name})` — un seul match sélectionné, le parent (MitreTechniquesField) gère l'append + le dédup. - - Plus de prop `techniqueId`/`techniqueName` en entrée (le picker est désormais un sélecteur "one-shot" qui se réinitialise après chaque sélection). - -**Pages** - -- **`SimulationFormPage.tsx`** : remplacer le `` standalone par un ``. Le state `rt.techniques` disparait du form (les techniques ont leur propre cycle de save via le champ lui-même — auto-save). Le bouton "Save Red Team" continue de batcher tous les AUTRES champs (name, description, commands, etc.) mais ne touche pas aux techniques. Affichage read-only (rôle SOC) : afficher les tags sans `×`, boutons Add/Quick Search masqués (`disabled` prop). - -- **`SimulationList.tsx`** : colonne MITRE — afficher `techniques[0]?.id + (techniques.length > 1 ? ` +${techniques.length - 1}` : '')`. Si `techniques` est vide, afficher `—`. - -**Tests Vitest** -- `MitreTechniqueTag.test.tsx` — render id+name, click × appelle onRemove. -- `MitreTechniquesField.test.tsx` — affiche tags, "Add technique" ouvre le modal matrix, "Quick search" ouvre le picker, dédup silencieuse, remove via × appelle onChange avec liste mise à jour. -- `MitreMatrixModal.test.tsx` — render colonnes par tactique, click toggle sélection, Apply renvoie liste, Cancel jette, Escape ferme, search filtre. -- Adapter `MitreTechniquePicker.test.tsx` (sprint 2) à la nouvelle signature `onSelect`. -- Adapter `SimulationFormPage.test.tsx` (sprint 2) — assertions sur `techniques` array au lieu de scalaire. - -**Quality bar** : typecheck + lint + vitest clean. +**US-23 — Dark mode** +- `Layout.tsx` : toggle theme dans la topbar. Hook `useTheme()` (localStorage + media query). 3 états avec cycle. +- `tailwind.config.ts` : `darkMode: 'class'`. Tokens étendus avec variantes dark (recommandé via CSS variables sous `.dark { ... }` dans `index.css`, comme ça les composants n'ont pas à dupliquer leurs classes). +- Audit tous les composants : aucune couleur hardcodée (pas de `bg-white`, `text-black`, `#xxxxxx` inline). Tous passent un check visuel light + dark. ### Règles - Lit le summary backend EN PREMIER. -- Pas d'invention d'endpoints — `GET /api/mitre/matrix` est le seul nouveau, déjà spec'd. -- Réutiliser `LoadingState`, `ErrorState`, `ConfirmDialog`, `useToast`, action bar pattern (sprint 2) existants. -- Respect DESIGN.md tokens (palette + spacing). Tags = `bg-primary-soft text-primary-deep rounded-full px-md py-xxs gap-xxs text-[14px]`. -- Pas de nouvelle dépendance npm sans escalade au team-lead. +- Pas d'invention d'endpoints. +- Réutiliser les patterns sprint 1/2/3. +- Respect DESIGN.md tokens. +- Pas de dépendance npm sans escalade (sauf `lucide-react` autorisé). +- **Interdiction absolue de toucher `e2e/`, `backend/`, `.claude/agents/`, `scripts/`, `Makefile`.** --- -## 4. Brief — Test verifier +## 4. Brief — Team-lead infra (US-24 + US-25, en parallèle des builders) -E2e Playwright. Un fichier par US : -- `us13-multi-techniques.spec.ts` — AC-13.1 → AC-13.5 (focus API + données) -- `us14-techniques-tags.spec.ts` — AC-14.1 → AC-14.5 (UI tags + remove) -- `us15-mitre-matrix-modal.spec.ts` — AC-15.1 → AC-15.5 (modal interaction + a11y) -- `us16-regression-sprint2.spec.ts` — re-exécuter les ACs critiques sprint 2 (auto-transition US-8, workflow US-11, SOC restrictions US-9) avec le nouveau modèle. +**US-24 — Process hygiene** +- Créer `.claude/agents/design-reviewer.md` avec frontmatter agent (model `opus`, tools : `Read`, `Glob`, `Grep`, `Bash` lecture seule). Brief : revoit diff frontend + screenshots, audit alignement / DESIGN.md tokens / cohérence visuelle / responsive. +- Mettre à jour `.claude/agents/frontend-builder.md` : DoD strict sur les screenshots. +- Mettre à jour SPEC.md § Workflows : insérer design-reviewer entre frontend-builder et code-reviewer. -Mettre à jour les e2e sprint 2 qui assertaient `mitre_technique_id` / `mitre_technique_name` scalaires (US-8, US-10 selon le grep). +**US-25 — PR helper** +- Écrire `scripts/open-pr.sh` (cf AC-25.1). +- Target Makefile `open-pr`. +- Documenter README.md. +- Dogfood en fin de sprint. --- -## 5. Definition of Done — Sprint 3 +## 5. Brief — Test verifier -- [ ] Tous les AC US-13 → US-16 passent. -- [ ] Backend tests verts (`pytest -q`). Ruff + mypy clean. -- [ ] Frontend tests verts (`npm run test -- --run`). Typecheck + lint clean. -- [ ] E2e Playwright suite verte (sprint 1 + 2 + 3). -- [ ] Migration Alembic testée upgrade + downgrade. -- [ ] SPEC.md mis à jour (multi-techniques acté). -- [ ] README.md mis à jour (mention matrice + multi-tech dans la description workflow). -- [ ] CHANGELOG.md sprint 3 entry sous [Unreleased]. -- [ ] Code-reviewer sans BLOCKER. -- [ ] **Frontend-builder a screenshot la matrice modale + la simulation form avec tags AVANT de marquer la tâche terminée (lesson learned sprint 2).** -- [ ] PR ouverte + récap synthétique team-lead. +E2e Playwright : +- `us17-ui-polish.spec.ts` — AC-17.1 (single button), AC-17.3 (alignment via locator boundingBox). +- `us18-done-readonly-reopen.spec.ts` — AC-18.1 → AC-18.5. +- `us19-engagement-auto-status.spec.ts` — AC-19.1 → AC-19.4. +- `us20-matrix-fits-modal.spec.ts` — AC-20.1, AC-20.4 (no horizontal scroll via `boundingBox`). +- `us21-tactic-selection.spec.ts` — AC-21.4 → AC-21.7. +- `us22-mitre-input-redesign.spec.ts` — AC-22.1 → AC-22.5. +- `us23-dark-mode.spec.ts` — AC-23.1 → AC-23.3. + +US-24/25 non e2e (process / repo files). Couverture par dogfood (la PR sprint 4 elle-même est ouverte via `make open-pr`). + +Adapter les sprint 2/3 e2e si l'audit boutons (AC-17.2) renomme certains labels. --- -## 6. Décisions arrêtées (utilisateur 2026-05-27) +## 6. Décisions arrêtées -1. **Storage multi-tech** : colonne JSON `[{id, name}]` (KISS, pattern `commands` sprint 2). -2. **Sub-techniques dans la matrice** : OUI, affichées avec expand/collapse par technique parent. Sub-techniques sont aussi accessibles via Quick Search en plus. -3. **API shape** : `PATCH` reçoit `{technique_ids: ["T1059", "T1059.001", ...]}` — IDs uniquement (parents et subs au même niveau dans la liste). Backend résout names depuis le bundle. -4. **Rétrocompat** : migration backfill `[{id, name}]` depuis les scalaires. Pas de rétrocompat API. -5. **MitreTechniquePicker** : clean rewrite de la signature (`onSelect({id, name})`). -6. **Matrix layout** : colonnes par tactique, 220px fixe, scroll horizontal global. -7. **Apply de la modale matrice** : auto-save immédiat (PATCH déclenché par `MitreTechniquesField` quand le modal renvoie sa liste via `onApply`). Add/remove via tag × ou Quick Search aussi auto-save. -8. **Sprint 4 framing** (anticipation, NE PAS implémenter dans sprint 3) : Dark mode (toggle + tokens dark + persistence) + Hygiène process UI (`design-reviewer` agent + screenshot mandatory dans brief frontend-builder). Connecteur C2 reporté au-delà. Les builders sprint 3 N'ajoutent PAS de tokens dark, N'invoquent PAS le design-reviewer (qui n'existe pas encore). Seule la lesson `screenshots mandatory` est déjà appliquée en sprint 3 dans le brief frontend (§3). +1. **Tactic storage** : colonne JSON `tactic_ids` séparée. ✓ 2026-05-27 +2. **Dark mode default** : `system` (suit `prefers-color-scheme`, fallback `light` si non détecté). ✓ 2026-05-27 +3. **Matrix CSS fidelity** : look similaire qualitatif (frontend-builder itère, pas pixel-perfect). ✓ 2026-05-27 +4. **Reopen target** : `done → review_required`. ✓ mémoire (sprint 4 scope) +5. **Reopen RBAC** : admin + redteam + soc. ✓ mémoire +6. **Engagement auto trigger** : `planned → active` sur 1ère simu in_progress (auto-transition ou manual). Pas de retour arrière auto. ✓ mémoire +7. **PR helper token source** : `~/.git-credentials` (parse user + token via sed, cf [[reference-gitea-pr-api]]). ✓ 2026-05-27 +8. **Workflow design-reviewer** : insérée entre frontend-builder et code-reviewer, read-only. Diff frontend + screenshots. Format rapport à la code-reviewer mais focus visuel/design. ✓ mémoire +9. **Screenshots frontend-builder** : MANDATORY au sprint 4, en sortie du frontend-builder, paths absolus dans summary, refus de marquer la tâche done sans. ✓ mémoire --- ## 7. Plan d'exécution -1. ✅ User a validé les 8 décisions §6 (2026-05-27). -2. ✅ Team-lead a mis à jour SPEC.md (§0). -3. ✅ Spec-reviewer : APPROVED WITH NOTES après 2 passes (5 items au total, tous traités). -4. ✅ Backend-builder : commits `b5ea292` + `673b25e` (model + migration + matrix endpoint + 503 unloaded, 162 passing). -5. ✅ Frontend-builder : commit `771483f` (MitreTechniquesField + MitreMatrixModal + tags + auto-save + screenshots, 84 passing). -6. ✅ Code-reviewer : APPROVED WITH NITS (2 MINORs + 4 NITs). -7. ✅ Post-review fixes : `4596f09` + `393b6ed` backend (164 passing) + `39f4076` frontend (86 passing). -8. ✅ Test-verifier : commit `df8a6b6` (105/106 sprint 3 e2e verts, 1 pré-existant sprint 1 — DB pollution, non-régression). -9. 🟡 Team-lead : récap + PR en cours. -4. 🔵 Backend-builder : modèle + migration + endpoints + tests. -5. 🔵 Frontend-builder : composants + page update + tests Vitest. Screenshots obligatoires avant "done". -6. 🔵 Code-reviewer : LSP-first. -7. 🔵 Test-verifier : e2e US-13 → US-16 + adaptation sprint 2. -8. 🟢 Team-lead : PR + récap. +1. ✅ Team-lead a re-appliqué le SPEC sprint 3 oublié (`ba313a3`). +2. ✅ User a validé les 4 décisions ouvertes (tactic separated, theme system, matrix qualitative, token from ~/.git-credentials). Avec les 5 acquises en mémoire (sprint 4 scope), ça fait 9 décisions arrêtées. +3. 🟡 Team-lead met à jour SPEC.md § Workflows + § Décisions techniques (§0). +4. 🟡 Spec-reviewer valide le plan vs SPEC.md (anti-trous comme à sprint 3 — RBAC field-level, batch SQLite, scope ambigu). +5. 🔵 Backend-builder : modèle + migration 0004 + workflow done-readonly/reopen + engagement auto-lifecycle + tactic_ids + tests. +6. 🔵 Frontend-builder : UI polish + done read-only + matrix overhaul + tactic selection + input redesign + dark mode + screenshots. +7. 🔵 Team-lead (US-24 + US-25 en parallèle de frontend) : design-reviewer agent + frontend-builder.md update + scripts/open-pr.sh + Makefile target. +8. 🔵 Design-reviewer (NEW STEP) : revoit diff frontend + screenshots. +9. 🔵 Code-reviewer : revoit le diff complet (LSP-first). +10. 🔵 Test-verifier : e2e US-17 → US-23. +11. 🟢 Team-lead : PR via `make open-pr` (dogfood AC-25.4) + récap. From 0f6ae857b353ec6789e3396e0d73354ed5d37646 Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 27 May 2026 19:41:34 +0200 Subject: [PATCH 03/15] feat(infra): design-reviewer agent + PR helper (US-24 + US-25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit US-24 — Process hygiene UI: - New .claude/agents/design-reviewer.md (model: opus, read-only) — visual + design-system reviewer that runs after frontend-builder and before code-reviewer. Audits alignment, DESIGN.md tokens, light/dark consistency, typo hierarchy, whitespace rhythm, responsive sanity at 1280x720, button convention, V1 a11y. Output format mirrors code-reviewer. - Updated .claude/agents/frontend-builder.md DoD: screenshots are MANDATORY (one per feature/state introduced or modified, light+dark when theming is in scope). Hard block on "Dev server not started" — must be flagged explicitly. Screenshots feed the design-reviewer step. US-25 — PR helper: - scripts/open-pr.sh wraps `POST /api/v1/repos/{owner}/{repo}/pulls`. Detects host/owner/repo from `git remote get-url origin`, reads basic-auth credentials from `~/.git-credentials` (same source as `git push`, no token in env), uses jq to compose the multiline-safe payload. Validates args, prints PR URL on success, exits non-zero with the server message on failure. - Makefile target `open-pr TITLE="..." BODY=path/to/body.md [BASE=main]` wraps the script with the same arg validation. - README.md "Make targets" table extended. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/agents/design-reviewer.md | 85 ++++++++++++++++++ .claude/agents/frontend-builder.md | 16 ++++ Makefile | 14 ++- README.md | 1 + scripts/open-pr.sh | 136 +++++++++++++++++++++++++++++ 5 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 .claude/agents/design-reviewer.md create mode 100755 scripts/open-pr.sh diff --git a/.claude/agents/design-reviewer.md b/.claude/agents/design-reviewer.md new file mode 100644 index 0000000..9afc862 --- /dev/null +++ b/.claude/agents/design-reviewer.md @@ -0,0 +1,85 @@ +--- +name: design-reviewer +description: Reviews ONLY the frontend diff of the current sprint plus the screenshots delivered by the frontend-builder. Focuses on visual quality — alignment, typography hierarchy, DESIGN.md token compliance, light/dark consistency, responsive sanity at 1280x720. Read-only, never patches code. Use at the end of every sprint, AFTER frontend-builder marks the task complete and BEFORE code-reviewer. +model: opus +tools: Read, Glob, Grep, Bash +--- + +You are the **Design Reviewer** for the Mimic project (BAS WebUI based on MITRE ATT&CK for Purple Team exercises). You review the **visual output** of the current sprint — not its logic. You flag visual defects ; you do not patch code. + +## Scope discipline (critical) + +You review **only the frontend diff of the current sprint** plus the screenshots the frontend-builder attached to their summary. You do NOT touch backend, e2e, or anything outside `frontend/`. Use: +```bash +git diff ...HEAD -- frontend/ +git diff ...HEAD --name-only -- frontend/ +``` +The sprint base branch is in `tasks/todo.md`. If unsure, ask the team-lead. + +## Your input + +1. The **screenshots** (paths in the frontend-builder's summary). View each one with the `Read` tool — they are PNG images and the tool renders them visually. +2. **DESIGN.md** — your spec for tokens (palette, typography, spacing, radii, shadows). Every visual choice must trace back to a token. +3. The **diff** for `frontend/src/components/`, `frontend/src/pages/`, `frontend/src/styles/`, `frontend/tailwind.config.ts`, `frontend/src/styles/*.css`. +4. **SPEC.md § UI/UX** for theming + button convention + modal rules. +5. The current sprint's `tasks/todo.md` § 1 (user stories) — to know which screens were intended to change. + +## What you look for + +In order of importance: + +1. **Alignment defects** — labels and inputs on different baselines, buttons sitting on the wrong row, grids that look jagged. Inspect at 1280×720 viewport since that's the project's reference. +2. **Token violations** — any color, spacing, radius, or font size that is NOT a DESIGN.md token. Hardcoded `#hexhex`, `text-white`, `bg-gray-500`, arbitrary `px` values, or off-system Tailwind classes are flags. CSS variables tied to dark mode are fine. +3. **Light / dark consistency** — both states use the same component logic, only colors swap. A light-only color leaking into dark mode (or vice versa) is a defect. Verify each screenshot pair (`*-light.png` + `*-dark.png`) tells the same visual story. +4. **Typography hierarchy** — display vs body vs caption sizes follow the scale in DESIGN.md. A heading that uses a body weight, or vice versa, is a defect. +5. **Whitespace rhythm** — DESIGN.md ships a base 8 px scale with named tokens (`xs`, `sm`, `md`, …). Padding/margins that fall outside this rhythm are flags. +6. **Responsive sanity** — at 1280×720 nothing overflows the viewport without an intentional scroll affordance. Modal content should fit without horizontal scroll unless explicitly spec'd otherwise. +7. **Button convention** (sprint 4+) — icon + short label (≤ 8 chars) preferred to phrases. Long-form buttons need a justification (workflow-critical label without an obvious icon). +8. **Accessibility scope V1** — focus visible on every interactive element ; ARIA roles present on dialogs and listboxes ; color contrast not relying on red/green alone. Full WCAG conformance is OUT OF SCOPE V1 — don't over-flag. +9. **Cohérence inter-écrans** — the same component renders the same way on every page (e.g., `StatusBadge` looks identical on the engagements list and on the detail page). Sprint-introduced inconsistencies are defects. + +## What you NEVER do + +- Edit any file. +- Run destructive git commands. +- Review backend code, e2e tests, or any non-`frontend/` change. +- Re-review prior sprints' UI (out of scope). +- Mark APPROVED if open findings remain. +- Patch a defect — even a one-character CSS fix. Only flag. The frontend-builder owns the fix. + +## Output format + +``` +## Design Review — Sprint + +### Verdict +APPROVED | NEEDS-FIX + +### Screenshots audited +- list of each screenshot path + a one-line visual summary + +### Findings (assigned to frontend-builder) +For each: +- Severity: [ALIGN] | [TOKEN] | [DARK] | [TYPO] | [SPACE] | [RESP] | [BTN] | [A11Y] | [COHER] | [NIT] +- Screenshot or file:line where it shows +- What is wrong (concretely — "Password label sits 24px lower than Username label" is good ; "alignment is off" is not) +- Suggested fix (1-2 lines — class change, token to use, no patch) + +### Token compliance +- list of any hardcoded colors / sizes that escaped DESIGN.md, with file:line + +### Light/dark consistency +- per pair of screenshots, OK or specific divergence noted + +### Coverage gaps +- screens that should have been screenshot but weren't (vs. the brief's expected list) +``` + +When verdict is APPROVED, notify the team-lead so the code-reviewer can take over. When NEEDS-FIX, the findings go back to the frontend-builder via the team-lead. + +## Principles + +- KISS — flag the visible defects, not the abstract concerns. +- One screenshot tells more than ten paragraphs ; quote pixel deltas or color hexes when relevant. +- Trust the frontend-builder's choices when they sit within DESIGN.md ; push back when they don't. +- Don't re-litigate decisions already settled in `tasks/todo.md` § Décisions arrêtées. diff --git a/.claude/agents/frontend-builder.md b/.claude/agents/frontend-builder.md index 61cefd5..4c52a72 100644 --- a/.claude/agents/frontend-builder.md +++ b/.claude/agents/frontend-builder.md @@ -44,6 +44,21 @@ cd frontend && npm run test -- --run If any of these fail, fix the cause before reporting completion. +### Screenshots — MANDATORY (sprint 4+) + +You MUST also start the dev server (`npm run dev` inside `frontend/`) and capture **one screenshot per feature or state you introduced or modified**. Concretely : + +- Every new page → 1 screenshot. +- Every modified page → 1 screenshot of the new state. +- Every component with multiple visual states (loading / error / empty / populated / read-only / disabled) → 1 screenshot per distinct state you introduced or changed. +- If theming is in scope this sprint → 1 light + 1 dark screenshot per screen above. + +Save them under `$CLAUDE_JOB_DIR` (or `/tmp/mimic-sprint-N/`) with descriptive names. **List the absolute paths in your final summary, grouped per screen.** + +If you genuinely cannot start the dev server (port conflict, build broke, env missing), say so EXPLICITLY in the summary, list the technical reasons, and DO NOT silently skip. A "Dev server not started" line is a hard block — the team-lead must decide whether to accept or send back. + +Screenshots are the **design-reviewer**'s primary input. Without them, the design-review step cannot run, the sprint cannot ship. + ## Output format (when you return to the team-lead) A short Markdown summary: @@ -53,6 +68,7 @@ A short Markdown summary: - **Mismatches with API** (if any — flagged, not patched) - **Open questions / design ambiguities** (escalate, don't decide) - **Test results** (vitest summary, typecheck/lint status) +- **Screenshots delivered** (absolute paths, grouped per screen, light + dark when in scope) — see § Before you finish - **CLAUDE.md rules that helped** ## Principles diff --git a/Makefile b/Makefile index e701465..1a623eb 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ VOLUME ?= mimic-data # Override explicitly with `make CONTAINER_CMD=podman` or `export CONTAINER_CMD=podman`. CONTAINER_CMD ?= $(shell if command -v docker >/dev/null 2>&1; then echo docker; else echo podman; fi) -.PHONY: build start stop restart update logs create-admin update-mitre test-backend test-frontend test-e2e clean +.PHONY: build start stop restart update logs create-admin update-mitre test-backend test-frontend test-e2e clean open-pr build: $(CONTAINER_CMD) build -f docker/Dockerfile -t $(IMAGE) . @@ -60,3 +60,15 @@ clean: -$(CONTAINER_CMD) rm -f $(CONTAINER) 2>/dev/null -$(CONTAINER_CMD) volume rm $(VOLUME) 2>/dev/null rm -rf backend/__pycache__ frontend/node_modules frontend/dist + +# Open a PR on the Gitea repo for the current branch. +# make open-pr TITLE="feat: sprint 4 — ..." BODY=path/to/body.md [BASE=main] +# Uses scripts/open-pr.sh, which reads ~/.git-credentials (no token in env). +open-pr: +ifndef TITLE + $(error TITLE is required: make open-pr TITLE="feat: ..." BODY=path/to/body.md) +endif +ifndef BODY + $(error BODY is required: make open-pr TITLE="feat: ..." BODY=path/to/body.md) +endif + ./scripts/open-pr.sh --title "$(TITLE)" --body "$(BODY)" --base "$(if $(BASE),$(BASE),main)" diff --git a/README.md b/README.md index 7a35150..900e8fa 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ mimic/ | `make test-frontend` | `npm run test -- --run` in `frontend/` | | `make test-e2e` | Playwright acceptance suite (container must be running) | | `make clean` | Remove container + volume + Python/Node caches | +| `make open-pr TITLE="…" BODY=path` | Open a PR on the Gitea repo for the current branch via the REST API. Reads credentials from `~/.git-credentials` (same source as `git push`) — no token in env. Wraps `scripts/open-pr.sh`. Defaults `BASE=main`. | --- diff --git a/scripts/open-pr.sh b/scripts/open-pr.sh new file mode 100755 index 0000000..c140c9a --- /dev/null +++ b/scripts/open-pr.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Open a pull request against the Mimic Gitea repository using the credentials +# already stored in ~/.git-credentials (the same token used for `git push`). +# +# Usage: +# scripts/open-pr.sh --title "feat: sprint N — short summary" \ +# --body path/to/body.md \ +# [--base main] \ +# [--head ] +# +# Or via the Makefile wrapper: +# make open-pr TITLE="feat: sprint 4 — UI polish" BODY=tasks/pr-body-sprint-4.md +# +# Output: prints the PR URL on success, exits non-zero on failure. + +set -euo pipefail + +# --- Arg parsing ------------------------------------------------------------ + +TITLE="" +BODY_FILE="" +BASE="main" +HEAD="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --title) + TITLE="${2:-}" + shift 2 + ;; + --body) + BODY_FILE="${2:-}" + shift 2 + ;; + --base) + BASE="${2:-}" + shift 2 + ;; + --head) + HEAD="${2:-}" + shift 2 + ;; + --sprint) + # purely informational; ignored — the title is what carries semantics + shift 2 + ;; + -h|--help) + sed -n '2,15p' "$0" + exit 0 + ;; + *) + echo "Unknown arg: $1" >&2 + exit 2 + ;; + esac +done + +[[ -n "$TITLE" ]] || { echo "--title is required" >&2; exit 2; } +[[ -n "$BODY_FILE" ]] || { echo "--body is required" >&2; exit 2; } +[[ -f "$BODY_FILE" ]] || { echo "body file not found: $BODY_FILE" >&2; exit 2; } + +# --- Credentials ------------------------------------------------------------ + +CRED_FILE="${HOME}/.git-credentials" +[[ -f "$CRED_FILE" ]] || { echo "no ~/.git-credentials — git push must have run at least once" >&2; exit 3; } + +# Detect Gitea host from origin remote +ORIGIN_URL=$(git remote get-url origin) +# Strip protocol, .git suffix → host/owner/repo +case "$ORIGIN_URL" in + https://*) + REST="${ORIGIN_URL#https://}" + ;; + *) + echo "origin is not https (got: $ORIGIN_URL) — this script supports HTTPS Gitea only" >&2 + exit 3 + ;; +esac +REST="${REST%.git}" +HOST="${REST%%/*}" +PATHPART="${REST#*/}" # owner/repo +OWNER="${PATHPART%%/*}" +REPO="${PATHPART#*/}" +REPO="${REPO%%/*}" # belt + braces in case of trailing slash + +# Match the credential line for this host +CRED_LINE=$(grep -E "^https://[^@]+@${HOST}\$" "$CRED_FILE" || true) +[[ -n "$CRED_LINE" ]] || { echo "no credential for host ${HOST} in ${CRED_FILE}" >&2; exit 3; } + +USER_PART=$(echo "$CRED_LINE" | sed -E 's|^https://([^:]+):.*|\1|') +TOKEN=$(echo "$CRED_LINE" | sed -E 's|^https://[^:]+:([^@]+)@.*$|\1|') + +[[ -n "$USER_PART" && -n "$TOKEN" ]] || { echo "could not parse user/token from credential" >&2; exit 3; } + +# --- Branch ----------------------------------------------------------------- + +if [[ -z "$HEAD" ]]; then + HEAD=$(git rev-parse --abbrev-ref HEAD) +fi +[[ "$HEAD" != "HEAD" ]] || { echo "detached HEAD — pass --head explicitly" >&2; exit 3; } + +# --- Compose payload -------------------------------------------------------- + +API_URL="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls" + +PAYLOAD=$(jq -n \ + --arg title "$TITLE" \ + --rawfile body "$BODY_FILE" \ + --arg head "$HEAD" \ + --arg base "$BASE" \ + '{title:$title, body:$body, head:$head, base:$base}') + +# --- POST ------------------------------------------------------------------- + +RESPONSE_FILE=$(mktemp) +HTTP_CODE=$(curl -sS -u "${USER_PART}:${TOKEN}" \ + -H "Content-Type: application/json" \ + -X POST \ + -d "$PAYLOAD" \ + -o "$RESPONSE_FILE" \ + -w "%{http_code}" \ + "$API_URL") + +if [[ "$HTTP_CODE" != "201" ]]; then + echo "PR creation failed (HTTP $HTTP_CODE):" >&2 + jq -r '.message // empty' "$RESPONSE_FILE" >&2 2>/dev/null || cat "$RESPONSE_FILE" >&2 + rm -f "$RESPONSE_FILE" + exit 4 +fi + +PR_URL=$(jq -r '.html_url' "$RESPONSE_FILE") +PR_NUMBER=$(jq -r '.number' "$RESPONSE_FILE") +rm -f "$RESPONSE_FILE" + +echo "Opened PR #${PR_NUMBER}" +echo "$PR_URL" From d5ab1fd26f57be2e812e21d7b9ccfafd4e9ee786 Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 27 May 2026 19:52:02 +0200 Subject: [PATCH 04/15] =?UTF-8?q?feat(backend):=20sprint=204=20=E2=80=94?= =?UTF-8?q?=20tactic=5Fids=20+=20done=20guard=20+=20engagement=20auto-stat?= =?UTF-8?q?us?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simulation model: add tactic_ids JSON column (nullable=False, default=[]) - Migration 0004: ADD COLUMN tactic_ids (server_default='[]', no batch needed) - mitre.py: add _TACTIC_IDS map, lookup_tactic(), get_tactic_name() - simulation_workflow.py: done guard (409) before RBAC; SOC gate += tactic_ids; _resolve_tactic_ids() validates against hardcoded map; auto-transition += tactic_ids; transition done→review_required is Reopen (all 3 roles); _maybe_activate_engagement hook - serializers.py: _enrich_tactics() → serialize_simulation adds tactics:[{id,name}] - test_simulations_tactics.py: valid/invalid/dedup/SOC gate/auto-transition/no-bundle - test_simulations_done_readonly.py: 409 all roles, Reopen all roles, invalid transitions, after-reopen ok - test_engagement_lifecycle.py: planned→active on auto-transition, already active/closed unchanged, migration 0004 round-trip - Updated test_simulations_patch.py + test_simulations_workflow.py for AC-18 behavior Co-Authored-By: Claude Sonnet 4.6 --- backend/app/models/simulation.py | 1 + backend/app/serializers.py | 15 ++ backend/app/services/mitre.py | 32 +++ backend/app/services/simulation_workflow.py | 74 +++++- .../versions/0004_simulation_tactic_ids.py | 33 +++ backend/tests/test_engagement_lifecycle.py | 178 +++++++++++++ .../tests/test_simulations_done_readonly.py | 191 ++++++++++++++ backend/tests/test_simulations_patch.py | 6 +- backend/tests/test_simulations_tactics.py | 237 ++++++++++++++++++ backend/tests/test_simulations_techniques.py | 2 +- backend/tests/test_simulations_workflow.py | 6 +- 11 files changed, 765 insertions(+), 10 deletions(-) create mode 100644 backend/migrations/versions/0004_simulation_tactic_ids.py create mode 100644 backend/tests/test_engagement_lifecycle.py create mode 100644 backend/tests/test_simulations_done_readonly.py create mode 100644 backend/tests/test_simulations_tactics.py diff --git a/backend/app/models/simulation.py b/backend/app/models/simulation.py index 74d99df..9dfc0cf 100644 --- a/backend/app/models/simulation.py +++ b/backend/app/models/simulation.py @@ -26,6 +26,7 @@ class Simulation(db.Model): # type: ignore[name-defined] ) name = db.Column(db.String(255), nullable=False) techniques = db.Column(db.JSON, nullable=False, default=list) + tactic_ids = db.Column(db.JSON, nullable=False, default=list) description = db.Column(db.Text, nullable=True) commands = db.Column(db.Text, nullable=True) prerequisites = db.Column(db.Text, nullable=True) diff --git a/backend/app/serializers.py b/backend/app/serializers.py index d54e9cc..41bf4d6 100644 --- a/backend/app/serializers.py +++ b/backend/app/serializers.py @@ -30,12 +30,27 @@ def _enrich_techniques(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: ] +def _enrich_tactics(tactic_ids: list[str]) -> list[dict[str, str]]: + """Resolve TA-ids to {id, name} at runtime.""" + from backend.app.services import mitre as mitre_svc + + result = [] + for tid in tactic_ids or []: + entry = mitre_svc.lookup_tactic(tid) + if entry is not None: + result.append(entry) + else: + result.append({"id": tid, "name": ""}) + return result + + def serialize_simulation(simulation: Simulation) -> dict[str, Any]: return { "id": simulation.id, "engagement_id": simulation.engagement_id, "name": simulation.name, "techniques": _enrich_techniques(simulation.techniques or []), + "tactics": _enrich_tactics(simulation.tactic_ids or []), "description": simulation.description, "commands": simulation.commands, "prerequisites": simulation.prerequisites, diff --git a/backend/app/services/mitre.py b/backend/app/services/mitre.py index 6dd91e2..62909f5 100644 --- a/backend/app/services/mitre.py +++ b/backend/app/services/mitre.py @@ -26,6 +26,22 @@ _TACTIC_ORDER = [ "impact", ] +# TA-id → short-name mapping (MITRE Enterprise, IDs are not sequential). +_TACTIC_IDS: dict[str, str] = { + "TA0001": "initial-access", + "TA0002": "execution", + "TA0003": "persistence", + "TA0004": "privilege-escalation", + "TA0005": "defense-evasion", + "TA0006": "credential-access", + "TA0007": "discovery", + "TA0008": "lateral-movement", + "TA0009": "collection", + "TA0011": "command-and-control", + "TA0010": "exfiltration", + "TA0040": "impact", +} + TACTIC_NAMES: dict[str, str] = { "initial-access": "Initial Access", "execution": "Execution", @@ -181,6 +197,22 @@ def get_matrix() -> list[dict[str, Any]]: return _matrix +def lookup_tactic(tactic_id: str) -> dict[str, str] | None: + """Return {id, name} for a TA-id, or None if unknown.""" + short = _TACTIC_IDS.get(tactic_id) + if short is None: + return None + return {"id": tactic_id, "name": TACTIC_NAMES[short]} + + +def get_tactic_name(tactic_id: str) -> str | None: + """Return the display name for a TA-id, or None if unknown.""" + short = _TACTIC_IDS.get(tactic_id) + if short is None: + return None + return TACTIC_NAMES[short] + + def search(query: str, limit: int = 20) -> list[dict[str, Any]]: """Return up to `limit` techniques matching `query`. diff --git a/backend/app/services/simulation_workflow.py b/backend/app/services/simulation_workflow.py index 2df406d..03f6386 100644 --- a/backend/app/services/simulation_workflow.py +++ b/backend/app/services/simulation_workflow.py @@ -10,7 +10,7 @@ from backend.app.extensions import db from backend.app.models import User from backend.app.models.simulation import Simulation, SimulationStatus -# Fields only admin/redteam may write (excluding technique_ids which is handled separately). +# Fields only admin/redteam may write (excluding technique_ids/tactic_ids handled separately). REDTEAM_FIELDS = frozenset( { "name", @@ -58,7 +58,6 @@ def _resolve_technique_ids( if not mitre_svc.mitre_loaded: return None, (jsonify({"error": "mitre bundle not loaded"}), 503) - # Dedup, preserve order. seen: dict[str, None] = dict.fromkeys(technique_ids) resolved: list[dict[str, str]] = [] for tid in seen: @@ -69,6 +68,36 @@ def _resolve_technique_ids( return resolved, None +def _resolve_tactic_ids( + tactic_ids: list[str], +) -> tuple[list[str] | None, tuple[Any, int] | None]: + """Validate and deduplicate tactic TA-ids. + + Returns (deduped_list, None) on success or (None, error_tuple) on failure. + Bundle does not need to be loaded — validation is against the hardcoded _TACTIC_IDS map. + """ + from backend.app.services import mitre as mitre_svc + + seen: dict[str, None] = dict.fromkeys(tactic_ids) + for tid in seen: + if mitre_svc.lookup_tactic(tid) is None: + return None, (jsonify({"error": f"unknown tactic id: {tid}"}), 400) + return list(seen), None + + +def _maybe_activate_engagement(simulation: Simulation) -> None: + """If simulation's engagement is planned, advance it to active. + + Caller must commit — do not commit here to avoid double-commit. + """ + from backend.app.models.engagement import Engagement, EngagementStatus + + engagement: Engagement | None = getattr(simulation, "engagement", None) + if engagement is not None and engagement.status == EngagementStatus.PLANNED: + engagement.status = EngagementStatus.ACTIVE + db.session.add(engagement) + + def apply_patch( simulation: Simulation, payload: dict[str, Any], user: User ) -> tuple[Any, int] | None: @@ -77,6 +106,10 @@ def apply_patch( Returns a (response, status_code) tuple on error, or None on success (caller is responsible for committing). """ + # Done guard — applies to ALL roles before any RBAC check. + if simulation.status == SimulationStatus.DONE: + return jsonify({"error": "simulation is done — reopen first"}), 409 + role = user.role.value if role == "soc": @@ -86,8 +119,10 @@ def apply_patch( ): return jsonify({"error": "simulation not ready for SOC review"}), 403 - # SOC must not send redteam fields or technique_ids. - redteam_keys_in_payload = (REDTEAM_FIELDS | {"technique_ids"}) & payload.keys() + # SOC must not send redteam fields, technique_ids, or tactic_ids. + redteam_keys_in_payload = ( + REDTEAM_FIELDS | {"technique_ids", "tactic_ids"} + ) & payload.keys() if redteam_keys_in_payload: return jsonify({"error": "soc cannot edit redteam fields"}), 403 @@ -121,6 +156,16 @@ def apply_patch( if err is not None: return err + # Validate and deduplicate tactic_ids upfront. + resolved_tactic_ids: list[str] | None = None + if "tactic_ids" in payload: + raw_tids = payload["tactic_ids"] + if not isinstance(raw_tids, list): + return jsonify({"error": "tactic_ids must be a list"}), 400 + resolved_tactic_ids, err = _resolve_tactic_ids(raw_tids) + if err is not None: + return err + # Apply scalar redteam fields. for field in redteam_keys_present: if field == "executed_at": @@ -132,19 +177,26 @@ def apply_patch( if resolved_techniques is not None: simulation.techniques = resolved_techniques + # Apply resolved tactic_ids. + if resolved_tactic_ids is not None: + simulation.tactic_ids = resolved_tactic_ids + # Apply SOC fields (admin/redteam may also write them). for field in SOC_FIELDS: if field in payload: setattr(simulation, field, payload[field]) # Auto-transition pending → in_progress. - # Triggers when any redteam scalar has a non-empty value, OR technique_ids is non-empty. + # Triggers when any redteam scalar has a non-empty value, technique_ids or tactic_ids non-empty. auto_trigger = any(_is_non_empty(payload[k]) for k in redteam_keys_present) if not auto_trigger and "technique_ids" in payload: auto_trigger = len(payload["technique_ids"]) > 0 + if not auto_trigger and "tactic_ids" in payload: + auto_trigger = len(payload["tactic_ids"]) > 0 if simulation.status == SimulationStatus.PENDING and auto_trigger: simulation.status = SimulationStatus.IN_PROGRESS + _maybe_activate_engagement(simulation) simulation.updated_at = datetime.now(UTC) return None @@ -154,6 +206,13 @@ def transition( simulation: Simulation, to_status: str, user: User ) -> tuple[Any, int] | None: """Attempt a manual transition. Returns error tuple or None on success.""" + # Special case: done → review_required (Reopen), allowed for all 3 roles. + if to_status == "review_required" and simulation.status == SimulationStatus.DONE: + simulation.status = SimulationStatus.REVIEW_REQUIRED + simulation.updated_at = datetime.now(UTC) + db.session.commit() + return None + rule = _ALLOWED_TRANSITIONS.get(to_status) if rule is None: return jsonify({"error": "invalid transition"}), 409 @@ -166,5 +225,10 @@ def transition( simulation.status = SimulationStatus(to_status) simulation.updated_at = datetime.now(UTC) + + # Hook: auto-activate engagement when simulation enters in_progress via manual transition. + if simulation.status == SimulationStatus.IN_PROGRESS: + _maybe_activate_engagement(simulation) + db.session.commit() return None diff --git a/backend/migrations/versions/0004_simulation_tactic_ids.py b/backend/migrations/versions/0004_simulation_tactic_ids.py new file mode 100644 index 0000000..70622e7 --- /dev/null +++ b/backend/migrations/versions/0004_simulation_tactic_ids.py @@ -0,0 +1,33 @@ +"""add tactic_ids JSON column to simulations + +Revision ID: 0004 +Revises: 0003 +Create Date: 2026-05-27 00:00:00.000000 +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.sql import text + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ADD COLUMN is safe on SQLite without batch mode. + # server_default='[]' satisfies NOT NULL for existing rows. + op.add_column( + "simulations", + sa.Column( + "tactic_ids", + sa.JSON(), + nullable=False, + server_default=text("'[]'"), + ), + ) + + +def downgrade() -> None: + with op.batch_alter_table("simulations") as batch_op: + batch_op.drop_column("tactic_ids") diff --git a/backend/tests/test_engagement_lifecycle.py b/backend/tests/test_engagement_lifecycle.py new file mode 100644 index 0000000..b3f0c26 --- /dev/null +++ b/backend/tests/test_engagement_lifecycle.py @@ -0,0 +1,178 @@ +"""Sprint 4 — engagement auto-status planned→active (AC-19).""" +from __future__ import annotations + +from flask.testing import FlaskClient + +from backend.tests.conftest import auth_headers as _h + + +def _make_engagement(client: FlaskClient, token: str, **kwargs) -> dict: + payload = {"name": "Eng", "start_date": "2026-01-01", **kwargs} + resp = client.post("/api/engagements", headers=_h(token), json=payload) + assert resp.status_code == 201 + return resp.get_json() + + +def _get_engagement(client: FlaskClient, token: str, eid: int) -> dict: + resp = client.get(f"/api/engagements/{eid}", headers=_h(token)) + assert resp.status_code == 200 + return resp.get_json() + + +def _make_sim(client: FlaskClient, token: str, eid: int) -> dict: + resp = client.post( + f"/api/engagements/{eid}/simulations", + headers=_h(token), + json={"name": "Sim"}, + ) + assert resp.status_code == 201 + return resp.get_json() + + +def _patch_sim(client: FlaskClient, token: str, sid: int, payload: dict) -> dict: + resp = client.patch(f"/api/simulations/{sid}", headers=_h(token), json=payload) + assert resp.status_code == 200 + return resp.get_json() + + +# --------------------------------------------------------------------------- +# AC-19.1 — Auto-activate engagement on first sim in_progress +# --------------------------------------------------------------------------- + + +def test_sim_creation_does_not_activate_engagement( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + _make_sim(client, redteam_token, eng["id"]) + + eng_data = _get_engagement(client, redteam_token, eng["id"]) + assert eng_data["status"] == "planned" + + +def test_patch_rt_field_activates_planned_engagement( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + assert sim["status"] == "pending" + + sim_data = _patch_sim(client, redteam_token, sim["id"], {"description": "started"}) + assert sim_data["status"] == "in_progress" + + eng_data = _get_engagement(client, redteam_token, eng["id"]) + assert eng_data["status"] == "active" + + +def test_patch_tactic_ids_activates_planned_engagement( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + _patch_sim(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]}) + + eng_data = _get_engagement(client, redteam_token, eng["id"]) + assert eng_data["status"] == "active" + + +# --------------------------------------------------------------------------- +# AC-19.2 — Already active → stays active (no change) +# --------------------------------------------------------------------------- + + +def test_patch_rt_field_does_not_change_active_engagement( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + # First patch triggers activation. + _patch_sim(client, redteam_token, sim["id"], {"description": "started"}) + + # Second patch: engagement should remain active (no state change). + _patch_sim(client, redteam_token, sim["id"], {"description": "updated"}) + + eng_data = _get_engagement(client, redteam_token, eng["id"]) + assert eng_data["status"] == "active" + + +# --------------------------------------------------------------------------- +# AC-19.3 — Engagement in closed state → not touched +# --------------------------------------------------------------------------- + + +def test_patch_does_not_reopen_closed_engagement( + client: FlaskClient, redteam_token: str, admin_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + # Manually close the engagement via API. + close_resp = client.patch( + f"/api/engagements/{eng['id']}", + headers=_h(admin_token), + json={"status": "closed"}, + ) + assert close_resp.status_code == 200 + + # PATCH a sim field that would normally trigger in_progress. + _patch_sim(client, redteam_token, sim["id"], {"description": "new work"}) + + eng_data = _get_engagement(client, redteam_token, eng["id"]) + assert eng_data["status"] == "closed" + + +# --------------------------------------------------------------------------- +# Migration 0004 — tactic_ids column NOT NULL after upgrade +# --------------------------------------------------------------------------- + + +def test_migration_0004_tactic_ids_not_null_after_upgrade() -> None: + """Alembic round-trip: tactic_ids column is NOT NULL after migration 0004.""" + import importlib + + import sqlalchemy as _sa + from alembic.operations import Operations + from alembic.runtime.migration import MigrationContext + + engine = _sa.create_engine("sqlite:///:memory:") + + # Create post-0003 schema (simulations with techniques column). + with engine.begin() as conn: + conn.execute(_sa.text( + "CREATE TABLE simulations (" + " id INTEGER PRIMARY KEY," + " techniques TEXT NOT NULL DEFAULT '[]'" + ")" + )) + conn.execute(_sa.text( + "INSERT INTO simulations (id, techniques) VALUES (1, '[]')" + )) + + with engine.begin() as conn: + ctx = MigrationContext.configure(conn, opts={"as_sql": False}) + ops = Operations(ctx) + + import alembic.op as _op_module + _op_module._proxy = ops # type: ignore[attr-defined] + + spec = importlib.util.spec_from_file_location( + "mig_0004", + "/home/user/Documents/01_Projects/mimic/.claude/worktrees/sprint-4-ui-polish/backend/migrations/versions/0004_simulation_tactic_ids.py", + ) + assert spec is not None and spec.loader is not None + mig = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mig) # type: ignore[union-attr] + mig.upgrade() + + insp = _sa.inspect(engine) + cols = {c["name"]: c for c in insp.get_columns("simulations")} + assert "tactic_ids" in cols, "tactic_ids column must exist after upgrade" + assert cols["tactic_ids"]["nullable"] is False, "tactic_ids must be NOT NULL" + + # Existing row should have server_default applied. + with engine.connect() as conn: + row = conn.execute(_sa.text("SELECT tactic_ids FROM simulations WHERE id=1")).fetchone() + assert row is not None + import json + assert json.loads(row[0]) == [] diff --git a/backend/tests/test_simulations_done_readonly.py b/backend/tests/test_simulations_done_readonly.py new file mode 100644 index 0000000..8f5609c --- /dev/null +++ b/backend/tests/test_simulations_done_readonly.py @@ -0,0 +1,191 @@ +"""Sprint 4 — done read-only + Reopen tests (AC-18).""" +from __future__ import annotations + +import pytest +from flask.testing import FlaskClient + +from backend.tests.conftest import auth_headers as _h + + +def _make_engagement(client: FlaskClient, token: str) -> dict: + resp = client.post( + "/api/engagements", + headers=_h(token), + json={"name": "Eng", "start_date": "2026-01-01"}, + ) + assert resp.status_code == 201 + return resp.get_json() + + +def _make_sim(client: FlaskClient, token: str, eid: int) -> dict: + resp = client.post( + f"/api/engagements/{eid}/simulations", + headers=_h(token), + json={"name": "Sim"}, + ) + assert resp.status_code == 201 + return resp.get_json() + + +def _advance_to_done(client: FlaskClient, redteam_token: str, soc_token: str, sid: int) -> None: + client.post( + f"/api/simulations/{sid}/transition", + headers=_h(redteam_token), + json={"to": "review_required"}, + ) + client.post( + f"/api/simulations/{sid}/transition", + headers=_h(soc_token), + json={"to": "done"}, + ) + + +def _patch(client: FlaskClient, token: str, sid: int, payload: dict): + return client.patch( + f"/api/simulations/{sid}", + headers=_h(token), + json=payload, + ) + + +def _transition(client: FlaskClient, token: str, sid: int, to: str): + return client.post( + f"/api/simulations/{sid}/transition", + headers=_h(token), + json={"to": to}, + ) + + +# --------------------------------------------------------------------------- +# AC-18.1 — PATCH on done → 409 for all roles +# --------------------------------------------------------------------------- + + +def test_patch_done_sim_admin_returns_409( + client: FlaskClient, redteam_token: str, soc_token: str, admin_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _advance_to_done(client, redteam_token, soc_token, sim["id"]) + + resp = _patch(client, admin_token, sim["id"], {"name": "renamed"}) + assert resp.status_code == 409 + assert resp.get_json()["error"] == "simulation is done — reopen first" + + +def test_patch_done_sim_redteam_returns_409( + client: FlaskClient, redteam_token: str, soc_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _advance_to_done(client, redteam_token, soc_token, sim["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"description": "x"}) + assert resp.status_code == 409 + + +def test_patch_done_sim_soc_returns_409( + client: FlaskClient, redteam_token: str, soc_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _advance_to_done(client, redteam_token, soc_token, sim["id"]) + + resp = _patch(client, soc_token, sim["id"], {"soc_comment": "afterthought"}) + assert resp.status_code == 409 + + +# --------------------------------------------------------------------------- +# AC-18.2 — Reopen: done → review_required, all 3 roles +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("role", ["redteam", "soc", "admin"]) +def test_reopen_done_sim_allowed_for_all_roles( + client: FlaskClient, + redteam_token: str, + soc_token: str, + admin_token: str, + role: str, +) -> None: + token = {"redteam": redteam_token, "soc": soc_token, "admin": admin_token}[role] + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _advance_to_done(client, redteam_token, soc_token, sim["id"]) + + resp = _transition(client, token, sim["id"], "review_required") + assert resp.status_code == 200 + assert resp.get_json()["status"] == "review_required" + + +# --------------------------------------------------------------------------- +# AC-18.3 — Other transitions from done → 409 +# --------------------------------------------------------------------------- + + +def test_transition_done_to_done_rejected( + client: FlaskClient, redteam_token: str, soc_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _advance_to_done(client, redteam_token, soc_token, sim["id"]) + + resp = _transition(client, redteam_token, sim["id"], "done") + assert resp.status_code == 409 + + +def test_transition_done_to_in_progress_rejected( + client: FlaskClient, redteam_token: str, soc_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _advance_to_done(client, redteam_token, soc_token, sim["id"]) + + resp = _transition(client, redteam_token, sim["id"], "in_progress") + assert resp.status_code == 409 + + +def test_transition_done_to_pending_rejected( + client: FlaskClient, redteam_token: str, soc_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _advance_to_done(client, redteam_token, soc_token, sim["id"]) + + resp = _transition(client, redteam_token, sim["id"], "pending") + assert resp.status_code == 409 + + +# --------------------------------------------------------------------------- +# After reopen, PATCH is allowed again +# --------------------------------------------------------------------------- + + +def test_patch_allowed_after_reopen( + client: FlaskClient, redteam_token: str, soc_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _advance_to_done(client, redteam_token, soc_token, sim["id"]) + _transition(client, redteam_token, sim["id"], "review_required") + + resp = _patch(client, soc_token, sim["id"], {"soc_comment": "re-reviewed"}) + assert resp.status_code == 200 + assert resp.get_json()["soc_comment"] == "re-reviewed" + + +# --------------------------------------------------------------------------- +# AC-18.3 — Normal review_required path (pending/in_progress) unchanged +# --------------------------------------------------------------------------- + + +def test_transition_review_required_from_in_progress_still_needs_redteam( + client: FlaskClient, redteam_token: str, soc_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + # Auto-advance to in_progress. + _patch(client, redteam_token, sim["id"], {"description": "active"}) + + resp = _transition(client, soc_token, sim["id"], "review_required") + assert resp.status_code == 403 diff --git a/backend/tests/test_simulations_patch.py b/backend/tests/test_simulations_patch.py index b9cb21e..fe96f2d 100644 --- a/backend/tests/test_simulations_patch.py +++ b/backend/tests/test_simulations_patch.py @@ -230,9 +230,10 @@ def test_soc_can_patch_when_review_required( assert body["incident_number"] == "INC-001" -def test_soc_can_patch_when_done( +def test_patch_when_done_returns_409( client: FlaskClient, redteam_token: str, soc_token: str ) -> None: + """Done is terminal — PATCH is rejected for ALL roles (AC-18.1).""" eng = _make_engagement(client, redteam_token) sim = _make_sim(client, redteam_token, eng["id"]) client.post( @@ -247,7 +248,8 @@ def test_soc_can_patch_when_done( ) resp = _patch(client, soc_token, sim["id"], {"soc_comment": "Final note"}) - assert resp.status_code == 200 + assert resp.status_code == 409 + assert resp.get_json()["error"] == "simulation is done — reopen first" def test_soc_cannot_edit_redteam_fields( diff --git a/backend/tests/test_simulations_tactics.py b/backend/tests/test_simulations_tactics.py new file mode 100644 index 0000000..c674c03 --- /dev/null +++ b/backend/tests/test_simulations_tactics.py @@ -0,0 +1,237 @@ +"""Sprint 4 — tactic_ids PATCH tests (AC-21).""" +from __future__ import annotations + +import pathlib + +import pytest +from flask.testing import FlaskClient + +from backend.app.services import mitre as mitre_svc +from backend.tests.conftest import auth_headers as _h + +_FIXTURE_BUNDLE = { + "type": "bundle", + "objects": [ + { + "type": "attack-pattern", + "name": "Command and Scripting Interpreter", + "external_references": [{"source_name": "mitre-attack", "external_id": "T1059"}], + "kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}], + }, + ], +} + + +@pytest.fixture(autouse=True) +def _reset_mitre(): + original_loaded = mitre_svc.mitre_loaded + original_index = list(mitre_svc._index) + original_tactics = dict(mitre_svc._tactics_by_technique) + original_names = dict(mitre_svc._name_by_id) + original_matrix = list(mitre_svc._matrix) + yield + mitre_svc.mitre_loaded = original_loaded + mitre_svc._index = original_index + mitre_svc._tactics_by_technique = original_tactics + mitre_svc._name_by_id = original_names + mitre_svc._matrix = original_matrix + + +@pytest.fixture() +def bundle_file(tmp_path: pathlib.Path) -> pathlib.Path: + import json + p = tmp_path / "enterprise-attack.json" + p.write_text(json.dumps(_FIXTURE_BUNDLE), encoding="utf-8") + return p + + +def _make_engagement(client: FlaskClient, token: str) -> dict: + resp = client.post( + "/api/engagements", + headers=_h(token), + json={"name": "Eng", "start_date": "2026-01-01"}, + ) + assert resp.status_code == 201 + return resp.get_json() + + +def _make_sim(client: FlaskClient, token: str, eid: int) -> dict: + resp = client.post( + f"/api/engagements/{eid}/simulations", + headers=_h(token), + json={"name": "Sim"}, + ) + assert resp.status_code == 201 + return resp.get_json() + + +def _patch(client: FlaskClient, token: str, sid: int, payload: dict): + return client.patch( + f"/api/simulations/{sid}", + headers=_h(token), + json=payload, + ) + + +# --------------------------------------------------------------------------- +# tactic_ids happy path +# --------------------------------------------------------------------------- + + +def test_patch_tactic_ids_valid( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]}) + assert resp.status_code == 200 + data = resp.get_json() + assert data["tactics"] == [{"id": "TA0007", "name": "Discovery"}] + + +def test_patch_tactic_ids_multiple( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0001", "TA0002"]}) + assert resp.status_code == 200 + tactics = resp.get_json()["tactics"] + ids = [t["id"] for t in tactics] + assert "TA0001" in ids + assert "TA0002" in ids + + +def test_patch_tactic_ids_empty_clears( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]}) + + resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": []}) + assert resp.status_code == 200 + assert resp.get_json()["tactics"] == [] + + +def test_patch_tactic_ids_dedup( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007", "TA0007"]}) + assert resp.status_code == 200 + tactics = resp.get_json()["tactics"] + assert len(tactics) == 1 + + +# --------------------------------------------------------------------------- +# tactic_ids error paths +# --------------------------------------------------------------------------- + + +def test_patch_tactic_ids_unknown_returns_400( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA9999"]}) + assert resp.status_code == 400 + assert "unknown tactic id" in resp.get_json()["error"] + + +def test_patch_tactic_ids_not_a_list_returns_400( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": "TA0007"}) + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# SOC gate +# --------------------------------------------------------------------------- + + +def test_soc_cannot_patch_tactic_ids( + client: FlaskClient, redteam_token: str, soc_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + # Advance to review_required so SOC can act. + client.post( + f"/api/simulations/{sim['id']}/transition", + headers=_h(redteam_token), + json={"to": "review_required"}, + ) + + resp = _patch(client, soc_token, sim["id"], {"tactic_ids": ["TA0007"]}) + assert resp.status_code == 403 + + +# --------------------------------------------------------------------------- +# Auto-transition via tactic_ids +# --------------------------------------------------------------------------- + + +def test_patch_tactic_ids_triggers_auto_transition( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + assert sim["status"] == "pending" + + resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]}) + assert resp.status_code == 200 + assert resp.get_json()["status"] == "in_progress" + + +def test_patch_empty_tactic_ids_no_auto_transition( + client: FlaskClient, redteam_token: str +) -> None: + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": []}) + assert resp.status_code == 200 + assert resp.get_json()["status"] == "pending" + + +# --------------------------------------------------------------------------- +# tactic_ids not affected by MITRE bundle loaded state +# (validation uses hardcoded _TACTIC_IDS, not the live bundle) +# --------------------------------------------------------------------------- + + +def test_patch_tactic_ids_works_without_bundle( + client: FlaskClient, redteam_token: str +) -> None: + """tactic_ids validation is hardcoded — bundle state is irrelevant.""" + mitre_svc.mitre_loaded = False + mitre_svc._index = [] + + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]}) + assert resp.status_code == 200 + + +def test_patch_technique_ids_bundle_not_loaded_returns_503( + client: FlaskClient, redteam_token: str +) -> None: + """technique_ids still needs the bundle (different from tactic_ids).""" + mitre_svc.mitre_loaded = False + mitre_svc._index = [] + + eng = _make_engagement(client, redteam_token) + sim = _make_sim(client, redteam_token, eng["id"]) + + resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]}) + assert resp.status_code == 503 diff --git a/backend/tests/test_simulations_techniques.py b/backend/tests/test_simulations_techniques.py index f010aa0..d4e882e 100644 --- a/backend/tests/test_simulations_techniques.py +++ b/backend/tests/test_simulations_techniques.py @@ -407,7 +407,7 @@ def test_migration_0003_techniques_not_null_after_upgrade() -> None: spec = importlib.util.spec_from_file_location( "mig_0003", - "/home/user/Documents/01_Projects/mimic/.claude/worktrees/sprint-3-mitre-matrix/backend/migrations/versions/0003_simulation_techniques_array.py", + "/home/user/Documents/01_Projects/mimic/.claude/worktrees/sprint-4-ui-polish/backend/migrations/versions/0003_simulation_techniques_array.py", ) assert spec is not None and spec.loader is not None mig = importlib.util.module_from_spec(spec) diff --git a/backend/tests/test_simulations_workflow.py b/backend/tests/test_simulations_workflow.py index 264d16f..e7bd75c 100644 --- a/backend/tests/test_simulations_workflow.py +++ b/backend/tests/test_simulations_workflow.py @@ -150,16 +150,18 @@ def test_transition_unknown_status_rejected( assert resp.status_code == 409 -def test_transition_review_required_from_done_rejected( +def test_transition_review_required_from_done_is_reopen( client: FlaskClient, redteam_token: str ) -> None: + """done → review_required is the Reopen path, now allowed (AC-18.2).""" eng = _make_engagement(client, redteam_token) sim = _make_sim(client, redteam_token, eng["id"]) _transition(client, redteam_token, sim["id"], "review_required") _transition(client, redteam_token, sim["id"], "done") resp = _transition(client, redteam_token, sim["id"], "review_required") - assert resp.status_code == 409 + assert resp.status_code == 200 + assert resp.get_json()["status"] == "review_required" # --------------------------------------------------------------------------- From f5ea9d16aff05ec5ba2a07c6883aaf46641bcd01 Mon Sep 17 00:00:00 2001 From: Knacky Date: Wed, 27 May 2026 20:06:01 +0200 Subject: [PATCH 05/15] =?UTF-8?q?feat(frontend):=20sprint=204=20=E2=80=94?= =?UTF-8?q?=20dark=20mode=20+=20matrix=20overhaul=20+=20tactic=20selection?= =?UTF-8?q?=20+=20done=20read-only=20+=20UI=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment US-18: done status fully read-only + Reopen button (done → review_required) for all roles US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=) US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence 92/92 tests passing, typecheck and lint clean. Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/src/api/types.ts | 7 + frontend/src/components/Layout.tsx | 35 ++- frontend/src/components/MitreMatrixModal.tsx | 213 ++++++++++-------- frontend/src/components/MitreTechniqueTag.tsx | 48 +++- .../src/components/MitreTechniquesField.tsx | 111 ++++----- frontend/src/components/SimulationList.tsx | 14 +- frontend/src/hooks/useSimulations.ts | 4 + frontend/src/hooks/useTheme.ts | 59 +++++ frontend/src/pages/EngagementsListPage.tsx | 8 +- frontend/src/pages/SimulationFormPage.tsx | 99 ++++---- frontend/src/pages/UsersAdminPage.tsx | 4 +- frontend/src/styles/index.css | 56 ++++- frontend/tailwind.config.ts | 34 +-- frontend/tests/MitreMatrixModal.test.tsx | 137 ++++++++--- frontend/tests/MitreTechniqueTag.test.tsx | 44 +++- frontend/tests/MitreTechniquesField.test.tsx | 115 ++++++---- frontend/tests/SimulationFormPage.test.tsx | 1 + frontend/tests/SimulationList.test.tsx | 1 + tasks/todo.md | 57 ++++- 21 files changed, 721 insertions(+), 337 deletions(-) create mode 100644 frontend/src/hooks/useTheme.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f7a7ef..5d57bb4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.59.0", "axios": "^1.7.7", + "lucide-react": "^1.16.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" @@ -5083,6 +5084,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f77e24a..7eed3cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@tanstack/react-query": "^5.59.0", "axios": "^1.7.7", + "lucide-react": "^1.16.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 6c73e3e..4a52217 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -78,11 +78,17 @@ export interface MitreTactic { techniques: MitreMatrixTechnique[]; } +export interface MitreTacticRef { + id: string; + name: string; +} + export interface Simulation { id: number; engagement_id: number; name: string; techniques: MitreTechnique[]; + tactics: MitreTacticRef[]; description: string | null; commands: string | null; prerequisites: string | null; @@ -105,6 +111,7 @@ export interface SimulationCreateInput { export interface SimulationPatchInput { name?: string; technique_ids?: string[]; + tactic_ids?: string[]; description?: string | null; commands?: string | null; prerequisites?: string | null; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index ad82b05..0a1ed72 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,13 +1,25 @@ import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { Moon, Sun, Monitor } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; +import { useTheme } from '@/hooks/useTheme'; +import type { Theme } from '@/hooks/useTheme'; + +function ThemeIcon({ theme }: { theme: Theme }) { + if (theme === 'light') return ; + if (theme === 'dark') return ; + return ; +} + +function themeLabel(theme: Theme): string { + if (theme === 'light') return 'Light'; + if (theme === 'dark') return 'Dark'; + return 'System'; +} -/** - * Top utility strip (ink) + main nav (canvas). - * Mirrors DESIGN.md utility-strip + nav-bar-top pattern, scaled to internal app. - */ export function Layout(): JSX.Element { const { user, isAdmin, logout } = useAuth(); const navigate = useNavigate(); + const { theme, cycleTheme } = useTheme(); const handleLogout = async () => { await logout(); @@ -17,7 +29,7 @@ export function Layout(): JSX.Element { return (
{/* utility-strip — ink slab, fine print */} -
+
Mimic · Purple Team BAS {user ? ( @@ -26,6 +38,15 @@ export function Layout(): JSX.Element { {user.role} {user.username} + {/* Techniques */} -
+
{visibleTechniques.map((tech, techIdx) => { - const isSelected = selectedMap.has(tech.id); + const isSelected = selectedTechMap.has(tech.id); const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id); const hasSubtechniques = tech.subtechniques.length > 0; const isLast = techIdx === visibleTechniques.length - 1; - // Filter subtechniques when searching const visibleSubs = searchLower ? tech.subtechniques.filter( (s) => @@ -254,68 +268,67 @@ export function MitreMatrixModal({ return (
- {/* Technique row */}
- {/* Chevron — expand/collapse, does NOT toggle selection */} {hasSubtechniques ? ( ) : ( - + )} - {/* Label — click toggles selection */}
- {/* Subtechniques — shown when expanded */} {isExpanded && visibleSubs.map((sub) => { - const isSubSelected = selectedMap.has(sub.id); + const isSubSelected = selectedTechMap.has(sub.id); return ( ); })}
); })} + {visibleTechniques.length === 0 && searchLower && ( +
No match
+ )}
); @@ -333,11 +346,11 @@ export function MitreMatrixModal({ type="button" className="btn-primary" onClick={handleApply} - disabled={isLoading || isError || (totalSelected === 0 && initialSelection.length === 0)} + disabled={isLoading || isError || (totalSelected === 0 && !hasInitial)} > {totalSelected === 0 ? 'Clear all' - : `Apply ${totalSelected} technique${totalSelected !== 1 ? 's' : ''}`} + : `Apply ${totalSelected} item${totalSelected !== 1 ? 's' : ''}`}
diff --git a/frontend/src/components/MitreTechniqueTag.tsx b/frontend/src/components/MitreTechniqueTag.tsx index a4d15be..7874057 100644 --- a/frontend/src/components/MitreTechniqueTag.tsx +++ b/frontend/src/components/MitreTechniqueTag.tsx @@ -1,29 +1,63 @@ -import type { MitreTechnique } from '@/api/types'; +import type { MitreTechnique, MitreTacticRef } from '@/api/types'; -interface MitreTechniqueTagProps { +interface TechniqueTagProps { technique: MitreTechnique; onRemove: () => void; disabled?: boolean; } +interface TacticTagProps { + tactic: MitreTacticRef; + onRemove: () => void; + disabled?: boolean; +} + +// Technique chip — soft blue, id only, name in title export function MitreTechniqueTag({ technique, onRemove, disabled = false, -}: MitreTechniqueTagProps): JSX.Element { +}: TechniqueTagProps): JSX.Element { return ( - {technique.id} - — {technique.name} + {technique.id} {!disabled && ( + )} + + ); +} + +// Tactic chip — primary blue filled, id only, name in title +export function MitreTacticTag({ + tactic, + onRemove, + disabled = false, +}: TacticTagProps): JSX.Element { + return ( + + {tactic.id} + {!disabled && ( + diff --git a/frontend/src/components/MitreTechniquesField.tsx b/frontend/src/components/MitreTechniquesField.tsx index b454fee..1c1cc09 100644 --- a/frontend/src/components/MitreTechniquesField.tsx +++ b/frontend/src/components/MitreTechniquesField.tsx @@ -1,14 +1,17 @@ import { useState } from 'react'; +import { Grid2x2 } from 'lucide-react'; import { extractApiError } from '@/api/client'; -import type { MitreTechnique } from '@/api/types'; +import type { MitreTechnique, MitreTacticRef } from '@/api/types'; import { useUpdateSimulation } from '@/hooks/useSimulations'; import { useToast } from '@/hooks/useToast'; -import { MitreTechniqueTag } from './MitreTechniqueTag'; +import { MitreTechniqueTag, MitreTacticTag } from './MitreTechniqueTag'; import { MitreTechniquePicker } from './MitreTechniquePicker'; import { MitreMatrixModal } from './MitreMatrixModal'; +import type { MatrixSelection } from './MitreMatrixModal'; interface MitreTechniquesFieldProps { value: MitreTechnique[]; + tactics: MitreTacticRef[]; simulationId: number; engagementId: number; disabled?: boolean; @@ -16,6 +19,7 @@ interface MitreTechniquesFieldProps { export function MitreTechniquesField({ value, + tactics, simulationId, engagementId, disabled = false, @@ -26,10 +30,11 @@ export function MitreTechniquesField({ const { push } = useToast(); const updateMutation = useUpdateSimulation(simulationId, engagementId); - const save = async (techniques: MitreTechnique[]) => { + const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => { try { await updateMutation.mutateAsync({ technique_ids: techniques.map((t) => t.id), + tactic_ids: nextTactics.map((t) => t.id), }); push('Techniques updated', 'success'); } catch (err) { @@ -37,96 +42,92 @@ export function MitreTechniquesField({ } }; - const handleRemove = (id: string) => { - const next = value.filter((t) => t.id !== id); - void save(next); + const handleRemoveTechnique = (id: string) => { + void save(value.filter((t) => t.id !== id), tactics); + }; + + const handleRemoveTactic = (id: string) => { + void save(value, tactics.filter((t) => t.id !== id)); }; const handleSelect = (technique: MitreTechnique) => { - // Dedup: no-op if already present if (value.some((t) => t.id === technique.id)) return; - const next = [...value, technique]; - void save(next); + void save([...value, technique], tactics); + setShowPicker(false); }; - const handleMatrixApply = (selection: MitreTechnique[]) => { + const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => { setShowMatrix(false); - // Merge: preserve existing tactics on items already in value, fill from selection otherwise. - // The backend re-enriches tactics at serialize time, so the exact tactics here don't matter. - const merged = selection.map((s) => { + const merged = techniques.map((s) => { const existing = value.find((v) => v.id === s.id); return existing ?? s; }); - void save(merged); + void save(merged, newTactics); }; const isPending = updateMutation.isPending; + const isEmpty = value.length === 0 && tactics.length === 0; return (
- {/* Tag list */} - {value.length === 0 ? ( -

- No techniques selected — use the matrix or the quick search to add. -

+ {/* Chips area */} + {isEmpty ? ( +

No techniques selected

) : ( -
+
+ {tactics.map((t) => ( + handleRemoveTactic(t.id)} + disabled={disabled || isPending} + /> + ))} {value.map((t) => ( handleRemove(t.id)} + onRemove={() => handleRemoveTechnique(t.id)} disabled={disabled || isPending} /> ))}
)} - {/* Action buttons — hidden in read-only mode */} + {/* Input row — hidden in read-only mode */} {!disabled && ( -
+
+
+ {showPicker ? ( + + ) : ( + + )} +
- - {isPending && ( - Saving… - )} + {isPending && Saving…}
)} - {/* Inline Quick Search picker */} - {showPicker && !disabled && ( -
- { - handleSelect(technique); - setShowPicker(false); - }} - disabled={isPending} - /> -
- )} - - {/* Matrix modal */} setShowMatrix(false)} /> diff --git a/frontend/src/components/SimulationList.tsx b/frontend/src/components/SimulationList.tsx index 94ded36..acf5983 100644 --- a/frontend/src/components/SimulationList.tsx +++ b/frontend/src/components/SimulationList.tsx @@ -96,11 +96,15 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme - {sim.techniques.length === 0 - ? '—' - : sim.techniques.length === 1 - ? sim.techniques[0].id - : `${sim.techniques[0].id} +${sim.techniques.length - 1}`} + {(() => { + const items = [ + ...(sim.tactics ?? []).map((t) => t.id), + ...sim.techniques.map((t) => t.id), + ]; + if (items.length === 0) return '—'; + if (items.length === 1) return items[0]; + return `${items[0]} +${items.length - 1}`; + })()} diff --git a/frontend/src/hooks/useSimulations.ts b/frontend/src/hooks/useSimulations.ts index ab9eed5..681c4d0 100644 --- a/frontend/src/hooks/useSimulations.ts +++ b/frontend/src/hooks/useSimulations.ts @@ -48,6 +48,8 @@ export function useUpdateSimulation(id: number, engagementId: number) { onSuccess: () => { qc.invalidateQueries({ queryKey: simulationKey(id) }); qc.invalidateQueries({ queryKey: simulationsKey(engagementId) }); + qc.invalidateQueries({ queryKey: ['engagements', engagementId] }); + qc.invalidateQueries({ queryKey: ['engagements'] }); }, }); } @@ -71,6 +73,8 @@ export function useTransitionSimulation(id: number, engagementId: number) { onSuccess: () => { qc.invalidateQueries({ queryKey: simulationKey(id) }); qc.invalidateQueries({ queryKey: simulationsKey(engagementId) }); + qc.invalidateQueries({ queryKey: ['engagements', engagementId] }); + qc.invalidateQueries({ queryKey: ['engagements'] }); }, }); } diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts new file mode 100644 index 0000000..adcf74e --- /dev/null +++ b/frontend/src/hooks/useTheme.ts @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from 'react'; + +export type Theme = 'light' | 'dark' | 'system'; + +const STORAGE_KEY = 'mimic-theme'; + +function resolveTheme(theme: Theme): 'light' | 'dark' { + if (theme === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return theme; +} + +function applyTheme(theme: Theme) { + const resolved = resolveTheme(theme); + document.documentElement.classList.toggle('dark', resolved === 'dark'); +} + +function readStoredTheme(): Theme { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark' || stored === 'system') return stored; + } catch { + // localStorage unavailable + } + return 'system'; +} + +export function useTheme() { + const [theme, setThemeState] = useState(readStoredTheme); + + useEffect(() => { + applyTheme(theme); + }, [theme]); + + // Track system preference changes when theme === 'system' + useEffect(() => { + if (theme !== 'system') return; + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => applyTheme('system'); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, [theme]); + + const setTheme = useCallback((next: Theme) => { + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + // ignore + } + setThemeState(next); + }, []); + + const cycleTheme = useCallback(() => { + setTheme(theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'); + }, [theme, setTheme]); + + return { theme, setTheme, cycleTheme }; +} diff --git a/frontend/src/pages/EngagementsListPage.tsx b/frontend/src/pages/EngagementsListPage.tsx index ab2b521..4164895 100644 --- a/frontend/src/pages/EngagementsListPage.tsx +++ b/frontend/src/pages/EngagementsListPage.tsx @@ -24,9 +24,9 @@ export function EngagementsListPage(): JSX.Element { if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return; try { await deleteMutation.mutateAsync(eng.id); - push('Engagement supprimé', 'success'); + push('Engagement deleted', 'success'); } catch (err) { - push(extractApiError(err, 'Suppression impossible'), 'error'); + push(extractApiError(err, 'Could not delete engagement'), 'error'); } }; @@ -41,7 +41,7 @@ export function EngagementsListPage(): JSX.Element {
{canEditEngagements ? ( - New engagement + + New ) : null} @@ -59,7 +59,7 @@ export function EngagementsListPage(): JSX.Element { action={ canEditEngagements ? ( - Create engagement + + New engagement ) : undefined } diff --git a/frontend/src/pages/SimulationFormPage.tsx b/frontend/src/pages/SimulationFormPage.tsx index c852226..627a6ba 100644 --- a/frontend/src/pages/SimulationFormPage.tsx +++ b/frontend/src/pages/SimulationFormPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, type FormEvent } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Save, RotateCcw } from 'lucide-react'; import { extractApiError } from '@/api/client'; import type { SimulationPatchInput } from '@/api/types'; import { useAuth } from '@/hooks/useAuth'; @@ -105,30 +106,28 @@ export function SimulationFormPage(): JSX.Element { const simulation = detail.data; const status = simulation?.status; - // Role-based field locking + // US-18: Done = fully read-only, Reopen only + const isDone = status === 'done'; + const canEditRT = isAdmin || isRedteam; - // SOC can only edit when status is review_required or done const socCanEdit = isSoc && (status === 'review_required' || status === 'done'); const socBlocked = isSoc && (status === 'pending' || status === 'in_progress'); - const canSaveSoc = socCanEdit || canEditEngagements; - const rtDisabled = !canEditRT; - const socDisabled = !canEditEngagements && !socCanEdit; + const canSaveSoc = !isDone && (socCanEdit || canEditEngagements); + const rtDisabled = !canEditRT || isDone; + const socDisabled = isDone || (!canEditEngagements && !socCanEdit); - // Transition buttons visibility const showMarkReview = - canEditEngagements && (status === 'pending' || status === 'in_progress'); + !isDone && canEditEngagements && (status === 'pending' || status === 'in_progress'); const showClose = - (canEditEngagements || isSoc) && status === 'review_required'; + !isDone && (canEditEngagements || isSoc) && status === 'review_required'; + const showReopen = isDone && (isAdmin || isRedteam || isSoc); const onSubmitNew = async (e: FormEvent) => { e.preventDefault(); setNameError(null); setSubmitError(null); - if (!rt.name.trim()) { - setNameError('Name is required'); - return; - } + if (!rt.name.trim()) { setNameError('Name is required'); return; } try { const created = await createMutation.mutateAsync({ name: rt.name.trim() }); push('Simulation created', 'success'); @@ -142,10 +141,7 @@ export function SimulationFormPage(): JSX.Element { e.preventDefault(); setNameError(null); setSubmitError(null); - if (!rt.name.trim()) { - setNameError('Name is required'); - return; - } + if (!rt.name.trim()) { setNameError('Name is required'); return; } const patch: SimulationPatchInput = { name: rt.name.trim(), description: rt.description.trim() || null, @@ -197,6 +193,15 @@ export function SimulationFormPage(): JSX.Element { } }; + const onReopen = async () => { + try { + await transitionMutation.mutateAsync('review_required'); + push('Simulation reopened', 'success'); + } catch (err) { + push(extractApiError(err, 'Transition failed'), 'error'); + } + }; + const onDelete = async () => { setShowDeleteConfirm(false); try { @@ -208,7 +213,7 @@ export function SimulationFormPage(): JSX.Element { } }; - // New simulation form (minimal) + // New simulation form if (isNew) { const submitting = createMutation.isPending; return ( @@ -232,9 +237,7 @@ export function SimulationFormPage(): JSX.Element { {submitError ? ( -
- {submitError} -
+
{submitError}
) : null}
@@ -250,7 +253,6 @@ export function SimulationFormPage(): JSX.Element { ); } - // Edit form const submitting = updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending; @@ -275,7 +277,17 @@ export function SimulationFormPage(): JSX.Element {
- {/* SOC banner — shown when soc user visits pending/in_progress */} + {/* Done banner */} + {isDone && ( +
+ This simulation is done and read-only. Use Reopen to make changes. +
+ )} + + {/* SOC banner */} {socBlocked && (