Compare commits

...

16 Commits

Author SHA1 Message Date
9873c535c6 Merge pull request 'feat: sprint 4 — UI polish + dark mode + workflow tightening + process hygiene' (#7) from sprint/4-ui-polish into main
Reviewed-on: #7
2026-05-28 04:01:21 +00:00
Knacky
6d2bb091e2 docs: sprint 4 wrap-up — CHANGELOG + README + 7 lessons + plan final
- CHANGELOG: sprint 4 entry under [Unreleased] (covers all 9 US: dark mode, MITRE matrix overhaul, tactic_ids, done read-only + Reopen, engagement auto-status, UI polish, design-reviewer agent, PR helper, screenshots mandatory). Sprint 3 moved to its own [Sprint 3] section.
- README: status bump, test counts refreshed (193/92/158).
- tasks/lessons.md: 7 sprint-4 lessons captured (git status before sprint close, endpoint round-trip mismatch caught only by e2e, ink vs slab token split, structural row layout > class tweaks, hardcoded paths in migration tests, screenshots with auth, builder cross-context summaries as accidental re-dispatch).
- tasks/todo.md: status flipped to 🟢 SPRINT COMPLET, execution sequence ticks updated with commit hashes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:41:47 +02:00
Knacky
43ab7073f1 test(e2e): un-skip AC-21.6 — backend matrix fix landed
Remove test.fail annotation from AC-21.6 "Apply from modal includes
tactic in result". GET /api/mitre/matrix now returns tactic_id in TA-format
("TA0007") so the PATCH succeeds and the tactic chip appears.

Update button selector in both AC-21.6 tests from title*="discovery"
to title*="TA0007" to match the fixed matrix response format.

Suite: 158 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:38:17 +02:00
Knacky
7d81ce9785 test(e2e): fill coverage gaps — +N suffix + focus-trap cycle
Add two tests omitted from the initial sprint 4 run:
- us21: SimulationList MITRE column shows "TA0007 +2" for 1 tactic + 2 techniques
- us20: MitreMatrixModal Tab wraps to first focusable, Shift+Tab wraps to last

Suite: 158 passed, 0 failed (1 expected test.fail for AC-21.6 slug defect).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:33:18 +02:00
Knacky
a824df06b2 fix(backend): AC-21.6 — matrix tactic_id returns TA-format (TA0007 not slug)
- mitre.py: add _SLUG_TO_TA_ID reverse map; _build_matrix() now emits tactic_id
  as TA-id (e.g. "TA0007") so frontend can send it back verbatim in PATCH tactic_ids
- test_mitre.py: update all matrix assertions to use TA-ids; add
  test_get_matrix_tactic_id_is_ta_format regression guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:30:48 +02:00
Knacky
5aa839d105 test(e2e): sprint 4 acceptance tests — US-17 to US-23
Add new spec files for US-17 (UI polish), US-18 (done read-only + reopen),
US-19 (engagement auto-status), US-20 (matrix fits modal), US-21 (tactic
selection), US-22 (MITRE input redesign), US-23 (dark mode).

Adapt sprint 2/3 specs for sprint 4 UI renames: matrix icon button replaces
text buttons, inline search replaces Quick Search, Save replaces Save Red Team,
New replaces New Engagement, topbar uses bg-slab tokens, Apply N item(s) replaces
Apply N technique(s), done→review_required transition now valid (Reopen flow).

Mark AC-21.6 Apply-from-modal as test.fail: known defect where /api/mitre/matrix
returns slug tactic IDs but PATCH /simulations/:id expects TA-format IDs.

Final result: 156 passed, 0 failed (1 expected failure via test.fail).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:27:12 +02:00
Knacky
e99286ef8e fix(frontend): sprint 4 post-code-review — btn-ink uses slab token + unify New CTA label
- btn-ink hover: bg-slab-hover (unnecessary new token) → bg-paper (existing token,
  same #1f2937 value in dark, avoids token sprawl)
- tailwind.config.ts: remove slab-hover token added in fc530af
- EngagementsListPage: both CTAs unified to "+ New" (≤8 chars convention, AC-17.2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:40:06 +02:00
Knacky
988de841e5 fix(backend): sprint 4 post-review — relative paths + dead branch removal
- test_engagement_lifecycle.py, test_simulations_techniques.py: replace hardcoded
  absolute paths with Path(__file__).parent.parent / migrations/... (portable)
- simulation_workflow.py: remove dead branch in transition() — the IN_PROGRESS
  hook was unreachable since _ALLOWED_TRANSITIONS only targets review_required/done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:39:37 +02:00
Knacky
fc530af78b fix(frontend): post-code-review NITs — btn-ink uses @apply bg-slab, unify New engagement label
- btn-ink: replace inline background-color #111827 with @apply bg-slab (and add
  slab-hover token #1f2937 for the hover state) so the token system is consistent
- EngagementsListPage: header button "+ New" → "+ New engagement" to match
  empty-state CTA label

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:39:01 +02:00
Knacky
9964d058f4 fix(frontend): sprint 4 design-review — slab token + UsersAdmin alignment + dark hairlines + badge contrast
- bump dark hairline from #374151 → #4b5563 for visible table borders
- topbar header bg-canvas → bg-paper for dark-mode lift vs canvas body
- UsersAdminPage create-form: Option A structural 3-row grid (labels / inputs / hints)
  to fix AC-17.3 alignment; removes FormField wrapper that caused row-height misalignment
- EngagementsListPage: replace text "+ New" with lucide Plus icon per design spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:28:32 +02:00
Knacky
892692f3b8 fix(frontend): post-design-review — slab token split + badge contrast + modal backdrop + dark shadows
- Add fixed slab/slab-text/slab-muted tokens so utility strip and footer never
  invert to near-white in dark mode (root token split: ink is themed text,
  slab is fixed dark surface)
- btn-ink uses fixed #111827 so confirm dialogs stay dark-on-dark readable
- Toast error surface switched to slab; success uses text-white (not text-ink-on)
- StatusBadge active and SimulationStatusBadge review_required/done use text-white
  instead of text-canvas/text-ink-on (prevents near-black text on colored pill
  in dark mode)
- Modal backdrops (MitreMatrixModal, ConfirmDialog) switched to .modal-backdrop
  class (fixed rgba(0,0,0,0.6)) instead of bg-ink/60 which turned near-white
- Card shadow lifted in dark mode via .dark .card-product override
- MitreMatrixModal panel uses shadow-floating-dark in dark mode
- UsersAdminPage form: items-start + explicit label-height spacer on button
  column for pixel-perfect baseline alignment (AC-17.3 structural fix)

92/92 tests passing, typecheck and lint clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:19:16 +02:00
Knacky
f5ea9d16af feat(frontend): sprint 4 — dark mode + matrix overhaul + tactic selection + done read-only + UI polish
US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment
US-18: done status fully read-only + Reopen button (done → review_required) for all roles
US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation
US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look
US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids
US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=)
US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence

92/92 tests passing, typecheck and lint clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:06:01 +02:00
Knacky
d5ab1fd26f feat(backend): sprint 4 — tactic_ids + done guard + engagement auto-status
- Simulation model: add tactic_ids JSON column (nullable=False, default=[])
- Migration 0004: ADD COLUMN tactic_ids (server_default='[]', no batch needed)
- mitre.py: add _TACTIC_IDS map, lookup_tactic(), get_tactic_name()
- simulation_workflow.py: done guard (409) before RBAC; SOC gate += tactic_ids;
  _resolve_tactic_ids() validates against hardcoded map; auto-transition += tactic_ids;
  transition done→review_required is Reopen (all 3 roles); _maybe_activate_engagement hook
- serializers.py: _enrich_tactics() → serialize_simulation adds tactics:[{id,name}]
- test_simulations_tactics.py: valid/invalid/dedup/SOC gate/auto-transition/no-bundle
- test_simulations_done_readonly.py: 409 all roles, Reopen all roles, invalid transitions, after-reopen ok
- test_engagement_lifecycle.py: planned→active on auto-transition, already active/closed unchanged, migration 0004 round-trip
- Updated test_simulations_patch.py + test_simulations_workflow.py for AC-18 behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:52:02 +02:00
Knacky
0f6ae857b3 feat(infra): design-reviewer agent + PR helper (US-24 + US-25)
US-24 — Process hygiene UI:
- New .claude/agents/design-reviewer.md (model: opus, read-only) — visual + design-system reviewer that runs after frontend-builder and before code-reviewer. Audits alignment, DESIGN.md tokens, light/dark consistency, typo hierarchy, whitespace rhythm, responsive sanity at 1280x720, button convention, V1 a11y. Output format mirrors code-reviewer.
- Updated .claude/agents/frontend-builder.md DoD: screenshots are MANDATORY (one per feature/state introduced or modified, light+dark when theming is in scope). Hard block on "Dev server not started" — must be flagged explicitly. Screenshots feed the design-reviewer step.

US-25 — PR helper:
- scripts/open-pr.sh wraps `POST /api/v1/repos/{owner}/{repo}/pulls`. Detects host/owner/repo from `git remote get-url origin`, reads basic-auth credentials from `~/.git-credentials` (same source as `git push`, no token in env), uses jq to compose the multiline-safe payload. Validates args, prints PR URL on success, exits non-zero with the server message on failure.
- Makefile target `open-pr TITLE="..." BODY=path/to/body.md [BASE=main]` wraps the script with the same arg validation.
- README.md "Make targets" table extended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:41:34 +02:00
Knacky
89eccad1eb docs(sprint-4): plan + SPEC updates (Done terminal, engagement auto, UI/UX, workflows)
- tasks/todo.md: sprint 4 plan with 9 user stories (US-17 → US-25), 9 décisions arrêtées
- SPEC.md § Fonctionnement: Done is terminal, Reopen returns to review_required (open to all roles); engagement auto-flips planned → active when any simulation hits in_progress, no auto-rollback
- SPEC.md § Référentiel MITRE: sprint 3 multi-tech + sprint 4 tactic_ids separated field
- SPEC.md § UI/UX (new): theming light/dark/system with system default, button convention (icon + ≤8-char label), modal focus trap V1
- SPEC.md § Workflows: design-reviewer inserted between frontend-builder and code-reviewer; PR via make open-pr

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:41:16 +02:00
Knacky
ba313a3880 docs(spec): carry over sprint 3 SPEC update missed in PR #6
The sprint 3 plan §0 updated SPEC.md § Simulation to reflect multi-techniques
(plural + autocomplete + matrix modal + sub-techniques). That edit sat in the
sprint 3 worktree but was never committed, so PR #6 merged the multi-tech
code without the corresponding spec text. Applying it here at the start of
sprint 4 so SPEC and main are aligned again.

Lesson captured in tasks/lessons.md for sprint 4 wrap-up: always
git status before declaring sprint complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:14:25 +02:00
60 changed files with 3737 additions and 682 deletions

View File

@@ -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 <sprint-base-branch>...HEAD -- frontend/
git diff <sprint-base-branch>...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 <N>
### 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.

View File

@@ -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

View File

@@ -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/<sid>` 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/<sid>` 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/<sid>/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)

View File

@@ -7,7 +7,7 @@ VOLUME ?= mimic-data
# Override explicitly with `make <target> 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)"

View File

@@ -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 3Multi-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 4UI 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)
```
---

21
SPEC.md
View File

@@ -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.

View File

@@ -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)

View File

@@ -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,

View File

@@ -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`.

View File

@@ -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

View File

@@ -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")

View File

@@ -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]) == []

View File

@@ -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"

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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]

View File

@@ -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"
# ---------------------------------------------------------------------------

View File

@@ -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');

View File

@@ -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);

View File

@@ -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');

View File

@@ -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 });

View File

@@ -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);
});

View File

@@ -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);
});
});

View File

@@ -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<Simulation> {
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<void> {
await makeClient(token).delete(`/simulations/${simId}`);
}
/** Drive a simulation from pending → in_progress → review_required → done */
async function driveSimToDone(token: string, simId: number): Promise<void> {
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);
});
});

View File

@@ -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<Simulation> {
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<void> {
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);
});
});

View File

@@ -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<Simulation> {
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<void> {
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<HTMLElement>(
'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<HTMLElement>(
'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<HTMLElement>(
'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);
});
});

View File

@@ -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<Simulation> {
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<void> {
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);
});
});

View File

@@ -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<Simulation> {
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<void> {
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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});

View File

@@ -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).

View File

@@ -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();

View File

@@ -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",

View File

@@ -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"

View File

@@ -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;

View File

@@ -24,7 +24,7 @@ export function ConfirmDialog({
aria-labelledby="confirm-dialog-title"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="absolute inset-0 bg-ink/40" onClick={onCancel} aria-hidden="true" />
<div className="modal-backdrop absolute inset-0" onClick={onCancel} aria-hidden="true" />
<div className="relative card-product shadow-floating max-w-sm w-full mx-md flex flex-col gap-md">
<h2 id="confirm-dialog-title" className="text-[20px] font-medium text-ink">
{title}

View File

@@ -1,13 +1,25 @@
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom';
import { Moon, Sun, Monitor } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import type { Theme } from '@/hooks/useTheme';
function ThemeIcon({ theme }: { theme: Theme }) {
if (theme === 'light') return <Sun size={16} aria-hidden />;
if (theme === 'dark') return <Moon size={16} aria-hidden />;
return <Monitor size={16} aria-hidden />;
}
function themeLabel(theme: Theme): string {
if (theme === 'light') return 'Light';
if (theme === 'dark') return 'Dark';
return 'System';
}
/**
* Top utility strip (ink) + main nav (canvas).
* Mirrors DESIGN.md utility-strip + nav-bar-top pattern, scaled to internal app.
*/
export function Layout(): JSX.Element {
const { user, isAdmin, logout } = useAuth();
const navigate = useNavigate();
const { theme, cycleTheme } = useTheme();
const handleLogout = async () => {
await logout();
@@ -16,16 +28,25 @@ export function Layout(): JSX.Element {
return (
<div className="min-h-full flex flex-col bg-canvas">
{/* utility-strip — ink slab, fine print */}
<div className="bg-ink text-ink-on text-[14px] h-9 flex items-center">
{/* utility-strip — fixed dark slab, never inverts */}
<div className="bg-slab text-slab-text text-[14px] h-9 flex items-center">
<div className="mx-auto w-full max-w-page px-xl flex items-center justify-between">
<span className="font-medium tracking-[0.5px] uppercase">Mimic · Purple Team BAS</span>
{user ? (
<div className="flex items-center gap-md">
<span className="text-[12px] uppercase tracking-[0.5px] text-steel">
<span className="text-[12px] uppercase tracking-[0.5px] text-slab-muted">
{user.role}
</span>
<span className="text-[14px]">{user.username}</span>
<button
type="button"
onClick={cycleTheme}
aria-label={`Theme: ${themeLabel(theme)} — click to cycle`}
className="flex items-center gap-xxs text-[12px] text-slab-muted hover:text-slab-text transition-colors"
>
<ThemeIcon theme={theme} />
<span className="uppercase tracking-[0.5px]">{themeLabel(theme)}</span>
</button>
<button
type="button"
onClick={handleLogout}
@@ -38,12 +59,12 @@ export function Layout(): JSX.Element {
</div>
</div>
{/* nav-bar-top — canvas with hairline */}
<header className="bg-canvas border-b border-hairline">
{/* nav-bar-top — paper gives dark-mode lift vs canvas body */}
<header className="bg-paper border-b border-hairline">
<div className="mx-auto w-full max-w-page px-xl h-16 flex items-center justify-between">
<Link to="/engagements" className="flex items-center gap-sm" aria-label="Mimic home">
<span className="inline-block h-6 w-6 rotate-12 bg-primary" aria-hidden />
<span className="text-[20px] font-medium tracking-tight">Mimic</span>
<span className="text-[20px] font-medium tracking-tight text-ink">Mimic</span>
</Link>
<nav className="flex items-center gap-md">
@@ -80,9 +101,9 @@ export function Layout(): JSX.Element {
</div>
</main>
{/* footer — ink slab close */}
<footer className="bg-ink text-ink-on">
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-steel">
{/* footer — fixed dark slab, never inverts */}
<footer className="bg-slab text-slab-text">
<div className="mx-auto w-full max-w-page px-xl py-xl text-[12px] text-slab-muted">
Mimic Internal Purple Team tooling. Authorized engagements only.
</div>
</footer>

View File

@@ -3,24 +3,32 @@ import { LoadingState } from './LoadingState';
import { ErrorState } from './ErrorState';
import { extractApiError } from '@/api/client';
import { useMitreMatrix } from '@/hooks/useMitre';
import type { MitreTechnique } from '@/api/types';
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
export interface MatrixSelection {
techniques: MitreTechnique[];
tactics: MitreTacticRef[];
}
interface MitreMatrixModalProps {
isOpen: boolean;
initialSelection: MitreTechnique[];
onApply: (selection: MitreTechnique[]) => void;
initialTechniques: MitreTechnique[];
initialTactics: MitreTacticRef[];
onApply: (selection: MatrixSelection) => void;
onCancel: () => void;
}
function techniqueInTactic(
tacticTechniques: { id: string; subtechniques: { id: string }[] }[],
selection: Set<string>,
function countSelected(
techniques: { id: string; subtechniques: { id: string }[] }[],
techMap: Set<string>,
tacticId: string,
tacticMap: Set<string>,
): number {
let count = 0;
for (const t of tacticTechniques) {
if (selection.has(t.id)) count++;
let count = tacticMap.has(tacticId) ? 1 : 0;
for (const t of techniques) {
if (techMap.has(t.id)) count++;
for (const s of t.subtechniques) {
if (selection.has(s.id)) count++;
if (techMap.has(s.id)) count++;
}
}
return count;
@@ -28,15 +36,18 @@ function techniqueInTactic(
export function MitreMatrixModal({
isOpen,
initialSelection,
initialTechniques,
initialTactics,
onApply,
onCancel,
}: MitreMatrixModalProps): JSX.Element | null {
const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen);
// Selected IDs → Map id → {id, name} for reconstruct
const [selectedMap, setSelectedMap] = useState<Map<string, { id: string; name: string }>>(
() => new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])),
const [selectedTechMap, setSelectedTechMap] = useState<Map<string, { id: string; name: string }>>(
() => new Map(initialTechniques.map((t) => [t.id, { id: t.id, name: t.name }])),
);
const [selectedTacticSet, setSelectedTacticSet] = useState<Set<string>>(
() => new Set(initialTactics.map((t) => t.id)),
);
const [expandedTechniques, setExpandedTechniques] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
@@ -44,24 +55,21 @@ export function MitreMatrixModal({
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Reset local state when modal opens with new initialSelection
useEffect(() => {
if (isOpen) {
setSelectedMap(new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])));
setSelectedTechMap(new Map(initialTechniques.map((t) => [t.id, { id: t.id, name: t.name }])));
setSelectedTacticSet(new Set(initialTactics.map((t) => t.id)));
setExpandedTechniques(new Set());
setSearch('');
}
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// Focus search input on open
useEffect(() => {
if (isOpen) {
// Small delay lets the DOM render before focus
setTimeout(() => searchInputRef.current?.focus(), 0);
}
}, [isOpen]);
// Escape closes modal
useEffect(() => {
if (!isOpen) return;
const handler = (e: KeyboardEvent) => {
@@ -87,28 +95,26 @@ export function MitreMatrixModal({
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
};
if (!isOpen) return null;
const toggleTechnique = (id: string, name: string) => {
setSelectedMap((prev) => {
setSelectedTechMap((prev) => {
const next = new Map(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.set(id, { id, name });
}
if (next.has(id)) next.delete(id); else next.set(id, { id, name });
return next;
});
};
const toggleTactic = (tacticId: string) => {
setSelectedTacticSet((prev) => {
const next = new Set(prev);
if (next.has(tacticId)) next.delete(tacticId); else next.add(tacticId);
return next;
});
};
@@ -116,18 +122,13 @@ export function MitreMatrixModal({
const toggleExpand = (id: string) => {
setExpandedTechniques((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const searchLower = search.toLowerCase().trim();
// Figure out which technique IDs should be auto-expanded due to a sub-technique match
const autoExpanded = new Set<string>();
if (searchLower && matrix) {
for (const tactic of matrix) {
@@ -141,40 +142,41 @@ export function MitreMatrixModal({
}
const handleApply = () => {
// Reconstruct MitreTechnique[] from selected IDs.
// tactics are not available here; parent will use what it has or send []
const selection: MitreTechnique[] = Array.from(selectedMap.values()).map((t) => ({
const techniques: MitreTechnique[] = Array.from(selectedTechMap.values()).map((t) => ({
id: t.id,
name: t.name,
tactics: [],
}));
onApply(selection);
// Reconstruct tactic refs from matrix data
const tactics: MitreTacticRef[] = matrix
? matrix
.filter((t) => selectedTacticSet.has(t.tactic_id))
.map((t) => ({ id: t.tactic_id, name: t.tactic_name }))
: Array.from(selectedTacticSet).map((id) => ({ id, name: id }));
onApply({ techniques, tactics });
};
const totalSelected = selectedMap.size;
const totalTechSelected = selectedTechMap.size;
const totalTacticSelected = selectedTacticSet.size;
const totalSelected = totalTechSelected + totalTacticSelected;
const hasInitial = initialTechniques.length + initialTactics.length > 0;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-ink/60"
onClick={onCancel}
aria-hidden="true"
/>
<div className="modal-backdrop absolute inset-0" onClick={onCancel} aria-hidden="true" />
{/* Modal container */}
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="matrix-modal-title"
className="relative bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden flex flex-col"
style={{ width: '1200px' }}
className="relative bg-canvas rounded-xl shadow-floating dark:shadow-floating-dark max-w-[98vw] max-h-[80vh] overflow-hidden flex flex-col"
style={{ width: '1400px' }}
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-xl py-md border-b border-hairline flex-shrink-0">
<h2 id="matrix-modal-title" className="text-[20px] font-medium text-ink">
<h2 id="matrix-modal-title" className="text-[18px] font-medium text-ink">
MITRE ATT&amp;CK Matrix
</h2>
<input
@@ -183,25 +185,31 @@ export function MitreMatrixModal({
placeholder="Filter techniques…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="text-input w-64"
className="text-input w-56 h-9 text-[14px]"
aria-label="Filter techniques"
/>
</div>
{/* Body */}
<div className="flex-1 overflow-auto px-xl py-md">
{/* Body — overflow-y-auto, NO overflow-x */}
<div className="flex-1 overflow-y-auto overflow-x-hidden px-md py-md">
{isLoading && <LoadingState label="Loading MITRE matrix…" />}
{isError && (
<ErrorState
message={extractApiError(error, 'Could not load MITRE matrix')}
/>
<ErrorState message={extractApiError(error, 'Could not load MITRE matrix')} />
)}
{!isLoading && !isError && matrix && (
<div className="flex gap-sm" style={{ minWidth: 'max-content' }}>
<div
className="grid gap-xxs"
style={{ gridTemplateColumns: `repeat(${matrix.length}, minmax(0, 1fr))` }}
>
{matrix.map((tactic) => {
const selectedCount = techniqueInTactic(tactic.techniques, new Set(selectedMap.keys()));
const tacticSelected = selectedTacticSet.has(tactic.tactic_id);
const selectedCount = countSelected(
tactic.techniques,
new Set(selectedTechMap.keys()),
tactic.tactic_id,
selectedTacticSet,
);
// Filter techniques for this tactic
const visibleTechniques = tactic.techniques.filter((tech) => {
if (!searchLower) return true;
const techMatch =
@@ -215,35 +223,41 @@ export function MitreMatrixModal({
return techMatch || subMatch;
});
if (visibleTechniques.length === 0) return null;
return (
<div
key={tactic.tactic_id}
className="flex-shrink-0"
style={{ width: '220px' }}
<div key={tactic.tactic_id} className="flex flex-col min-w-0">
{/* Tactic header — clickable to toggle tactic selection */}
<button
type="button"
onClick={() => toggleTactic(tactic.tactic_id)}
title={`${tactic.tactic_name} (${tactic.tactic_id}) — click to tag this tactic`}
className={`w-full text-left px-xs py-xxs rounded-t-sm border border-b-0 border-hairline transition-colors ${
tacticSelected
? 'bg-primary border-primary'
: 'bg-cloud hover:bg-fog'
}`}
>
{/* Tactic header */}
<div className="bg-cloud rounded-t-md px-sm py-xs border border-hairline border-b-0">
<div className="text-[11px] uppercase tracking-[0.5px] text-graphite font-medium leading-none">
<div className={`text-[10px] uppercase tracking-[0.6px] font-semibold leading-tight truncate ${
tacticSelected ? 'text-white' : 'text-graphite'
}`}>
{tactic.tactic_name}
</div>
{selectedCount > 0 && (
<div className="text-[11px] text-primary-deep font-medium mt-xxs">
{selectedCount} selected
<div className={`text-[10px] font-medium leading-none mt-[2px] ${
tacticSelected ? 'text-white/80' : 'text-primary-deep'
}`}>
{selectedCount} sel.
</div>
)}
</div>
</button>
{/* Techniques */}
<div className="border border-hairline rounded-b-md overflow-hidden">
<div className="border border-hairline rounded-b-sm overflow-hidden flex flex-col">
{visibleTechniques.map((tech, techIdx) => {
const isSelected = selectedMap.has(tech.id);
const isSelected = selectedTechMap.has(tech.id);
const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id);
const hasSubtechniques = tech.subtechniques.length > 0;
const isLast = techIdx === visibleTechniques.length - 1;
// Filter subtechniques when searching
const visibleSubs = searchLower
? tech.subtechniques.filter(
(s) =>
@@ -254,68 +268,67 @@ export function MitreMatrixModal({
return (
<div key={tech.id} className={!isLast ? 'border-b border-hairline' : ''}>
{/* Technique row */}
<div
className={`flex items-start px-sm py-xs text-[13px] ${
isSelected ? 'bg-primary text-canvas' : 'bg-canvas text-ink hover:bg-cloud'
className={`flex items-start px-xs py-xxs text-[11px] ${
isSelected ? 'bg-primary' : 'bg-canvas hover:bg-cloud'
}`}
>
{/* Chevron — expand/collapse, does NOT toggle selection */}
{hasSubtechniques ? (
<button
type="button"
aria-label={isExpanded ? `Collapse ${tech.id}` : `Expand ${tech.id}`}
onClick={() => toggleExpand(tech.id)}
className={`mr-xxs flex-shrink-0 text-[11px] w-4 leading-none mt-[1px] ${
isSelected ? 'text-canvas' : 'text-graphite'
className={`mr-[2px] flex-shrink-0 text-[9px] w-3 leading-none mt-[1px] ${
isSelected ? 'text-white' : 'text-graphite'
}`}
>
{isExpanded ? '▾' : '▸'}
</button>
) : (
<span className="mr-xxs w-4 flex-shrink-0" />
<span className="mr-[2px] w-3 flex-shrink-0" />
)}
{/* Label — click toggles selection */}
<button
type="button"
onClick={() => toggleTechnique(tech.id, tech.name)}
className={`text-left leading-snug flex-1 min-w-0 ${
isSelected ? 'text-canvas' : 'text-ink'
title={`${tech.id}${tech.name}`}
className={`text-left leading-tight flex-1 min-w-0 ${
isSelected ? 'text-white' : 'text-ink'
}`}
>
<span className="font-medium">{tech.id}</span>
<br />
<span className={isSelected ? 'text-canvas/80' : 'text-charcoal'}>
<span className="font-semibold block truncate">{tech.id}</span>
<span className={`block truncate text-[10px] ${isSelected ? 'text-white/80' : 'text-charcoal'}`}>
{tech.name}
</span>
</button>
</div>
{/* Subtechniques — shown when expanded */}
{isExpanded &&
visibleSubs.map((sub) => {
const isSubSelected = selectedMap.has(sub.id);
const isSubSelected = selectedTechMap.has(sub.id);
return (
<button
key={sub.id}
type="button"
onClick={() => toggleTechnique(sub.id, sub.name)}
className={`w-full text-left pl-md pr-sm py-xxs text-[12px] border-t border-hairline leading-snug ${
title={`${sub.id}${sub.name}`}
className={`w-full text-left pl-[14px] pr-xs py-[2px] text-[10px] border-t border-hairline leading-tight ${
isSubSelected
? 'bg-primary-soft text-primary-deep'
: 'bg-cloud text-charcoal hover:bg-fog'
}`}
>
<span className="font-medium">{sub.id}</span>
{' — '}
{sub.name}
<span className="font-semibold block truncate">{sub.id}</span>
<span className="block truncate">{sub.name}</span>
</button>
);
})}
</div>
);
})}
{visibleTechniques.length === 0 && searchLower && (
<div className="px-xs py-xxs text-[10px] text-graphite italic">No match</div>
)}
</div>
</div>
);
@@ -333,11 +346,11 @@ export function MitreMatrixModal({
type="button"
className="btn-primary"
onClick={handleApply}
disabled={isLoading || isError || (totalSelected === 0 && initialSelection.length === 0)}
disabled={isLoading || isError || (totalSelected === 0 && !hasInitial)}
>
{totalSelected === 0
? 'Clear all'
: `Apply ${totalSelected} technique${totalSelected !== 1 ? 's' : ''}`}
: `Apply ${totalSelected} item${totalSelected !== 1 ? 's' : ''}`}
</button>
</div>
</div>

View File

@@ -1,29 +1,63 @@
import type { MitreTechnique } from '@/api/types';
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
interface MitreTechniqueTagProps {
interface TechniqueTagProps {
technique: MitreTechnique;
onRemove: () => void;
disabled?: boolean;
}
interface TacticTagProps {
tactic: MitreTacticRef;
onRemove: () => void;
disabled?: boolean;
}
// Technique chip — soft blue, id only, name in title
export function MitreTechniqueTag({
technique,
onRemove,
disabled = false,
}: MitreTechniqueTagProps): JSX.Element {
}: TechniqueTagProps): JSX.Element {
return (
<span
data-testid="mitre-technique-tag"
className="inline-flex items-center gap-xxs bg-primary-soft text-primary-deep rounded-full px-md py-xxs text-[14px]"
title={`${technique.id}${technique.name}`}
className="inline-flex items-center gap-xxs bg-primary-soft text-primary-deep rounded-full px-sm py-xxs text-[13px] font-medium"
>
<span className="font-medium">{technique.id}</span>
<span className="text-primary-deep opacity-75"> {technique.name}</span>
{technique.id}
{!disabled && (
<button
type="button"
aria-label={`Remove ${technique.id}`}
onClick={onRemove}
className="ml-xxs text-primary-deep opacity-60 hover:opacity-100 leading-none"
className="text-primary-deep opacity-60 hover:opacity-100 leading-none"
>
×
</button>
)}
</span>
);
}
// Tactic chip — primary blue filled, id only, name in title
export function MitreTacticTag({
tactic,
onRemove,
disabled = false,
}: TacticTagProps): JSX.Element {
return (
<span
data-testid="mitre-tactic-tag"
title={`${tactic.id}${tactic.name}`}
className="inline-flex items-center gap-xxs bg-primary text-white rounded-full px-sm py-xxs text-[13px] font-medium"
>
{tactic.id}
{!disabled && (
<button
type="button"
aria-label={`Remove ${tactic.id}`}
onClick={onRemove}
className="text-white opacity-60 hover:opacity-100 leading-none"
>
×
</button>

View File

@@ -1,14 +1,17 @@
import { useState } from 'react';
import { Grid2x2 } from 'lucide-react';
import { extractApiError } from '@/api/client';
import type { MitreTechnique } from '@/api/types';
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
import { useUpdateSimulation } from '@/hooks/useSimulations';
import { useToast } from '@/hooks/useToast';
import { MitreTechniqueTag } from './MitreTechniqueTag';
import { MitreTechniqueTag, MitreTacticTag } from './MitreTechniqueTag';
import { MitreTechniquePicker } from './MitreTechniquePicker';
import { MitreMatrixModal } from './MitreMatrixModal';
import type { MatrixSelection } from './MitreMatrixModal';
interface MitreTechniquesFieldProps {
value: MitreTechnique[];
tactics: MitreTacticRef[];
simulationId: number;
engagementId: number;
disabled?: boolean;
@@ -16,6 +19,7 @@ interface MitreTechniquesFieldProps {
export function MitreTechniquesField({
value,
tactics,
simulationId,
engagementId,
disabled = false,
@@ -26,10 +30,11 @@ export function MitreTechniquesField({
const { push } = useToast();
const updateMutation = useUpdateSimulation(simulationId, engagementId);
const save = async (techniques: MitreTechnique[]) => {
const save = async (techniques: MitreTechnique[], nextTactics: MitreTacticRef[]) => {
try {
await updateMutation.mutateAsync({
technique_ids: techniques.map((t) => t.id),
tactic_ids: nextTactics.map((t) => t.id),
});
push('Techniques updated', 'success');
} catch (err) {
@@ -37,96 +42,92 @@ export function MitreTechniquesField({
}
};
const handleRemove = (id: string) => {
const next = value.filter((t) => t.id !== id);
void save(next);
const handleRemoveTechnique = (id: string) => {
void save(value.filter((t) => t.id !== id), tactics);
};
const handleRemoveTactic = (id: string) => {
void save(value, tactics.filter((t) => t.id !== id));
};
const handleSelect = (technique: MitreTechnique) => {
// Dedup: no-op if already present
if (value.some((t) => t.id === technique.id)) return;
const next = [...value, technique];
void save(next);
void save([...value, technique], tactics);
setShowPicker(false);
};
const handleMatrixApply = (selection: MitreTechnique[]) => {
const handleMatrixApply = ({ techniques, tactics: newTactics }: MatrixSelection) => {
setShowMatrix(false);
// Merge: preserve existing tactics on items already in value, fill from selection otherwise.
// The backend re-enriches tactics at serialize time, so the exact tactics here don't matter.
const merged = selection.map((s) => {
const merged = techniques.map((s) => {
const existing = value.find((v) => v.id === s.id);
return existing ?? s;
});
void save(merged);
void save(merged, newTactics);
};
const isPending = updateMutation.isPending;
const isEmpty = value.length === 0 && tactics.length === 0;
return (
<div className="flex flex-col gap-sm">
{/* Tag list */}
{value.length === 0 ? (
<p className="text-[14px] text-graphite">
No techniques selected use the matrix or the quick search to add.
</p>
{/* Chips area */}
{isEmpty ? (
<p className="text-[13px] text-graphite">No techniques selected</p>
) : (
<div className="flex flex-wrap gap-sm" data-testid="techniques-tag-list">
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
{tactics.map((t) => (
<MitreTacticTag
key={t.id}
tactic={t}
onRemove={() => handleRemoveTactic(t.id)}
disabled={disabled || isPending}
/>
))}
{value.map((t) => (
<MitreTechniqueTag
key={t.id}
technique={t}
onRemove={() => handleRemove(t.id)}
onRemove={() => handleRemoveTechnique(t.id)}
disabled={disabled || isPending}
/>
))}
</div>
)}
{/* Action buttons — hidden in read-only mode */}
{/* Input row — hidden in read-only mode */}
{!disabled && (
<div className="flex items-center gap-sm">
<div className="flex items-center gap-xs">
<div className="flex-1 max-w-sm">
{showPicker ? (
<MitreTechniquePicker onSelect={handleSelect} disabled={isPending} />
) : (
<button
type="button"
className="btn-outline"
onClick={() => {
setShowPicker(false);
setShowMatrix(true);
}}
className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full"
onClick={() => setShowPicker(true)}
disabled={isPending}
>
Add technique
Search technique (e.g. T1059)
</button>
<button
type="button"
className="btn-outline-ink"
onClick={() => setShowPicker((v) => !v)}
disabled={isPending}
>
Quick search
</button>
{isPending && (
<span className="text-[13px] text-graphite">Saving</span>
)}
</div>
<button
type="button"
aria-label="Open MITRE matrix"
onClick={() => { setShowPicker(false); setShowMatrix(true); }}
disabled={isPending}
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-md border border-steel text-graphite hover:text-ink hover:border-ink transition-colors"
>
<Grid2x2 size={16} />
</button>
{isPending && <span className="text-[12px] text-graphite">Saving</span>}
</div>
)}
{/* Inline Quick Search picker */}
{showPicker && !disabled && (
<div className="max-w-md">
<MitreTechniquePicker
onSelect={(technique) => {
handleSelect(technique);
setShowPicker(false);
}}
disabled={isPending}
/>
</div>
)}
{/* Matrix modal */}
<MitreMatrixModal
isOpen={showMatrix}
initialSelection={value}
initialTechniques={value}
initialTactics={tactics}
onApply={handleMatrixApply}
onCancel={() => setShowMatrix(false)}
/>

View File

@@ -96,11 +96,15 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
</Link>
</td>
<td className="px-xl py-md text-charcoal text-[14px]">
{sim.techniques.length === 0
? '—'
: sim.techniques.length === 1
? sim.techniques[0].id
: `${sim.techniques[0].id} +${sim.techniques.length - 1}`}
{(() => {
const items = [
...(sim.tactics ?? []).map((t) => t.id),
...sim.techniques.map((t) => t.id),
];
if (items.length === 0) return '—';
if (items.length === 1) return items[0];
return `${items[0]} +${items.length - 1}`;
})()}
</td>
<td className="px-xl py-md">
<SimulationStatusBadge status={sim.status} />

View File

@@ -7,12 +7,13 @@ const LABELS: Record<SimulationStatus, string> = {
done: 'Done',
};
// pending=fog, in_progress=primary-soft, review_required=bloom-coral, done=storm-deep
// Fixed colors — badge backgrounds are decorative/semantic, not themeable.
// text-white is hardcoded (not text-canvas) so dark mode doesn't invert it to near-black.
const STYLES: Record<SimulationStatus, string> = {
pending: 'bg-fog text-charcoal border border-hairline',
in_progress: 'bg-primary-soft text-primary-deep',
review_required: 'bg-bloom-coral text-canvas',
done: 'bg-storm-deep text-canvas',
review_required: 'bg-bloom-coral text-white',
done: 'bg-storm-deep text-white',
};
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {

View File

@@ -10,7 +10,7 @@ const STYLES: Record<EngagementStatus, string> = {
// Outlined ink for planned (neutral), filled primary for active (engagement live),
// outlined steel for closed (muted). Stays within DESIGN.md palette.
planned: 'bg-canvas text-ink border border-ink',
active: 'bg-primary text-ink-on',
active: 'bg-primary text-white',
closed: 'bg-cloud text-graphite border border-hairline',
};

View File

@@ -15,10 +15,11 @@ export function ToastViewport(): JSX.Element {
{toasts.map((t) => {
const isError = t.kind === 'error';
const isSuccess = t.kind === 'success';
// Fixed colors: toasts don't theme (error=dark slab, success=primary blue)
const surface = isError
? 'bg-ink text-ink-on'
? 'bg-slab text-slab-text'
: isSuccess
? 'bg-primary text-ink-on'
? 'bg-primary text-white'
: 'bg-canvas text-ink border border-hairline';
return (
<div

View File

@@ -48,6 +48,8 @@ export function useUpdateSimulation(id: number, engagementId: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
qc.invalidateQueries({ queryKey: ['engagements'] });
},
});
}
@@ -71,6 +73,8 @@ export function useTransitionSimulation(id: number, engagementId: number) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
qc.invalidateQueries({ queryKey: ['engagements', engagementId] });
qc.invalidateQueries({ queryKey: ['engagements'] });
},
});
}

View File

@@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from 'react';
export type Theme = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'mimic-theme';
function resolveTheme(theme: Theme): 'light' | 'dark' {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return theme;
}
function applyTheme(theme: Theme) {
const resolved = resolveTheme(theme);
document.documentElement.classList.toggle('dark', resolved === 'dark');
}
function readStoredTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
} catch {
// localStorage unavailable
}
return 'system';
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(readStoredTheme);
useEffect(() => {
applyTheme(theme);
}, [theme]);
// Track system preference changes when theme === 'system'
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme('system');
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
const setTheme = useCallback((next: Theme) => {
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// ignore
}
setThemeState(next);
}, []);
const cycleTheme = useCallback(() => {
setTheme(theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light');
}, [theme, setTheme]);
return { theme, setTheme, cycleTheme };
}

View File

@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { Plus } from 'lucide-react';
import { extractApiError } from '@/api/client';
import type { Engagement } from '@/api/types';
import { useDeleteEngagement, useEngagementsList } from '@/hooks/useEngagements';
@@ -24,9 +25,9 @@ export function EngagementsListPage(): JSX.Element {
if (!window.confirm(`Delete engagement "${eng.name}"? This cannot be undone.`)) return;
try {
await deleteMutation.mutateAsync(eng.id);
push('Engagement supprimé', 'success');
push('Engagement deleted', 'success');
} catch (err) {
push(extractApiError(err, 'Suppression impossible'), 'error');
push(extractApiError(err, 'Could not delete engagement'), 'error');
}
};
@@ -41,7 +42,7 @@ export function EngagementsListPage(): JSX.Element {
</div>
{canEditEngagements ? (
<Link to="/engagements/new" className="btn-primary">
New engagement
<Plus size={14} aria-hidden /> New
</Link>
) : null}
</header>
@@ -59,7 +60,7 @@ export function EngagementsListPage(): JSX.Element {
action={
canEditEngagements ? (
<Link to="/engagements/new" className="btn-primary">
Create engagement
<Plus size={14} aria-hidden /> New
</Link>
) : undefined
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Save, RotateCcw } from 'lucide-react';
import { extractApiError } from '@/api/client';
import type { SimulationPatchInput } from '@/api/types';
import { useAuth } from '@/hooks/useAuth';
@@ -105,30 +106,28 @@ export function SimulationFormPage(): JSX.Element {
const simulation = detail.data;
const status = simulation?.status;
// Role-based field locking
// US-18: Done = fully read-only, Reopen only
const isDone = status === 'done';
const canEditRT = isAdmin || isRedteam;
// SOC can only edit when status is review_required or done
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
const canSaveSoc = socCanEdit || canEditEngagements;
const rtDisabled = !canEditRT;
const socDisabled = !canEditEngagements && !socCanEdit;
const canSaveSoc = !isDone && (socCanEdit || canEditEngagements);
const rtDisabled = !canEditRT || isDone;
const socDisabled = isDone || (!canEditEngagements && !socCanEdit);
// Transition buttons visibility
const showMarkReview =
canEditEngagements && (status === 'pending' || status === 'in_progress');
!isDone && canEditEngagements && (status === 'pending' || status === 'in_progress');
const showClose =
(canEditEngagements || isSoc) && status === 'review_required';
!isDone && (canEditEngagements || isSoc) && status === 'review_required';
const showReopen = isDone && (isAdmin || isRedteam || isSoc);
const onSubmitNew = async (e: FormEvent) => {
e.preventDefault();
setNameError(null);
setSubmitError(null);
if (!rt.name.trim()) {
setNameError('Name is required');
return;
}
if (!rt.name.trim()) { setNameError('Name is required'); return; }
try {
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
push('Simulation created', 'success');
@@ -142,10 +141,7 @@ export function SimulationFormPage(): JSX.Element {
e.preventDefault();
setNameError(null);
setSubmitError(null);
if (!rt.name.trim()) {
setNameError('Name is required');
return;
}
if (!rt.name.trim()) { setNameError('Name is required'); return; }
const patch: SimulationPatchInput = {
name: rt.name.trim(),
description: rt.description.trim() || null,
@@ -197,6 +193,15 @@ export function SimulationFormPage(): JSX.Element {
}
};
const onReopen = async () => {
try {
await transitionMutation.mutateAsync('review_required');
push('Simulation reopened', 'success');
} catch (err) {
push(extractApiError(err, 'Transition failed'), 'error');
}
};
const onDelete = async () => {
setShowDeleteConfirm(false);
try {
@@ -208,7 +213,7 @@ export function SimulationFormPage(): JSX.Element {
}
};
// New simulation form (minimal)
// New simulation form
if (isNew) {
const submitting = createMutation.isPending;
return (
@@ -232,9 +237,7 @@ export function SimulationFormPage(): JSX.Element {
</FormField>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</div>
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
) : null}
<div className="flex items-center gap-md pt-sm">
@@ -250,7 +253,6 @@ export function SimulationFormPage(): JSX.Element {
);
}
// Edit form
const submitting =
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
@@ -275,7 +277,17 @@ export function SimulationFormPage(): JSX.Element {
</div>
</header>
{/* SOC banner — shown when soc user visits pending/in_progress */}
{/* Done banner */}
{isDone && (
<div
role="status"
className="rounded-xl px-xl py-md bg-cloud border border-hairline text-[14px] text-charcoal"
>
This simulation is <strong>done</strong> and read-only. Use Reopen to make changes.
</div>
)}
{/* SOC banner */}
{socBlocked && (
<div
role="alert"
@@ -289,7 +301,7 @@ export function SimulationFormPage(): JSX.Element {
{/* Red Team card */}
<form
id="rt-form"
onSubmit={canEditRT ? onSaveRT : (e) => e.preventDefault()}
onSubmit={canEditRT && !isDone ? onSaveRT : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
@@ -307,9 +319,10 @@ export function SimulationFormPage(): JSX.Element {
</FormField>
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">MITRE Techniques</span>
<span className="text-[14px] font-medium text-ink">MITRE Techniques &amp; Tactics</span>
<MitreTechniquesField
value={simulation?.techniques ?? []}
tactics={simulation?.tactics ?? []}
simulationId={simulationId as number}
engagementId={engagementId as number}
disabled={rtDisabled}
@@ -326,11 +339,7 @@ export function SimulationFormPage(): JSX.Element {
/>
</FormField>
<FormField
label="Commands"
htmlFor="sim-commands"
hint="One command per line"
>
<FormField label="Commands" htmlFor="sim-commands" hint="One command per line">
<TextArea
id="sim-commands"
name="commands"
@@ -422,24 +431,38 @@ export function SimulationFormPage(): JSX.Element {
disabled={socDisabled}
/>
</FormField>
</form>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</div>
<div role="alert" className="text-[14px] text-bloom-deep">{submitError}</div>
) : null}
{/* Unified sticky action bar */}
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
{canEditRT && (
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
{/* Done state: Reopen only */}
{showReopen && (
<button
type="button"
className="btn-outline"
onClick={onReopen}
disabled={transitionMutation.isPending}
data-testid="reopen-btn"
>
<RotateCcw size={14} aria-hidden />
Reopen
</button>
)}
{canSaveSoc && (
{/* Normal state buttons */}
{!isDone && canEditRT && (
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
<Save size={14} aria-hidden />
{updateMutation.isPending ? 'Saving…' : 'Save'}
</button>
)}
{!isDone && canSaveSoc && (
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
<Save size={14} aria-hidden />
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
</button>
)}
@@ -463,7 +486,7 @@ export function SimulationFormPage(): JSX.Element {
Close
</button>
)}
{canEditEngagements && simulationId && (
{!isDone && canEditEngagements && simulationId && (
<button
type="button"
className="btn-text-link text-bloom-deep ml-auto"

View File

@@ -110,16 +110,34 @@ export function UsersAdminPage(): JSX.Element {
<section className="card-product flex flex-col gap-md">
<h2 className="text-[20px] font-medium">Create account</h2>
<form onSubmit={onCreate} className="grid grid-cols-1 md:grid-cols-4 gap-md items-start">
<FormField label="Username" htmlFor="new-username" required>
{/*
Option A structural fix (AC-17.3): labels / inputs / hints in 3 explicit grid rows
so the browser can never misalign them by collapsing different-height cells.
grid-rows-[auto_auto_auto] ensures row 1 = labels, row 2 = inputs, row 3 = hints.
*/}
<form
onSubmit={onCreate}
className="grid grid-cols-1 md:grid-cols-4 md:grid-rows-[auto_auto_auto] gap-x-md gap-y-xs"
>
{/* Row 1 — labels */}
<label htmlFor="new-username" className="text-[14px] font-medium text-ink">
Username <span className="text-bloom-deep">*</span>
</label>
<label htmlFor="new-password" className="text-[14px] font-medium text-ink">
Password <span className="text-bloom-deep">*</span>
</label>
<label htmlFor="new-role" className="text-[14px] font-medium text-ink">
Role <span className="text-bloom-deep">*</span>
</label>
<div aria-hidden="true" />
{/* Row 2 — inputs + button (all same height = h-11) */}
<TextInput
id="new-username"
value={createForm.username}
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
required
/>
</FormField>
<FormField label="Password" htmlFor="new-password" required hint="≥ 8 characters">
<TextInput
id="new-password"
type="password"
@@ -128,20 +146,21 @@ export function UsersAdminPage(): JSX.Element {
required
minLength={8}
/>
</FormField>
<FormField label="Role" htmlFor="new-role" required>
<Select
id="new-role"
value={createForm.role}
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value as Role })}
options={ROLE_OPTIONS}
/>
</FormField>
<div className="self-end">
<button type="submit" className="btn-primary w-full" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating…' : 'Create'}
</button>
</div>
{/* Row 3 — hints */}
<div aria-hidden="true" />
<span className="text-[12px] text-graphite"> 8 characters</span>
<div aria-hidden="true" />
<div aria-hidden="true" />
</form>
{createError ? (
<div role="alert" className="text-[14px] text-bloom-deep">

View File

@@ -5,6 +5,41 @@
@tailwind utilities;
@layer base {
/* Light mode — default */
:root {
--color-canvas: #ffffff;
--color-paper: #ffffff;
--color-cloud: #f7f7f7;
--color-fog: #e8e8e8;
--color-steel: #c2c2c2;
--color-hairline: #e8e8e8;
--color-ink: #1a1a1a;
--color-ink-soft: #292929;
--color-ink-deep: #000000;
--color-ink-on: #ffffff;
--color-charcoal: #3d3d3d;
--color-graphite: #636363;
/* DESIGN.md: body line-height 1.4 when substituting Inter */
font-size: 16.5px;
}
/* Dark mode overrides */
.dark {
--color-canvas: #111827;
--color-paper: #1f2937;
--color-cloud: #1f2937;
--color-fog: #374151;
--color-steel: #4b5563;
--color-hairline: #4b5563;
--color-ink: #f9fafb;
--color-ink-soft: #e5e7eb;
--color-ink-deep: #ffffff;
--color-ink-on: #111827;
--color-charcoal: #d1d5db;
--color-graphite: #9ca3af;
}
html,
body,
#root {
@@ -13,15 +48,9 @@
body {
@apply bg-canvas text-ink font-sans antialiased;
/* DESIGN.md: body line-height 1.4 when substituting Inter */
font-feature-settings: 'cv11', 'ss01';
}
/* Compensate for Inter being slightly narrower than Forma DJR Micro (~3%) */
:root {
font-size: 16.5px;
}
h1,
h2,
h3,
@@ -38,8 +67,9 @@
* DESIGN.md component recipes.
* Buttons stay sharp (rounded-md = 4px); cards stay soft (rounded-xl = 16px).
*/
.btn-primary {
@apply inline-flex items-center justify-center bg-primary text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
@apply inline-flex items-center justify-center gap-xs bg-primary text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
}
.btn-primary:hover {
@apply bg-primary-deep;
@@ -48,32 +78,36 @@
@apply bg-steel cursor-not-allowed;
}
/* btn-ink uses fixed dark slab so it doesn't invert in dark mode */
.btn-ink {
@apply inline-flex items-center justify-center bg-ink text-ink-on uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
@apply inline-flex items-center justify-center gap-xs bg-slab text-white uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
}
.btn-ink:hover {
@apply bg-ink-soft;
@apply bg-paper;
}
.btn-ink:disabled {
@apply bg-steel cursor-not-allowed;
}
.btn-outline {
@apply inline-flex items-center justify-center bg-canvas text-primary border border-primary uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
@apply inline-flex items-center justify-center gap-xs bg-canvas text-primary border border-primary uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
}
.btn-outline:hover {
@apply bg-primary-soft;
}
.btn-outline:disabled {
@apply border-steel text-steel cursor-not-allowed;
}
.btn-outline-ink {
@apply inline-flex items-center justify-center bg-canvas text-ink border border-ink uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
@apply inline-flex items-center justify-center gap-xs bg-canvas text-ink border border-ink uppercase tracking-[0.7px] font-semibold text-[14px] leading-[1.4] rounded-md px-xl py-sm h-11 transition-colors;
}
.btn-outline-ink:hover {
@apply bg-cloud;
}
.btn-text-link {
@apply inline-flex items-center text-primary font-medium text-[16px] leading-[1.38] underline-offset-2 hover:underline;
@apply inline-flex items-center gap-xxs text-primary font-medium text-[16px] leading-[1.38] underline-offset-2 hover:underline;
}
.text-input {
@@ -83,9 +117,17 @@
.card-product {
@apply bg-canvas rounded-xl p-xl shadow-soft-lift;
}
.dark .card-product {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.32);
}
/* Fixed-color modal backdrop — must not use themed ink (inverts in dark mode) */
.modal-backdrop {
background-color: rgba(0, 0, 0, 0.6);
}
.badge-pill-ink {
@apply inline-flex items-center bg-ink text-ink-on rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
@apply inline-flex items-center bg-ink text-white rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium;
}
.badge-pill-outline {

View File

@@ -3,36 +3,42 @@ import type { Config } from 'tailwindcss';
/**
* Tokens mirror DESIGN.md.
* Forma DJR Micro substitut: Inter (bundled locally via @fontsource-variable/inter).
* Dark mode: class-based, toggled by adding 'dark' to <html>.
*/
const config: Config = {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
// Brand & Accent
// Brand & Accent — primary stays fixed (HP Electric Blue never inverts)
primary: {
DEFAULT: '#024ad8',
bright: '#296ef9',
deep: '#0e3191',
soft: '#c9e0fc',
},
// Surface
canvas: '#ffffff',
paper: '#ffffff',
cloud: '#f7f7f7',
fog: '#e8e8e8',
steel: '#c2c2c2',
hairline: '#e8e8e8',
// Text
// Surface — backed by CSS vars so dark mode works via .dark class
canvas: 'var(--color-canvas)',
paper: 'var(--color-paper)',
cloud: 'var(--color-cloud)',
fog: 'var(--color-fog)',
steel: 'var(--color-steel)',
hairline: 'var(--color-hairline)',
// Text — also CSS vars
ink: {
DEFAULT: '#1a1a1a',
deep: '#000000',
soft: '#292929',
on: '#ffffff',
DEFAULT: 'var(--color-ink)',
deep: 'var(--color-ink-deep)',
soft: 'var(--color-ink-soft)',
on: 'var(--color-ink-on)',
},
charcoal: '#3d3d3d',
graphite: '#636363',
// Semantic / decorative
charcoal: 'var(--color-charcoal)',
graphite: 'var(--color-graphite)',
// Fixed dark slab — never inverts in dark mode (utility strip, footer, dark bands)
slab: '#111827',
'slab-text': '#f9fafb',
'slab-muted': '#6b7280',
// Semantic / decorative — fixed (not themeable)
bloom: {
coral: '#ff5050',
rose: '#f9d4d2',
@@ -89,6 +95,8 @@ const config: Config = {
boxShadow: {
'soft-lift': '0 2px 8px rgba(26, 26, 26, 0.08)',
floating: '0 8px 24px rgba(26, 26, 26, 0.12)',
'soft-lift-dark': '0 2px 8px rgba(0, 0, 0, 0.32)',
'floating-dark': '0 8px 24px rgba(0, 0, 0, 0.48)',
},
maxWidth: {
page: '1366px',

View File

@@ -44,6 +44,8 @@ const SELECTION: MitreTechnique[] = [
{ id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] },
];
const NO_TACTICS: never[] = [];
describe('MitreMatrixModal', () => {
let mock: MockAdapter;
@@ -58,14 +60,26 @@ describe('MitreMatrixModal', () => {
it('returns null when isOpen=false', () => {
const { container } = renderWithProviders(
<MitreMatrixModal isOpen={false} initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen={false}
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
it('renders dialog with tactic columns when open', async () => {
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('Initial Access')).toBeInTheDocument();
@@ -75,7 +89,13 @@ describe('MitreMatrixModal', () => {
it('renders techniques for each tactic', async () => {
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('T1078')).toBeInTheDocument();
@@ -88,12 +108,17 @@ describe('MitreMatrixModal', () => {
const user = userEvent.setup();
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={onApply}
onCancel={vi.fn()}
/>,
);
await waitFor(() => screen.getByText('T1078'));
// Click the label button for T1078 to select it
const t1078Btn = screen.getAllByRole('button').find(
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
);
@@ -102,7 +127,9 @@ describe('MitreMatrixModal', () => {
await user.click(screen.getByRole('button', { name: /Apply/i }));
expect(onApply).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'T1078' })]),
expect.objectContaining({
techniques: expect.arrayContaining([expect.objectContaining({ id: 'T1078' })]),
}),
);
});
@@ -112,7 +139,13 @@ describe('MitreMatrixModal', () => {
const user = userEvent.setup();
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={onCancel} />,
<MitreMatrixModal
isOpen
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={onApply}
onCancel={onCancel}
/>,
);
await user.click(screen.getByRole('button', { name: /Cancel/i }));
@@ -126,7 +159,13 @@ describe('MitreMatrixModal', () => {
const user = userEvent.setup();
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
<MitreMatrixModal
isOpen
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={onCancel}
/>,
);
await user.keyboard('{Escape}');
@@ -134,21 +173,31 @@ describe('MitreMatrixModal', () => {
expect(onCancel).toHaveBeenCalled();
});
it('shows initial selection as selected', async () => {
it('shows initial technique selection as selected (count in header)', async () => {
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen
initialTechniques={SELECTION}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={vi.fn()}
/>,
);
await waitFor(() => screen.getByText('T1078'));
// T1078 should show selected count in tactic header
expect(screen.getByText('1 selected')).toBeInTheDocument();
expect(screen.getByText(/1 sel\./i)).toBeInTheDocument();
});
it('search filter narrows visible techniques', async () => {
const user = userEvent.setup();
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={vi.fn()}
/>,
);
await waitFor(() => screen.getByText('T1078'));
@@ -156,7 +205,6 @@ describe('MitreMatrixModal', () => {
const searchInput = screen.getByPlaceholderText(/Filter techniques/i);
await user.type(searchInput, 'T1059');
// T1059 column should be visible, T1078 should not
expect(screen.queryByText('T1078')).toBeNull();
expect(screen.getByText('T1059')).toBeInTheDocument();
});
@@ -164,64 +212,84 @@ describe('MitreMatrixModal', () => {
it('chevron expands subtechniques', async () => {
const user = userEvent.setup();
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={vi.fn()}
/>,
);
await waitFor(() => screen.getByText('T1078'));
// Subtechniques should not be visible initially
expect(screen.queryByText(/Default Accounts/)).toBeNull();
// Click the expand chevron for T1078
const expandBtn = screen.getByRole('button', { name: /Expand T1078/i });
await user.click(expandBtn);
expect(screen.getByText(/Default Accounts/)).toBeInTheDocument();
});
it('Apply button shows technique count', async () => {
it('Apply button shows item count when techniques selected', async () => {
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen
initialTechniques={SELECTION}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={vi.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByRole('button', { name: /Apply 1 technique/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Apply 1 item/i })).toBeInTheDocument();
});
});
it('Apply button is disabled when no techniques selected and no initial selection', async () => {
it('Apply button is disabled when nothing selected and no initial selection', async () => {
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={vi.fn()}
/>,
);
await waitFor(() => screen.getByText('T1078'));
// Label is "Clear all" when totalSelected === 0, but it's disabled when initialSelection is also empty
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
expect(applyBtn).toBeDisabled();
});
it('Apply button shows "Clear all" and stays enabled when initial selection is deselected', async () => {
it('Apply button shows "Clear all" and is enabled when initial selection is deselected', async () => {
const onApply = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={onApply} onCancel={vi.fn()} />,
<MitreMatrixModal
isOpen
initialTechniques={SELECTION}
initialTactics={NO_TACTICS}
onApply={onApply}
onCancel={vi.fn()}
/>,
);
await waitFor(() => screen.getByText('T1078'));
// Deselect T1078 (it was pre-selected)
const t1078Btn = screen.getAllByRole('button').find(
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
);
await user.click(t1078Btn!);
// Button should show "Clear all" and be enabled (user explicitly clearing the list)
const applyBtn = screen.getByRole('button', { name: /Clear all/i });
expect(applyBtn).not.toBeDisabled();
await user.click(applyBtn);
expect(onApply).toHaveBeenCalledWith([]);
expect(onApply).toHaveBeenCalledWith(
expect.objectContaining({ techniques: [], tactics: [] }),
);
});
it('backdrop click calls onCancel', async () => {
@@ -229,11 +297,16 @@ describe('MitreMatrixModal', () => {
const user = userEvent.setup();
renderWithProviders(
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
<MitreMatrixModal
isOpen
initialTechniques={[]}
initialTactics={NO_TACTICS}
onApply={vi.fn()}
onCancel={onCancel}
/>,
);
// Click the backdrop (the fixed inset div behind the modal)
const backdrop = document.querySelector('.bg-ink\\/60') as HTMLElement;
const backdrop = document.querySelector('.modal-backdrop') as HTMLElement;
if (backdrop) await user.click(backdrop);
expect(onCancel).toHaveBeenCalled();

View File

@@ -1,18 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MitreTechniqueTag } from '@/components/MitreTechniqueTag';
import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag';
import { renderWithProviders } from './utils';
import type { MitreTacticRef } from '@/api/types';
const TECHNIQUE = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
const TACTIC: MitreTacticRef = { id: 'TA0007', name: 'Discovery' };
describe('MitreTechniqueTag', () => {
it('renders id and name', () => {
it('renders id and name in title attribute (AC-22.2)', () => {
renderWithProviders(
<MitreTechniqueTag technique={TECHNIQUE} onRemove={vi.fn()} />,
);
expect(screen.getByText('T1059')).toBeInTheDocument();
expect(screen.getByText(/Command and Scripting Interpreter/)).toBeInTheDocument();
// Name is in title= only, not as visible text
expect(screen.getByTitle(/Command and Scripting Interpreter/)).toBeInTheDocument();
expect(screen.queryByText(/Command and Scripting Interpreter/)).toBeNull();
});
it('shows remove button when not disabled', () => {
@@ -39,3 +43,37 @@ describe('MitreTechniqueTag', () => {
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull();
});
});
describe('MitreTacticTag', () => {
it('renders tactic id with title containing name', () => {
renderWithProviders(
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} />,
);
expect(screen.getByText('TA0007')).toBeInTheDocument();
expect(screen.getByTitle(/Discovery/)).toBeInTheDocument();
});
it('shows remove button when not disabled', () => {
renderWithProviders(
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} />,
);
expect(screen.getByRole('button', { name: /Remove TA0007/i })).toBeInTheDocument();
});
it('clicking × calls onRemove', async () => {
const onRemove = vi.fn();
const user = userEvent.setup();
renderWithProviders(
<MitreTacticTag tactic={TACTIC} onRemove={onRemove} />,
);
await user.click(screen.getByRole('button', { name: /Remove TA0007/i }));
expect(onRemove).toHaveBeenCalledOnce();
});
it('hides remove button when disabled', () => {
renderWithProviders(
<MitreTacticTag tactic={TACTIC} onRemove={vi.fn()} disabled />,
);
expect(screen.queryByRole('button', { name: /Remove/i })).toBeNull();
});
});

View File

@@ -5,10 +5,11 @@ import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
import { renderWithProviders } from './utils';
import type { MitreTechnique } from '@/api/types';
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
const T1059: MitreTechnique = { id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] };
const T1078: MitreTechnique = { id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] };
const TA0007: MitreTacticRef = { id: 'TA0007', name: 'Discovery' };
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
@@ -23,6 +24,15 @@ vi.mock('@/hooks/useAuth', () => ({
}),
}));
const SIM_RESPONSE = {
id: 7, engagement_id: 42, name: 'test', techniques: [], tactics: [],
description: null, commands: null, prerequisites: null,
executed_at: null, execution_result: null, log_source: null,
logs: null, soc_comment: null, incident_number: null,
status: 'pending', created_at: '2026-01-01', updated_at: null,
created_by: { id: 1, username: 'alice' },
};
describe('MitreTechniquesField', () => {
let mock: MockAdapter;
@@ -34,61 +44,54 @@ describe('MitreTechniquesField', () => {
mock.restore();
});
it('shows empty state message when no techniques', () => {
it('shows empty state message when no techniques or tactics', () => {
renderWithProviders(
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
);
expect(screen.getByText(/No techniques selected/i)).toBeInTheDocument();
});
it('renders tags for each technique', () => {
it('renders technique tags for each technique', () => {
renderWithProviders(
<MitreTechniquesField value={[T1059, T1078]} simulationId={7} engagementId={42} />,
<MitreTechniquesField value={[T1059, T1078]} tactics={[]} simulationId={7} engagementId={42} />,
);
expect(screen.getAllByTestId('mitre-technique-tag')).toHaveLength(2);
expect(screen.getByText('T1059')).toBeInTheDocument();
expect(screen.getByText('T1078')).toBeInTheDocument();
expect(screen.getByTitle(/T1059/)).toBeInTheDocument();
expect(screen.getByTitle(/T1078/)).toBeInTheDocument();
});
it('shows Add technique and Quick search buttons when not disabled', () => {
it('renders tactic chips alongside technique chips', () => {
renderWithProviders(
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
<MitreTechniquesField value={[T1059]} tactics={[TA0007]} simulationId={7} engagementId={42} />,
);
expect(screen.getByRole('button', { name: /Add technique/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Quick search/i })).toBeInTheDocument();
expect(screen.getAllByTestId('mitre-tactic-tag')).toHaveLength(1);
expect(screen.getByTitle(/TA0007/)).toBeInTheDocument();
});
it('hides action buttons when disabled', () => {
it('shows search input and matrix icon when not disabled', () => {
renderWithProviders(
<MitreTechniquesField value={[T1059]} simulationId={7} engagementId={42} disabled />,
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
);
expect(screen.queryByRole('button', { name: /Add technique/i })).toBeNull();
expect(screen.queryByRole('button', { name: /Quick search/i })).toBeNull();
expect(screen.getByRole('button', { name: /Open MITRE matrix/i })).toBeInTheDocument();
// The search placeholder button
expect(screen.getByRole('button', { name: /Search technique/i })).toBeInTheDocument();
});
it('× button on tag calls PATCH with technique removed', async () => {
mock.onPatch('/simulations/7').reply(200, {
id: 7, engagement_id: 42, name: 'test', techniques: [],
description: null, commands: null, prerequisites: null,
executed_at: null, execution_result: null, log_source: null,
logs: null, soc_comment: null, incident_number: null,
status: 'pending', created_at: '2026-01-01', updated_at: null,
created_by: { id: 1, username: 'alice' },
it('hides input row when disabled', () => {
renderWithProviders(
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} disabled />,
);
expect(screen.queryByRole('button', { name: /Open MITRE matrix/i })).toBeNull();
});
// also mock GET simulations list for invalidation
it('× button on technique tag calls PATCH with technique removed', async () => {
mock.onPatch('/simulations/7').reply(200, SIM_RESPONSE);
mock.onGet('/engagements/42/simulations').reply(200, []);
mock.onGet('/simulations/7').reply(200, {
id: 7, engagement_id: 42, name: 'test', techniques: [],
description: null, commands: null, prerequisites: null,
executed_at: null, execution_result: null, log_source: null,
logs: null, soc_comment: null, incident_number: null,
status: 'pending', created_at: '2026-01-01', updated_at: null,
created_by: { id: 1, username: 'alice' },
});
mock.onGet('/simulations/7').reply(200, SIM_RESPONSE);
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[T1059, T1078]} simulationId={7} engagementId={42} />,
<MitreTechniquesField value={[T1059, T1078]} tactics={[]} simulationId={7} engagementId={42} />,
);
const removeBtn = screen.getByRole('button', { name: /Remove T1059/i });
@@ -98,15 +101,37 @@ describe('MitreTechniquesField', () => {
expect(mock.history.patch.length).toBe(1);
const body = JSON.parse(mock.history.patch[0].data as string);
expect(body.technique_ids).toEqual(['T1078']);
expect(body.tactic_ids).toEqual([]);
});
});
it('Quick search toggle shows picker input', async () => {
it('× button on tactic tag calls PATCH with tactic removed', async () => {
mock.onPatch('/simulations/7').reply(200, SIM_RESPONSE);
mock.onGet('/engagements/42/simulations').reply(200, []);
mock.onGet('/simulations/7').reply(200, SIM_RESPONSE);
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
<MitreTechniquesField value={[T1059]} tactics={[TA0007]} simulationId={7} engagementId={42} />,
);
await user.click(screen.getByRole('button', { name: /Quick search/i }));
const removeBtn = screen.getByRole('button', { name: /Remove TA0007/i });
await user.click(removeBtn);
await waitFor(() => {
expect(mock.history.patch.length).toBe(1);
const body = JSON.parse(mock.history.patch[0].data as string);
expect(body.tactic_ids).toEqual([]);
expect(body.technique_ids).toEqual(['T1059']);
});
});
it('clicking search placeholder shows combobox input', async () => {
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
);
await user.click(screen.getByRole('button', { name: /Search technique/i }));
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
@@ -114,35 +139,29 @@ describe('MitreTechniquesField', () => {
mock.onGet('/mitre/techniques').reply(200, [T1059]);
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[T1059]} simulationId={7} engagementId={42} />,
<MitreTechniquesField value={[T1059]} tactics={[]} simulationId={7} engagementId={42} />,
);
// Open the quick-search picker
await user.click(screen.getByRole('button', { name: /Quick search/i }));
await user.click(screen.getByRole('button', { name: /Search technique/i }));
const combobox = screen.getByRole('combobox');
expect(combobox).toBeInTheDocument();
// Type to trigger the search (debounce is 200ms but fake timers not needed — mock responds immediately)
await user.type(combobox, 'T1059');
// Wait for the option to appear in the listbox
const option = await screen.findByRole('option', { name: /T1059/i });
expect(option).toBeInTheDocument();
// Select it via pointerDown (mirrors the component's onPointerDown handler)
await user.pointer({ target: option, keys: '[MouseLeft>]' });
// Dedup guard should have fired — no PATCH should have been sent
expect(mock.history.patch.length).toBe(0);
});
it('opens matrix modal when Add technique is clicked', async () => {
it('opens matrix modal when matrix icon is clicked', async () => {
mock.onGet('/mitre/matrix').reply(200, []);
const user = userEvent.setup();
renderWithProviders(
<MitreTechniquesField value={[]} simulationId={7} engagementId={42} />,
<MitreTechniquesField value={[]} tactics={[]} simulationId={7} engagementId={42} />,
);
await user.click(screen.getByRole('button', { name: /Add technique/i }));
await user.click(screen.getByRole('button', { name: /Open MITRE matrix/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});

View File

@@ -12,6 +12,7 @@ const BASE_SIM: Simulation = {
engagement_id: 42,
name: 'Recon test',
techniques: [],
tactics: [],
description: 'Some description',
commands: 'whoami\nipconfig',
prerequisites: null,

View File

@@ -12,6 +12,7 @@ const SIMULATIONS: Simulation[] = [
engagement_id: 42,
name: 'Lateral movement test',
techniques: [{ id: 'T1021', name: 'Remote Services', tactics: ['lateral-movement'] }],
tactics: [],
description: null,
commands: null,
prerequisites: null,

136
scripts/open-pr.sh Executable file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Open a pull request against the Mimic Gitea repository using the credentials
# already stored in ~/.git-credentials (the same token used for `git push`).
#
# Usage:
# scripts/open-pr.sh --title "feat: sprint N — short summary" \
# --body path/to/body.md \
# [--base main] \
# [--head <current branch by default>]
#
# Or via the Makefile wrapper:
# make open-pr TITLE="feat: sprint 4 — UI polish" BODY=tasks/pr-body-sprint-4.md
#
# Output: prints the PR URL on success, exits non-zero on failure.
set -euo pipefail
# --- Arg parsing ------------------------------------------------------------
TITLE=""
BODY_FILE=""
BASE="main"
HEAD=""
while [[ $# -gt 0 ]]; do
case "$1" in
--title)
TITLE="${2:-}"
shift 2
;;
--body)
BODY_FILE="${2:-}"
shift 2
;;
--base)
BASE="${2:-}"
shift 2
;;
--head)
HEAD="${2:-}"
shift 2
;;
--sprint)
# purely informational; ignored — the title is what carries semantics
shift 2
;;
-h|--help)
sed -n '2,15p' "$0"
exit 0
;;
*)
echo "Unknown arg: $1" >&2
exit 2
;;
esac
done
[[ -n "$TITLE" ]] || { echo "--title is required" >&2; exit 2; }
[[ -n "$BODY_FILE" ]] || { echo "--body is required" >&2; exit 2; }
[[ -f "$BODY_FILE" ]] || { echo "body file not found: $BODY_FILE" >&2; exit 2; }
# --- Credentials ------------------------------------------------------------
CRED_FILE="${HOME}/.git-credentials"
[[ -f "$CRED_FILE" ]] || { echo "no ~/.git-credentials — git push must have run at least once" >&2; exit 3; }
# Detect Gitea host from origin remote
ORIGIN_URL=$(git remote get-url origin)
# Strip protocol, .git suffix → host/owner/repo
case "$ORIGIN_URL" in
https://*)
REST="${ORIGIN_URL#https://}"
;;
*)
echo "origin is not https (got: $ORIGIN_URL) — this script supports HTTPS Gitea only" >&2
exit 3
;;
esac
REST="${REST%.git}"
HOST="${REST%%/*}"
PATHPART="${REST#*/}" # owner/repo
OWNER="${PATHPART%%/*}"
REPO="${PATHPART#*/}"
REPO="${REPO%%/*}" # belt + braces in case of trailing slash
# Match the credential line for this host
CRED_LINE=$(grep -E "^https://[^@]+@${HOST}\$" "$CRED_FILE" || true)
[[ -n "$CRED_LINE" ]] || { echo "no credential for host ${HOST} in ${CRED_FILE}" >&2; exit 3; }
USER_PART=$(echo "$CRED_LINE" | sed -E 's|^https://([^:]+):.*|\1|')
TOKEN=$(echo "$CRED_LINE" | sed -E 's|^https://[^:]+:([^@]+)@.*$|\1|')
[[ -n "$USER_PART" && -n "$TOKEN" ]] || { echo "could not parse user/token from credential" >&2; exit 3; }
# --- Branch -----------------------------------------------------------------
if [[ -z "$HEAD" ]]; then
HEAD=$(git rev-parse --abbrev-ref HEAD)
fi
[[ "$HEAD" != "HEAD" ]] || { echo "detached HEAD — pass --head explicitly" >&2; exit 3; }
# --- Compose payload --------------------------------------------------------
API_URL="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls"
PAYLOAD=$(jq -n \
--arg title "$TITLE" \
--rawfile body "$BODY_FILE" \
--arg head "$HEAD" \
--arg base "$BASE" \
'{title:$title, body:$body, head:$head, base:$base}')
# --- POST -------------------------------------------------------------------
RESPONSE_FILE=$(mktemp)
HTTP_CODE=$(curl -sS -u "${USER_PART}:${TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
-d "$PAYLOAD" \
-o "$RESPONSE_FILE" \
-w "%{http_code}" \
"$API_URL")
if [[ "$HTTP_CODE" != "201" ]]; then
echo "PR creation failed (HTTP $HTTP_CODE):" >&2
jq -r '.message // empty' "$RESPONSE_FILE" >&2 2>/dev/null || cat "$RESPONSE_FILE" >&2
rm -f "$RESPONSE_FILE"
exit 4
fi
PR_URL=$(jq -r '.html_url' "$RESPONSE_FILE")
PR_NUMBER=$(jq -r '.number' "$RESPONSE_FILE")
rm -f "$RESPONSE_FILE"
echo "Opened PR #${PR_NUMBER}"
echo "$PR_URL"

View File

@@ -4,6 +4,44 @@ Recurring mistakes and the rule we adopted so the same issue doesn't bite twice.
---
## Sprint 4 (closed 2026-05-27)
### Process — git status before declaring sprint complete (team-lead)
**Context** : Sprint 3 §0 of the plan updated `SPEC.md`'s § Simulation line to plural multi-techniques. That edit sat in the sprint 3 worktree but was never committed; PR #6 merged the sprint 3 code WITHOUT the corresponding spec text. I rediscovered it at sprint 4 start with a stray `M SPEC.md` in the worktree, then carried over via `ba313a3`. Sprint 3 had shipped with SPEC and code briefly out of sync.
**Lesson** : at sprint wrap-up, the team-lead's final pre-PR step MUST be `git status` followed by a 5-second scan for unstaged hunks. If anything is M/?? in the listing, decide explicitly (commit, stash, or abandon). Don't trust commit lists alone.
### Process — Endpoint round-trip mismatch caught by e2e, missed by 3 spec-reviewer passes (team-lead + spec-reviewer)
**Context** : Sprint 3's `GET /api/mitre/matrix` returned `tactic_id` as a slug (`"discovery"`). Sprint 4 added `tactic_ids` PATCH validation against TA-format (`"TA0007"`). Both endpoints worked in isolation but the round-trip (matrix → frontend → PATCH) failed silently — frontend sent the slug from the matrix as a `tactic_ids` value, and the backend rejected with 400. Spec-reviewer ran 3 passes against the plan WITHOUT catching this contract mismatch because both formats were documented in different sections. The test-verifier's e2e caught it (AC-21.6 defect).
**Lesson** : when a sprint introduces a NEW data path that re-uses an EXISTING endpoint's output, the spec-reviewer should explicitly trace the round-trip in code (matrix output → frontend store → PATCH body → backend validator). Pure spec reading misses contract mismatches that only manifest when a feature wires two existing endpoints together. Add to the spec-reviewer checklist: "for any new feature reusing a prior endpoint, trace the actual data flow A → B → C in code, not just specs in isolation."
### Engineering — Token system: split themed text vs fixed surface (frontend-builder + design-reviewer)
**Context** : First dark mode pass made `--color-ink` themed (light = `#1a1a1a`, dark = `#f9fafb`). That correctly inverts for body text, but inverts WRONG for "dark slabs" (utility strip, footer, modal backdrop) that used `bg-ink` as a fixed-dark surface — they became near-white bars in dark mode. Three separate symptoms, one root cause. Design-reviewer (its first run) caught it.
**Lesson** : if a single token (`ink`) plays multiple roles (text + dark surface), split into a themed token (`ink` = text) and a fixed token (`slab` = dark surface that does NOT invert). Same for overlays — `bg-ink/60` for a backdrop inverts; `bg-overlay` or a dedicated `.modal-backdrop` class with `rgba(0,0,0,0.6)` stays correct. Audit token roles BEFORE wiring dark mode, not after.
### Engineering — Form alignment: structural row layout > class tweaks (frontend-builder × 3 attempts)
**Context** : `UsersAdminPage` "Create account" form had labels misaligned by 24px when one cell carried a hint (`≥ 8 characters`) and others didn't, because `items-end` aligned cells to row-bottom. Sprint 2 post-QA "fixed" it with class tweaks. Sprint 4 post-QA reported the bug back. Sprint 4 first attempt added another class tweak (still broken per design-reviewer). Third attempt finally refactored to an explicit 3-row grid (labels / inputs+button / hints) where the browser CAN'T misalign rows of different cells.
**Lesson** : after one failed alignment fix via class tweaks, the next attempt MUST be structural (explicit grid rows, or `display: subgrid`, or DOM restructure). Don't try a third class-level tweak on an alignment issue that survived a prior class-level fix — the problem is in the structure, not the styling.
### Engineering — Hardcoded absolute paths in migration tests (backend-builder × 2 sprints)
**Context** : Sprint 3 backend-builder hardcoded `/home/user/.../.claude/worktrees/sprint-3-mitre-matrix/` in the migration round-trip test to load the migration module via `importlib.util.spec_from_file_location`. Sprint 4 the worktree path was renamed and the test broke — backend-builder "fixed" it by hardcoding the NEW worktree path. Code-reviewer caught it with the same finding twice.
**Lesson** : any test that loads a sibling file by absolute path is wrong by default. Always derive paths from `__file__` (`Path(__file__).resolve().parent.parent / "migrations" / "versions" / "0004_*.py"`). Backend-builder mental checklist before completing a migration test: grep `/home/user/` and grep `worktrees/` in the diff — anything matching is a smell.
### Process — Screenshots-mandatory rule has a fragile transport (frontend-builder)
**Context** : Sprint 4 first round of post-design-review screenshots, frontend-builder couldn't authenticate Playwright cleanly and delivered 5 screenshots of the login page only — the 4 critical fixes (UsersAdmin alignment, slab in matrix, badge contrast on form, done state dark) were NOT visible on the login page and could not be validated. The DoD says "if dev server can't start, escalate explicitly", but here the dev server DID start — auth just didn't resolve before the screenshot. Builder DID flag it; team-lead bounced back with the exact round-1 auth flow path. Second round worked.
**Lesson** : the DoD's screenshots clause needs to cover not just "can the server start" but "can the screenshot capture authenticated UI". For sprint 5+, the brief should remind the builder to use the exact auth pattern that worked in round 1 (`page.goto('/login') → fill → submit → wait for navigation`) OR to seed localStorage with a valid token before `goto`. Don't accept login-page-only screenshots as "screenshots" when the feature being validated lives behind auth.
### Process — Builders received cross-context inline summaries as re-dispatches (sprint 3 + 4 — pattern)
**Context** : When dispatching to one builder I include the other builder's API summary inline (e.g., backend summary embedded in the frontend dispatch as context). Twice now the OTHER builder has interpreted that embedded summary as a re-dispatch to themselves, re-verified their work, and confirmed completion (sprint 3 503-on-unloaded + sprint 4 AC-21.6 round-trip both occurred this way). Net positive — they caught real gaps — but coordination cost.
**Lesson** : when embedding another builder's API contract as context for builder B, prefix it with `# REFERENCE — BUILDER A's SUMMARY, INLINE CONTEXT ONLY, NO ACTION NEEDED FROM YOU` (or equivalent). Builders inherit the whole message and don't always parse which sections target them.
### Engineering — Backend-builder's self-correction via re-dispatch surfaced two real defects (sprint 3 + 4)
**Context** : Twice now the backend-builder, after interpreting a frontend dispatch as a "re-dispatch", noticed an unspecified or wrong behavior I'd left in the brief and shipped a fix:
- Sprint 3: I left "Bundle non chargé → comportement à décider, je propose 503" ambiguous. Backend-builder picked 503 and added a regression test (`673b25e`).
- Sprint 4: backend-builder re-read the AC-21.6 inline brief and reasoned through but their code was correct; the actual fix came after the test-verifier's defect report.
**Lesson** : when something in a builder brief reads "à décider" / "TBD" / "we'll see", that's a SPEC HOLE — close it before dispatch, not after. Use AskUserQuestion or take a defensible default and document explicitly which it is.
---
## Sprint 3 (closed 2026-05-27)
### Process — Spec-review 2-pass after team-lead edits (team-lead)

View File

@@ -1,290 +1,339 @@
# Sprint 3MITRE matrix modal + multi-technique simulations
# Sprint 4UI polish + workflow tightening + dark mode + process hygiene
**Branche** : `sprint/3-mitre-matrix`
**Statut** : 🟢 SPRINT COMPLET — 105/105 sprint 3 e2e verts, code-review traité, PR prête
**Base** : `main` @ `e1d9738`
**Objectif** : remplacer la sélection MITRE mono-technique de sprint 2 par une sélection multi-techniques avec deux modes complémentaires : autocomplete (rapide) et matrice cliquable (exploration). Les techniques choisies s'affichent comme tags sur la simulation.
**Branche** : `sprint/4-ui-polish`
**Statut** : 🟢 SPRINT COMPLET — backend 193/193 + frontend 92/92 + e2e 158/158, PR prête
**Base** : `main` @ `27573f5` (sprint 3 mergé via PR #6) + `ba313a3` (carry-over SPEC sprint 3)
**Objectif** : absorber les 7 retours QA sprint 3 (UI/UX, workflow, alignement) + livrer le dark mode + durcir le process UI (design-reviewer agent + screenshots mandatory) + automatiser l'ouverture de PR. Pas de hotfix sprint 3 séparé — tout dans sprint 4 (décision user 2026-05-27).
---
## 0. Évolution SPEC.md à acter en début de sprint
## 0. SPEC.md updates
SPEC.md § Simulation dit aujourd'hui "Type d'attaque MITRE correspondant (peut être une liste de référence)" au singulier. Le team-lead met à jour cette ligne en début de sprint (pas de PR séparée) pour refléter le scope multi-techniques. Texte cible :
-`ba313a3` — § Simulation : "Type d'attaque MITRE correspondant (peut être une liste de référence)" → "Types d'attaque MITRE correspondants (multi-techniques) ..." (carry-over manquant de sprint 3 §0).
- 🟡 § Fonctionnement à enrichir en début de sprint 4 :
- Préciser que "Done" est terminal : aucune édition possible sans Reopen explicite.
- Préciser que la transition Reopen `Done → Review required` est ouverte à admin/redteam/soc.
- Préciser que la création/avancement d'une simu fait avancer l'engagement de `planned` à `active` automatiquement (jamais l'inverse).
- 🟡 § Décisions techniques à enrichir :
- Section "UI/UX" : convention boutons (icônes / symboles préférés aux longs libellés).
- Section "Theming" : dark mode supporté, toggle topbar, défaut = `prefers-color-scheme` du système, persistance `localStorage`.
> Types d'attaque MITRE correspondants (multi-techniques) — sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale.
L'évolution est tracée dans CHANGELOG.md § Changed du sprint 3.
L'évolution est tracée dans CHANGELOG.md § Changed sprint 4.
---
## 1. User stories
### US-13En tant que redteam, je sélectionne plusieurs techniques MITRE par simulation
**Pourquoi** : un test couvre souvent plusieurs TTPs (ex : Initial Access → Discovery → Execution). Mono-technique limite la description réelle d'un test.
### US-17UI polish : dédoublonnage boutons + alignement + icônes
**Pourquoi** : QA sprint 3 — `EngagementsListPage` montre 2 boutons "New engagement" + "Create engagement" qui font la même chose ; le bouton Create de `UsersAdminPage` reste mal aligné malgré le fix sprint 2.
**Critères d'acceptation**
- [ ] AC-13.1 : modèle `Simulation` n'a plus `mitre_technique_id` ni `mitre_technique_name` (scalaires). Remplacés par `techniques` (colonne JSON, liste d'objets `{id: str, name: str}`, défaut `[]`).
- [ ] AC-13.2 : migration Alembic `0003_simulation_techniques_array.py` :
- ajoute la colonne `techniques` (JSON)
- backfill les simulations existantes : si `mitre_technique_id` non null → `techniques = [{id, name}]`, sinon `techniques = []`
- drop les deux anciennes colonnes
- migration réversible (downgrade : prendre le premier élément, ré-injecter dans les scalaires, drop `techniques`)
- [ ] AC-13.3 : sérialisation simulation expose `techniques: [{id, name, tactics: [...]}]` — le backend enrichit chaque entrée avec ses `tactics` depuis le service MITRE au moment du serialize (snapshot d'`id`+`name` en DB, tactics dérivées au runtime depuis le bundle).
- [ ] AC-13.4 : `PATCH /api/simulations/<sid>` accepte `{technique_ids: ["T1059", "T1078"]}` (liste d'IDs string). Backend valide chaque ID contre le bundle MITRE, résout `name`, écrit `[{id, name}]` en DB. ID inconnu → 400 `{error: "unknown technique id: T9999"}`.
- [ ] AC-13.5 : la règle d'auto-transition `pending → in_progress` s'applique aussi à `technique_ids` quand la liste reçue est non vide.
- [ ] AC-17.1 : `EngagementsListPage` n'affiche qu'UN SEUL bouton "New engagement". Le doublon "Create engagement" est supprimé.
- [ ] AC-17.2 : convention nouveaux boutons d'action (Create / Add / Save / Delete) : icône lucide-react ou unicode + label court (≤ 8 chars), pas de phrases. Audit des boutons existants : ne refactoriser que ceux qui dépassent ce seuil, garder les "Mark for review" / "Clear all" qui sont déjà courts ou ont une sémantique sans icône évidente. Boutons à passer en icône+label : "Save Red Team" → "Save" + icône, "Save SOC" → "Save SOC" + icône, "ADD TECHNIQUE" → "+" + "Add", "QUICK SEARCH" → "🔍" + "Search".
- [ ] AC-17.3 : `UsersAdminPage` formulaire "Create account" — les 3 FormField (Username, Password, Role) ont leurs labels alignés sur la même baseline ET leurs inputs alignés sur la même baseline. Le bouton Create est aligné horizontalement avec la rangée des inputs. Pixel-perfect au niveau visuel à 1280×720.
### US-14En tant que redteam, je vois et retire les techniques d'une simulation sous forme de tags
**Pourquoi** : visualiser rapidement la couverture TTP d'un test.
### US-18Simulation `done` = read-only + Reopen
**Pourquoi** : QA sprint 3 — actuellement une simu `done` peut toujours être PATCHée, ce qui contredit le statut terminal.
**Critères d'acceptation**
- [ ] AC-14.1 : sur `SimulationFormPage`, à la place du seul `MitreTechniquePicker` du sprint 2, un composant `MitreTechniquesField` affiche :
- Liste des techniques sélectionnées sous forme de chips/tags (id + name, ex : `T1059 — Command and Scripting Interpreter`), avec un `×` cliquable pour retirer chaque technique.
- Bouton "Add technique" qui ouvre la modale matrice (US-15).
- Bouton "Quick search" qui ouvre l'autocomplete existant (réutilisation du `MitreTechniquePicker`) en mode "ajoute à la liste" (sélection = append, pas replace).
- État vide : message "No techniques selected — use the matrix or the quick search to add."
- [ ] AC-14.2 : retirer un tag (× sur le chip) déclenche un PATCH immédiat (auto-save) avec la liste mise à jour. La modale matrice (US-15) auto-save aussi via "Apply". Le picker Quick Search auto-save chaque sélection. Toast `'Techniques updated'` sur succès, toast erreur sinon. Pas de bouton Save manuel pour les techniques.
- [ ] AC-14.3 : sur `SimulationList` (table dans EngagementDetailPage), la colonne "MITRE" affiche un compteur + premier tag (ex : `T1059 +2` si 3 techniques sélectionnées). Si la liste est vide, afficher `—`.
- [ ] AC-14.4 : ordre des tags dans la simulation préservé entre lecture et écriture (pas de tri imposé côté serveur).
- [ ] AC-14.5 : tags affichés avec les couleurs/spacing DESIGN.md (`bg-primary-soft`, `text-primary-deep`, `rounded-full`, `px-md py-xxs`).
- [ ] AC-18.1 : `PATCH /api/simulations/<sid>` avec status courant `done` retourne **409** `{error: "simulation is done — reopen first"}` quel que soit le rôle.
- [ ] AC-18.2 : nouvelle transition `POST /api/simulations/<sid>/transition {to: "review_required"}` quand status courant == `done` → 200, autorisée admin + redteam + soc. Met à jour `updated_at`.
- [ ] AC-18.3 : la transition `→ review_required` depuis `pending`/`in_progress` garde le comportement sprint 2 (admin/redteam only). La nouvelle règle s'ajoute SEULEMENT pour le cas `done`.
- [ ] AC-18.4 : sur `SimulationFormPage`, quand status == `done` :
- Tous les champs (RT + SOC) sont disabled.
- `MitreTechniquesField` en read-only (chips sans ×, input + icône matrice masqués).
- L'action bar affiche UNIQUEMENT un bouton "Reopen" (visible admin/redteam/soc).
- Save RT, Save SOC, Mark for review, Close, Delete sont masqués.
- [ ] AC-18.5 : click Reopen → POST transition, toast `'Simulation reopened'`, badge se met à jour, les champs redeviennent éditables selon le rôle.
### US-15 — En tant que redteam, j'ouvre la matrice MITRE ATT&CK pour explorer et sélectionner des techniques
**Pourquoi** : l'autocomplete est efficace si on sait ce qu'on cherche ; la matrice est nécessaire pour "voir ce qui existe" et combiner par tactique.
### US-19 — Engagement auto-status `planned → active`
**Pourquoi** : QA sprint 3 — un engagement reste `planned` même quand ses simulations sont in_progress.
**Critères d'acceptation**
- [ ] AC-15.1 : nouvel endpoint `GET /api/mitre/matrix` (auth, tous rôles) → tree `[{tactic_id, tactic_name, techniques: [{id, name, subtechniques: [{id, name}]}]}]`. Chaque technique top-level embarque ses sub-techniques (`T1059``[T1059.001, T1059.002, ...]`). Ordre des tactiques = ordre canonique MITRE Enterprise (Initial Access → Execution → Persistence → ... → Exfiltration → Impact). 503 si bundle non chargé.
- [ ] AC-15.2 : composant `MitreMatrixModal` :
- Modal large (≥ 1100px), scroll vertical interne.
- Layout horizontal en colonnes : 1 colonne par tactique. Header de colonne = nom de la tactique + compteur de techniques sélectionnées dans cette tactique (sub-techniques incluses).
- Chaque technique top-level = bouton/cellule cliquable. État sélectionné visible (`bg-primary` + texte blanc).
- Si la technique a des sub-techniques (`subtechniques.length > 0`), un chevron (▸/▾) précède le nom. Click sur le chevron = expand/collapse (n'affecte PAS la sélection). Click sur le label = toggle sélection de la technique top-level.
- Sub-techniques affichées en cascade indentée sous leur parent quand expand. Cliquables individuellement (toggle de la sub). État visuel distinct : `bg-primary-soft` quand sélectionnée, indent `pl-md`, font-size légèrement plus petit.
- Sélectionner une sub-technique ne sélectionne PAS le parent (les deux sont indépendants côté data). Mais le compteur de tactique somme parent + subs sélectionnées.
- Champ de recherche en haut du modal qui filtre les techniques affichées (case-insensitive sur id ET name). Quand le filtre matche une sub-technique, son parent est automatiquement expand pour la rendre visible.
- Boutons en footer : "Cancel" (ferme sans appliquer), "Apply N techniques" (compteur = total parents + subs sélectionnés).
- [ ] AC-15.3 : la modale est ouverte depuis le bouton "Add technique" de US-14. Elle reçoit en input la liste actuelle de techniques sélectionnées et travaille sur une copie locale ; "Apply" déclenche directement le PATCH (auto-save, cf AC-14.2) et ferme la modale ; "Cancel" jette le diff local.
- [ ] AC-15.4 : Escape ferme la modale (= Cancel). Click sur le backdrop = Cancel.
- [ ] AC-15.5 : a11y V1 — **scope minimal explicite** : (1) focus initial sur le champ recherche à l'ouverture, (2) Tab cycle entre les éléments focusables de la modale (wrap : dernier élément → premier), (3) Escape ferme = onCancel, (4) ARIA `role="dialog"` + `aria-labelledby` sur le titre. Full WAI-ARIA dialog conformance (live regions, focus restoration au close, screen reader announcements détaillés) **out of scope V1** — c'est une dette assumée à reprendre dans un sprint a11y dédié.
- [ ] AC-19.1 : quand une simulation transitionne vers `in_progress` (auto-transition via PATCH RT-field non vide), si son engagement parent est `planned`, l'engagement passe à `active` dans la même unité de travail DB.
- [ ] AC-19.2 : si l'engagement est déjà `active` ou `closed`, pas de changement.
- [ ] AC-19.3 : aucun retour arrière auto. La transition `closed` reste manuelle.
- [ ] AC-19.4 : le frontend invalide `["engagement", eid]` et `["engagements"]` après chaque PATCH/transition simulation pour récupérer le statut à jour.
### US-16En tant que user (tous rôles), j'utilise les autres fonctionnalités sans régression
**Critères d'acceptation** (régression)
- [ ] AC-16.1 : workflow sprint 2 (auto-transition, transitions manuelles, RBAC SOC) inchangé — tous les ACs sprint 2 (US-7 → US-12) continuent de passer.
- [ ] AC-16.2 : l'ancien `MitreTechniquePicker` est conservé dans la base de code MAIS sa signature passe en clean rewrite (`onSelect({id, name})` au lieu de `onChange(id, name)`), wrappé par `MitreTechniquesField` en mode append.
- [ ] AC-16.3 : aucune e2e sprint 1/sprint 2 ne casse. Quelques assertions sprint 2 (US-8 et US-10) qui validaient le mono-technique sont mises à jour pour refléter la liste.
### US-20Matrice MITRE : look attack.mitre.org + pas de scroll horizontal
**Pourquoi** : QA sprint 3 — la matrice actuelle a un scroll horizontal et un layout maison.
**Critères d'acceptation**
- [ ] AC-20.1 : `MitreMatrixModal` est élargi à `max-w-[98vw]`.
- [ ] AC-20.2 : layout 12 colonnes (12 tactiques Enterprise) qui tiennent SANS scroll horizontal à 1280×720 min. Largeur cellule technique ~95-110px (vs 220px actuel), font `text-[12px]`.
- [ ] AC-20.3 : couleurs cohérentes DESIGN.md ET visuellement proches de attack.mitre.org : header tactic avec fond contrasté + label uppercase tracking, techniques en cellules `bg-canvas` avec hairline border, hover `bg-fog`, sélectionnée `bg-primary` texte blanc.
- [ ] AC-20.4 : scroll vertical autorisé (`max-h-[80vh] overflow-y-auto`). Jamais de scroll horizontal.
- [ ] AC-20.5 : sub-techniques expand/collapse PRÉSERVÉ — pas de régression sprint 3 AC-15.2. Compteur "N selected" par tactique reste lisible.
- [ ] AC-20.6 : screenshot comparaison Mimic matrix vs attack.mitre.org joint au summary frontend-builder.
### US-21 — Sélection de tactique en plus des techniques
**Pourquoi** : QA sprint 3 — l'utilisateur veut tagger une simulation par TACTIQUE (ex : `TA0007 Discovery`) sans devoir choisir une technique précise.
**Critères d'acceptation**
- [ ] AC-21.1 : modèle `Simulation` gagne un champ `tactic_ids` (colonne JSON, liste de strings TA-id, défaut `[]`). Séparé de `techniques`.
- [ ] AC-21.2 : migration Alembic `0004_simulation_tactic_ids.py` — ADD COLUMN `tactic_ids` (JSON, NOT NULL, default `[]`). Pas besoin de batch pour ADD COLUMN (SQLite natif). Aucun backfill (default suffit).
- [ ] AC-21.3 : sérialisation Simulation expose `tactics: [{id, name}]` enrichi à partir de `tactic_ids` (id snapshot + name dérivé du bundle MITRE au runtime, comme pour `techniques`).
- [ ] AC-21.4 : `PATCH /api/simulations/<sid>` accepte `{tactic_ids: ["TA0007", ...]}`. Validation : chaque ID doit exister dans `_TACTIC_IDS` (mapping TA-id → short-name, cf §2 Service MITRE). Dedup serveur. ID inconnu → 400. **Pas de check `mitre_loaded`** : les TA-ids sont une constante MITRE standard stable hardcodée dans `_TACTIC_IDS` — la validation ne dépend pas du bundle STIX runtime (contrairement aux `technique_ids` qui requièrent le bundle). Donc PATCH `tactic_ids` reste OK même si le bundle est absent (alors que `technique_ids` retourne 503). Spec-aligné avec l'implémentation et les tests post-code-review.
- [ ] AC-21.5 : `tactic_ids` est ajouté au gate SOC : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`. SOC envoie → 403. Auto-transition se déclenche aussi si `tactic_ids` non vide.
- [ ] AC-21.6 : `MitreMatrixModal` — le header de chaque colonne tactique devient cliquable (toggle de la tactique elle-même). État visuel distinct des techniques sélectionnées. Compteur passe à `N+M selected` (techniques + tactique).
- [ ] AC-21.7 : `MitreTechniquesField` — tactiques sélectionnées affichées comme chips distincts (style différencié : `bg-primary text-canvas` au lieu de `bg-primary-soft text-primary-deep`). × pour retirer. Auto-save sur add/remove.
### US-22 — Refonte input MITRE dans le form
**Pourquoi** : QA sprint 3 — pattern actuel (2 boutons textuels) trop verbeux.
**Critères d'acceptation**
- [ ] AC-22.1 : sous le label "MITRE Techniques", le composant affiche :
- Une rangée de chips (techniques + tactiques sélectionnées).
- En dessous, une rangée `[input texte autocomplete] [icône matrice]`.
- L'input fait l'autocomplete inline (debounce 200ms, dropdown ↑↓Enter, comme sprint 2 mais EMBARQUÉ).
- L'icône matrice à droite ouvre `MitreMatrixModal`.
- Aucun bouton textuel "Add Technique" ni "Quick Search".
- [ ] AC-22.2 : les chips affichent UNIQUEMENT la référence (T-id ou TA-id, ex : `T1059.001` ou `TA0007`). Le nom apparaît au survol via `title=` attribute.
- [ ] AC-22.3 : `MitreTechniquePicker` existant est intégré dans le nouveau layout comme l'autocomplete inline. Garde la signature `onSelect`.
- [ ] AC-22.4 : empty state : message court ("No techniques selected") dans la zone des chips. L'input et l'icône matrice restent visibles.
- [ ] AC-22.5 : mode read-only (SOC sur simu non-done, ou tous sur simu done) : chips sans ×, input + icône cachés.
### US-23 — Dark mode
**Pourquoi** : ergonomie demandée. Sprint 4 framing acté.
**Critères d'acceptation**
- [ ] AC-23.1 : un toggle theme dans la topbar (`Layout.tsx`), à droite du nom user. Icône lucide-react `Sun` / `Moon` / `Monitor`.
- [ ] AC-23.2 : 3 états : `light`, `dark`, `system` (auto = suit `prefers-color-scheme`). Toggle cycle entre les 3.
- [ ] AC-23.3 : persistance via `localStorage` (clé `mimic-theme`, valeur `'light'|'dark'|'system'`, défaut `'system'`).
- [ ] AC-23.4 : Tailwind `darkMode: 'class'` activé. Classe `dark` appliquée sur `<html>` selon le résolu. Tokens DESIGN.md étendus avec variantes dark (canvas, paper, ink, graphite, charcoal, etc.). Primary HP Electric Blue garde sa teinte.
- [ ] AC-23.5 : tous les composants principaux audités et utilisent les classes Tailwind `dark:bg-...` / `dark:text-...`. Pas de couleur hardcodée.
- [ ] AC-23.6 : screenshots light + dark de `EngagementsListPage`, `SimulationFormPage`, `MitreMatrixModal` ouverte. Joints au summary.
### US-24 — Process hygiene : design-reviewer agent + screenshots mandatory
**Pourquoi** : sprint 4 framing acté. Sprint 2/3 avait laissé passer des bugs visuels faute de pass design dédié.
**Critères d'acceptation**
- [ ] AC-24.1 : nouveau fichier `.claude/agents/design-reviewer.md`. Brief : revoit le diff frontend + les screenshots fournis par le frontend-builder, audit alignement / hiérarchie typo / DESIGN.md token usage / responsive sanity / cohérence visuelle. Read-only. Lance après frontend-builder, avant code-reviewer.
- [ ] AC-24.2 : `.claude/agents/frontend-builder.md` mis à jour pour rendre EXPLICITE que screenshots sont MANDATORY avant de marquer la tâche terminée (au moins 1 par feature visible / état modifié). Liste explicite des screenshots attendus dans le summary.
- [ ] AC-24.3 : workflow sprint mis à jour dans SPEC.md § Workflows : ajouter design-reviewer entre frontend-builder et code-reviewer.
### US-25 — Infra : PR helper script + Makefile target
**Pourquoi** : capitaliser le pattern Gitea API curl utilisé en sprint 3 pour automatiser les PRs.
**Critères d'acceptation**
- [ ] AC-25.1 : `scripts/open-pr.sh` (executable, `set -euo pipefail`). Lit `~/.git-credentials`. Args : `--sprint=N`, `--title="..."`, `--body=path`. Détecte la branche courante + owner/repo depuis `git remote get-url origin`. POST `/api/v1/repos/{owner}/{repo}/pulls`. Imprime PR URL.
- [ ] AC-25.2 : target Makefile `open-pr SPRINT=N TITLE="..." BODY=path` wrap le script.
- [ ] AC-25.3 : documenté dans README.md (1 paragraphe).
- [ ] AC-25.4 : team-lead utilise ce target pour ouvrir la PR sprint 4 (dogfooding).
---
## 2. Brief technique — Backend Builder
**Scope strict** : `backend/`. Pas de touche au frontend, e2e, docs (team-lead).
**Scope strict** : `backend/`. Pas de touche au frontend, e2e, `.claude/agents/`, `scripts/`, `Makefile`, docs.
### Livrables
**Modèle `Simulation`** (`backend/app/models/simulation.py`)
- Remplacer `mitre_technique_id`, `mitre_technique_name` (str nullable) par :
**Modèle `Simulation`** — ajout uniquement :
```python
tactic_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
```
**Migration Alembic `0004_simulation_tactic_ids.py`** :
- Upgrade : `op.add_column('simulations', sa.Column('tactic_ids', sa.JSON(), nullable=False, server_default=sa.text("'[]'")))`. ADD COLUMN OK sans batch sur SQLite. `server_default` règle le NOT NULL pour les lignes existantes.
- Downgrade : `with op.batch_alter_table('simulations') as batch_op: batch_op.drop_column('tactic_ids')`.
- Test : schéma post-upgrade a `tactic_ids` NOT NULL avec default `[]`.
**Serializer** : `serialize_simulation(sim)` ajoute `tactics: [{id, name}]` enrichi runtime.
**Service MITRE** :
- Sprint 3 a indexé les tactiques par **short-name** (`"initial-access"`, `"execution"`, `...`) dans `_TACTIC_ORDER` et `TACTIC_NAMES`. La SPEC et le plan sprint 4 utilisent la notation **TA-id** (`"TA0001"`, `"TA0007"`, etc.). Il faut un mapping TA-id → short-name pour valider/résoudre les `tactic_ids` reçus.
- Ajouter une constante module-level (12 entrées hardcodées, MITRE standard stable — attention, les TA-ids ne sont PAS séquentiels) :
```python
techniques: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
_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",
}
```
- Stockage : `[{"id": "T1059", "name": "Command and Scripting Interpreter"}, ...]`. Pas de `tactics` en DB (dérivé au serialize).
**Service workflow** (`backend/app/services/simulation_workflow.py`) — **mise à jour RBAC field-level OBLIGATOIRE**
- Dans le `REDTEAM_FIELDS` frozenset existant : **retirer** `"mitre_technique_id"` et `"mitre_technique_name"`, **ajouter** `"technique_ids"`.
- Sans ce changement : un user soc qui PATCH avec `{technique_ids: [...]}` reçoit un silent no-op (champ ignoré) au lieu du 403 attendu. La gate field-level RBAC pour `technique_ids` repose intégralement sur ce frozenset.
- Le `SOC_FIELDS` frozenset reste inchangé.
- Tester explicitement : `test_simulations_techniques.py` doit inclure "SOC PATCH technique_ids → 403" (cf. liste de tests plus bas).
**Migration Alembic `0003_simulation_techniques_array.py`**
- Upgrade :
1. Ajouter colonne `techniques` (JSON, nullable=True temporaire, default `'[]'`) — `op.add_column` direct OK.
2. Data migration : pour chaque ligne, si `mitre_technique_id` IS NOT NULL → set `techniques = '[{"id":"<id>","name":"<name>"}]'`, sinon `'[]'`.
3. ALTER column `techniques` → nullable=False — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** car SQLite ne supporte pas ALTER COLUMN nativement.
4. Drop columns `mitre_technique_id`, `mitre_technique_name` — **OBLIGATOIRE via `op.batch_alter_table('simulations', ...)`** (même raison : SQLite ne supporte pas DROP COLUMN hors batch mode).
- Downgrade : symétrique avec les MÊMES guards batch_alter_table pour les étapes ALTER/DROP. Recrée les 2 colonnes, prend le premier élément de `techniques` si non vide, drop `techniques`.
- Pattern à suivre : la migration `0002_add_simulations.py` (sprint 2) — vérifier le style batch_alter_table déjà en place.
**Serializer** (`backend/app/serializers.py`)
- `serialize_simulation(sim)` :
- Avant retour, enrichir chaque tag avec `tactics` depuis `mitre_svc.get_tactics(id)`. Si la technique a été retirée du bundle MITRE entre-temps, `tactics = []` (gracieux).
- `commands` reste tel quel (text brut, inchangé sprint 2).
**Service MITRE** (`backend/app/services/mitre.py`)
- Étendre l'index avec un dict `tactics_by_technique: dict[str, list[str]]` pour lookup O(1) au serialize.
- Nouvelle fonction `get_tactics(technique_id: str) -> list[str]`.
- Nouvelle fonction `lookup_name(technique_id: str) -> str | None` — utilisée par l'endpoint PATCH pour résoudre le name côté serveur (le client n'envoie que les IDs).
- Nouvelle fonction `get_matrix() -> list[dict]` :
```json
[
{"tactic_id": "TA0001", "tactic_name": "Initial Access",
"techniques": [
{"id": "T1078", "name": "Valid Accounts",
"subtechniques": [{"id": "T1078.001", "name": "Default Accounts"}, ...]},
...
]},
...
]
- Nouvelle fonction `lookup_tactic(tactic_id: str) -> dict | None` :
```python
short = _TACTIC_IDS.get(tactic_id)
if short is None:
return None
return {"id": tactic_id, "name": TACTIC_NAMES[short]}
```
Sub-techniques embarquées sous chaque parent (relation STIX `subtechnique-of` dans le bundle). Si la technique n'en a pas, `subtechniques: []`.
Ordre des tactiques : canonical MITRE Enterprise order (12 tactics). Lecture depuis les objets STIX `x-mitre-tactic` ordonnés par `x_mitre_shortname` natif OU constante module-level hardcodée si plus simple.
Ordre des techniques au sein d'une tactique : alphabétique par `name` (déterministe, lisible).
- Nouvelle fonction `get_tactic_name(tactic_id: str) -> str | None` : pareil mais retourne juste le name.
- Validation `tactic_ids` dans `simulation_workflow.py` : un id absent de `_TACTIC_IDS` → 400 `{"error": "unknown tactic id: <id>"}`.
**API** (`backend/app/api/simulations.py`)
- `GET /api/mitre/matrix` — nouvel endpoint, 200 + tree, 503 si bundle absent.
- `PATCH /api/simulations/<sid>` : le payload accepte maintenant `technique_ids: list[str]` à la place de `mitre_technique_id` + `mitre_technique_name`. Validation : tous les IDs doivent exister dans le bundle (400 sinon), `name` snapshot servi par `lookup_name`. Pas de rétrocompat avec les anciens champs scalaires (clean break — pas d'utilisateur externe).
- **Dedup serveur** : avant écriture en DB, dédupliquer la liste `technique_ids` en préservant l'ordre (`list(dict.fromkeys(technique_ids))`). Le client peut envoyer accidentellement des doublons (race UI ou bug), le serveur ne doit jamais persister deux fois la même technique.
- Auto-transition (AC-13.5) : un `technique_ids` non vide (≥1 élément) compte comme redteam-side filled, déclenche `pending → in_progress`. Liste vide = pas de trigger.
**Service workflow `simulation_workflow.py`** — modifications :
1. **Guard `done` (AC-18.1)** : tout en haut de `apply_patch`, AVANT le check RBAC, si `simulation.status == "done"` → 409 `{error: "simulation is done — reopen first"}`. Vaut pour TOUS les rôles, admin compris.
2. **SOC gate étendu** : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`.
3. **Validation `tactic_ids`** upfront (similaire à `technique_ids`) : tous les IDs validés contre le bundle, dedup `dict.fromkeys`. Bundle non chargé → 503.
4. **Auto-transition** : ajouter le check `len(payload["tactic_ids"]) > 0` au calcul `auto_trigger`.
5. **Transition `done → review_required` (AC-18.2)** — **implémentation précise** : le dict `_ALLOWED_TRANSITIONS` actuel est keyé par target status et a déjà une entrée `"review_required"` avec from={pending, in_progress} et roles={admin, redteam}. On NE peut PAS ajouter une 2e entrée avec la même clé. À la place, dans `transition()`, AVANT le lookup dict, ajoute un cas spécial qui suit les patterns existants du fichier :
```python
# transition() returns tuple[Any, int] | None — None on success, error tuple otherwise.
# Existing functions use datetime.now(UTC) (timezone-aware, not deprecated utcnow).
# Enum values are UPPERCASE: SimulationStatus.DONE, SimulationStatus.REVIEW_REQUIRED.
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
# ... reste de la fonction inchangée (dict lookup pour les autres cas)
```
Pas de check explicite du rôle ici — `@login_required` upstream + l'enum User limité à admin/redteam/soc rendent la défense superflue (KISS). Autres transitions depuis `done` (vers `pending`, `in_progress`, `done` lui-même) → 409 via le dict lookup qui ne les couvre pas.
6. **Hook engagement auto-status (AC-19.1)** : après une transition de simu vers `in_progress` (auto OU manual), appeler une fonction `_maybe_activate_engagement(simulation)` qui, si `simulation.engagement.status == "planned"`, set `engagement.status = "active"` et `db.session.add(engagement)`. **NE PAS appeler `db.session.commit()` dans le helper** — le caller (`api/simulations.py:update_simulation`) gère le commit final, sinon double-commit.
**API `simulations.py`** :
- PATCH : le check status==done est fait dans `apply_patch` (voir au-dessus).
- Transition : accepter le nouveau cas done → review_required pour admin/redteam/soc.
**Tests pytest**
- `test_simulations_techniques.py` (nouveau) :
- Création + PATCH `technique_ids` → simulation a la bonne liste, sérialisation expose `techniques` avec `tactics`.
- PATCH avec ID inconnu → 400.
- Auto-transition sur `technique_ids` non vide.
- Retirer toutes les techniques (`technique_ids: []`) → pas de trigger d'auto-transition (cohérent avec règle "valeur vide").
- **Dedup** : PATCH avec `technique_ids: ["T1059", "T1078", "T1059"]` → DB ne stocke que 2 entrées, ordre préservé (`T1059` en premier).
- `test_mitre.py` (existant) — ajouter :
- `get_matrix()` renvoie les bonnes tactiques dans le bon ordre.
- `lookup_name(unknown)` → None.
- `get_tactics(known)` → liste correcte (≥1 tactique).
- `test_simulations_crud.py` + `test_simulations_patch.py` + `test_simulations_workflow.py` (existants) — adapter toute assertion qui touchait `mitre_technique_id` / `mitre_technique_name`.
- Migration : test que les anciennes simulations en `pending` avec un id mono-tech sont upgradées en `techniques: [{id, name}]` (fixture inline ou test direct sur Alembic).
- `test_simulations_tactics.py` (nouveau) : PATCH valide, ID inconnu → 400, bundle absent → 503, dedup, auto-transition, SOC → 403.
- `test_simulations_done_readonly.py` (nouveau) : PATCH simu done → 409 (admin/redteam/soc). Reopen via transition → 200. Autres transitions depuis done → 409. Après reopen, PATCH OK.
- `test_engagement_lifecycle.py` (nouveau) : création simu → engagement reste `planned`. PATCH simu → simu in_progress + engagement active. Engagement déjà active → pas de changement. Engagement closed → pas de changement.
- Migration test : `tactic_ids` column NOT NULL après upgrade 0004 (similaire au pattern Alembic round-trip sprint 3).
- Adapter `test_simulations_crud.py`, `test_simulations_patch.py`, `test_simulations_workflow.py` si nécessaire pour les assertions sur `tactics` et la garde done.
**Quality bar** : ruff + mypy clean, tous les tests existants + nouveaux verts.
### Règles
- Pas de touche au frontend, `.claude/agents/`, `scripts/`, `Makefile`.
- Renvoyer le summary attendu (cf `.claude/agents/backend-builder.md`).
---
## 3. Brief technique — Frontend Builder
**Scope strict** : `frontend/` uniquement.
**Scope strict** : `frontend/` UNIQUEMENT.
**Note process (lesson learned sprint 2)** : avant de marquer la tâche terminée, lance le dev server et screenshot (a) la matrice modale ouverte avec ≥3 techniques sélectionnées et (b) la simulation form avec ≥2 tags affichés. Joins-les à ton summary final.
**SCREENSHOTS MANDATORY** (lesson sprint 2/3) : à la fin de ton travail, lance le dev server et fournis ≥ 5 screenshots :
1. `EngagementsListPage` light + dark
2. `SimulationFormPage` avec ≥ 2 chips technique + ≥ 1 chip tactique light + dark
3. `MitreMatrixModal` ouverte avec sélections light + dark
4. `UsersAdminPage` form "Create account" (alignement vérifié) light + dark
5. `SimulationFormPage` status `done` (read-only + Reopen visible) light
Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le EXPLICITEMENT avec les raisons techniques précises.
### Livrables
**Types** (`frontend/src/api/types.ts`)
- `MitreTechnique`: `{id: string, name: string, tactics: string[]}` (déjà existant pour le picker — réutiliser, ajouter `tactics` si manquant).
- Ajouter `MitreTactic`: `{tactic_id: string, tactic_name: string, techniques: MitreMatrixTechnique[]}` avec `MitreMatrixTechnique = {id: string, name: string, subtechniques: {id: string, name: string}[]}`.
- `Simulation.techniques: MitreTechnique[]` à la place de `mitre_technique_id` + `mitre_technique_name`. PATCH payload : `{technique_ids: string[]}`.
**US-17 — UI polish**
- `EngagementsListPage.tsx` : supprimer le doublon "Create engagement". Garder un seul CTA "New" + icône `+` (selon convention AC-17.2).
- `UsersAdminPage.tsx` : retravailler la grille pour pixel-perfect alignment. Choix laissé au builder (align-items: stretch + align-self, ou restructurer en 2 rangées).
- Audit boutons : refactoriser ceux qui dépassent ≤ 8 chars. Garder "Mark for review" / "Clear all" / "Reopen" sans icône si pas d'icône évidente. Boutons à passer en icône+label : "Save Red Team" → icône + "Save", "Save SOC" → icône + "Save SOC", "ADD TECHNIQUE" → "+" + "Add" (rendu obsolète par US-22), "QUICK SEARCH" → "🔍" + "Search" (rendu obsolète par US-22).
**API client** (`frontend/src/api/mitre.ts`)
- `searchMitreTechniques(q)` — existant, garder.
- `getMitreMatrix()` — nouveau, GET `/api/mitre/matrix`.
**US-18 — Done read-only + Reopen**
- `SimulationFormPage.tsx` :
- Quand `simulation.status === 'done'` : tous champs disabled, `MitreTechniquesField disabled`, action bar montre UNIQUEMENT "Reopen" + icône (``).
- Bouton Reopen : visible admin/redteam/soc, click → `useTransitionSimulation` to `review_required`, toast.
**Hooks** (`frontend/src/hooks/useMitre.ts`)
- `useMitreSearch(q, enabled)` — existant, garder.
- `useMitreMatrix(enabled)` — nouveau hook TanStack Query, `staleTime: Infinity` (la matrice ne change qu'avec `make update-mitre` + redémarrage).
**US-19 — Engagement auto-status (côté UI)**
- `useUpdateSimulation` et `useTransitionSimulation` : ajouter `["engagement", eid]` et `["engagements"]` aux invalidations après mutation réussie. Pas d'autre changement visuel.
- **Note (spec-reviewer Pass 3)** : `eid` n'est pas directement disponible dans la signature des hooks (qui prennent `sid`). Solution : lire `engagement_id` depuis la response simulation (le backend l'expose toujours, cf serialize_simulation sprint 2) OU le passer en arg supplémentaire au hook si plus propre. Pas un trou plan, juste à anticiper.
**Composants**
**US-20 — Matrice MITRE attack.mitre.org look**
- `MitreMatrixModal.tsx` overhaul :
- `max-w-[98vw]`, `max-h-[80vh] overflow-y-auto`, JAMAIS de scroll horizontal.
- `display: grid; grid-template-columns: repeat(12, minmax(0, 1fr))` pour répartir équitablement.
- Cellule technique : `text-[12px]`, padding minimal, hairline border.
- Header tactique : sticky top, fond contrasté, uppercase tracking, badge compteur à droite.
- Sub-techniques indent `pl-[8px]`, fond `bg-cloud`.
- Search input top inchangé.
- **`MitreTechniqueTag.tsx`** (nouveau) : chip affichant `{id} — {name}` avec un bouton `×`. Props : `technique: MitreTechnique`, `onRemove: () => void`, `disabled?: boolean`.
**US-21 — Tactic selection**
- `MitreMatrixModal.tsx` : header de tactique cliquable (toggle). État visuel distinct.
- Apply renvoie `{techniques, tactics}` au parent.
- `MitreTechniquesField.tsx` : tactic chips style différencié `bg-primary text-canvas`. Auto-save.
- **PATCH combiné (spec-reviewer fix #4)** : Apply depuis la matrice → UN SEUL PATCH `{technique_ids: [...], tactic_ids: [...]}` (les 2 listes ensemble). Pas 2 PATCH séquentiels (risque de race + risque que le 2nd appel hit le guard done). Pour les × remove ET les Quick Search adds, l'implémentation finale envoie aussi les 2 listes ensemble (`save({techniques, tactics})`) — fonctionnellement équivalent à un PATCH dimensionnel et plus simple à raisonner (single source of truth = local state). Spec-aligné post-code-review : "always send both dimensions" est la règle, le brief initial "dimension qui change" était over-spec. Toutes les mutations passent par `useUpdateSimulation` en un appel atomique.
- **`MitreTechniquesField.tsx`** (nouveau, dans `frontend/src/components/`) : conteneur qui orchestre la sélection multi-tech avec **auto-save** (PATCH déclenché par chaque add/remove/Apply).
- Props : `value: MitreTechnique[]`, `simulationId: number`, `disabled?: boolean`. (Pas de `onChange` du parent — le composant fait son propre PATCH via `useUpdateSimulation`.)
- UI : liste de `<MitreTechniqueTag>` + 2 boutons "Add technique" (ouvre matrix) et "Quick search" (ouvre/toggle picker autocomplete inline).
- Dédup : si l'utilisateur essaye d'ajouter une technique déjà présente, no-op silencieux (pas de PATCH non plus).
- Auto-save : chaque mutation (× sur tag, Apply matrice, sélection Quick Search) déclenche `useUpdateSimulation` avec `{technique_ids: [...]}`. Toast succès `'Techniques updated'`, toast erreur sinon. Pendant le PATCH : disable l'interaction (les × deviennent grisés, les boutons disabled).
**US-22 — Refonte input MITRE**
- `MitreTechniquesField.tsx` :
- Layout : chips area | input autocomplete inline + icône matrice button.
- Plus de boutons textuels "Add Technique" / "Quick Search".
- Chips compacts (T-id ou TA-id seul, name en `title=`).
- Empty state minimal.
- **`SimulationFormPage.tsx` — call site update (spec-reviewer fix #4)** : la signature de `MitreTechniquesField` change de `value: MitreTechnique[]` (sprint 3) à `value: {techniques: MitreTechnique[], tactics: MitreTactic[]}`. La page doit passer `value={{techniques: sim.techniques, tactics: sim.tactics}}` (le champ `sim.tactics` vient du nouveau serializer backend). TypeScript catch le miss mais flag-le explicitement pour ne pas l'oublier.
- **`MitreMatrixModal.tsx`** (nouveau) : modale matrice avec sub-techniques expand/collapse.
- Props : `isOpen: boolean`, `initialSelection: MitreTechnique[]`, `onApply: (selection: MitreTechnique[]) => void`, `onCancel: () => void`.
- État local : (a) copie de `initialSelection` mutée par les toggles, (b) `expandedTechniques: Set<string>` pour les IDs parents dépliés.
- Layout : flex horizontal scrollable, 1 colonne par tactique. Largeur fixe 220px par colonne pour cohérence visuelle.
- Chevron `▸/▾` à gauche du nom des techniques qui ont des sub-techniques (`subtechniques.length > 0`). Click chevron = toggle expand (mute le set `expandedTechniques`), ne modifie PAS la sélection.
- Click sur le label d'une technique top-level = toggle sa sélection (le chevron ne se déclenche pas dans ce cas — séparer les zones cliquables).
- Sub-techniques rendues en cascade indentée sous leur parent quand expand : `pl-md text-[12px] bg-cloud rounded` (vs parent `text-[14px]`). Cliquables individuellement, sélection indépendante du parent.
- Compteur header de tactique = nombre de techniques **parents + subs** sélectionnées dans cette tactique.
- Champ recherche en haut : filtre case-insensitive sur id ET name. Une sub-technique matchée force l'expand de son parent (modifie automatiquement `expandedTechniques`).
- Modale : `position: fixed`, backdrop `bg-ink/60`, container `bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden`.
- Footer : "Cancel" (jette les changements locaux + ferme), "Apply N techniques" (compteur total ; click → onApply renvoie la liste complète, parent fait le PATCH auto-save US-14.2).
- Focus trap (scope minimal V1, cf AC-15.5) :
- `useEffect` au mount → `searchInputRef.current?.focus()`.
- `onKeyDown` au niveau du container modale :
- `Tab` sans shift sur le dernier élément focusable → `preventDefault()` + focus le premier.
- `Shift+Tab` sur le premier → `preventDefault()` + focus le dernier.
- Récupérer la liste des focusables via `container.querySelectorAll('a, button, input, [tabindex]:not([tabindex="-1"])')`, ignorer ceux `disabled` ou `hidden`.
- Pas de focus restoration ni de live region — out of scope V1.
- Pas de dépendance npm.
- Escape → onCancel. Click backdrop → onCancel.
- **`MitreTechniquePicker.tsx`** (existant, sprint 2) : clean rewrite de la signature.
- Avant : `onChange(id: string | null, name: string | null)` qui remplaçait la valeur.
- Après : `onSelect({id, name})` — un seul match sélectionné, le parent (MitreTechniquesField) gère l'append + le dédup.
- Plus de prop `techniqueId`/`techniqueName` en entrée (le picker est désormais un sélecteur "one-shot" qui se réinitialise après chaque sélection).
**Pages**
- **`SimulationFormPage.tsx`** : remplacer le `<MitreTechniquePicker>` standalone par un `<MitreTechniquesField simulationId={sim.id}>`. Le state `rt.techniques` disparait du form (les techniques ont leur propre cycle de save via le champ lui-même — auto-save). Le bouton "Save Red Team" continue de batcher tous les AUTRES champs (name, description, commands, etc.) mais ne touche pas aux techniques. Affichage read-only (rôle SOC) : afficher les tags sans `×`, boutons Add/Quick Search masqués (`disabled` prop).
- **`SimulationList.tsx`** : colonne MITRE — afficher `techniques[0]?.id + (techniques.length > 1 ? ` +${techniques.length - 1}` : '')`. Si `techniques` est vide, afficher ``.
**Tests Vitest**
- `MitreTechniqueTag.test.tsx` — render id+name, click × appelle onRemove.
- `MitreTechniquesField.test.tsx` — affiche tags, "Add technique" ouvre le modal matrix, "Quick search" ouvre le picker, dédup silencieuse, remove via × appelle onChange avec liste mise à jour.
- `MitreMatrixModal.test.tsx` — render colonnes par tactique, click toggle sélection, Apply renvoie liste, Cancel jette, Escape ferme, search filtre.
- Adapter `MitreTechniquePicker.test.tsx` (sprint 2) à la nouvelle signature `onSelect`.
- Adapter `SimulationFormPage.test.tsx` (sprint 2) — assertions sur `techniques` array au lieu de scalaire.
**Quality bar** : typecheck + lint + vitest clean.
**US-23 — Dark mode**
- `Layout.tsx` : toggle theme dans la topbar. Hook `useTheme()` (localStorage + media query). 3 états avec cycle.
- `tailwind.config.ts` : `darkMode: 'class'`. Tokens étendus avec variantes dark (recommandé via CSS variables sous `.dark { ... }` dans `index.css`, comme ça les composants n'ont pas à dupliquer leurs classes).
- Audit tous les composants : aucune couleur hardcodée (pas de `bg-white`, `text-black`, `#xxxxxx` inline). Tous passent un check visuel light + dark.
### Règles
- Lit le summary backend EN PREMIER.
- Pas d'invention d'endpoints — `GET /api/mitre/matrix` est le seul nouveau, déjà spec'd.
- Réutiliser `LoadingState`, `ErrorState`, `ConfirmDialog`, `useToast`, action bar pattern (sprint 2) existants.
- Respect DESIGN.md tokens (palette + spacing). Tags = `bg-primary-soft text-primary-deep rounded-full px-md py-xxs gap-xxs text-[14px]`.
- Pas de nouvelle dépendance npm sans escalade au team-lead.
- Pas d'invention d'endpoints.
- Réutiliser les patterns sprint 1/2/3.
- Respect DESIGN.md tokens.
- Pas de dépendance npm sans escalade (sauf `lucide-react` autorisé).
- **Interdiction absolue de toucher `e2e/`, `backend/`, `.claude/agents/`, `scripts/`, `Makefile`.**
---
## 4. Brief — Test verifier
## 4. Brief — Team-lead infra (US-24 + US-25, en parallèle des builders)
E2e Playwright. Un fichier par US :
- `us13-multi-techniques.spec.ts` — AC-13.1 → AC-13.5 (focus API + données)
- `us14-techniques-tags.spec.ts` — AC-14.1 → AC-14.5 (UI tags + remove)
- `us15-mitre-matrix-modal.spec.ts` — AC-15.1 → AC-15.5 (modal interaction + a11y)
- `us16-regression-sprint2.spec.ts` — re-exécuter les ACs critiques sprint 2 (auto-transition US-8, workflow US-11, SOC restrictions US-9) avec le nouveau modèle.
**US-24 — Process hygiene**
- Créer `.claude/agents/design-reviewer.md` avec frontmatter agent (model `opus`, tools : `Read`, `Glob`, `Grep`, `Bash` lecture seule). Brief : revoit diff frontend + screenshots, audit alignement / DESIGN.md tokens / cohérence visuelle / responsive.
- Mettre à jour `.claude/agents/frontend-builder.md` : DoD strict sur les screenshots.
- Mettre à jour SPEC.md § Workflows : insérer design-reviewer entre frontend-builder et code-reviewer.
Mettre à jour les e2e sprint 2 qui assertaient `mitre_technique_id` / `mitre_technique_name` scalaires (US-8, US-10 selon le grep).
**US-25 — PR helper**
- Écrire `scripts/open-pr.sh` (cf AC-25.1).
- Target Makefile `open-pr`.
- Documenter README.md.
- Dogfood en fin de sprint.
---
## 5. Definition of Done — Sprint 3
## 5. Brief — Test verifier
- [ ] Tous les AC US-13 → US-16 passent.
- [ ] Backend tests verts (`pytest -q`). Ruff + mypy clean.
- [ ] Frontend tests verts (`npm run test -- --run`). Typecheck + lint clean.
- [ ] E2e Playwright suite verte (sprint 1 + 2 + 3).
- [ ] Migration Alembic testée upgrade + downgrade.
- [ ] SPEC.md mis à jour (multi-techniques acté).
- [ ] README.md mis à jour (mention matrice + multi-tech dans la description workflow).
- [ ] CHANGELOG.md sprint 3 entry sous [Unreleased].
- [ ] Code-reviewer sans BLOCKER.
- [ ] **Frontend-builder a screenshot la matrice modale + la simulation form avec tags AVANT de marquer la tâche terminée (lesson learned sprint 2).**
- [ ] PR ouverte + récap synthétique team-lead.
E2e Playwright :
- `us17-ui-polish.spec.ts` — AC-17.1 (single button), AC-17.3 (alignment via locator boundingBox).
- `us18-done-readonly-reopen.spec.ts` — AC-18.1 → AC-18.5.
- `us19-engagement-auto-status.spec.ts` — AC-19.1 → AC-19.4.
- `us20-matrix-fits-modal.spec.ts` — AC-20.1, AC-20.4 (no horizontal scroll via `boundingBox`).
- `us21-tactic-selection.spec.ts` — AC-21.4 → AC-21.7.
- `us22-mitre-input-redesign.spec.ts` — AC-22.1 → AC-22.5.
- `us23-dark-mode.spec.ts` — AC-23.1 → AC-23.3.
US-24/25 non e2e (process / repo files). Couverture par dogfood (la PR sprint 4 elle-même est ouverte via `make open-pr`).
Adapter les sprint 2/3 e2e si l'audit boutons (AC-17.2) renomme certains labels.
**Spec-reviewer INFO B** : AC-22.2 change le format des chips de "T1059 — Command and Scripting Interpreter" (sprint 3) à juste "T1059" (avec name dans `title=`). Les e2e sprint 3 (notamment `us14-techniques-tags.spec.ts`) qui assertent le format complet doivent être mis à jour. Pas seulement les labels boutons.
---
## 6. Décisions arrêtées (utilisateur 2026-05-27)
## 6. Décisions arrêtées
1. **Storage multi-tech** : colonne JSON `[{id, name}]` (KISS, pattern `commands` sprint 2).
2. **Sub-techniques dans la matrice** : OUI, affichées avec expand/collapse par technique parent. Sub-techniques sont aussi accessibles via Quick Search en plus.
3. **API shape** : `PATCH` reçoit `{technique_ids: ["T1059", "T1059.001", ...]}` — IDs uniquement (parents et subs au même niveau dans la liste). Backend résout names depuis le bundle.
4. **Rétrocompat** : migration backfill `[{id, name}]` depuis les scalaires. Pas de rétrocompat API.
5. **MitreTechniquePicker** : clean rewrite de la signature (`onSelect({id, name})`).
6. **Matrix layout** : colonnes par tactique, 220px fixe, scroll horizontal global.
7. **Apply de la modale matrice** : auto-save immédiat (PATCH déclenché par `MitreTechniquesField` quand le modal renvoie sa liste via `onApply`). Add/remove via tag × ou Quick Search aussi auto-save.
8. **Sprint 4 framing** (anticipation, NE PAS implémenter dans sprint 3) : Dark mode (toggle + tokens dark + persistence) + Hygiène process UI (`design-reviewer` agent + screenshot mandatory dans brief frontend-builder). Connecteur C2 reporté au-delà. Les builders sprint 3 N'ajoutent PAS de tokens dark, N'invoquent PAS le design-reviewer (qui n'existe pas encore). Seule la lesson `screenshots mandatory` est déjà appliquée en sprint 3 dans le brief frontend (§3).
1. **Tactic storage** : colonne JSON `tactic_ids` séparée. ✓ 2026-05-27
2. **Dark mode default** : `system` (suit `prefers-color-scheme`, fallback `light` si non détecté). ✓ 2026-05-27
3. **Matrix CSS fidelity** : look similaire qualitatif (frontend-builder itère, pas pixel-perfect). ✓ 2026-05-27
4. **Reopen target** : `done → review_required`. ✓ mémoire (sprint 4 scope)
5. **Reopen RBAC** : admin + redteam + soc. ✓ mémoire
6. **Engagement auto trigger** : `planned → active` sur 1ère simu in_progress (auto-transition ou manual). Pas de retour arrière auto. ✓ mémoire
7. **PR helper token source** : `~/.git-credentials` (parse user + token via sed, cf [[reference-gitea-pr-api]]). ✓ 2026-05-27
8. **Workflow design-reviewer** : insérée entre frontend-builder et code-reviewer, read-only. Diff frontend + screenshots. Format rapport à la code-reviewer mais focus visuel/design. ✓ mémoire
9. **Screenshots frontend-builder** : MANDATORY au sprint 4, en sortie du frontend-builder, paths absolus dans summary, refus de marquer la tâche done sans. ✓ mémoire
---
## 7. Plan d'exécution
1. ✅ User a validé les 8 décisions §6 (2026-05-27).
2. ✅ Team-lead a mis à jour SPEC.md (§0).
3. ✅ Spec-reviewer : APPROVED WITH NOTES après 2 passes (5 items au total, tous traités).
4. ✅ Backend-builder : commits `b5ea292` + `673b25e` (model + migration + matrix endpoint + 503 unloaded, 162 passing).
5. ✅ Frontend-builder : commit `771483f` (MitreTechniquesField + MitreMatrixModal + tags + auto-save + screenshots, 84 passing).
6. ✅ Code-reviewer : APPROVED WITH NITS (2 MINORs + 4 NITs).
7. ✅ Post-review fixes : `4596f09` + `393b6ed` backend (164 passing) + `39f4076` frontend (86 passing).
8. ✅ Test-verifier : commit `df8a6b6` (105/106 sprint 3 e2e verts, 1 pré-existant sprint 1 — DB pollution, non-régression).
9. 🟡 Team-lead : récap + PR en cours.
4. 🔵 Backend-builder : modèle + migration + endpoints + tests.
5. 🔵 Frontend-builder : composants + page update + tests Vitest. Screenshots obligatoires avant "done".
6. 🔵 Code-reviewer : LSP-first.
7. 🔵 Test-verifier : e2e US-13 → US-16 + adaptation sprint 2.
8. 🟢 Team-lead : PR + récap.
1. ✅ Team-lead a re-appliqué le SPEC sprint 3 oublié (`ba313a3`).
2. ✅ User a validé les 4 décisions ouvertes (tactic separated, theme system, matrix qualitative, token from ~/.git-credentials). Avec les 5 acquises en mémoire (sprint 4 scope), ça fait 9 décisions arrêtées.
3. 🟡 Team-lead met à jour SPEC.md § Workflows + § Décisions techniques (§0).
4. 🟡 Spec-reviewer valide le plan vs SPEC.md (anti-trous comme à sprint 3 — RBAC field-level, batch SQLite, scope ambigu).
5. 🔵 Backend-builder : modèle + migration 0004 + workflow done-readonly/reopen + engagement auto-lifecycle + tactic_ids + tests.
6. 🔵 Frontend-builder : UI polish + done read-only + matrix overhaul + tactic selection + input redesign + dark mode + screenshots.
7. 🔵 Team-lead (US-24 + US-25 en parallèle de frontend) : design-reviewer agent + frontend-builder.md update + scripts/open-pr.sh + Makefile target.
8. 🔵 Design-reviewer (NEW STEP) : revoit diff frontend + screenshots.
9. 🔵 Code-reviewer : revoit le diff complet (LSP-first).
10. 🔵 Test-verifier : e2e US-17 → US-23.
11. 🟢 Team-lead : PR via `make open-pr` (dogfood AC-25.4) + récap.