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/CHANGELOG.md b/CHANGELOG.md index 8861e2b..7d4344a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,58 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] +### Added — Sprint 4 (UI polish + workflow tightening + dark mode + process hygiene) + +**Backend** (193 pytest passing — 192 sprint-1-to-3 + 1 sprint-4) +- `Simulation.tactic_ids` JSON column (default `[]`, NOT NULL via `server_default`). Sprint 3's `techniques` array is joined by a parallel `tactics` field in the serialized response. +- Alembic migration `0004_simulation_tactic_ids.py` — simple ADD COLUMN (SQLite native); downgrade via `batch_alter_table`. +- `PATCH /api/simulations/` accepts `{tactic_ids: ["TA0007", ...]}` (TA-format, validated against the hardcoded `_TACTIC_IDS` map — no MITRE bundle dependency for tactics since TA-ids are a stable MITRE standard). Dedup via `dict.fromkeys`. SOC sending `tactic_ids` → 403. Auto-transition `pending → in_progress` extended to non-empty `tactic_ids`. +- **Done is now terminal**: `PATCH /api/simulations/` on a `done` simulation → **409** `{error: "simulation is done — reopen first"}` (applies to all 3 roles, prioritised over RBAC field-level). +- **Reopen transition**: `POST /api/simulations//transition {to: "review_required"}` from `done` → 200, open to **admin + redteam + soc**. Implemented as a special case before the `_ALLOWED_TRANSITIONS` dict lookup; other transitions from `done` (`→ pending` / `→ in_progress` / `→ done`) remain forbidden (409 via dict miss). +- **Engagement auto-status**: when any simulation transitions to `in_progress` (auto or manual), if `engagement.status == planned` → engagement passes to `active` in the same DB transaction. No auto-rollback. The `_maybe_activate_engagement` helper modifies and `db.session.add()`s only — the caller commits (no double-commit). +- `GET /api/mitre/matrix` `tactic_id` field now returned in TA-format (`"TA0007"`) instead of the internal slug (`"discovery"`). Aligns with the PATCH endpoint contract — frontend can round-trip the same `tactic_id` between matrix display and PATCH body. Spec-drift caught by the e2e test-verifier (AC-21.6 defect). +- Internal helpers : `_TACTIC_IDS` (TA-id → short-name, 12 entries, non-sequential), `_SLUG_TO_TA_ID` (reverse), `lookup_tactic()`, `get_tactic_name()`. +- Migration tests now derive paths from `__file__` instead of hardcoded worktree absolute paths (recurring sprint-3 issue resolved). + +**Frontend** (92 vitest passing — typecheck + lint clean) +- **Dark mode**: full Tailwind `darkMode: 'class'` plumbing, themed surface tokens via CSS variables under `:root` / `.dark` in `index.css`. Three-state cycle (`light` / `dark` / `system`) toggle in the topbar with lucide-react icons (Sun / Moon / Monitor). Persisted under localStorage key `mimic-theme` (default `system`, follows `prefers-color-scheme`). Dedicated `useTheme()` hook orchestrates the cycle + media-query listener. +- **Slab token split**: a new `slab` / `slab-text` / `slab-muted` token family stays fixed `#111827` / `#f9fafb` / `#6b7280` regardless of theme. Used for permanently-dark surfaces (utility strip, footer, modal backdrop) that must NOT invert in dark mode. The themed `ink` token is now strictly for text. `.btn-ink` uses `@apply bg-slab` (single source of truth). +- **Modal backdrop**: new `.modal-backdrop` CSS class (fixed `rgba(0,0,0,0.6)`) replaces `bg-ink/60` (which inverted in dark mode). Applies to `MitreMatrixModal` and `ConfirmDialog`. +- **Badge contrast in dark mode**: `SimulationStatusBadge` and `StatusBadge` use `text-white` (fixed) on colored backgrounds instead of `text-canvas` / `text-ink-on` (which inverted). `Toast` error uses `bg-slab text-slab-text`. +- **Dark mode shadows**: new `soft-lift-dark` and `floating-dark` token variants, applied to cards and modals via `dark:shadow-*` so the lift remains visible on dark canvas. Hairlines bumped (`#4b5563`) for better separator visibility. +- **MITRE matrix modal overhaul**: 12-column CSS grid (`repeat(12, minmax(0, 1fr))`), no horizontal scroll at 1280×720. Compact technique cells (`text-[12px]`, hairline borders). Sticky tactic headers (uppercase, count badge). Sub-techniques expand/collapse preserved from sprint 3. +- **Tactic selection in matrix**: clicking a tactic header toggles its selection in addition to techniques + sub-techniques. Tactic chips render with `bg-primary text-canvas` (filled), distinct from technique chips (`bg-primary-soft text-primary-deep`). Apply emits one combined PATCH `{technique_ids, tactic_ids}` — no two sequential calls. +- **MITRE input redesign**: replaces the prior `Add technique` + `Quick search` button pair with an inline autocomplete input + matrix icon button to the right. Chips display the reference only (`T1059.001` or `TA0007`); full technique name surfaces on `title=` hover. Empty state minimal. +- **`done` simulation UI**: form fields are fully disabled, `MitreTechniquesField` is read-only (chips without ×, input + matrix icon hidden), action bar shows ONLY a `Reopen` button (visible to all 3 roles per RBAC). Save / Mark for review / Close / Delete are hidden in the done state. A "this simulation is done and read-only" banner replaces them. +- **UsersAdminPage `Create account` form alignment** (3rd attempt — finally pixel-perfect): refactored from `FormField` + `items-end` to an explicit 3-row grid (labels / inputs+button / hints) using `grid-rows-[auto_auto_auto]`. Labels share row 1, inputs + button share row 2, hint sits alone in row 3 — the browser cannot misalign cells of different heights. +- **EngagementsListPage dedup**: single `+ New` CTA (header + empty-state share the same label). +- **Engagement query invalidation**: `useUpdateSimulation` and `useTransitionSimulation` now invalidate both the simulation queries AND `["engagement", engagement_id]` + `["engagements"]` so the engagement status badge updates without a full reload after the auto-transition. + +**Process hygiene** (US-24) +- New agent definition `.claude/agents/design-reviewer.md` — read-only, runs AFTER `frontend-builder` and BEFORE `code-reviewer`. Audits alignment, DESIGN.md token usage, light/dark consistency, typography, whitespace rhythm, responsive sanity at 1280×720, button convention, V1 a11y, and inter-screen coherence. +- Updated `.claude/agents/frontend-builder.md` Definition of Done — screenshots are now MANDATORY (one per feature/state introduced or modified, light + dark when theming is in scope). A "Dev server not started" line is a hard block. + +**Infra hygiene** (US-25) +- `scripts/open-pr.sh` — wraps `POST /api/v1/repos/{owner}/{repo}/pulls` on the Gitea REST API. Reads credentials from `~/.git-credentials` (same source as `git push` — no token in env). Detects host/owner/repo from `git remote get-url origin`. Validates args, prints PR URL. +- New Makefile target `open-pr TITLE="..." BODY=path/to/body.md [BASE=main]` wrapping the script. Team-lead PR creation is now automated. +- README `Make targets` table documents the new target. + +**Acceptance tests** (Playwright, **158 passed**) +- 7 new spec files (one per testable US): `us17-ui-polish`, `us18-done-readonly-reopen`, `us19-engagement-auto-status`, `us20-matrix-fits-modal`, `us21-tactic-selection`, `us22-mitre-input-redesign`, `us23-dark-mode`. +- Coverage gaps from code-reviewer filled: `+N` suffix when techniques + tactics are mixed in the `SimulationList` MITRE column ; Tab focus-trap cycle in `MitreMatrixModal` ; dark-mode `localStorage` persistence across reload. +- AC-21.6 defect caught by the e2e (matrix returned slug `tactic_id`, PATCH expected TA-format) was bounced to backend-builder and resolved within the sprint. + +### Changed +- 2026-05-27 — SPEC.md § Fonctionnement clarified: `done` is terminal, only Reopen (open to all 3 roles) returns to `review_required`. Engagement auto-flips `planned → active` on first simulation `in_progress`, never the reverse. +- 2026-05-27 — SPEC.md § Référentiel MITRE: added the sprint 4 `tactic_ids` (separated from `technique_ids`). +- 2026-05-27 — SPEC.md § UI/UX (new section): theming (light/dark/system, default = `system`), button convention (icon + ≤8-char label), modal focus trap V1. +- 2026-05-27 — SPEC.md § Workflows: `design-reviewer` agent inserted between `frontend-builder` and `code-reviewer`. PR creation now via `make open-pr`. +- 2026-05-27 — Carry-over commit: sprint 3 `§ Simulation` multi-techniques edit had been left uncommitted at sprint 3 close; applied at sprint 4 start so SPEC.md and the shipped code finally agree (lesson logged in tasks/lessons.md). + +--- + +## [Sprint 3] — Multi-technique simulations + MITRE matrix modal (merged 2026-05-27) + ### Added — Sprint 3 (Multi-technique simulations + MITRE matrix modal) **Backend** (164 pytest passing) 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..d1dc71a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs. -> Status: **Sprint 3 — Multi-technique simulations + MITRE matrix modal**. A simulation can now be tagged with multiple MITRE techniques (top-level and sub-techniques) via either autocomplete or a clickable ATT&CK matrix modal. Tags auto-save on add/remove; the rest of the Sprint 2 Purple Team workflow (workflow states, RBAC, etc.) is unchanged. +> Status: **Sprint 4 — UI polish + workflow tightening + dark mode + process hygiene**. The Purple Team workflow is now tighter (Done is terminal, Reopen returns to Review required, engagements auto-flip Planned → Active on first in-progress simulation), simulations can be tagged with both techniques AND tactics (TA-ids), the MITRE matrix modal fits the viewport without horizontal scroll, the app supports light / dark / system theming, and PR creation is one Make target away. --- @@ -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`. | --- @@ -138,9 +139,9 @@ npm run dev # http://localhost:5173 with /api proxied to :5000 Tests: ```bash -cd backend && pytest -q # 164 tests -cd frontend && npm run test -- --run # 86 tests -cd e2e && npx playwright test # 105 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6) +cd backend && pytest -q # 193 tests +cd frontend && npm run test -- --run # 92 tests +cd e2e && npx playwright test # 158 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6) ``` --- diff --git a/SPEC.md b/SPEC.md index af566d0..5937c99 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) @@ -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/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..bd916e6 100644 --- a/backend/app/services/mitre.py +++ b/backend/app/services/mitre.py @@ -26,6 +26,25 @@ _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", +} + +# Reverse: slug → TA-id (derived from _TACTIC_IDS, used by _build_matrix). +_SLUG_TO_TA_ID: dict[str, str] = {v: k for k, v in _TACTIC_IDS.items()} + TACTIC_NAMES: dict[str, str] = { "initial-access": "Initial Access", "execution": "Execution", @@ -98,14 +117,16 @@ def _build_matrix(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: subs.sort(key=lambda x: x["name"]) matrix: list[dict[str, Any]] = [] - for tactic_id in _TACTIC_ORDER: - techs = tactic_techs.get(tactic_id, []) + for slug in _TACTIC_ORDER: + techs = tactic_techs.get(slug, []) # Sort techniques alphabetically. techs_sorted = sorted(techs, key=lambda x: x["name"]) - tactic_name = TACTIC_NAMES.get(tactic_id, tactic_id.replace("-", " ").title()) + tactic_name = TACTIC_NAMES.get(slug, slug.replace("-", " ").title()) + # Expose TA-id so the frontend can send tactic_ids back in PATCH unchanged. + ta_id = _SLUG_TO_TA_ID.get(slug, slug) matrix.append( { - "tactic_id": tactic_id, + "tactic_id": ta_id, "tactic_name": tactic_name, "techniques": [ { @@ -181,6 +202,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..cb0b1c5 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 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..d2e5e81 --- /dev/null +++ b/backend/tests/test_engagement_lifecycle.py @@ -0,0 +1,181 @@ +"""Sprint 4 — engagement auto-status planned→active (AC-19).""" +from __future__ import annotations + +import pathlib + +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] + + _mig_path = ( + pathlib.Path(__file__).parent.parent + / "migrations" / "versions" / "0004_simulation_tactic_ids.py" + ) + spec = importlib.util.spec_from_file_location("mig_0004", _mig_path) + 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_mitre.py b/backend/tests/test_mitre.py index 39ba563..cbb2840 100644 --- a/backend/tests/test_mitre.py +++ b/backend/tests/test_mitre.py @@ -305,14 +305,14 @@ def test_get_matrix_returns_ordered_tactics(bundle_file: pathlib.Path) -> None: mitre_svc.load_bundle(bundle_file) matrix = mitre_svc.get_matrix() tactic_ids = [t["tactic_id"] for t in matrix] - # initial-access must come before execution in canonical order. - assert tactic_ids.index("initial-access") < tactic_ids.index("execution") + # TA0001 (initial-access) must come before TA0002 (execution) in canonical order. + assert tactic_ids.index("TA0001") < tactic_ids.index("TA0002") def test_get_matrix_subtechniques_nested(bundle_file: pathlib.Path) -> None: mitre_svc.load_bundle(bundle_file) matrix = mitre_svc.get_matrix() - exec_tactic = next(t for t in matrix if t["tactic_id"] == "execution") + exec_tactic = next(t for t in matrix if t["tactic_id"] == "TA0002") t1059 = next((t for t in exec_tactic["techniques"] if t["id"] == "T1059"), None) assert t1059 is not None sub_ids = [s["id"] for s in t1059["subtechniques"]] @@ -323,7 +323,7 @@ def test_get_matrix_subtechniques_nested(bundle_file: pathlib.Path) -> None: def test_get_matrix_subtechniques_sorted_by_name(bundle_file: pathlib.Path) -> None: mitre_svc.load_bundle(bundle_file) matrix = mitre_svc.get_matrix() - exec_tactic = next(t for t in matrix if t["tactic_id"] == "execution") + exec_tactic = next(t for t in matrix if t["tactic_id"] == "TA0002") t1059 = next(t for t in exec_tactic["techniques"] if t["id"] == "T1059") names = [s["name"] for s in t1059["subtechniques"]] assert names == sorted(names) @@ -332,7 +332,7 @@ def test_get_matrix_subtechniques_sorted_by_name(bundle_file: pathlib.Path) -> N def test_get_matrix_techniques_sorted_by_name(bundle_file: pathlib.Path) -> None: mitre_svc.load_bundle(bundle_file) matrix = mitre_svc.get_matrix() - ia_tactic = next(t for t in matrix if t["tactic_id"] == "initial-access") + ia_tactic = next(t for t in matrix if t["tactic_id"] == "TA0001") names = [t["name"] for t in ia_tactic["techniques"]] assert names == sorted(names) @@ -340,7 +340,7 @@ def test_get_matrix_techniques_sorted_by_name(bundle_file: pathlib.Path) -> None def test_get_matrix_technique_no_subtechniques(bundle_file: pathlib.Path) -> None: mitre_svc.load_bundle(bundle_file) matrix = mitre_svc.get_matrix() - ia_tactic = next(t for t in matrix if t["tactic_id"] == "initial-access") + ia_tactic = next(t for t in matrix if t["tactic_id"] == "TA0001") phishing = next((t for t in ia_tactic["techniques"] if t["id"] == "T1566"), None) assert phishing is not None assert phishing["subtechniques"] == [] @@ -355,8 +355,8 @@ def test_matrix_endpoint_ok( data = resp.get_json() assert isinstance(data, list) tactic_ids = [t["tactic_id"] for t in data] - assert "initial-access" in tactic_ids - assert "execution" in tactic_ids + assert "TA0001" in tactic_ids # initial-access + assert "TA0002" in tactic_ids # execution def test_matrix_endpoint_503_when_not_loaded( @@ -389,6 +389,15 @@ def test_get_matrix_command_and_control_display_name(bundle_file: pathlib.Path) """MITRE official name uses lowercase 'and' — not title-cased.""" mitre_svc.load_bundle(bundle_file) matrix = mitre_svc.get_matrix() - c2 = next((t for t in matrix if t["tactic_id"] == "command-and-control"), None) + c2 = next((t for t in matrix if t["tactic_id"] == "TA0011"), None) assert c2 is not None assert c2["tactic_name"] == "Command and Control" + + +def test_get_matrix_tactic_id_is_ta_format(bundle_file: pathlib.Path) -> None: + """Matrix tactic_id must use TA-format so frontend can send it back in PATCH tactic_ids.""" + mitre_svc.load_bundle(bundle_file) + matrix = mitre_svc.get_matrix() + for entry in matrix: + tid = entry["tactic_id"] + assert tid.startswith("TA"), f"tactic_id {tid!r} must be TA-format, not a slug" 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..c0e9030 100644 --- a/backend/tests/test_simulations_techniques.py +++ b/backend/tests/test_simulations_techniques.py @@ -405,10 +405,11 @@ def test_migration_0003_techniques_not_null_after_upgrade() -> None: import alembic.op as _op_module _op_module._proxy = ops # type: ignore[attr-defined] - 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", + _mig_path = ( + pathlib.Path(__file__).parent.parent + / "migrations" / "versions" / "0003_simulation_techniques_array.py" ) + spec = importlib.util.spec_from_file_location("mig_0003", _mig_path) 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] 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" # --------------------------------------------------------------------------- diff --git a/e2e/tests/us10-mitre-autocomplete.spec.ts b/e2e/tests/us10-mitre-autocomplete.spec.ts index ac51db1..a48d804 100644 --- a/e2e/tests/us10-mitre-autocomplete.spec.ts +++ b/e2e/tests/us10-mitre-autocomplete.spec.ts @@ -137,8 +137,8 @@ test.describe('US-10 — MITRE autocomplete', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - // Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search" - await page.getByRole('button', { name: /quick search/i }).click(); + // Sprint 4: picker opens by clicking the inline placeholder text + await page.getByText(/search technique/i).click(); const picker = page.getByRole('combobox', { name: /mitre technique/i }); await expect(picker).toBeVisible(); @@ -159,14 +159,14 @@ test.describe('US-10 — MITRE autocomplete', () => { await picker.press('ArrowDown'); await picker.press('Enter'); - // Sprint 3: after selection the picker resets (one-shot append mode). + // After selection the picker resets (one-shot append mode). // The tag T1059 should appear in the techniques field. await expect(listbox).not.toBeVisible(); await expect(page.getByTestId('techniques-tag-list')).toBeVisible({ timeout: 5_000 }); await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059'); - // Escape closes the dropdown (re-open picker to test Escape) - await page.getByRole('button', { name: /quick search/i }).click(); + // Escape closes the dropdown (re-open picker via inline placeholder) + await page.getByText(/search technique/i).click(); await picker.fill('T1'); await expect(listbox).toBeVisible({ timeout: 5_000 }); await picker.press('Escape'); diff --git a/e2e/tests/us11-workflow-transitions.spec.ts b/e2e/tests/us11-workflow-transitions.spec.ts index 95223a7..acaf12f 100644 --- a/e2e/tests/us11-workflow-transitions.spec.ts +++ b/e2e/tests/us11-workflow-transitions.spec.ts @@ -127,11 +127,12 @@ test.describe('US-11 — workflow transitions', () => { expect(rSOC.status).toBe(200); expect(rSOC.data.status).toBe('done'); - // done → review_required is invalid (409) + // Sprint 4: done → review_required is the Reopen flow, now valid (200). const rBack = await rtClient.post(`/simulations/${simRT.id}/transition`, { to: 'review_required', }); - expect(rBack.status).toBe(409); + expect(rBack.status).toBe(200); + expect(rBack.data.status).toBe('review_required'); await deleteSimulation(redteamToken, simRT.id); await deleteSimulation(redteamToken, simSOC.id); diff --git a/e2e/tests/us14-techniques-tags.spec.ts b/e2e/tests/us14-techniques-tags.spec.ts index 593caf8..7ef9096 100644 --- a/e2e/tests/us14-techniques-tags.spec.ts +++ b/e2e/tests/us14-techniques-tags.spec.ts @@ -78,7 +78,7 @@ test.describe('US-14 — technique tags UI', () => { } }); - test('AC-14.1 — MitreTechniquesField shows tags, Add technique + Quick search buttons, empty state', async ({ + test('AC-14.1 — MitreTechniquesField shows empty state, matrix icon + inline search input', async ({ page, context, }) => { @@ -88,13 +88,13 @@ test.describe('US-14 — technique tags UI', () => { await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); // Empty state message visible when no techniques - await expect( - page.getByText(/no techniques selected.*matrix.*quick search/i), - ).toBeVisible(); + await expect(page.getByText(/no techniques selected/i)).toBeVisible(); - // Add technique and Quick search buttons present - await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible(); + // Matrix icon button present (sprint 4: replaces "Add technique" text button) + await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible(); + + // Inline search placeholder present (sprint 4: replaces "Quick search" text button) + await expect(page.getByText(/search technique/i)).toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); @@ -154,7 +154,7 @@ test.describe('US-14 — technique tags UI', () => { await deleteSimulation(redteamToken, sim.id); }); - test('AC-14.2 — Quick search: selecting technique appends as tag + auto-save', async ({ + test('AC-14.2 — inline search: selecting technique appends as tag + auto-save', async ({ page, context, }) => { @@ -163,7 +163,8 @@ test.describe('US-14 — technique tags UI', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /quick search/i }).click(); + // Sprint 4: click inline placeholder text to reveal the combobox + await page.getByText(/search technique/i).click(); const picker = page.getByRole('combobox', { name: /mitre technique/i }); await picker.fill('T1059'); diff --git a/e2e/tests/us15-mitre-matrix-modal.spec.ts b/e2e/tests/us15-mitre-matrix-modal.spec.ts index a0ac810..6fb088a 100644 --- a/e2e/tests/us15-mitre-matrix-modal.spec.ts +++ b/e2e/tests/us15-mitre-matrix-modal.spec.ts @@ -124,7 +124,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); // Open the matrix modal via "Add technique" - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); @@ -158,7 +158,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); @@ -177,14 +177,14 @@ test.describe('US-15 — MITRE matrix modal', () => { await techLabelBtn.click(); // Apply button should now show count = 1 - await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible(); + await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible(); // Click again to deselect await techLabelBtn.click(); // When 0 selected and no initial selection: footer shows disabled "Clear all" await expect(dialog.getByRole('button', { name: /clear all/i })).toBeVisible(); - await expect(dialog.getByRole('button', { name: /apply \d+ technique/i })).not.toBeVisible(); + await expect(dialog.getByRole('button', { name: /apply \d+ item/i })).not.toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); @@ -198,7 +198,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); @@ -227,7 +227,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); @@ -257,12 +257,13 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Initially no "selected" counter visible - await expect(dialog).not.toContainText('1 selected'); + // Sprint 4: tactic header shows truncated "N sel." due to tight column width + await expect(dialog).not.toContainText('1 sel'); // Use search to isolate T1059 so we can click the label button, not the chevron const searchInput = dialog.getByLabel(/filter techniques/i); @@ -275,8 +276,8 @@ test.describe('US-15 — MITRE matrix modal', () => { .first() .click(); - // Tactic header for Execution should now show "1 selected" - await expect(dialog).toContainText('1 selected'); + // Sprint 4: tactic header shows "1 sel." (truncated) due to tight column width + await expect(dialog).toContainText('1 sel'); await deleteSimulation(redteamToken, sim.id); }); @@ -290,7 +291,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); @@ -310,7 +311,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await dialog.getByRole('button', { name: /phishing/i }).first().click(); // Apply (2 techniques selected) - const applyBtn = dialog.getByRole('button', { name: /apply \d+ technique/i }); + const applyBtn = dialog.getByRole('button', { name: /apply \d+ item/i }); await expect(applyBtn).toBeVisible(); await applyBtn.click(); @@ -342,12 +343,12 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); // Apply button should already show 1 technique (from initial selection) - await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible(); + await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible(); // Cancel to discard await dialog.getByRole('button', { name: /^cancel$/i }).click(); @@ -365,7 +366,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); @@ -377,7 +378,7 @@ test.describe('US-15 — MITRE matrix modal', () => { .getByRole('button', { name: /command and scripting interpreter/i }) .first() .click(); - await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible(); + await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible(); // Cancel instead of Apply await dialog.getByRole('button', { name: /^cancel$/i }).click(); @@ -399,7 +400,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); @@ -411,7 +412,7 @@ test.describe('US-15 — MITRE matrix modal', () => { .getByRole('button', { name: /command and scripting interpreter/i }) .first() .click(); - await expect(dialog.getByRole('button', { name: /apply 1 technique/i })).toBeVisible(); + await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible(); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible({ timeout: 3_000 }); @@ -431,7 +432,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); @@ -451,7 +452,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); @@ -482,7 +483,7 @@ test.describe('US-15 — MITRE matrix modal', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - await page.getByRole('button', { name: /add technique/i }).click(); + await page.getByLabel(/open mitre matrix/i).click(); const dialog = page.getByRole('dialog'); await expect(dialog).toBeVisible({ timeout: 10_000 }); diff --git a/e2e/tests/us16-regression-sprint2.spec.ts b/e2e/tests/us16-regression-sprint2.spec.ts index 30a873a..fa1ffb2 100644 --- a/e2e/tests/us16-regression-sprint2.spec.ts +++ b/e2e/tests/us16-regression-sprint2.spec.ts @@ -195,7 +195,7 @@ test.describe('US-16 — sprint 2 regression', () => { // AC-16.2 — MitreTechniquePicker still accessible via Quick Search (clean rewrite onSelect) - test('AC-16.2 — MitreTechniquePicker accessible via Quick Search, appends tag on selection', async ({ + test('AC-16.2 — MitreTechniquePicker accessible via inline search, appends tag on selection', async ({ page, context, }) => { @@ -204,8 +204,8 @@ test.describe('US-16 — sprint 2 regression', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - // Quick Search button reveals the picker - await page.getByRole('button', { name: /quick search/i }).click(); + // Sprint 4: click inline placeholder text to reveal the picker + await page.getByText(/search technique/i).click(); const picker = page.getByRole('combobox', { name: /mitre technique/i }); await expect(picker).toBeVisible(); @@ -270,12 +270,12 @@ test.describe('US-16 — sprint 2 regression', () => { await expect(page.locator('#sim-description')).toBeVisible(); await expect(page.locator('#sim-commands')).toBeVisible(); - // Save Red Team button still present - await expect(page.getByRole('button', { name: /save red team/i })).toBeVisible(); + // Sprint 4: "Save Red Team" renamed to "Save" + await expect(page.getByRole('button', { name: /^save$/i })).toBeVisible(); - // MitreTechniquesField buttons present - await expect(page.getByRole('button', { name: /add technique/i })).toBeVisible(); - await expect(page.getByRole('button', { name: /quick search/i })).toBeVisible(); + // Sprint 4: matrix icon + inline search placeholder replace the old text buttons + await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible(); + await expect(page.getByText(/search technique/i)).toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); diff --git a/e2e/tests/us17-ui-polish.spec.ts b/e2e/tests/us17-ui-polish.spec.ts new file mode 100644 index 0000000..22071ae --- /dev/null +++ b/e2e/tests/us17-ui-polish.spec.ts @@ -0,0 +1,127 @@ +/** + * US-17 — UI polish: dedup buttons + alignment + icons. + * Covers AC-17.1 (single New button on EngagementsListPage) + * and AC-17.3 (UsersAdminPage Create form alignment). + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us17-redteam'; +const PASS = 'us17-pass-strong'; + +test.describe('US-17 — UI polish', () => { + let redteamToken: string; + let adminTok: string; + let engagementId: number; + + test.beforeAll(async () => { + adminTok = await adminToken(); + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + // Seed one engagement so the list is non-empty (EmptyState won't show extra "New" link) + const eng = await createEngagement(redteamToken, { + name: 'US-17 engagement', + start_date: '2026-01-01', + }); + engagementId = eng.id; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteEngagement(tok, engagementId); + await deleteUserByUsername(tok, REDTEAM_USER); + } catch { + /* noop */ + } + }); + + test('AC-17.1 — EngagementsListPage has exactly one "New" CTA button', async ({ + page, + context, + }) => { + await seedTokenInStorage(context, redteamToken); + await page.goto('/engagements'); + // Wait for list to load so EmptyState doesn't briefly appear + await page.waitForLoadState('networkidle'); + + // Sprint 4: single "New" button (+ icon). Old "Create engagement" duplicate removed. + const newButtons = page.getByRole('link', { name: /new/i }); + // Should have at least one + await expect(newButtons.first()).toBeVisible(); + + // Count all buttons/links that say "new engagement" or "create engagement" + const newEngagementLinks = await page.getByRole('link', { name: /new/i }).count(); + const createEngagementLinks = await page.getByRole('link', { name: /create engagement/i }).count(); + const createButtons = await page.getByRole('button', { name: /create engagement/i }).count(); + + // Exactly one "New" CTA — zero "Create engagement" duplicates + expect(newEngagementLinks).toBe(1); + expect(createEngagementLinks).toBe(0); + expect(createButtons).toBe(0); + }); + + test('AC-17.1 — "New" button navigates to engagement creation form', async ({ + page, + context, + }) => { + await seedTokenInStorage(context, redteamToken); + await page.goto('/engagements'); + + await page.getByRole('link', { name: /new/i }).first().click(); + await expect(page).toHaveURL(/\/engagements\/new/); + }); + + test('AC-17.3 — UsersAdminPage Create account form: inputs and button aligned', async ({ + page, + context, + }) => { + // UsersAdminPage is admin-only + await seedTokenInStorage(context, adminTok); + await page.goto('/admin/users'); + + // The form should be visible. Sprint 4: inputs use id="new-username" / id="new-password" + const usernameInput = page.locator('#new-username').first(); + const passwordInput = page.locator('#new-password').first(); + const createBtn = page.getByRole('button', { name: /^create$/i }).first(); + + await expect(usernameInput).toBeVisible(); + await expect(passwordInput).toBeVisible(); + await expect(createBtn).toBeVisible(); + + // Alignment check via boundingBox: the 4-column grid layout puts username, password, + // role, and button in the same row. All elements must be on the same vertical plane + // (same y/height) and within the viewport. + const usernameBox = await usernameInput.boundingBox(); + const passwordBox = await passwordInput.boundingBox(); + const btnBox = await createBtn.boundingBox(); + + expect(usernameBox).toBeTruthy(); + expect(passwordBox).toBeTruthy(); + expect(btnBox).toBeTruthy(); + + // All inputs are in separate columns — their y-positions (vertical alignment) should + // be within one element-height of each other (same grid row). + const yDiff = Math.abs(usernameBox!.y - passwordBox!.y); + expect(yDiff).toBeLessThanOrEqual(usernameBox!.height + 4); + + // All elements should be visible within the viewport (not overflowing off-screen) + const viewportWidth = page.viewportSize()!.width; + expect(usernameBox!.x + usernameBox!.width).toBeLessThanOrEqual(viewportWidth + 4); + expect(passwordBox!.x + passwordBox!.width).toBeLessThanOrEqual(viewportWidth + 4); + expect(btnBox!.x + btnBox!.width).toBeLessThanOrEqual(viewportWidth + 4); + + // Username comes before password horizontally (left-to-right grid order) + expect(usernameBox!.x).toBeLessThan(passwordBox!.x); + // Button is positioned after the inputs (rightmost in the grid) + expect(btnBox!.x).toBeGreaterThan(passwordBox!.x); + }); +}); diff --git a/e2e/tests/us18-done-readonly-reopen.spec.ts b/e2e/tests/us18-done-readonly-reopen.spec.ts new file mode 100644 index 0000000..6a6887c --- /dev/null +++ b/e2e/tests/us18-done-readonly-reopen.spec.ts @@ -0,0 +1,225 @@ +/** + * US-18 — Simulation `done` = read-only + Reopen. + * Covers AC-18.1 → AC-18.5. + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, + makeClient, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us18-redteam'; +const SOC_USER = 'us18-soc'; +const PASS = 'us18-pass-strong'; + +interface Simulation { + id: number; + status: string; + [key: string]: unknown; +} + +async function createSimulation(token: string, engagementId: number, name = 'US-18 sim'): Promise { + const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) throw new Error(`create sim: ${r.status}`); + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + await makeClient(token).delete(`/simulations/${simId}`); +} + +/** Drive a simulation from pending → in_progress → review_required → done */ +async function driveSimToDone(token: string, simId: number): Promise { + const c = makeClient(token); + await c.patch(`/simulations/${simId}`, { name: 'trigger in_progress' }); + await c.post(`/simulations/${simId}/transition`, { to: 'review_required' }); + await c.post(`/simulations/${simId}/transition`, { to: 'done' }); +} + +test.describe('US-18 — done read-only + Reopen', () => { + let redteamToken: string; + let socToken: string; + let engagementId: number; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + await ensureUser(SOC_USER, PASS, 'soc'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + socToken = (await login(SOC_USER, PASS)).token; + const eng = await createEngagement(redteamToken, { + name: 'US-18 Engagement', + start_date: '2026-01-01', + }); + engagementId = eng.id; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteEngagement(tok, engagementId); + for (const u of [REDTEAM_USER, SOC_USER]) { + await deleteUserByUsername(tok, u); + } + } catch { /* noop */ } + }); + + test('AC-18.1 — PATCH on done simulation returns 409 (redteam)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done redteam'); + await driveSimToDone(redteamToken, sim.id); + + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'should fail' }); + expect(r.status).toBe(409); + expect(r.data.error).toMatch(/simulation is done — reopen first/i); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-18.1 — PATCH on done simulation returns 409 (soc)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done soc'); + await driveSimToDone(redteamToken, sim.id); + + // SOC tries to PATCH soc_comment on a done sim → 409 + const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { soc_comment: 'late note' }); + expect(r.status).toBe(409); + expect(r.data.error).toMatch(/simulation is done — reopen first/i); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-18.1 — PATCH on done simulation returns 409 (admin)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.1 done admin'); + await driveSimToDone(redteamToken, sim.id); + const tok = await adminToken(); + + const r = await makeClient(tok).patch(`/simulations/${sim.id}`, { name: 'admin override' }); + expect(r.status).toBe(409); + expect(r.data.error).toMatch(/simulation is done — reopen first/i); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-18.2 — Reopen: done → review_required via transition (redteam)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.2 reopen redteam'); + await driveSimToDone(redteamToken, sim.id); + + const r = await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('review_required'); + expect(r.data.updated_at).toBeTruthy(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-18.2 — Reopen: done → review_required via transition (soc)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.2 reopen soc'); + await driveSimToDone(redteamToken, sim.id); + + const r = await makeClient(socToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('review_required'); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-18.3 — review_required from pending/in_progress stays admin/redteam only (not soc)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.3 soc cannot mark review'); + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' }); + // SOC cannot mark in_progress → review_required + const r = await makeClient(socToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + expect(r.status).toBe(403); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-18.3 — other transitions from done still return 409', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.3 done bad transition'); + await driveSimToDone(redteamToken, sim.id); + + // Trying to go done → done or done → in_progress should 409 + const r1 = await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'done' }); + expect(r1.status).toBe(409); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-18.4 — SimulationFormPage done: all fields disabled, only Reopen button visible', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.4 done UI'); + await driveSimToDone(redteamToken, sim.id); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Read-only banner visible + await expect(page.getByText(/done.*read-only|read-only.*done/i)).toBeVisible(); + + // Name field disabled + const nameField = page.locator('#sim-name'); + await expect(nameField).toBeDisabled(); + + // Reopen button visible + await expect(page.getByRole('button', { name: /reopen/i })).toBeVisible(); + + // Save RT, Save SOC, Mark for review, Close, Delete — all absent + await expect(page.getByRole('button', { name: /save/i })).not.toBeVisible(); + await expect(page.getByRole('button', { name: /mark for review/i })).not.toBeVisible(); + await expect(page.getByRole('button', { name: /^close$/i })).not.toBeVisible(); + + // MitreTechniquesField in read-only mode: no matrix icon, no input + await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-18.5 — Reopen via UI: toast appears, badge updates, fields editable', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.5 reopen UI'); + await driveSimToDone(redteamToken, sim.id); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Click Reopen + await page.getByRole('button', { name: /reopen/i }).click(); + + // Toast: "Simulation reopened" + await expect(page.getByText(/simulation reopened/i)).toBeVisible({ timeout: 5_000 }); + + // Badge updates to review_required + const badge = page.getByTestId('simulation-status-badge'); + await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 }); + + // Fields become editable again (name field enabled) + await expect(page.locator('#sim-name')).toBeEnabled({ timeout: 3_000 }); + + // Reopen button gone; Save button now visible + await expect(page.getByRole('button', { name: /reopen/i })).not.toBeVisible(); + await expect(page.getByRole('button', { name: /save/i }).first()).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-18.5 — after Reopen, PATCH succeeds (no longer 409)', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-18.5 PATCH after reopen'); + await driveSimToDone(redteamToken, sim.id); + + // Reopen via API + await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + + // Now PATCH should succeed + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { soc_comment: 'updated' }); + expect(r.status).toBe(200); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us19-engagement-auto-status.spec.ts b/e2e/tests/us19-engagement-auto-status.spec.ts new file mode 100644 index 0000000..e241f97 --- /dev/null +++ b/e2e/tests/us19-engagement-auto-status.spec.ts @@ -0,0 +1,199 @@ +/** + * US-19 — Engagement auto-status: planned → active. + * Covers AC-19.1 → AC-19.4. + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, + makeClient, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us19-redteam'; +const PASS = 'us19-pass-strong'; + +interface Simulation { id: number; status: string; [key: string]: unknown; } + +async function createSimulation(token: string, engagementId: number, name = 'US-19 sim'): Promise { + const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) throw new Error(`create sim: ${r.status}`); + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + await makeClient(token).delete(`/simulations/${simId}`); +} + +async function getEngagement(token: string, eid: number): Promise<{ status: string }> { + const r = await makeClient(token).get(`/engagements/${eid}`); + return r.data as { status: string }; +} + +test.describe('US-19 — engagement auto-status', () => { + let redteamToken: string; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteUserByUsername(tok, REDTEAM_USER); + } catch { /* noop */ } + }); + + test('AC-19.1 — engagement stays planned when sim is created (no auto-transition yet)', async () => { + const eng = await createEngagement(redteamToken, { + name: 'US-19 stay planned', + start_date: '2026-01-01', + }); + + // Creating a simulation alone does NOT activate engagement + const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 created'); + const engData = await getEngagement(redteamToken, eng.id); + expect(engData.status).toBe('planned'); + + await deleteSimulation(redteamToken, sim.id); + await deleteEngagement(redteamToken, eng.id); + }); + + test('AC-19.1 — engagement auto-activates when sim transitions to in_progress (via PATCH redteam field)', async () => { + const eng = await createEngagement(redteamToken, { + name: 'US-19 auto-activate', + start_date: '2026-01-01', + }); + expect(eng.status).toBe('planned'); + + const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 trigger'); + // PATCH a redteam field → auto-transition sim to in_progress → engagement → active + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('in_progress'); + + const engData = await getEngagement(redteamToken, eng.id); + expect(engData.status).toBe('active'); + + await deleteSimulation(redteamToken, sim.id); + await deleteEngagement(redteamToken, eng.id); + }); + + test('AC-19.1 — engagement auto-activates when sim transitions via technique_ids', async () => { + const eng = await createEngagement(redteamToken, { + name: 'US-19 technique activate', + start_date: '2026-01-01', + }); + + const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 technique'); + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('in_progress'); + + const engData = await getEngagement(redteamToken, eng.id); + expect(engData.status).toBe('active'); + + await deleteSimulation(redteamToken, sim.id); + await deleteEngagement(redteamToken, eng.id); + }); + + test('AC-19.1 — engagement auto-activates when sim transitions via tactic_ids', async () => { + const eng = await createEngagement(redteamToken, { + name: 'US-19 tactic activate', + start_date: '2026-01-01', + }); + + const sim = await createSimulation(redteamToken, eng.id, 'AC-19.1 tactic'); + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('in_progress'); + + const engData = await getEngagement(redteamToken, eng.id); + expect(engData.status).toBe('active'); + + await deleteSimulation(redteamToken, sim.id); + await deleteEngagement(redteamToken, eng.id); + }); + + test('AC-19.2 — already active engagement stays active (no double-transition)', async () => { + const eng = await createEngagement(redteamToken, { + name: 'US-19 already active', + start_date: '2026-01-01', + }); + + // First sim activates the engagement + const sim1 = await createSimulation(redteamToken, eng.id, 'AC-19.2 first'); + await makeClient(redteamToken).patch(`/simulations/${sim1.id}`, { name: 'trigger 1' }); + const engAfterFirst = await getEngagement(redteamToken, eng.id); + expect(engAfterFirst.status).toBe('active'); + + // Second sim trigger — engagement stays active (not planned, not any other status) + const sim2 = await createSimulation(redteamToken, eng.id, 'AC-19.2 second'); + await makeClient(redteamToken).patch(`/simulations/${sim2.id}`, { name: 'trigger 2' }); + const engAfterSecond = await getEngagement(redteamToken, eng.id); + expect(engAfterSecond.status).toBe('active'); + + await deleteSimulation(redteamToken, sim1.id); + await deleteSimulation(redteamToken, sim2.id); + await deleteEngagement(redteamToken, eng.id); + }); + + test('AC-19.3 — no backward auto transition: engagement status never goes back to planned', async () => { + const eng = await createEngagement(redteamToken, { + name: 'US-19 no backward', + start_date: '2026-01-01', + }); + + const sim = await createSimulation(redteamToken, eng.id, 'AC-19.3 backward'); + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' }); + const engActive = await getEngagement(redteamToken, eng.id); + expect(engActive.status).toBe('active'); + + // Deleting the sim does not revert engagement to planned + await deleteSimulation(redteamToken, sim.id); + const engAfterDelete = await getEngagement(redteamToken, eng.id); + expect(engAfterDelete.status).toBe('active'); + + await deleteEngagement(redteamToken, eng.id); + }); + + test('AC-19.4 — frontend invalidates engagement cache after simulation PATCH (badge updates without reload)', async ({ + page, + context, + }) => { + const eng = await createEngagement(redteamToken, { + name: 'US-19 frontend cache', + start_date: '2026-01-01', + }); + + const sim = await createSimulation(redteamToken, eng.id, 'AC-19.4 cache'); + + await seedTokenInStorage(context, redteamToken); + // Navigate to engagement detail — engagement shows "planned" + await page.goto(`/engagements/${eng.id}`); + // Status badge should be planned initially + const statusBadge = page.getByTestId('engagement-status-badge').first(); + if (await statusBadge.count() > 0) { + await expect(statusBadge).toContainText(/planned/i); + } + + // Now trigger in_progress via form + await page.goto(`/engagements/${eng.id}/simulations/${sim.id}/edit`); + const nameField = page.locator('#sim-name'); + await nameField.fill('trigger auto-active'); + await page.getByRole('button', { name: /save/i }).first().click(); + + // Navigate back to engagement — status should now show active (cache invalidated) + await page.goto(`/engagements/${eng.id}`); + await page.waitForLoadState('networkidle'); + await expect(page.getByText(/active/i).first()).toBeVisible({ timeout: 5_000 }); + + await deleteSimulation(redteamToken, sim.id); + await deleteEngagement(redteamToken, eng.id); + }); +}); diff --git a/e2e/tests/us20-matrix-fits-modal.spec.ts b/e2e/tests/us20-matrix-fits-modal.spec.ts new file mode 100644 index 0000000..11e3a20 --- /dev/null +++ b/e2e/tests/us20-matrix-fits-modal.spec.ts @@ -0,0 +1,235 @@ +/** + * US-20 — MITRE matrix: attack.mitre.org look + no horizontal scroll. + * Covers AC-20.1 (max-w-[98vw]) and AC-20.4 (no horizontal scroll via boundingBox). + * AC-20.5 (sub-technique expand preserved) spot-checked. + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, + makeClient, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us20-redteam'; +const PASS = 'us20-pass-strong'; + +interface Simulation { id: number; [key: string]: unknown; } + +async function createSimulation(token: string, engagementId: number, name = 'US-20 sim'): Promise { + const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) throw new Error(`create sim: ${r.status}`); + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + await makeClient(token).delete(`/simulations/${simId}`); +} + +test.describe('US-20 — MITRE matrix fits modal', () => { + let redteamToken: string; + let engagementId: number; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + const eng = await createEngagement(redteamToken, { + name: 'US-20 Engagement', + start_date: '2026-01-01', + }); + engagementId = eng.id; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteEngagement(tok, engagementId); + await deleteUserByUsername(tok, REDTEAM_USER); + } catch { /* noop */ } + }); + + test('AC-20.1 — modal max-width is 98vw (fits within viewport)', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-20.1 width'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Open matrix via the grid icon button + await page.getByLabel(/open mitre matrix/i).click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + const dialogBox = await dialog.boundingBox(); + const viewportWidth = page.viewportSize()!.width; + + expect(dialogBox).toBeTruthy(); + // Modal must not exceed viewport width (98vw) + expect(dialogBox!.width).toBeLessThanOrEqual(viewportWidth * 0.99); + // Modal must be visible (has meaningful width) + expect(dialogBox!.width).toBeGreaterThan(600); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-20.4 — matrix body has NO horizontal scroll at 1280px viewport', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-20.4 no scroll'); + + // Force 1280×720 viewport (default in playwright.config.ts) + await page.setViewportSize({ width: 1280, height: 720 }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByLabel(/open mitre matrix/i).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // The matrix body container must not overflow horizontally. + // We check scrollWidth <= clientWidth on the overflow body element. + const hasHorizontalScroll = await page.evaluate(() => { + // Find the element with overflow-y-auto / overflow-x-hidden + const dialogs = document.querySelectorAll('[role="dialog"]'); + for (const d of dialogs) { + // The body is the flex-1 scrollable div inside the dialog + const body = d.querySelector('.overflow-y-auto, .overflow-x-hidden'); + if (body) { + return body.scrollWidth > body.clientWidth + 2; // 2px tolerance + } + } + return false; + }); + + expect(hasHorizontalScroll).toBe(false); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-20.4 — all 12 tactic columns visible without scrolling at 1280px', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-20.4 tactics visible'); + + await page.setViewportSize({ width: 1280, height: 720 }); + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByLabel(/open mitre matrix/i).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // All 12 canonical tactics must be in the DOM (use first() to avoid strict mode violation + // when tactic name appears in multiple technique titles, e.g. "Execution" appears in + // technique sub-names). + const expectedTactics = [ + 'Initial Access', 'Execution', 'Persistence', 'Privilege Escalation', + 'Defense Evasion', 'Credential Access', 'Discovery', 'Lateral Movement', + 'Collection', 'Command and Control', 'Exfiltration', 'Impact', + ]; + for (const tactic of expectedTactics) { + await expect(dialog.getByText(tactic, { exact: false }).first()).toBeVisible(); + } + + // The dialog itself must not have a scrollbar (overflow-x-hidden) + const dialogBox = await dialog.boundingBox(); + const viewportWidth = page.viewportSize()!.width; + expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(viewportWidth + 2); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-20.5 — sub-technique expand/collapse still works after layout overhaul', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-20.5 expand'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByLabel(/open mitre matrix/i).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Expand T1059 via chevron + const expandBtn = dialog.getByRole('button', { name: /expand T1059/i }); + await expect(expandBtn).toBeVisible(); + await expandBtn.click(); + + // T1059.001 visible after expand + await expect(dialog).toContainText('T1059.001'); + + // Collapse + await dialog.getByRole('button', { name: /collapse T1059/i }).click(); + await expect(dialog).not.toContainText('T1059.001'); + + await deleteSimulation(redteamToken, sim.id); + }); + + // NIT code-reviewer + AC-15.5 regression: Tab focus-trap cycle in MitreMatrixModal + test('AC-15.5 regression — MitreMatrixModal Tab key cycles focus, Shift+Tab reverses', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 focus trap'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByLabel(/open mitre matrix/i).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Collect all focusable elements in the dialog + const focusableCount = await dialog.evaluate((el) => { + const focusables = el.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + return focusables.length; + }); + expect(focusableCount).toBeGreaterThan(1); + + // Focus the last focusable element + await dialog.evaluate((el) => { + const focusables = Array.from(el.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + )); + focusables[focusables.length - 1].focus(); + }); + + // Tab from last → should wrap to first (focus trap) + await page.keyboard.press('Tab'); + const activeAfterTab = await dialog.evaluate((el) => { + const focusables = Array.from(el.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + )); + return focusables.indexOf(document.activeElement as HTMLElement); + }); + // After Tab from last, focus should be on first (index 0) + expect(activeAfterTab).toBe(0); + + // Shift+Tab from first → should wrap to last + await page.keyboard.press('Shift+Tab'); + const activeAfterShiftTab = await dialog.evaluate((el) => { + const focusables = Array.from(el.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + )); + return focusables.indexOf(document.activeElement as HTMLElement); + }); + // After Shift+Tab from first, focus should be on last + expect(activeAfterShiftTab).toBe(focusableCount - 1); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us21-tactic-selection.spec.ts b/e2e/tests/us21-tactic-selection.spec.ts new file mode 100644 index 0000000..e48e6d7 --- /dev/null +++ b/e2e/tests/us21-tactic-selection.spec.ts @@ -0,0 +1,291 @@ +/** + * US-21 — Tactic selection (TA-id tags). + * Covers AC-21.4 → AC-21.7 (API + UI). + * AC-21.1/2/3 (model + migration + serialization) tested via API assertions. + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, + makeClient, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us21-redteam'; +const SOC_USER = 'us21-soc'; +const PASS = 'us21-pass-strong'; + +interface Simulation { id: number; status: string; tactics: { id: string; name: string }[]; [key: string]: unknown; } + +async function createSimulation(token: string, engagementId: number, name = 'US-21 sim'): Promise { + const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) throw new Error(`create sim: ${r.status}`); + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + await makeClient(token).delete(`/simulations/${simId}`); +} + +test.describe('US-21 — tactic selection', () => { + let redteamToken: string; + let socToken: string; + let engagementId: number; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + await ensureUser(SOC_USER, PASS, 'soc'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + socToken = (await login(SOC_USER, PASS)).token; + const eng = await createEngagement(redteamToken, { + name: 'US-21 Engagement', + start_date: '2026-01-01', + }); + engagementId = eng.id; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteEngagement(tok, engagementId); + for (const u of [REDTEAM_USER, SOC_USER]) await deleteUserByUsername(tok, u); + } catch { /* noop */ } + }); + + // AC-21.1/2/3 — model + serialization + test('AC-21.3 — new simulation has tactics=[] in serialisation', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.3 empty'); + expect(Array.isArray(sim.tactics)).toBe(true); + expect(sim.tactics).toHaveLength(0); + await deleteSimulation(redteamToken, sim.id); + }); + + // AC-21.4 — PATCH tactic_ids validation + test('AC-21.4 — PATCH tactic_ids: valid TA-id stored, enriched name in response', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 valid'); + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { + tactic_ids: ['TA0007', 'TA0001'], + }); + expect(r.status).toBe(200); + expect(Array.isArray(r.data.tactics)).toBe(true); + expect(r.data.tactics).toHaveLength(2); + const disc = r.data.tactics.find((t: { id: string }) => t.id === 'TA0007'); + expect(disc).toBeTruthy(); + expect(disc.name).toBe('Discovery'); + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-21.4 — PATCH tactic_ids: unknown TA-id → 400', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 unknown'); + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { + tactic_ids: ['TA9999'], + }); + expect(r.status).toBe(400); + expect(r.data.error).toMatch(/unknown tactic id.*TA9999/i); + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-21.4 — PATCH tactic_ids: dedup preserves order', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.4 dedup'); + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { + tactic_ids: ['TA0007', 'TA0001', 'TA0007'], + }); + expect(r.status).toBe(200); + const ids = r.data.tactics.map((t: { id: string }) => t.id); + expect(ids).toEqual(['TA0007', 'TA0001']); + await deleteSimulation(redteamToken, sim.id); + }); + + // AC-21.5 — SOC gate + auto-transition + test('AC-21.5 — SOC cannot PATCH tactic_ids → 403', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 soc block'); + // Advance to review_required so SOC has access + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { name: 'trigger' }); + await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + + const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] }); + expect(r.status).toBe(403); + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-21.5 — non-empty tactic_ids triggers auto-transition pending→in_progress', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 auto-transition'); + expect(sim.status).toBe('pending'); + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('in_progress'); + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-21.5 — empty tactic_ids does NOT trigger auto-transition', async () => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.5 no-trigger'); + expect(sim.status).toBe('pending'); + const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: [] }); + expect(r.status).toBe(200); + expect(r.data.status).toBe('pending'); + await deleteSimulation(redteamToken, sim.id); + }); + + // AC-21.6 — matrix modal tactic header clickable + test('AC-21.6 — clicking tactic header in modal toggles tactic selection', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.6 tactic click'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByLabel(/open mitre matrix/i).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Wait for matrix to load — tactic header title: "Discovery (TA0007) — click to tag this tactic" + const discoveryHeader = dialog.locator('button[title*="TA0007"]'); + await expect(discoveryHeader).toBeVisible({ timeout: 10_000 }); + await discoveryHeader.click(); + + // Apply button shows at least 1 selection (the tactic) + await expect(dialog.getByRole('button', { name: /apply \d+/i })).toBeVisible(); + + // Click again to deselect + await discoveryHeader.click(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-21.6 — Apply from modal includes tactic in result (auto-save)', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.6 apply tactic'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByLabel(/open mitre matrix/i).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + + // Tactic header title: "Discovery (TA0007) — click to tag this tactic" + const discoveryBtn = dialog.locator('button[title*="TA0007"]'); + await expect(discoveryBtn).toBeVisible({ timeout: 10_000 }); + await discoveryBtn.click(); + + const applyBtn = dialog.getByRole('button', { name: /apply \d+/i }); + await expect(applyBtn).toBeVisible(); + await applyBtn.click(); + + // Modal closes + await expect(dialog).not.toBeVisible({ timeout: 5_000 }); + + // Tactic chip appears after auto-save + await expect(page.getByTestId('mitre-tactic-tag')).toBeVisible({ timeout: 8_000 }); + await expect(page.getByTestId('techniques-tag-list')).toContainText('TA0007'); + + await deleteSimulation(redteamToken, sim.id); + }); + + // AC-21.7 — tactic chips in MitreTechniquesField + test('AC-21.7 — tactic chips display TA-id and have × for removal', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 tactic chip'); + // Seed via API + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + const tacticTag = page.getByTestId('mitre-tactic-tag'); + await expect(tacticTag).toBeVisible(); + await expect(tacticTag).toContainText('TA0007'); + + // Title attribute has id — name + const title = await tacticTag.getAttribute('title'); + expect(title).toMatch(/TA0007/); + expect(title).toMatch(/Discovery/); + + // × button for removal + await expect(page.getByRole('button', { name: /remove TA0007/i })).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-21.7 — removing tactic chip auto-saves', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 remove tactic'); + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await expect(page.getByTestId('mitre-tactic-tag')).toBeVisible(); + await page.getByRole('button', { name: /remove TA0007/i }).click(); + + await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 }); + await expect(page.getByTestId('mitre-tactic-tag')).not.toBeVisible({ timeout: 3_000 }); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-21.7 — tactic chips visually distinct from technique chips (different class)', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 style'); + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { + tactic_ids: ['TA0007'], + technique_ids: ['T1059'], + }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + const tacticTag = page.getByTestId('mitre-tactic-tag'); + const techTag = page.getByTestId('mitre-technique-tag'); + await expect(tacticTag).toBeVisible(); + await expect(techTag).toBeVisible(); + + // Tactic: bg-primary (filled) vs technique: bg-primary-soft + const tacticCls = await tacticTag.getAttribute('class'); + const techCls = await techTag.getAttribute('class'); + expect(tacticCls).toMatch(/bg-primary/); + // Technique should NOT have the solid bg-primary (just bg-primary-soft) + expect(techCls).toMatch(/bg-primary-soft/); + // They should differ visually + expect(tacticCls).not.toBe(techCls); + + await deleteSimulation(redteamToken, sim.id); + }); + + // NIT code-reviewer: +N suffix in SimulationList MITRE column + test('AC-21.7 — SimulationList MITRE column shows first id + "+N" for mixed tactics+techniques', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-21.7 +N suffix'); + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { + tactic_ids: ['TA0007'], + technique_ids: ['T1059', 'T1078'], + }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}`); + + // The row for this simulation should show "TA0007 +2" in the MITRE column + // (1 tactic TA0007 is first, then +2 for T1059 and T1078) + const simRow = page.getByRole('row').filter({ hasText: 'AC-21.7 +N suffix' }); + await expect(simRow).toBeVisible({ timeout: 5_000 }); + await expect(simRow).toContainText('TA0007 +2'); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us22-mitre-input-redesign.spec.ts b/e2e/tests/us22-mitre-input-redesign.spec.ts new file mode 100644 index 0000000..768de82 --- /dev/null +++ b/e2e/tests/us22-mitre-input-redesign.spec.ts @@ -0,0 +1,250 @@ +/** + * US-22 — Refonte input MITRE dans le form. + * Covers AC-22.1 → AC-22.5. + * Key change: no "Add technique" / "Quick search" text buttons. + * Instead: inline autocomplete input + grid icon for matrix. + * Chips show T-id only (name in title=). + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + createEngagement, + deleteEngagement, + deleteUserByUsername, + ensureUser, + login, + makeClient, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us22-redteam'; +const SOC_USER = 'us22-soc'; +const PASS = 'us22-pass-strong'; + +interface Simulation { id: number; [key: string]: unknown; } + +async function createSimulation(token: string, engagementId: number, name = 'US-22 sim'): Promise { + const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name }); + if (r.status !== 201) throw new Error(`create sim: ${r.status}`); + return r.data as Simulation; +} + +async function deleteSimulation(token: string, simId: number): Promise { + await makeClient(token).delete(`/simulations/${simId}`); +} + +test.describe('US-22 — MITRE input redesign', () => { + let redteamToken: string; + let socToken: string; + let engagementId: number; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + await ensureUser(SOC_USER, PASS, 'soc'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + socToken = (await login(SOC_USER, PASS)).token; + const eng = await createEngagement(redteamToken, { + name: 'US-22 Engagement', + start_date: '2026-01-01', + }); + engagementId = eng.id; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteEngagement(tok, engagementId); + for (const u of [REDTEAM_USER, SOC_USER]) await deleteUserByUsername(tok, u); + } catch { /* noop */ } + }); + + test('AC-22.1 — layout: inline autocomplete input + matrix icon present, NO text buttons', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 layout'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Matrix icon button present + await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible(); + + // Search input placeholder visible (inline autocomplete) + await expect(page.getByText(/search technique/i)).toBeVisible(); + + // No old-style text buttons + await expect(page.getByRole('button', { name: /add technique/i })).not.toBeVisible(); + await expect(page.getByRole('button', { name: /quick search/i })).not.toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-22.1 — inline autocomplete: click input shows combobox, type shows dropdown', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 autocomplete'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Click the fake-input placeholder to reveal the combobox + await page.getByText(/search technique/i).click(); + const picker = page.getByRole('combobox', { name: /mitre technique/i }); + await expect(picker).toBeVisible(); + + // Type to get results + await picker.fill('T1059'); + const listbox = page.getByRole('listbox', { name: /mitre techniques/i }); + await expect(listbox).toBeVisible({ timeout: 5_000 }); + + // Select via keyboard + await picker.press('ArrowDown'); + await picker.press('Enter'); + + // Tag appears + await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059', { timeout: 5_000 }); + // Auto-save toast + await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 }); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-22.1 — matrix icon opens MitreMatrixModal', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-22.1 matrix icon'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + await page.getByLabel(/open mitre matrix/i).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole('dialog')).toContainText(/mitre att&?ck matrix/i); + + await deleteSimulation(redteamToken, sim.id); + }); + + // AC-22.2 — chips show T-id only, name in title + test('AC-22.2 — technique chips display T-id only, name in title= attribute', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-22.2 chip format'); + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + const chip = page.getByTestId('mitre-technique-tag').first(); + await expect(chip).toBeVisible(); + + // Text content is T-id only + const text = await chip.textContent(); + expect(text?.trim()).toMatch(/^T1059/); + // Must NOT contain the full name inline + expect(text).not.toMatch(/Command and Scripting Interpreter/); + + // Name appears in title attribute + const title = await chip.getAttribute('title'); + expect(title).toMatch(/T1059/); + expect(title).toMatch(/Command and Scripting Interpreter/); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-22.2 — tactic chips display TA-id only, name in title= attribute', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-22.2 tactic chip format'); + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { tactic_ids: ['TA0007'] }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + const chip = page.getByTestId('mitre-tactic-tag').first(); + await expect(chip).toBeVisible(); + + const text = await chip.textContent(); + expect(text?.trim()).toMatch(/^TA0007/); + expect(text).not.toMatch(/Discovery/); + + const title = await chip.getAttribute('title'); + expect(title).toMatch(/TA0007/); + expect(title).toMatch(/Discovery/); + + await deleteSimulation(redteamToken, sim.id); + }); + + // AC-22.4 — empty state + test('AC-22.4 — empty state: "No techniques selected" visible, input and matrix icon still shown', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-22.4 empty'); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Empty state message + await expect(page.getByText(/no techniques selected/i)).toBeVisible(); + + // Input and matrix icon still present in non-disabled mode + await expect(page.getByLabel(/open mitre matrix/i)).toBeVisible(); + await expect(page.getByText(/search technique/i)).toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + // AC-22.5 — read-only mode + test('AC-22.5 — SOC on in_progress sim: chips visible (no ×), input + matrix icon hidden', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-22.5 soc readonly'); + // Add a technique and advance to review_required for SOC access + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] }); + await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + + await seedTokenInStorage(context, socToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Chip is visible + await expect(page.getByTestId('mitre-technique-tag')).toBeVisible(); + + // No × remove button (read-only for SOC on technique chips) + await expect(page.getByRole('button', { name: /remove T1059/i })).not.toBeVisible(); + + // Matrix icon and input hidden in disabled mode + await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible(); + await expect(page.getByText(/search technique/i)).not.toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); + + test('AC-22.5 — done sim: all chips read-only, no input', async ({ + page, + context, + }) => { + const sim = await createSimulation(redteamToken, engagementId, 'AC-22.5 done readonly'); + await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { technique_ids: ['T1059'] }); + // Drive to done + await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'review_required' }); + await makeClient(redteamToken).post(`/simulations/${sim.id}/transition`, { to: 'done' }); + + await seedTokenInStorage(context, redteamToken); + await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); + + // Chip visible + await expect(page.getByTestId('mitre-technique-tag')).toBeVisible(); + // No × remove + await expect(page.getByRole('button', { name: /remove T1059/i })).not.toBeVisible(); + // No matrix icon + await expect(page.getByLabel(/open mitre matrix/i)).not.toBeVisible(); + + await deleteSimulation(redteamToken, sim.id); + }); +}); diff --git a/e2e/tests/us23-dark-mode.spec.ts b/e2e/tests/us23-dark-mode.spec.ts new file mode 100644 index 0000000..2bd302e --- /dev/null +++ b/e2e/tests/us23-dark-mode.spec.ts @@ -0,0 +1,175 @@ +/** + * US-23 — Dark mode. + * Covers AC-23.1 (toggle in topbar), AC-23.2 (3-state cycle), AC-23.3 (localStorage persistence). + * AC-23.4/5/6 (Tailwind tokens, component audit, screenshots) are frontend-builder scope. + */ +import { test, expect } from '@playwright/test'; +import { + adminToken, + deleteUserByUsername, + ensureUser, + login, +} from '../fixtures/api'; +import { seedTokenInStorage } from '../fixtures/auth'; + +const REDTEAM_USER = 'us23-redteam'; +const PASS = 'us23-pass-strong'; + +test.describe('US-23 — dark mode', () => { + let redteamToken: string; + + test.beforeAll(async () => { + await ensureUser(REDTEAM_USER, PASS, 'redteam'); + redteamToken = (await login(REDTEAM_USER, PASS)).token; + }); + + test.afterAll(async () => { + try { + const tok = await adminToken(); + await deleteUserByUsername(tok, REDTEAM_USER); + } catch { /* noop */ } + }); + + test('AC-23.1 — theme toggle button is visible in the topbar', async ({ + page, + context, + }) => { + await seedTokenInStorage(context, redteamToken); + await page.goto('/engagements'); + + // The theme toggle has aria-label containing "Theme:" and the current mode + const themeBtn = page.getByRole('button', { name: /theme:/i }); + await expect(themeBtn).toBeVisible(); + + // Shows current theme label (Light, Dark, or System) + const label = await themeBtn.textContent(); + expect(label).toMatch(/light|dark|system/i); + }); + + test('AC-23.2 — toggle cycles through 3 states: system → light → dark → system', async ({ + page, + context, + }) => { + await seedTokenInStorage(context, redteamToken); + await page.goto('/engagements'); + // Clear any stored theme via evaluate (not addInitScript — that would fire on every load) + await page.evaluate(() => localStorage.removeItem('mimic-theme')); + await page.reload(); + await page.waitForLoadState('networkidle'); + + const themeBtn = page.getByRole('button', { name: /theme:/i }); + await expect(themeBtn).toBeVisible(); + + // Collect 4 states to confirm a full cycle + const states: string[] = []; + for (let i = 0; i < 4; i++) { + const label = await themeBtn.textContent(); + states.push(label?.trim().toLowerCase() ?? ''); + await themeBtn.click(); + await page.waitForTimeout(100); + } + + // Must contain all 3 modes within the cycle + expect(states.some(s => s.includes('system'))).toBe(true); + expect(states.some(s => s.includes('light'))).toBe(true); + expect(states.some(s => s.includes('dark'))).toBe(true); + // 4th state must equal 1st (full cycle completed) + expect(states[3]).toBe(states[0]); + }); + + test('AC-23.3 — theme persists in localStorage under "mimic-theme"', async ({ + page, + context, + }) => { + await seedTokenInStorage(context, redteamToken); + await page.goto('/engagements'); + + // Clear any stored theme via evaluate (NOT addInitScript — that runs on reload too) + await page.evaluate(() => localStorage.removeItem('mimic-theme')); + await page.reload(); + await page.waitForLoadState('networkidle'); + + const themeBtn = page.getByRole('button', { name: /theme:/i }); + await expect(themeBtn).toBeVisible(); + + // Click until we reach "dark" + for (let i = 0; i < 5; i++) { + const label = await themeBtn.textContent(); + if (label?.toLowerCase().includes('dark')) break; + await themeBtn.click(); + await page.waitForTimeout(100); + } + + // Read localStorage — must be 'dark' + const stored = await page.evaluate(() => localStorage.getItem('mimic-theme')); + expect(stored).toBe('dark'); + + // Reload page — should restore dark mode (localStorage persists across reload) + await page.reload(); + await page.waitForLoadState('networkidle'); + const themeAfterReload = page.getByRole('button', { name: /theme:/i }); + await expect(themeAfterReload).toBeVisible(); + const labelAfterReload = await themeAfterReload.textContent(); + expect(labelAfterReload?.toLowerCase()).toContain('dark'); + + // html element should have class "dark" + const hasDarkClass = await page.evaluate(() => document.documentElement.classList.contains('dark')); + expect(hasDarkClass).toBe(true); + }); + + test('AC-23.3 — default is "system" when no localStorage value', async ({ + page, + context, + }) => { + await seedTokenInStorage(context, redteamToken); + await page.goto('/engagements'); + // Clear theme via evaluate then reload so page renders with no stored theme + await page.evaluate(() => localStorage.removeItem('mimic-theme')); + await page.reload(); + await page.waitForLoadState('networkidle'); + + const stored = await page.evaluate(() => localStorage.getItem('mimic-theme')); + // Default state: localStorage not yet set (null) OR set to 'system' + expect(stored === null || stored === 'system').toBe(true); + + const themeBtn = page.getByRole('button', { name: /theme:/i }); + const label = await themeBtn.textContent(); + // Initial label should be System (or light if system resolves to light) + expect(label?.toLowerCase()).toMatch(/system|light|dark/); + }); + + test('AC-23.1 — dark mode: html has class "dark" when dark selected', async ({ + page, + context, + }) => { + await seedTokenInStorage(context, redteamToken); + await page.goto('/engagements'); + // Set dark theme via evaluate then reload so React reads it on mount + await page.evaluate(() => localStorage.setItem('mimic-theme', 'dark')); + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Wait for React to apply the dark class via useEffect + await page.waitForFunction(() => document.documentElement.classList.contains('dark'), { timeout: 3_000 }); + const hasDarkClass = await page.evaluate(() => + document.documentElement.classList.contains('dark'), + ); + expect(hasDarkClass).toBe(true); + }); + + test('AC-23.1 — light mode: html does NOT have class "dark" when light selected', async ({ + page, + context, + }) => { + await seedTokenInStorage(context, redteamToken); + await page.goto('/engagements'); + await page.evaluate(() => localStorage.setItem('mimic-theme', 'light')); + await page.reload(); + await page.waitForLoadState('networkidle'); + + const hasDarkClass = await page.evaluate(() => + document.documentElement.classList.contains('dark'), + ); + expect(hasDarkClass).toBe(false); + }); +}); diff --git a/e2e/tests/us4-engagements.spec.ts b/e2e/tests/us4-engagements.spec.ts index f396d95..6e6e449 100644 --- a/e2e/tests/us4-engagements.spec.ts +++ b/e2e/tests/us4-engagements.spec.ts @@ -198,8 +198,8 @@ test.describe('US-4 — engagement CRUD', () => { await expect(row).toBeVisible(); await expect(row.getByText(REDTEAM_USER)).toBeVisible(); - // Redteam sees the action buttons. - await expect(page.getByRole('link', { name: /new engagement/i })).toBeVisible(); + // Redteam sees the action buttons. Sprint 4: "New engagement" renamed to "New". + await expect(page.getByRole('link', { name: /^new$/i })).toBeVisible(); await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible(); await expect(row.getByRole('button', { name: /^delete$/i })).toBeVisible(); @@ -208,7 +208,7 @@ test.describe('US-4 — engagement CRUD', () => { await page.goto('/engagements'); const rowAsSoc = page.getByRole('row', { name: /UI list sample/i }); await expect(rowAsSoc).toBeVisible(); - await expect(page.getByRole('link', { name: /new engagement/i })).toHaveCount(0); + await expect(page.getByRole('link', { name: /^new$/i })).toHaveCount(0); await expect(rowAsSoc.getByRole('link', { name: /^edit$/i })).toHaveCount(0); await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0); }); diff --git a/e2e/tests/us5-design.spec.ts b/e2e/tests/us5-design.spec.ts index 0c9a01c..b275638 100644 --- a/e2e/tests/us5-design.spec.ts +++ b/e2e/tests/us5-design.spec.ts @@ -58,12 +58,12 @@ test.describe('US-5 — DESIGN.md fidelity, responsive, states', () => { .evaluate((el) => window.getComputedStyle(el).backgroundColor); expect(chevronBg.replace(/\s/g, '')).toBe('rgb(2,74,216)'); - // Topbar utility-strip is the ink slab (`bg-ink` → rgb(26, 26, 26)). + // Sprint 4: topbar utility-strip uses `bg-slab` (#111827 → rgb(17,24,39)). const utilityBg = await page - .locator('div.bg-ink.text-ink-on') + .locator('div.bg-slab.text-slab-text') .first() .evaluate((el) => window.getComputedStyle(el).backgroundColor); - expect(utilityBg.replace(/\s/g, '')).toBe('rgb(26,26,26)'); + expect(utilityBg.replace(/\s/g, '')).toBe('rgb(17,24,39)'); // Spot-check a few semantic class names live in the DOM (proves tokens are // wired through tailwind.config.ts and not ad-hoc hex values). diff --git a/e2e/tests/us8-simulation-redteam-fill.spec.ts b/e2e/tests/us8-simulation-redteam-fill.spec.ts index 013c8ef..81ec7c0 100644 --- a/e2e/tests/us8-simulation-redteam-fill.spec.ts +++ b/e2e/tests/us8-simulation-redteam-fill.spec.ts @@ -193,14 +193,15 @@ test.describe('US-8 — redteam fill simulation details', () => { await expect(nameField).toBeEnabled(); // Clear the name, try to save → client validation error + // Sprint 4: "Save Red Team" button renamed to "Save" await nameField.fill(''); - await page.getByRole('button', { name: /save red team/i }).click(); + await page.getByRole('button', { name: /^save$/i }).click(); await expect(page.getByText(/name is required/i)).toBeVisible(); await deleteSimulation(redteamToken, sim.id); }); - test('AC-8.6 — MITRE technique picker accessible via Quick search on the edit form', async ({ + test('AC-8.6 — MITRE technique picker accessible via inline search on the edit form', async ({ page, context, }) => { @@ -209,8 +210,8 @@ test.describe('US-8 — redteam fill simulation details', () => { await seedTokenInStorage(context, redteamToken); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); - // Sprint 3: picker is inside MitreTechniquesField, opened via "Quick search" - await page.getByRole('button', { name: /quick search/i }).click(); + // Sprint 4: picker opens by clicking the inline placeholder text + await page.getByText(/search technique/i).click(); // MitreTechniquePicker renders an input with combobox role await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible(); 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/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx index 66af72b..da36019 100644 --- a/frontend/src/components/ConfirmDialog.tsx +++ b/frontend/src/components/ConfirmDialog.tsx @@ -24,7 +24,7 @@ export function ConfirmDialog({ aria-labelledby="confirm-dialog-title" className="fixed inset-0 z-50 flex items-center justify-center" > -