Compare commits

...

93 Commits

Author SHA1 Message Date
Knacky
8b5b5d94d8 fix(frontend): floating dropdowns and matrix modal frame use bg-paper
MitreTechniquePicker dropdown, SimulationList overflow menu,
ExportEngagementButton format menu, and MitreMatrixModal dialog frame
all used bg-canvas as their surface color. With the tinted canvas
(#f3f5f8), these floating surfaces appeared slightly grey instead of
clean white. Switched to bg-paper (#ffffff light / #1f2937 dark).
MitreMatrixModal cell hover (bg-canvas) intentionally preserved —
matrix cells sit on canvas, not on paper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:12:07 +02:00
Knacky
76bcb04c8f style(frontend): inputs + outline buttons inside cards use bg-paper
.text-input, .btn-outline, .btn-outline-ink were using bg-canvas which
now resolves to the tinted #f3f5f8 instead of white, making inputs and
Cancel/outlined buttons visually gritty on white paper cards. Switching
all three to bg-paper restores white surfaces inside cards in light mode.
Dark mode unaffected (canvas/paper both resolve correctly there).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:11:59 +02:00
Knacky
88b97cef2e feat(frontend): 2-column layout for EngagementFormPage in edit mode
In edit mode with canEditEngagements, wraps [form | C2ConfigCard] in a
lg:grid-cols-2 responsive grid with items-start alignment. Stacks to
single column on screens narrower than lg. In create mode, retains the
existing max-w-2xl single-column layout. No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:01:09 +02:00
Knacky
e4b1d6cb57 style(frontend): tint canvas light to #f3f5f8 for cards-on-canvas contrast
Canvas and paper were both #ffffff in light mode — cards only separated
by a 1px hairline, causing eye fatigue. Tints the canvas token to a
very pale cool neutral (#f3f5f8) so paper cards lift naturally without
shadow or radius, preserving brutalism. Dark mode tokens unchanged.
Updates DESIGN.md Surface section with rationale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 11:01:00 +02:00
Knacky
a9fe2fc528 docs(sprint-9): plan UI 2-col engagement + global contrast pass 2026-06-11 10:53:16 +02:00
Knacky
38e282a126 fix(backend): complete c2 task→simulation mapping per spec + sanitize adapter errors (sprint 8 code-review)
mapping.py — full §0.11 contract:
1. execution_result: append '$ <command>\n<output>\n' block (previously
   wrote raw output without command header, making multi-task blobs
   unreadable in exports)
2. executed_at: set from task.completed_at when currently null (was
   completely missing — simulation.executed_at stayed null forever)
3. commands: append task.command deduplicated line-by-line (was
   completely missing — simulation.commands stayed empty)

mythic.py — sanitize transport errors:
Replace 'raise C2Error(str(exc))' (which leaks the Mythic URL via
requests exception repr) with 'raise C2Error(f"C2 transport error:
{type(exc).__name__}")'. Original exc stays chained for backend logs.

api/c2.py — remove redundant 'task.mapping_applied = True' in import
endpoint (apply_task_to_simulation() already sets it).

test_c2_mapping.py — full rewrite: 19 tests covering command blocks,
executed_at set/preserve, commands dedup, idempotency.

test_c2_adapter_mythic.py — add URL-leak sanitization assertion.

468 passed; ruff + mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:28:49 +02:00
Knacky
7d3d39639e fix(backend): expose c2_task.source in GET /c2/tasks response
Serialize source as t.source.value (string) in list_simulation_tasks.
Updated test_c2_tasks_list shape assertion to include 'source' and
assert value is 'mimic' for execute-created tasks. Added test in
test_c2_import to assert source='import' in GET /c2/tasks after import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:24:22 +02:00
Knacky
184a2a16c9 fix(frontend): a11y on clickable rows + correct c2 source field + pill metric alignment (sprint 8 design-review)
F1: add tabIndex/role/onKeyDown/aria-expanded to C2TasksPanel expander rows and
    C2CallbackPicker callback rows; focus-visible ring via Tailwind utilities
F2: add source:'mimic'|'import' to C2TaskListItem; C2TasksPanel reads task.source
    instead of mapping_applied for the Source badge label
F3: align C2TaskStatusBadge and C2CallbackPicker Active/Inactive pill metrics to
    py-[6px] text-[14px] font-medium (matches SimulationStatusBadge / StatusBadge)
F4: replace hand-rolled Source pill class string with badge-pill-outline recipe
Tests: 212/212 passing (+3 new: Enter/Space key on expander, Enter key on callback row)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:22:45 +02:00
Knacky
7ff153905b feat(frontend): c2 tasks panel + history import (sprint 8 phase 2)
- Add getC2Tasks / listCallbackHistory / importC2 API functions + types
- useC2Tasks with 2500ms polling (stops when all tasks completed)
- useC2CallbackHistory, useImportC2 hooks
- C2TaskStatusBadge, C2TasksPanel (expandable output rows, polling indicator)
- C2CallbackPicker extracted as shared component (reused in both modals)
- ImportC2HistoryModal: 2-step callback picker → paginated history table
- SimulationFormPage: RT card + tasks panel share left grid column; Import C2 history button
- 37 new tests (api/c2, C2TasksPanel, ImportC2HistoryModal, SimulationFormPage panel visibility)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:11:12 +02:00
Knacky
8f23f59601 feat(backend): c2 callback history + task import (sprint 8 M4)
Command source decision: extended C2TaskStatus with command: str | None
(default None). Added command_name to _GET_TASK_QUERY so get_task() returns
command in a single round-trip — no separate history fetch needed on import.
4-line change, zero cascading test impact.

adapter.py:
- C2TaskStatus: add command: str | None = None field
- C2HistoricalTask: new dataclass (display_id, command, params, status,
  completed, timestamp) for history rows
- C2TaskPage.items: typed as list[C2HistoricalTask] (was list[dict])

mythic.py:
- _GET_TASK_QUERY: add command_name field
- _LIST_CALLBACK_TASKS_QUERY: new query (order_by id desc, limit/offset)
- _COUNT_CALLBACK_TASKS_QUERY: new aggregate query for total
- get_task(): surfaces command_name as status.command
- list_callback_tasks(): two _post() calls (tasks + count), allow_redirects=False

fake.py:
- _FAKE_HISTORY: frozen deterministic history (cb1=12, cb2=0, cb3=5 tasks)
- list_callback_tasks(): serves from _FAKE_HISTORY, pagination applied
- get_task(): returns command from _tasks dict

api/c2.py:
- GET /api/engagements/<eid>/c2/callbacks/<cid>/history: page+page_size
  defaults 1/25, cap 100, reject <1, 502 on adapter error
- POST /api/simulations/<sid>/c2/import: idempotent per (sim,mythic_id) pair,
  source=import, completed tasks get output+mapping_applied, incomplete tasks
  stored for poll-on-read pickup, auto-transition pending→in_progress

60 new tests (456 total); pytest/ruff/mypy all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:09:29 +02:00
Knacky
b83316f715 fix(backend): repair 0006 migration tests for alembic 1.18.x proxy api
Replace deprecated alembic.op._proxy assignment with
ops._install_proxy() / ops._remove_proxy() pattern required
by Alembic >= 1.13. Consistent with test_migration_0007_c2.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:02:46 +02:00
Knacky
873e52a2a1 feat(backend): c2 poll-on-read + output mapping (sprint 8 M3)
- adapter.py: add completed_at field to C2TaskStatus dataclass
- mythic.py: implement get_task() (GraphQL task query) and
  get_task_output() (response query + decode_response_text concat)
- fake.py: deterministic state progression via per-instance call counter;
  get_task_output raises C2Error until completed
- mapping.py: apply_task_to_simulation() idempotent output mapper
  (mapping_applied anchor prevents double-writes)
- migration 0007: add mapping_applied BOOLEAN NOT NULL DEFAULT false to c2_task
- c2_task model: mapping_applied column added
- api/c2.py: GET /api/simulations/<id>/c2/tasks poll-on-read endpoint;
  refreshes incomplete tasks from C2, fetches output on completion,
  applies mapping, skips re-polling for completed tasks; best-effort
  (C2Error on individual task skipped, returns 200 with stale status)
- 51 new tests (396 total); pytest/ruff/mypy all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:56:06 +02:00
Knacky
5ff6ae8940 feat(frontend): c2 config card + execute modal (sprint 8 phase 1)
- frontend/src/api/c2.ts: 6 typed API calls (getC2Config, putC2Config,
  deleteC2Config, testC2Config, listCallbacks, executeC2) following the
  frozen M1+M2 backend contracts
- frontend/src/api/types.ts: C2Config, C2ConfigInput, C2TestResult,
  C2Callback, C2CallbacksResponse, C2Task, C2ExecuteInput, C2ExecuteResponse
- frontend/src/hooks/useC2.ts: useC2Config, useUpdateC2Config,
  useDeleteC2Config, useTestC2Config, useC2Callbacks, useExecuteC2
- frontend/src/components/C2ConfigCard.tsx: engagement-scoped C2 config
  card (url + write-only token + verify-tls + save/delete/test-connection),
  503 disabled state, ConfirmDialog on delete
- frontend/src/components/ExecuteViaC2Modal.tsx: callback picker table
  (mono data cells), commands textarea pre-filled from rt.commands,
  Launch disabled until row selected + non-empty commands
- frontend/src/pages/EngagementFormPage.tsx: embed C2ConfigCard in edit
  mode only, admin+redteam only (canEditEngagements gate)
- frontend/src/pages/SimulationFormPage.tsx: Execute via C2 button in RT
  card, visible only when !isDone && canEditRT && hasC2Config; opens modal
- Tests: 33 new tests across api/c2, components/C2ConfigCard,
  components/ExecuteViaC2Modal, EngagementFormPage, SimulationFormPage
  (172 total, 139 baseline + 33 new, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:50:11 +02:00
Knacky
53755a31d6 feat(backend): c2 callbacks + execute endpoints (sprint 8 M2)
- Add C2Error exception to adapter ABC
- Add promote_to_in_progress() helper to simulation_workflow (pending→in_progress)
- Flesh out MythicAdapter: list_callbacks() (GraphQL query) + create_task() (mutation)
- Expand FakeAdapter to 3 deterministic callbacks; switch task store to per-instance
- Add GET /api/engagements/<id>/c2/callbacks — lists active callbacks via adapter
- Add POST /api/simulations/<id>/c2/execute — issues tasks, stores C2Task rows,
  auto-transitions pending→in_progress, blocks on done (409)
- Both endpoints: SOC=403, 503 no-key, 502 adapter error, sanitized error messages
- Add requests-mock==1.12.1 to requirements.txt
- 42 new tests (342 total, 300 M1 baseline preserved green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:38:07 +02:00
Knacky
9a9c98beab feat(backend): c2 crypto + config CRUD + adapter scaffolding (sprint 8 M1)
- Add Fernet crypto service (MIMIC_ENCRYPTION_KEY env, C2Disabled on absent key)
- Add Alembic migration 0006: c2_config + c2_task tables with cascade FKs
- Add C2Config and C2Task SQLAlchemy models
- Add C2Adapter ABC with dataclasses (C2Health, C2Callback, C2TaskStatus, C2TaskPage)
- Add FakeAdapter (deterministic in-memory, MIMIC_C2_ADAPTER=fake)
- Add MythicAdapter scaffold: test_connection() live, M2+ raise NotImplementedError
- Add decode_response_text() helper for base64/binary Mythic responses
- Add GET/PUT/DELETE/POST-test /api/engagements/<id>/c2-config endpoints
- RBAC: admin+redteam OK, SOC 403; 503 guard when encryption key absent
- Token never returned in API responses; stored Fernet-encrypted only
- 42 new tests (300 total, 258 baseline preserved green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 19:20:52 +02:00
Knacky
813e69ee01 docs(spec): add C2 integration section (sprint 8 commit #1)
Introduce the SPEC section for the Mythic C2 integration layer.
Covers RBAC (RT-only, SOC=403), per-engagement Fernet-encrypted config,
c2_config + c2_task data model with ON DELETE CASCADE, full endpoint
list, output mapping rules (append-only, idempotent), 2500 ms polling
and the fake/real adapter selection via MIMIC_C2_ADAPTER.

Also patch tasks/todo.md: fix pytest baseline (256 from main, not 253),
make cascade-delete explicit, pin the MythicMeta/Mythic_Scripting source
version and document defensive base64 handling.

Closes spec-reviewer WARN-1 (SPEC ↔ plan parity), WARN-2 (cascade),
INFO-1 (pinned source), INFO-3 (baseline).
2026-06-10 19:07:35 +02:00
6ca614a3f3 Merge pull request 'feat(design): terminal-SOC aesthetic refresh (sprint 7)' (#10) from sprint/7-design into main
Reviewed-on: #10
2026-06-10 16:40:20 +00:00
Knacky
1997a8c621 feat(frontend): side-by-side red-team/SOC columns on simulation screen
Wrap RT and SOC form cards in a responsive 2-column grid
(grid gap-xl lg:grid-cols-2 items-start) on the simulation edit view.
Drop the max-w-3xl constraint from the outer container so the grid
can use full page width (matching EngagementDetailPage). Header,
banners, submitError, and sticky action bar remain full-width above
the grid. The isNew create form keeps its current narrow layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 18:17:54 +02:00
Knacky
573281f454 fix(design): code-reviewer polish — dedupe tag-mitre recipe, drop dead boxShadow tokens, cover StatusBadge classes
F1: MitreTechniqueTag inline classes replaced with className="tag-mitre gap-xxs"
    (index.css .tag-mitre is now the single source of truth for technique tags).
F2: boxShadow block removed from tailwind.config.ts — no component referenced
    shadow-soft-lift / shadow-floating / *-dark; DESIGN.md §Don'ts prohibits shadows.
F4: StatusBadge.test.tsx gains 3 class assertions (bg-warn-soft / bg-primary-soft / bg-cloud)
    mirroring SimulationStatusBadge.test.tsx pattern. Test count: 136 → 139.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 05:53:53 +02:00
Knacky
6995c4c860 fix(design): address design-reviewer findings F1-F6 — nav slab, spinner, badge coverage, mono discipline
F3: nav-bar-top bg-paper → bg-slab text-slab-text (3-band slab anchoring restored).
    NavLinks: text-slab-muted default, text-slab-text + border-primary active.
    Logo span: text-ink → text-slab-text.
F2: (you) label extracted from font-mono td into adjacent font-sans span.
F1: Loader2 circular spinner → EXPORTING… text with animate-pulse (terminal-SOC compatible).

Screenshots regenerated:
- All 8 pages light+dark (01→10)
- 05-simulation-form-edit light+dark (F6)
- 11-mitre-matrix-modal light+dark (F6)
- 12-toast-success light+dark (F6)
- 13-confirm-dialog light+dark (F6)
- admin-light/dark-open/closed regenerated from HEAD (F4)

F4: StatusBadge.tsx confirmed single code path — planned → bg-warn-soft (no divergence in code).
    Divergence in prior captures was stale cache; regenerated admin-* confirm consistency.
F5: Simulations seeded (pending/in_progress/review_required/done) via API;
    10-sim-list-badges shows all 4 semantic badge colors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:43:08 +02:00
Knacky
7ea2fe490c docs: add sprint 7 terminal-SOC design refresh to CHANGELOG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:01:29 +02:00
Knacky
0e69eb901c test(frontend): update vitest assertions for new design tokens 2026-06-09 18:46:03 +02:00
Knacky
5cc830554c refactor(pages): apply terminal-SOC aesthetic to all 8 pages 2026-06-09 18:44:47 +02:00
Knacky
ec7800ae38 refactor(components): squared shapes + mono data cells across all components 2026-06-09 18:42:26 +02:00
Knacky
c791e50108 feat(design): bundle JetBrains Mono for data cells 2026-06-09 18:39:13 +02:00
Knacky
64da94f10d feat(design): refresh tailwind tokens for brutalist terminal aesthetic 2026-06-09 18:38:43 +02:00
Knacky
6c05cc2e11 docs(design): rewrite DESIGN.md for terminal-SOC aesthetic 2026-06-09 18:37:44 +02:00
Knacky
5627d7dcfa docs(sprint 7): plan — terminal-SOC design refresh
9 binding decisions locked with user 2026-06-09 (4-question + 4-question
+ 3-question rounds). Visual direction Bloomberg / terminal SOC. Border
radius 0 except status pills and avatars. Palette kept (primary blue +
slab + canvas/paper/cloud/fog/ink), ADD success/warn semantic tokens.
Scope: 8 pages + 17 components + tokens + DESIGN.md rewrite, all in one
sprint. JetBrains Mono for data only (Inter stays for body/headers).
Light + dark both kept. Zero transitions (brutalist).

Plan validated by spec-reviewer pre-pass: APPROVED with 3 findings
addressed inline (D9 added, R2 reworded, semantic tokens promoted from
optional to locked).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 18:34:45 +02:00
Knacky
c85ece46b9 chore(agents): frontend-builder must invoke Skill frontend-design before UI work
Adds a mandatory pre-step to the frontend-builder agent prompt: invoke the
frontend-design skill at sprint start before creating or modifying any
visible UI component. DESIGN.md rules project-specific tokens; the skill
covers universal principles (typographic hierarchy, alignment, contrast,
focus, density, motion). Skip allowed only for pure logic/data-layer work
with no visual change.

Authored locally during sprint 6 (uncommitted in worktree), bundled into
sprint 7 hygiene as the first commit so it takes effect immediately for
the design refresh work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 18:34:36 +02:00
e27babed5b Merge pull request 'feat: sprint 6 — engagement export (md/csv/pdf)' (#9) from sprint/6-export into main
Reviewed-on: #9
2026-06-09 16:19:02 +00:00
Knacky
e41679b331 fix(export): render PDF in A4 landscape for 7-column readability
Add @page { size: A4 landscape } to _CSS, reduce font-size to 11px,
and set table-layout: fixed + word-break: break-word so 7 columns
fit without overflow. Unit test asserts the landscape rule is present
in the rendered HTML.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:13:46 +02:00
Knacky
2d1c113f0c docs: log 2 MEDIUM security fixes in CHANGELOG (post-refactor)
CSV multiline injection + Markdown stored-XSS regressions caught by
security-guidance@claude-code-plugins on the 7-column refactor.
Backend fix in 3a9d9d3 (257 pytest, ruff/mypy clean). PR #9 body
counter bumped 255 → 257.
2026-06-08 19:29:59 +02:00
Knacky
3a9d9d3203 fix(security): defuse CSV formula injection in multiline exécution cell + HTML-escape Markdown table cells
Finding 1 — CSV multiline formula injection:
- Split _format_execution into _format_execution_text (MD/PDF, no sanitization) and
  _format_execution_csv (CSV, applies _csv_safe to each user-controlled component before join)
- Moved _CSV_FORMULA_TRIGGERS + _csv_safe above the format helpers (required by _format_execution_csv)
- Outer _csv_safe on the Exécution cell retained as belt-and-braces for the empty-date case
- New test: test_render_engagement_csv_defuses_formula_in_inner_execution_lines

Finding 2 — Stored XSS in Markdown table:
- _cell() in render_engagement_markdown now calls _html_escape() (quote=True, default)
  before pipe-escaping and \n→<br/> substitution — correct order preserved
- New test: test_render_engagement_markdown_escapes_html_in_table_cells

255 → 257 passed, ruff clean, mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:29:10 +02:00
Knacky
4d9447082f docs: sprint 6 amendment — 7-column schema in CHANGELOG + PR body
Post-review user decision (2026-06-08) switched the export payload to a
fixed 7-column FR handoff schema (Scénario / Test / Source de log /
Commentaires SOC / Exécution / Logs remontés au SIEM / Cyber incident).

Logged in CHANGELOG [Unreleased] Changed section with commit refs
(SPEC fdab324, backend 7335b9f, e2e aeb4bdb) and updated PR #9 body
counters: 255 pytest (was 253), 136 vitest unchanged, 223 e2e
unchanged.
2026-06-08 19:23:02 +02:00
Knacky
aeb4bdb025 test(e2e): adapt export specs to 7-column schema (Scénario/Test/...)
Update AC-29.2 (Markdown) to assert | Scénario | GFM table header.
Update AC-29.3 (CSV) to assert exact 7 FR column names instead of 'name'.
Update AC-31.4 (empty engagement) MD to assert table absent, CSV header
to assert exact 7 FR columns.
Drop unused sim1/sim2 vars and makeClient import (NIT cleanup).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:21:51 +02:00
Knacky
7335b9f2c6 refactor(export): switch render output to fixed 7-column schema (Scénario, Test, ...)
All three renderers (MD, CSV, PDF) now emit a uniform 7-column table with
French headers matching the RT↔SOC handoff contract locked in SPEC.md fdab324.

Helpers added:
- _format_execution(sim): canonical 3-part concat (executed_at / commands / execution_result)
- _MD_HEADERS / _HTML_HEADERS / _CSV_HEADERS unified to the same 7 FR strings

Helpers removed (no longer called):
- _tactic_names() — MITRE tactics dropped from export
- _enrich_sim_techniques() — MITRE techniques dropped from export

Fields dropped from export: status, techniques, tactic_ids, prerequisites, id,
created_at, updated_at (intentional — focused RT↔SOC handoff, see SPEC §Export).

_csv_safe() still applied to all 7 user-controlled cells including Exécution concat.

Tests updated: 255 passed, ruff clean, mypy clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:15:49 +02:00
Knacky
fdab324217 docs(spec): export — switch to fixed 7-column handoff schema
User decision 2026-06-08 (post-PR-9, pre-merge): the export schema is
now a fixed 7-column layout focused on the RT↔SOC handoff, applied
uniformly across Markdown / CSV / PDF.

Columns (French headers): Scénario, Test, Source de log,
Commentaires SOC, Exécution (multiline concat of executed_at +
commands + execution_result, no labels), Logs remontés au SIEM,
Cyber incident.

Removed from the export (intentional): simulation status, MITRE
techniques and tactics, prerequisites, id, created_at, updated_at.
The export is a handoff product, not a full data dump.

This is the spec change that drives the upcoming render refactor
in services/export.py. SPEC committed first per the sprint-6
positional fix (FIRST commit, not at sprint close).
2026-06-08 19:10:42 +02:00
Knacky
e4a672c443 docs: sprint 6 wrap-up — README + CHANGELOG + 6 lessons
- README "Status" bumped to sprint 6 + test counts (253 backend, 136
  frontend, 223 e2e).
- CHANGELOG [Unreleased] section for sprint 6: backend, frontend, e2e,
  security, and changed-section notes (SPEC commit-first + mimic team).
- 6 sprint-6 lessons in tasks/lessons.md:
  1. SPEC.md commit-first tamed the 4-sprint recurrence
  2. Persistent team mimic + idle members > "never idle"
  3. Security plugin caught CSV formula injection mid-sprint
  4. Stdlib first before custom helpers
  5. Tests that mock at module level can't exercise the target's branches
  6. _engagement param for signature symmetry across render trio

This is the team-lead wrap-up commit. PR body in tasks/pr-body-sprint-6.md
will be ingested by make open-pr.
2026-06-08 18:35:21 +02:00
Knacky
b572a67066 test(e2e): sprint 6 acceptance — US-29 / US-30 / US-31
Adds 3 Playwright spec files covering all 13 ACs for the engagement
export feature:
- us29-export-formats.spec.ts (8 tests): dropdown, md/csv/pdf downloads,
  admin + redteam, filename convention
- us30-export-rbac.spec.ts (3 tests): SOC button absent, SOC 403, no-token 401
- us31-export-robustness.spec.ts (4 tests): missing format 400, bad format 400,
  unknown engagement 404, zero-sim export OK

Total: 201 → 223 Playwright tests. No regressions on sprints 1–5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:31:32 +02:00
Knacky
3725d4415e chore: code-review cleanups (NITs + filename defense-in-depth test)
- NIT-1: remove dead _technique_names() and _technique_ids() helpers (no callers)
- NIT-2: rename engagement → _engagement in render_engagement_csv signature
- NIT-4: remove duplicate inline User import in test_export_csv_escapes_special_characters
- NIT-5: add comment on _CSV_FORMULA_TRIGGERS explaining \t and \r inclusion
- REUSE: replace custom _html_escape with stdlib html.escape (quote=True default)
- Remove now-unnecessary type: ignore comments on weasyprint (stubs resolve cleanly)
- Add test_export_filename_never_contains_quote_or_crlf defense-in-depth test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:23:39 +02:00
Knacky
123d9812bc test: cover Content-Disposition fallback in ExportEngagementButton 2026-06-08 18:20:29 +02:00
Knacky
57dbd14347 fix(security): defuse CSV formula injection in engagement export (MEDIUM)
Authenticated red-team users could craft any user-controlled string field
(name, description, commands, prerequisites, execution_result, log_source,
logs, soc_comment, incident_number, MITRE technique IDs) starting with =,
+, -, @, \t or \r. When the SOC analyst opens the exported CSV in Excel /
LibreOffice / Google Sheets — explicitly the consumption flow this sprint
optimizes for — the spreadsheet executes the field as a formula on the
SOC's machine.

Fix: new helper _csv_safe() prefixes a single apostrophe to any string
starting with a formula-trigger character, forcing the spreadsheet to
render the cell as text. Applied to every user-controlled field in
render_engagement_csv. Numeric and ISO-date fields are not wrapped.

Tests:
- test_render_engagement_csv_escapes_formula_injection_in_name
- test_render_engagement_csv_escapes_formula_injection_in_commands
- test_render_engagement_csv_does_not_alter_safe_strings

Result: 249 → 252 passing (the 1 remaining failure is pre-existing
test_index_without_built_frontend_returns_json, unrelated to this fix).

Flagged by security-guidance@claude-code-plugins automated review.
2026-06-08 18:13:16 +02:00
Knacky
25877c4092 test: ExportEngagementButton + EngagementDetailPage RBAC tests
9 tests for ExportEngagementButton (render, open, close-outside,
Escape, per-format trigger, loading state, error toast).
3 RBAC tests for EngagementDetailPage (admin/redteam see Export,
soc does not). Total: 121 → 133 vitest passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:04:56 +02:00
Knacky
100441bdeb feat: ExportEngagementButton + exports API client
Add split-button dropdown [Export ▼] on EngagementDetailPage that
downloads engagement as Markdown, CSV, or PDF via
GET /api/engagements/<id>/export?format=md|csv|pdf.

Both halves open the dropdown (no default left-click action).
RBAC-gated with canEditEngagements (admin + redteam only).
Loading state per item, toast on error, click-outside + Escape close.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 18:04:49 +02:00
Knacky
5471c8fd89 test: add export endpoint + render unit tests (226 → 249 passing)
- test_export_engagement.py: 13 endpoint tests — RBAC (admin/redteam ok, SOC 403,
  401 unauthenticated), CSV column contract, CSV special char escaping, PDF magic bytes,
  400 on missing/unknown format, 404 on missing engagement, zero-simulations edge case,
  filename slugification.
- test_export_render.py: 10 unit tests on pure render functions — header fields,
  simulation order, techniques/tactics enrichment, SOC fields always rendered,
  backtick safety in commands, CSV header row, multi-technique pipe join, PDF magic
  bytes, MITRE bundle not loaded does not crash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:57:40 +02:00
Knacky
f1a7965ab9 chore: add WeasyPrint system deps to Dockerfile python stage
apt-get install libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b
libfontconfig1 shared-mime-info — minimal set for text-only PDF rendering.
libgdk-pixbuf-2.0-0 excluded (no images in PDF, verified via weasyprint --info).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:57:31 +02:00
Knacky
87e4409530 feat: add engagement export service and endpoint (md/csv/pdf)
- New module backend/app/services/export.py with render_engagement_markdown,
  render_engagement_csv, render_engagement_pdf, _render_engagement_html helper,
  and _export_filename slugifier (NFKD + fallback "unnamed").
- Extend engagements_bp with GET /api/engagements/<int:eid>/export?format=md|csv|pdf,
  gated @role_required("admin","redteam"). Returns 400 on missing/unknown format,
  404 on unknown engagement, correct Content-Type + Content-Disposition headers.
- Reuses _enrich_techniques and _enrich_tactics from serializers.py; resilient
  to MITRE bundle not loaded (returns empty tactics, no crash).
- Adds weasyprint>=60.0 to backend/requirements.txt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:57:22 +02:00
Knacky
cf006a2ba8 docs(plan): sprint 6 — apply spec-reviewer Pass 1 fixes (1 BLOCKER + 6 WARN)
Fixes applied:
- BLOCKER §2 : EngagementDetailPage.test.tsx → "nouveau" (n'existe pas
  encore), pas "existant — adapter".
- WARN §1 : "Première ligne du summary" obligatoire pour backend-builder
  avec le path final EXACT (anti-URL-drift, lesson sprint 5).
- WARN §0/§1 : slug avec NFKD-strip pour accents + fallback "unnamed"
  pour edge case nom 100% non-alphanum.
- WARN §2 : ExportEngagementButton les DEUX moitiés ouvrent le dropdown
  (pas d'action par défaut — différence vs NewSimulationDropdown).
- WARN §2 : exports.ts throw Error sur non-2xx pour pipeline toast.
- WARN §1 : created_by rendu username-only en MD/CSV (pas la dict).
- WARN §1 : PDF généré depuis les DONNÉES (pas depuis le string Markdown).

NITs incorporés :
- gdk-pixbuf-2.0-0 retiré du set minimal (text-only PDF), avec note
  pour confirmer via weasyprint --info.
- data-testid="export-dropdown" sur le wrapper pour AC-30.1.
- AC-29.3 : compter rows via csv.reader, pas file.split.
- §0 point 14 : style explicite btn-outline (cohérence header).
- Test MITRE-bundle-not-loaded ajouté à test_export_render.py.

Plan prêt pour spec-reviewer Pass 2.
2026-06-07 18:38:41 +02:00
Knacky
01434c04a7 docs(plan): sprint 6 — engagement export (md/csv/pdf) plan
3 user stories scoped (US-29 export formats, US-30 SOC zero access,
US-31 format/engagement robustness). Backend extends engagements_bp
with GET /api/engagements/<id>/export?format=md|csv|pdf returning the
rendered file, no DB schema change. Frontend adds an
ExportEngagementButton split-button dropdown on EngagementDetailPage,
gated to admin+redteam.

Binding decisions locked with the user: 3 formats Markdown/CSV/PDF,
RBAC admin+redteam, engagement + all simulations RT+SOC, single
endpoint with format query param. WeasyPrint chosen for PDF (Python
HTML→PDF, ~50MB cairo/pango deps to add to Dockerfile, accepted).

Plan ready for spec-reviewer Pass 1.
2026-06-07 18:29:59 +02:00
Knacky
7aaa5ccc6d docs(spec): add § Export d'engagement section (sprint 6)
Specifies the new export feature contract:
- 3 formats : Markdown, CSV, PDF
- Engagement header + all simulations RT + SOC
- Endpoint unique GET /api/engagements/<id>/export?format=md|csv|pdf
- RBAC admin + redteam (SOC zero access, cohérent avec Templates)
- Filename normalisé engagement-<id>-<slug>-YYYYMMDD.<ext>

Committed as commit #1 of sprint 6 — applies lesson learned in sprints 3/4/5
where the SPEC section sat as uncommitted M SPEC.md until sprint-close
discovery. Per lessons.md §sprint-5 fix candidate "Stage SPEC.md as part
of the FIRST sprint commit, not as a separate later commit."
2026-06-07 18:29:49 +02:00
678ee8fbfb Merge pull request 'feat: sprint 5 — simulation templates + instantiation + nav + dropdown' (#8) from sprint/5-templates into main
Reviewed-on: #8
2026-06-07 16:08:38 +00:00
Knacky
e18ec2bf79 docs(lessons): sprint 5 — fold in the recurrent SPEC-uncommitted lesson with concrete fix candidates 2026-05-28 07:25:09 +02:00
Knacky
cbc176ab82 docs(spec): carry over sprint 5 SPEC update missed in sprint 5 commits
Sprint 5 plan §0 added a new ## Templates de simulations section to SPEC.md
(between § Fonctionnement and § Authentification & rôles). The edit sat in
the sprint 5 worktree but was never committed across the 9 sprint commits,
so PR #8 currently does not include the corresponding spec text.

This is the THIRD sprint running this happens (sprint 3 → fixed at sprint 4
start; sprint 4 → fixed at sprint 5 start; sprint 5 → fixed here mid-PR
because I caught the M SPEC.md before merge).

Lesson updated in tasks/lessons.md to make the "git status pre-sprint-close"
discipline harder to forget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:24:43 +02:00
Knacky
54959c7d5b test(e2e): sprint 5 acceptance — US-26 / US-27 / US-28 + adaptations dropdown sprint 2-4
- us26: add AC-26.4 isinstance guard (technique_ids string→400) + AC-26.7 cascade test (DELETE template does not affect instantiated sim)
- us27: add NIT-1 dropdown Escape/click-outside close, NIT-2 empty-engagement dropdown visibility
- 49 sprint 5 tests passing, 206/207 full suite passing (us1 pre-existing isolation issue)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:23:33 +02:00
Knacky
2e59743af5 docs: sprint 5 wrap-up — CHANGELOG + README + 6 lessons + plan final
- CHANGELOG: sprint 5 entry under [Unreleased] (templates CRUD + instantiation + nav + dropdown + decorrelation). Sprint 4 moved to its own [Sprint 4] section.
- README: status bump to sprint 5, test counts refreshed (226/121/201).
- tasks/lessons.md: 6 sprint-5 lessons captured (spec-reviewer 2-pass before dispatch finally clicked, endpoint path drift caught visually not by spec-review, screenshot script mocks lag path changes, silent URL "improvements" by backend, apply_patch wrong primitive for creation copy paths, IntegrityError catch beats pre-check SELECT, SendMessage rule applies to all team agents).
- tasks/todo.md: status flipped to 🟢 SPRINT COMPLET.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:18:21 +02:00
Knacky
7c011db6d9 test(e2e): sprint 5 acceptance tests — US-26 → US-28 + dropdown adaptations
Add three new spec files:
- us26-templates-crud: API CRUD (AC-26.3–26.7) + UI list/form/delete/redirect (AC-26.8)
- us27-instantiate-from-template: template_id copy + name override + 404 + decoupling
  (AC-27.1–27.3) + no auto-transition/engagement-activate (AC-27.4–27.5) + dropdown
  UI + picker modal + empty state + SOC gate (AC-27.6–27.7)
- us28-templates-nav: Templates link admin+redteam only, SOC redirect, form editable (AC-28.1–28.3)

Adapt sprint 2/3 e2e for sprint 5 dropdown:
- us4-engagements: getByRole link "New simulation" → getByTestId "new-simulation-btn"
- us7-simulation-create: same — split-button dropdown replaced the link

Suite: 201 passed (1 pre-existing flaky in us3 re DB state, unrelated to sprint 5).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:15:04 +02:00
Knacky
55f993fa24 fix(backend): sprint 5 post-review — name fallback, isinstance guards, 400 tests
- create_simulation: name falls back to template.name when template_id provided
  and name is absent/empty (AC-27.1)
- templates POST/PATCH: isinstance(list) check on technique_ids/tactic_ids
  before resolving, returns 400 with clear message
- 5 new tests: unknown technique_id → 400 (POST+PATCH), unknown tactic_id → 400
  (POST+PATCH), name fallback to template.name
- mypy: merged template branch into if/else to eliminate union-attr false positives

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:04:25 +02:00
Knacky
33a0ca30bb fix(frontend): sprint 5 post-code-review — dropdown close-on-outside + empty-state dropdown
- useEffect pointerdown + Escape listeners when dropdown open (NIT 1)
- empty state now renders NewSimulationDropdown instead of plain Link (NIT 2)
- 3 new Vitest: close-on-outside, close-on-Escape, empty-state has dropdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:02:34 +02:00
Knacky
20783118ee fix(frontend): sprint 5 design-review — dropdown dark + Plus icon + re-shoots
- shadow-floating dark:shadow-floating-dark on dropdown menu (Fix 1)
- hover:bg-cloud dark:hover:bg-fog on dropdown items (Fix 2)
- Plus icon + "New" label on split-button primary half (Fix 3)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:50:19 +02:00
Knacky
2b700115e8 fix(frontend): sprint 5 — correct API path /simulation-templates → /templates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:43:33 +02:00
Knacky
90fc5bab6c feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
  extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
  useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
  New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
  with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
  (Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
Knacky
1f327e9aa8 feat(backend): sprint 5 — SimulationTemplate CRUD + instantiation
- SimulationTemplate model + migration 0005 (CREATE TABLE + name index)
- 5 CRUD endpoints under /api/templates (admin|redteam only, SOC 403)
- POST /api/engagements/<eid>/simulations extended with optional template_id
- serialize_template() reusing _enrich_techniques/_enrich_tactics helpers
- IntegrityError → 409 for duplicate name on both POST and PATCH
- 28 new tests (CRUD, RBAC, dedup, instantiation, migration round-trip)
- 221 tests pass; ruff clean; mypy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:25:19 +02:00
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
27573f5228 Merge pull request 'feat: sprint 3 — multi-technique simulations + MITRE matrix modal' (#6) from sprint/3-mitre-matrix into main
Reviewed-on: #6
2026-05-27 17:11:22 +00:00
Knacky
b001f57774 docs: sprint 3 wrap-up — README + CHANGELOG + lessons + plan final
- README: status bump to sprint 3, test counts refreshed (164/86/105), IPv6 note for the e2e runner
- CHANGELOG: sprint 3 entry under [Unreleased] (multi-tech model + matrix endpoint + auto-save UI); sprint 2 moved to its own [Sprint 2] section (merged 2026-05-27)
- tasks/lessons.md: 6 lessons captured (2-pass spec-review, inline summary scoping, "test in brief means test in commit" discipline, SQLite batch_alter_table, real migration round-trip, modal Apply 0 disambiguation)
- 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 04:55:12 +02:00
Knacky
df8a6b605b test(e2e): sprint 3 acceptance tests — US-13 to US-16
Four new spec files covering the MITRE multi-technique feature:
- us13: API contract (techniques array, dedup, unknown ID → 400, SOC 403, auto-transition)
- us14: tag UI (empty state, add/remove auto-save, SimulationList column, order, styling)
- us15: matrix modal (tactic tree, layout, select/expand/search, Apply/Cancel/Escape/backdrop, a11y)
- us16: sprint 2 regression (workflow, badge, SOC RBAC, picker still works)

Updated sprint 2 specs (us8, us10) to use technique_ids array and Quick search button
instead of deprecated scalar mitre_technique_id/name fields.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 04:51:34 +02:00
Knacky
393b6ed416 fix(backend): sprint 3 post-review — migration nullable + dead code + tactic names + tests
- Migration 0003: enforce techniques NOT NULL via batch_alter_table (AC-13.1 DDL spec)
- Migration 0003: remove unused _sims table proxy and orphaned column/table imports
- mitre.py: rename _TACTIC_NAMES → TACTIC_NAMES (public); add all 12 correct display names
- mitre.py: use TACTIC_NAMES dict in _build_matrix() to fix "Command And Control" → "Command and Control"
- test_mitre.py: add T1071 fixture entry under command-and-control; assert tactic_name lowercase "and"
- test_simulations_techniques.py: real Alembic round-trip test asserting techniques NOT NULL after upgrade
2026-05-27 04:31:10 +02:00
Knacky
4596f09e71 fix(backend): sprint 3 post-review — nullable migration + dead code + tactic names
- Migration 0003: enforce techniques NOT NULL via batch_alter_table
- Migration 0003: remove unused _sims table proxy and dead column/table imports
- mitre.py: add _TACTIC_NAMES dict to fix 'Command And Control' → 'Command and Control'
2026-05-27 04:25:20 +02:00
Knacky
39f4076a81 fix(frontend): sprint 3 post-review — real dedup test + Apply 0 guard + Link stopPropagation
- MitreTechniquesField test: rewrite dedup test to actually exercise picker
  selection path — types query, waits for option, fires pointerDown,
  asserts no PATCH sent (dedup guard in handleSelect now truly covered)
- MitreMatrixModal: Apply button disabled only when totalSelected === 0
  AND initialSelection.length === 0 (no-op case); when totalSelected === 0
  but initialSelection was non-empty, shows "Clear all" and stays enabled
  so user can explicitly wipe the list
- MitreMatrixModal tests: update disabled test to match "Clear all" label,
  add "Clear all" enabled + onApply([]) path test
- SimulationList: stopPropagation on Name <Link> to prevent double-navigate
  with row onClick handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 04:23:46 +02:00
Knacky
771483f3b0 feat(frontend): sprint 3 — multi-technique MITRE selection + matrix modal
- types: replace mitre_technique_id/name scalars with techniques:MitreTechnique[]
  on Simulation; add MitreTactic/MitreMatrixTechnique/MitreMatrixSubtechnique;
  SimulationPatchInput now uses technique_ids:string[]
- api/mitre.ts: add getMitreMatrix() → GET /api/mitre/matrix
- hooks/useMitre: add useMitreMatrix(enabled) with staleTime:Infinity
- MitreTechniquePicker: clean rewrite — onSelect(technique) one-shot, resets
  input after selection, no incoming value props
- MitreTechniqueTag: chip component with id+name and × remove button
- MitreMatrixModal: tactic columns (220px fixed), expand/collapse subtechniques,
  search filter (auto-expands parent on sub match), selection state, focus trap
  (Tab wrap, Escape, search autofocus), backdrop click cancel, Apply N techniques
- MitreTechniquesField: orchestrates tags+picker+matrix with auto-save PATCH on
  every add/remove/Apply, dedup guard, disabled read-only mode for SOC
- SimulationFormPage: swap MitreTechniquePicker for MitreTechniquesField; remove
  technique state from RT form (techniques have independent auto-save cycle)
- SimulationList: MITRE column → T1059 +2 counter format, — when empty
- Tests: 84 passing (13 test files); new suites for Tag, Field, Modal;
  MitreTechniquePicker + SimulationFormPage + SimulationList adapted to new API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 04:04:23 +02:00
Knacky
673b25e0b0 fix(backend): PATCH technique_ids returns 503 when MITRE bundle not loaded
Added bundle-loaded guard in _resolve_technique_ids() before attempting any
lookup; matches behavior of GET /api/mitre/matrix and GET /api/mitre/techniques.
Added corresponding test case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:58:30 +02:00
Knacky
b5ea2929de feat(backend): sprint 3 — multi-technique simulations + MITRE matrix
- Simulation model: replace mitre_technique_id/name scalars with techniques JSON column [{id, name}]
- Alembic migration 0003: add techniques, backfill from scalars, drop old columns (reversible)
- MITRE service: add get_tactics(), lookup_name(), get_matrix() with canonical tactic order and sub-technique nesting
- serializer: enrich techniques with tactics from service at serialize time (graceful empty tactics if bundle outdated)
- simulation_workflow: PATCH now accepts technique_ids list, validates against bundle, deduplicates preserving order, auto-transitions on non-empty list
- simulations API: add GET /api/mitre/matrix endpoint (503 if bundle absent)
- test_mitre.py: updated _reset_mitre fixture, added T1059.006 sub-technique, 14 new tests for get_tactics/lookup_name/get_matrix/matrix endpoint
- test_simulations_techniques.py: 20 new tests covering AC-13.1 to AC-13.5 (create, PATCH, dedup, auto-transition, SOC blocked, migration backfill logic)

Total: 161 tests passing. ruff clean. mypy: no new errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 03:56:02 +02:00
e1d9738f23 Merge pull request 'sprint/2-simulations' (#5) from sprint/2-simulations into main
Reviewed-on: #5
2026-05-26 17:06:31 +00:00
Knacky
ddf48dd1d1 docs(changelog): post-QA fixes summary + e2e suite now fully green
- Document the 4 post-QA fixes (i18n FR→EN, password field alignment,
  execution_result TextArea, unified sticky action bar)
- Update the e2e suite status: 68/68 passing on both docker and podman
  (sprint 1 us1/us6 failures resolved by b3124ba's auto-detect that also
  landed in those specs)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:14:37 +02:00
Knacky
da2ce68660 test(e2e): align assertions with i18n fix — French→English labels
- us7: "Nouvelle simulation" → "New simulation" (3 assertions)
- us4: "Nouvelle simulation" → "New simulation" (1 assertion)
- us9: "Simulation pas encore en revue" → "Simulation not yet ready for review" (1 assertion)
- us11: "Marquer en revue" → "Mark for review" (6 assertions), "Clôturer" → /^close$/i (7 assertions)
- us12: "Supprimer" → /^delete$/i (4 assertions), "Supprimer la simulation" → "Delete simulation" (1 assertion)

No other French strings found in e2e/tests/. Suite: 68/68 pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:13:33 +02:00
Knacky
2a7d27bf02 fix(frontend): post-QA sprint 2 — i18n + alignment + textarea + action bar layout
- Translate all remaining French strings to English (toasts, buttons, banner)
- Fix UsersAdminPage create-form grid alignment: items-start + self-end on button wrapper
- Change execution_result from TextInput to TextArea (5 rows, multiline)
- Replace split Save RT / Save SOC footers + workflow div with a single sticky
  action bar (Save Red Team | Save SOC | Mark for review | Close | Delete)
- Update Vitest assertions to use English button labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:08:46 +02:00
52611337c2 Merge pull request 'fix(make): auto-detect docker/podman so Makefile works on either engine' (#4) from sprint/2-simulations into main
Reviewed-on: #4
2026-05-26 10:34:01 +00:00
Knacky
b3124ba4dd fix(make): auto-detect docker/podman so Makefile works on either engine
- Makefile: introduce CONTAINER_CMD ?= $(shell command -v docker || echo podman),
  replace all 12 hardcoded `docker` invocations with $(CONTAINER_CMD). User can
  override with `make <target> CONTAINER_CMD=podman` or env export.
- e2e/tests/us1-bootstrap-admin.spec.ts: AC-1.4 regex updated to match the new
  variable form `$(CONTAINER_CMD) exec … flask create-admin` (was hardcoded
  `docker exec`). RUNTIME default also auto-detects (same logic as Makefile)
  so the test exec'es the right engine without a MIMIC_CONTAINER_CMD export.
- e2e/tests/us6-deployment.spec.ts: same RUNTIME auto-detect so the make-dry-run
  regex assertions on lines 75 + 77 match what the Makefile actually emits on
  a podman-only host.
- README + CHANGELOG document the new behavior.

Fixes the user-reported issue: "Le makefile ne fonctionne pas sur ma machine
qui n'a que podman."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:20:29 +02:00
160 changed files with 19673 additions and 1236 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

@@ -11,10 +11,22 @@ You are the **Frontend Builder** for the Mimic project (BAS WebUI based on MITRE
Read these files first, in order: Read these files first, in order:
1. `SPEC.md` — global spec and technical decisions. 1. `SPEC.md` — global spec and technical decisions.
2. `DESIGN.md` — UI design system. **Mandatory** — every component you build must follow it. 2. `DESIGN.md` — UI design system. **Mandatory** — every component you build must follow it (tokens, slab, btn-outline, etc.).
3. The **backend-builder's summary** for the current sprint (in `tasks/todo.md` or the latest team-lead dispatch). This is your API contract. 3. The **backend-builder's summary** for the current sprint (in `tasks/todo.md` or the latest team-lead dispatch). This is your API contract.
4. `tasks/lessons.md` — past mistakes to avoid. 4. `tasks/lessons.md` — past mistakes to avoid.
## Mandatory skill — `frontend-design`
Before creating or modifying **any visible UI component** (new page, new component, layout change, state additions like loading/error/empty), you MUST invoke the `frontend-design` skill once at the start of the sprint via:
```
Skill({ skill: "frontend-design" })
```
`DESIGN.md` rules the **project-specific** tokens and motifs (slab, btn-outline, palette, BAS layout patterns). `frontend-design` adds the **universal** principles `DESIGN.md` doesn't restate: typographic hierarchy, alignment grid, contrast ratios, focus states, density rhythm, motion restraint. The two are complementary — `DESIGN.md` wins on tokens/component shape, `frontend-design` wins on visual craft.
Exception: pure logic/data-layer work with no visible UI change (hook refactor, query key rename, internal type tightening) — skip the skill, note it in your summary.
## What you build ## What you build
- React components under `frontend/src/components/` - React components under `frontend/src/components/`
@@ -44,6 +56,21 @@ cd frontend && npm run test -- --run
If any of these fail, fix the cause before reporting completion. 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) ## Output format (when you return to the team-lead)
A short Markdown summary: A short Markdown summary:
@@ -53,6 +80,7 @@ A short Markdown summary:
- **Mismatches with API** (if any — flagged, not patched) - **Mismatches with API** (if any — flagged, not patched)
- **Open questions / design ambiguities** (escalate, don't decide) - **Open questions / design ambiguities** (escalate, don't decide)
- **Test results** (vitest summary, typecheck/lint status) - **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** - **CLAUDE.md rules that helped**
## Principles ## Principles

View File

@@ -6,6 +6,192 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [Unreleased]
### Changed — Sprint 7 (Terminal-SOC design refresh)
**Frontend** (136 vitest passing — unchanged count, 3 assertions updated for new token names)
- `DESIGN.md` — complete rewrite from HP-catalog (346 lines) to terminal-SOC brutalist spec. Covers palette (success/warn tokens added), typography (Inter + JetBrains Mono), layout, shapes (border-radius 0 rule), component patterns, and Do/Don't list.
- `frontend/tailwind.config.ts` — added `success` / `warn` semantic color tokens (WCAG AA in both light+dark); added `fontFamily.mono` (JetBrains Mono Variable); reduced all `display-*` scale (xxl 72→40, xl 56→32, lg 44→28, md 32→24, sm 24→20, xs 20→16); `borderRadius` reduced to `none: '0px'` + `pill: '9999px'` only; `section` spacing 40px→48px.
- `frontend/src/styles/index.css` — CSS vars for `--color-success[-soft]` / `--color-warn[-soft]` (light + dark variants); all `.btn-*` classes: `rounded-none`, no `transition-colors`; `.text-input`: `rounded-none`, no transition; `.card-product`: `rounded-none`, `border border-hairline`, no shadow; `.badge-pill-*` kept `rounded-pill`; added `.tag-mitre` (angular MITRE tags, `font-mono`).
- `frontend/src/styles/fonts.css` — added `@import '@fontsource-variable/jetbrains-mono/index.css'` (local, no CDN).
- `frontend/src/components/StatusBadge.tsx``rounded-lg``rounded-pill`; new semantic colors: `planned → warn-soft`, `active → primary-soft`, `closed → cloud/graphite`.
- `frontend/src/components/SimulationStatusBadge.tsx``rounded-lg``rounded-pill`; semantic mapping: `pending → cloud`, `in_progress → primary-soft`, `review_required → warn-soft`, `done → success-soft`.
- `frontend/src/components/Toast.tsx` — removed `rounded-xl` and shadow; left border strips: `error → border-l-bloom-deep`, `success → border-l-success`, default → `border-l-primary`.
- `frontend/src/components/MitreTechniqueTag.tsx``rounded-full``rounded-none` on both technique and tactic tags; added `font-mono` (MITRE IDs are data).
- `frontend/src/components/ExportEngagementButton.tsx` — removed `rounded-r-none` / `rounded-l-none` from split-button; dropdown: `rounded-md shadow-floating``rounded-none`.
- `frontend/src/components/SimulationList.tsx` — dropdown: `rounded-md shadow-floating``rounded-none`; MITRE column + `executed_at`: added `font-mono`; split-button: removed radius classes.
- `frontend/src/pages/SimulationFormPage.tsx` — h1: `text-[44px]``text-[32px]`; Done/SOC banners: `rounded-xl``rounded-none`.
- `frontend/src/pages/UsersAdminPage.tsx` — h1: `text-[44px]``text-[32px]`; username column + created_at column: added `font-mono`.
- `frontend/tests/SimulationStatusBadge.test.tsx` — updated 3 assertions for renamed semantic tokens (`bg-fog``bg-cloud`, `bg-bloom-coral``bg-warn-soft`, `bg-storm-deep``bg-success-soft`).
**No backend changes.** No DB schema change. No migration.
### Added — Sprint 6 (Engagement export)
**Backend** (253 pytest passing — 226 sprint-1-to-4 + 28 sprint 5 + 5 sprint 5 post-code-review + 23 sprint 6 + 1 CSV-injection defense-in-depth test)
- `backend/app/services/export.py` (new, 302 lines) — 3 pure render functions (`render_engagement_markdown`, `render_engagement_csv`, `render_engagement_pdf`) + filename slugifier (`_export_filename`) + HTML helper for the PDF pipeline + CSV formula-injection defense helper (`_csv_safe`).
- New endpoint `GET /api/engagements/<int:eid>/export?format=md|csv|pdf` extended on the existing `engagements_bp`. Decorator `@role_required("admin", "redteam")` (SOC → 403). 400 on missing/unknown format, 404 on unknown engagement. Returns the rendered file body with `Content-Type` matching the format and `Content-Disposition: attachment; filename="engagement-<id>-<slug>-YYYYMMDD.<ext>"`.
- Filename slugifier uses `unicodedata.normalize('NFKD', ...).encode('ascii', 'ignore')` to strip accents (`Opération``operation`) and falls back to `"unnamed"` when the slug is empty after stripping.
- Markdown rendering uses fenced code blocks with `~~~bash` (tildes, not backticks) so backticks in commands don't break the fence. SOC fields are always rendered, even when blank (consistency for handoff). `_creator()` helper renders the username string only (not the `{id, username}` dict).
- CSV rendering uses stdlib `csv.writer` (handles multiline / quotes / commas natively). `_csv_safe()` prefixes a single apostrophe to any string starting with `=`, `+`, `-`, `@`, `\t`, or `\r` — defuses Excel / LibreOffice / Google Sheets formula injection on the SOC analyst's machine when they open the exported CSV. Applied to all user-controlled string fields; ISO dates and the enum status value are exempted.
- PDF rendering via **WeasyPrint** (Python HTML→PDF). The PDF is generated from the same engagement DATA as the Markdown (not from the Markdown string) via `_render_engagement_html()` and `weasyprint.HTML(string=html).write_pdf()`. CSS inline (≤ 30 lines). All user-controlled fields HTML-escaped via stdlib `html.escape()`.
- `docker/Dockerfile` python stage now installs minimal WeasyPrint deps: `libcairo2 libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libfontconfig1 shared-mime-info`. `libgdk-pixbuf-2.0-0` deliberately excluded (text-only PDF).
- `weasyprint>=60.0` added to `backend/requirements.txt`.
- No DB schema change. No migration.
**Frontend** (136 vitest passing — 121 sprint-1-to-5 + 12 sprint 6 + 3 sprint 6 coverage-gap fix)
- `frontend/src/components/ExportEngagementButton.tsx` (new) — split-button dropdown `[Export ▼]` with `Download` + `ChevronDown` lucide icons. **Both halves open the dropdown** (no default left-click action — different semantic from sprint 5's `NewSimulationDropdown` where left navigates blank), because there is no obvious default format among MD/CSV/PDF. Loading state per-item, toast on error. Click-outside + Escape close (reuses the `useEffect` + `pointerdown` + `keydown` pattern from `NewSimulationDropdown`). `data-testid="export-dropdown"` for e2e selection. Visual: shares `btn-outline` class with the neighbour `Edit` button.
- `frontend/src/api/exports.ts` (new) — `downloadEngagementExport(engagementId, format)` with `responseType: 'blob'`. Reads `Content-Disposition: attachment; filename="..."`, falls back to `engagement-<id>.<ext>` when the header is absent or malformed. Throws an `Error` on non-2xx (caller catches and toasts). Helper `parseContentDispositionFilename()`.
- `frontend/src/pages/EngagementDetailPage.tsx` (edited) — integrates `<ExportEngagementButton engagementId={engagement.id} />` in the header next to the `Edit` CTA. Gated by `canEditEngagements` from `useAuth` (admin + redteam).
- New test file `frontend/tests/exports.test.ts` covers the API client directly via `axios-mock-adapter` (the component test file mocks `downloadEngagementExport` entirely, so the fallback logic inside `exports.ts` wasn't reachable from there — new file lets the real function run for 3 dedicated tests).
**Acceptance tests** (Playwright, **223 passed** — baseline sprint 5 = 201, +22 sprint 6)
- 3 new spec files (one per US): `us29-export-formats.spec.ts` (8 tests), `us30-export-rbac.spec.ts` (3 tests), `us31-export-robustness.spec.ts` (5 tests).
- No regression on sprints 15: full pre-sprint-6 suite still green.
**Security**
- CSV formula injection (MEDIUM) flagged by `security-guidance@claude-code-plugins` automated review during the sprint, fixed mid-sprint (commit `57dbd14`). 3 dedicated unit tests cover the apostrophe-prefix on `=`, `@` triggers and the no-op on safe strings.
- Defense-in-depth: a property test (`test_export_filename_never_contains_quote_or_crlf`) asserts the slugifier output never contains `"`, `\r`, or `\n` — guards against Content-Disposition header injection if someone later weakens the slug regex.
### Changed
- 2026-06-07 — SPEC.md § Export d'engagement added (between § Templates de simulations and § Stacks techniques). Committed as the **first** sprint commit (`7aaa5cc`), applying the fix-candidate from sprint 5's recurrent "SPEC.md uncommitted at sprint close" lesson. Four-sprint recurrence finally broken.
- 2026-06-08 — Team `mimic` (persistent `.claude/teams/mimic/config.json`) instantiated with the full 7-agent project roster (backend-builder, frontend-builder, spec-reviewer, code-reviewer, design-reviewer, test-verifier, devil-advocate). Agents are spawned with an idle prompt at sprint start and woken via SendMessage per phase — flip vs the old "spawn-with-task-only" policy that hit the "team roster is flat" gotcha when respawning. Persistent across sprints from sprint 7+.
- 2026-06-08 (post-review, pre-merge) — **Export schema switched to a fixed 7-column handoff layout** uniform across MD/CSV/PDF. Columns (FR headers): `Scénario`, `Test`, `Source de log`, `Commentaires SOC`, `Exécution` (multi-line concat without labels — `executed_at``commands``execution_result`), `Logs remontés au SIEM`, `Cyber incident`. Removed from the export (intentional, handoff-focused): simulation status, MITRE techniques/tactics, prerequisites, id, created_at, updated_at. Markdown switched from narrative-per-simulation to a GFM table. PDF switched from sectioned HTML to a single `<table>`. SPEC `fdab324`, backend refactor `7335b9f`, e2e adaptation `aeb4bdb`. Final counters: backend 257 pytest, frontend 136 vitest, e2e 223 Playwright.
- 2026-06-08 (post-refactor, pre-merge) — **Two MEDIUM security regressions fixed** in the 7-column refactor (`3a9d9d3`), flagged by `security-guidance@claude-code-plugins`:
1. **CSV formula injection inside the multi-line `Exécution` cell**: `_csv_safe` only checks `cell[0]`. With `executed_at` non-null, the cell starts with a safe date digit, but inner lines (commands, execution_result) starting with `=`/`+`/`-`/`@` evaded defense. Fix: `_format_execution_csv()` applies `_csv_safe` per user-controlled component BEFORE the multi-line concat. Outer `_csv_safe` on the assembled cell retained as belt-and-braces.
2. **Stored XSS in Markdown table cells**: the new GFM table allows inline HTML (we use it for `<br/>`). A `sim.commands = "<script>alert(1)</script>"` would be rendered raw by MD viewers that interpret inline HTML (Notion, Obsidian, GitHub preview). Fix: `_cell()` now calls `html.escape()` on each value BEFORE the pipe-escape and `\n``<br/>` substitution — mirrors the `_render_engagement_html` PDF defense. The `<br/>` we insert ourselves stays unescaped (it's not user-controlled). 2 dedicated regression tests added.
- 2026-06-09 (post-merge-review) — PDF export: A4 landscape orientation (user feedback post-merge-review). `@page { size: A4 landscape; }` added to `_CSS`; `font-size` reduced to 11px and `table-layout: fixed; word-break: break-word` added to prevent 7-column overflow on narrower portrait layout.
---
## [Sprint 5] — Simulation templates + instantiation + nav + dropdown (merged 2026-05-28)
### Added — Sprint 5 (Simulation templates)
**Backend** (226 pytest passing — 193 sprint-1-to-4 + 28 sprint 5 + 5 post-code-review)
- `SimulationTemplate` model (table `simulation_templates`) — UNIQUE constraint on `name`, JSON `techniques` + `tactic_ids` (default `[]`, NOT NULL via `server_default`), Text fields `description` / `commands` / `prerequisites`, FK `created_by_id` to `users`, `created_at` / `updated_at`.
- Alembic migration `0005_simulation_templates.py` — CREATE TABLE (SQLite native, no batch); downgrade via DROP TABLE.
- 5 new endpoints under `/api/templates`, all gated `@role_required("admin", "redteam")` (SOC → 403):
- `GET /api/templates` — list, sorted name ASC, serialized with enriched `techniques: [{id, name, tactics}]` and `tactics: [{id, name}]`.
- `POST /api/templates` — create. `name` required (400 if empty), unique (409 via `IntegrityError` catch, no pre-check race). `technique_ids` / `tactic_ids` validated upfront — type check `isinstance(list)` (400 with friendly message) THEN resolved against the bundle / `_TACTIC_IDS` (400 with id on unknown).
- `GET /api/templates/<tid>` — single, 404 on miss.
- `PATCH /api/templates/<tid>` — partial update. Same validations. 409 on `name` conflict; no-op rename (`name == current`) returns 200.
- `DELETE /api/templates/<tid>` — 204. **No cascade** to instantiated simulations (decoupling guarantee).
- `POST /api/engagements/<eid>/simulations` extended with optional `template_id`. When provided:
- Template loaded (404 on miss).
- Fields copied directly onto the new `Simulation` ORM object (`techniques`, `tactic_ids`, `description`, `commands`, `prerequisites`, and `name` if missing from body).
- **Explicit non-call to `apply_patch()` / `_resolve_*` helpers** — avoids re-hitting the MITRE bundle AND avoids triggering the auto-transition `pending → in_progress`. Status stays `pending`, engagement stays `planned` (no `_maybe_activate_engagement` call). Decorrelation: no `template_id` FK on `Simulation`, deep copy of JSON arrays.
- New helpers in `mitre.py` reused / re-exposed; new `serialize_template()` in `serializers.py` mirrors `serialize_simulation` (minus SOC fields, status, executed_at) and uses the shared `_enrich_techniques` + `_enrich_tactics` (no duplication).
- All migration tests (0003, 0004, 0005) now use `Path(__file__).resolve().parent.parent / "migrations" / "versions" / "..."` — sprint 4's hardcoded-path MAJOR is closed for the third sprint running.
**Frontend** (121 vitest passing — 92 sprint-1-to-4 + 26 sprint 5 + 3 post-code-review)
- New page `TemplatesListPage` (`/admin/templates`, admin+redteam only) — table (Name / MITRE count / Created by / Updated / Actions), `+ New` CTA with Plus icon.
- New page `TemplateFormPage` (`/admin/templates/new` and `/admin/templates/:id/edit`) — single-column FormField stack (sidesteps the multi-column grid trap that broke AC-17.3 on UsersAdminPage). Includes `MitreTechniquePicker` + `MitreMatrixModal` inline (NOT `MitreTechniquesField` — that one auto-saves; template form needs batched save). Delete via `ConfirmDialog`.
- New component `TemplatePickerModal` — modal listing all templates (Name / MITRE count / Created by). Empty state when `useTemplates()` returns `[]`: "No templates available — Create one from the Templates page."
- New nav link "Templates" in `Layout.tsx` topbar — visible to admin + redteam only, masked for SOC. Mirrors the pattern used by the "Users" link.
- `SimulationList` "New" button refactored into a **split-button dropdown**: `[+ New] [▼]`. Primary half → `/.../simulations/new` (blank). Dropdown → "Blank" + "From template…". Open dropdown closes on click-outside or Escape (sprint 3 picker pattern). Empty-state `SimulationList` now also exposes the same dropdown (so users can instantiate from a template on a fresh engagement without creating a blank first).
- `dark:shadow-floating-dark` consistently applied to the new dropdown and `TemplatePickerModal` — matches the sprint 4 shadow token model. `dark:hover:bg-fog` on dropdown items for contrast.
- New types: `SimulationTemplate`, `SimulationTemplateCreateInput`, `SimulationTemplatePatchInput`. `SimulationCreateInput` extended with `template_id?: number`.
- New TanStack Query hooks (`useTemplates`, `useTemplate`, `useCreateTemplate`, `useUpdateTemplate`, `useDeleteTemplate`) with cache invalidation on mutations.
- API client `frontend/src/api/templates.ts` — 5 calls to `/api/templates*`. (Sprint-5 in-flight bug : initial commit `90fc5ba` used `/simulation-templates` paths everywhere; caught immediately, fixed in `2b70011`.)
**Acceptance tests** (Playwright, **201 passed**)
- 3 new spec files (one per US): `us26-templates-crud.spec.ts` (22 tests), `us27-instantiate-from-template.spec.ts` (14 tests), `us28-templates-nav.spec.ts` (8 tests).
- Coverage gaps from code-reviewer filled: bidirectional template↔instance decorrelation, dropdown click-outside + Escape, SOC + template_id 403.
- Sprint 2/3 spec adapts: `us4-engagements.spec.ts` and `us7-simulation-create.spec.ts` now use `getByTestId('new-simulation-btn')` instead of `getByRole('link', /new simulation/)` — the link became a split-button dropdown.
- 1 pre-existing flaky in `us3-users-admin AC-3.4` (DB contamination across runs) — predates sprint 5, unrelated.
### Changed
- 2026-05-28 — SPEC.md § Templates de simulations added (between § Fonctionnement and § Authentification & rôles). Spells out the decoupling rule and the SOC-zero-access RBAC.
- 2026-05-28 — `POST /api/engagements/<eid>/simulations` API contract: `name` is now optional when `template_id` is provided (falls back to `template.name`).
---
## [Sprint 4] — UI polish + workflow tightening + dark mode + process hygiene (merged 2026-05-28)
### 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)
- `Simulation.techniques` JSON column replaces the scalar `mitre_technique_id` / `mitre_technique_name` pair. Stored as `[{"id", "name"}]`; tactics are derived at serialize time from the MITRE service (snapshot pattern survives bundle updates).
- Alembic migration `0003_simulation_techniques_array.py` — reversible upgrade (backfill from scalars → drop scalars → enforce `NOT NULL` via `batch_alter_table`) and symmetric downgrade.
- `PATCH /api/simulations/<sid>` now accepts `{technique_ids: ["T1059", "T1059.001", ...]}` (flat list of T-IDs, parents and subs at the same level). Server validates each ID against the bundle (400 on unknown), deduplicates while preserving order, resolves names, and rejects SOC payloads (403). Returns 503 if the bundle isn't loaded.
- `GET /api/mitre/matrix` — new endpoint returning the full Enterprise tree `[{tactic_id, tactic_name, techniques: [{id, name, subtechniques: [{id, name}]}]}]`. Tactics in canonical order (Initial Access → Impact). Techniques sorted alphabetically per tactic; sub-techniques nested under their parent via dot-ID detection.
- `mitre_svc` extended with `get_tactics(id)`, `lookup_name(id)`, `get_matrix()`, and a `TACTIC_NAMES` constant fixing the cosmetic `"Command And Control"``"Command and Control"` (MITRE canonical capitalisation).
- `REDTEAM_FIELDS | {"technique_ids"}` SOC gate in `simulation_workflow.apply_patch` preserves the sprint 2 field-level RBAC pattern.
- Auto-transition `pending → in_progress` extended: triggers when `technique_ids` is non-empty (consistent with the "non-empty value" rule from sprint 2). Empty list does not trigger.
**Frontend** (86 vitest passing)
- `MitreTechniquesField` orchestrates multi-technique selection with **auto-save** — every add (Quick Search / matrix Apply) and every remove (× on tag chip) triggers a PATCH via `useUpdateSimulation`. Toast feedback on success/error; UI disabled during the in-flight PATCH; silent dedup if the user re-adds an already-present technique.
- `MitreTechniqueTag` — chip component (`bg-primary-soft text-primary-deep rounded-full`) with an × remove button.
- `MitreMatrixModal` — full-width modal, one column per tactic (220px fixed), horizontal scroll. Each technique top-level is clickable (toggle); a chevron expands/collapses sub-techniques rendered in cascade. Search filter (case-insensitive on id + name) auto-expands the parent of a matched sub-technique. Tactic header shows a "N selected" counter (parents + subs). Footer: Cancel + "Apply N technique(s)" (or "Clear all" when N=0 and there's an existing selection). Focus trap V1: search input auto-focus on open, Tab cycles within the modal, Escape and backdrop click both = Cancel.
- `MitreTechniquePicker` (sprint 2) clean-rewritten to a one-shot `onSelect({id, name})` signature; no incoming value props. The picker resets after each selection — the parent (`MitreTechniquesField`) handles append + dedup.
- `SimulationList` MITRE column displays `T1059 +2` when 3 techniques are selected (first id + remainder counter) or `—` when empty.
- `SimulationFormPage``MitreTechniquesField` replaces the old standalone `MitreTechniquePicker`. The technique state moves out of the RT form (independent auto-save cycle); the Save Red Team button still batches the other RT fields.
**Acceptance tests** (Playwright)
- 4 new spec files: `us13-multi-techniques.spec.ts`, `us14-techniques-tags.spec.ts`, `us15-mitre-matrix-modal.spec.ts`, `us16-regression-sprint2.spec.ts` — all ACs (AC-13.1 → AC-16.3) pass.
- Sprint 2 specs `us8-simulation-redteam-fill.spec.ts` and `us10-mitre-autocomplete.spec.ts` adapted to the new `techniques: []` array (no more scalar field assertions).
### Changed
- 2026-05-27 — SPEC.md § Simulation: "Type d'attaque MITRE correspondant" (singular) → "Types d'attaque MITRE correspondants (multi-techniques) — sélectionnables par autocomplete OU via la matrice ATT&CK affichée en modale. Sub-techniques supportées."
- 2026-05-27 — Breaking API change: `mitre_technique_id` and `mitre_technique_name` removed from the `Simulation` payload (both directions). Replaced by `techniques: [{id, name, tactics}]` in responses and `technique_ids: string[]` in PATCH requests. No backwards-compatibility shim (no external consumer at this stage).
---
## [Sprint 2] — Simulations + MITRE ATT&CK (merged 2026-05-27)
### Added — Sprint 2 (Simulations + MITRE ATT&CK) ### Added — Sprint 2 (Simulations + MITRE ATT&CK)
**Backend** (Flask + SQLAlchemy, 131 pytest passing) **Backend** (Flask + SQLAlchemy, 131 pytest passing)
@@ -25,14 +211,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- `ConfirmDialog`: generic modal used by the delete flow. - `ConfirmDialog`: generic modal used by the delete flow.
- TanStack Query hooks: `useEngagementSimulations`, `useSimulation`, `useCreateSimulation`, `useUpdateSimulation`, `useDeleteSimulation`, `useTransitionSimulation`, `useMitreSearch`. Mutations invalidate both the simulation detail key and the engagement-scoped list key. - TanStack Query hooks: `useEngagementSimulations`, `useSimulation`, `useCreateSimulation`, `useUpdateSimulation`, `useDeleteSimulation`, `useTransitionSimulation`, `useMitreSearch`. Mutations invalidate both the simulation detail key and the engagement-scoped list key.
**Acceptance tests** (Playwright, 68 specs) **Acceptance tests** (Playwright, **68/68 passing**)
- 6 new spec files (one per user story US-7 → US-12), 32 tests, all green. - 6 new spec files (one per user story US-7 → US-12), 32 tests, all green.
- `us4-engagements.spec.ts` AC-4.9 assertion refreshed: the Sprint 1 placeholder text was correctly replaced by the new `SimulationList` (the test now asserts the new heading + "Nouvelle simulation" link). - `us4-engagements.spec.ts` AC-4.9 assertion refreshed: the Sprint 1 placeholder text was correctly replaced by the new `SimulationList` (the test now asserts the new heading + "New simulation" link).
- 5 pre-existing failures in `us1-bootstrap-admin.spec.ts` and `us6-deployment.spec.ts` remain — they hard-code `docker` in the test body and fail in dev environments that only have `podman`. The fixtures already support `MIMIC_CONTAINER_CMD`; the test bodies don't yet. Out of scope for Sprint 2 — to be picked up later. - Sprint 1 docker-hardcoded tests (`us1`, `us6`) now resolve thanks to the podman auto-detect added to those specs in the same sprint — full suite is green on both docker and podman hosts.
- E2e assertions translated to match the i18n cleanup (French → English) shipped in the post-QA fix.
**Post-QA fixes (2026-05-26)**
- All French labels in the frontend translated to English (convention: anglais partout). Affected: `SimulationList`, `SimulationFormPage`, `ConfirmDialog` strings.
- `UsersAdminPage` "Create account" form: grid alignment fixed — the password field's `hint="≥ 8 characters"` was pushing labels out of alignment with `items-end`. Now uses `items-start` + `self-end` button wrapper so labels sit at the same baseline and the Create button stays bottom-aligned.
- `SimulationFormPage` "Execution result" field: switched from single-line `TextInput` to multiline `TextArea` (5 rows).
- `SimulationFormPage` actions reorganised: single sticky action bar at the bottom of the page replaces the previous split between RT-card footer, SOC-card footer, and workflow div. Layout: Save Red Team · Save SOC · | · Mark for review · Close · (right-aligned) Delete.
### Changed ### Changed
- 2026-05-26 — `make update-mitre` upgraded from no-op placeholder to a real `curl` + optional container restart (Sprint 1 marker resolved). - 2026-05-26 — `make update-mitre` upgraded from no-op placeholder to a real `curl` + optional container restart (Sprint 1 marker resolved).
- 2026-05-26 — `EngagementDetailPage` no longer renders the "Simulations à venir au Sprint 2" placeholder; it embeds `<SimulationList>` instead. - 2026-05-26 — `EngagementDetailPage` no longer renders the "Simulations à venir au Sprint 2" placeholder; it embeds `<SimulationList>` instead.
- 2026-05-26 — Makefile now auto-detects the container engine (`CONTAINER_CMD ?= docker || podman`) instead of hard-coding `docker`. Override with `make <target> CONTAINER_CMD=podman` or `export CONTAINER_CMD=…`. The matching e2e tests (`us1`, `us6`) were updated to mirror the same detection so they pass on podman-only machines without an explicit `MIMIC_CONTAINER_CMD` export.
--- ---

438
DESIGN.md
View File

@@ -1,345 +1,263 @@
## Overview ## Overview
HP reads like a long-running consumer-electronics catalog crossed with an enterprise-software product page. The whole system sits on **pure white** (`{colors.canvas}``#ffffff`) with thin gray panels (`{colors.cloud}` / `{colors.fog}`) for alternating section bands. There is one chromatic action color — **HP Electric Blue** (`{colors.primary}``#024ad8`) — and one ink color (`{colors.ink}``#1a1a1a`); together they do ninety percent of the work. Type is a single family across every surface: **Forma DJR Micro**, HP's bespoke geometric grotesque, set at weight 500 for headlines and 400 for body — clean, neutral, slightly mechanical. Mimic is a **BAS (Breach and Attack Simulation) purple-team console** — not a product catalog, not a marketing page. The aesthetic is **Bloomberg Terminal / SOC dashboard**: dense, angular, semantic-color-driven, zero ornament. Every surface decision reinforces operational trust: data is primary, chrome is invisible.
The signature gesture is **angular blue chevrons** — sharp 0-radius slashes derived from the HP wordmark's pair of parallel slashes — that anchor the homepage hero, the laptop-page hero, and the printer pricing page. They appear on the left and right edges of the primary banner card, layered behind product photography. Outside those decorative slashes, every other surface is rectilinear with **soft 816px corners** on cards and a 4px corner on buttons. The system sits on a **pale-tinted canvas** (light: `#f3f5f8`) / **dark slab** (dark) with one chromatic action color — **Electric Blue** (`{colors.primary}``#024ad8`) — and semantic status signals (`success`, `warn`, `bloom-deep`). Inter handles body/headers/labels. JetBrains Mono carries data: IDs, ISO dates, commands, execution output, MITRE technique codes, metrics — anything that must be read at a glance without typographic distraction.
The system breaks into three voice modes: a **white commercial body** for product browsing (cards, category icons, pricing tiers); a **dark navy slab** (`{colors.ink}` near-black) for testimonial bands, the closing "How can we help?" footer-prelude, and the page footer; and a **light fog band** (`{colors.cloud}` / `{colors.fog}`) for utility sections like comparison strips and FAQ accordions. The blue accent appears only on filled CTAs, link text, the chevron decorations, and the active price-stamp on a featured tier — never as a section background. **No rounded corners on containers.** No shadows on interactive surfaces. No transitions. Hover is instantaneous. Focus rings are sharp. This is a tool, not a storefront.
**Key Characteristics:** **Key Characteristics:**
- Pure white canvas (`{colors.canvas}`) with deep ink (`{colors.ink}`) running every body surface; light fog bands (`{colors.cloud}`, `{colors.fog}`) alternate for section rhythm - Angular surfaces everywhere (`border-radius: 0`) — exception: status pills (`rounded-pill`) and avatars
- HP Electric Blue (`{colors.primary}`) is the lone CTA fill and link color; it appears at most twice per viewport - Zero transitions / zero animations — state changes are immediate
- Bespoke Forma DJR Micro across every surface — display, body, button, caption — at weights 400 / 500 / 600 / 700 - Semantic color signals: primary blue = action, success green = confirmed, warn amber = pending/caution, bloom-deep = destructive/error
- Cards round at `{rounded.xl}` (16px) for product/pricing tiles; buttons sit at `{rounded.md}` (4px) with capitalize labels - Monospace data discipline: `font-mono` ONLY for IDs, dates, codes, output, metrics — never for headers, labels, prose
- Geometric blue chevrons (`{colors.primary}` rectangles cut at 45°) frame hero photography and reinforce the wordmark - Dense spacing: section gap 48px (not 80px), card padding 1216px on dense surfaces
- Dark-navy slabs (`{colors.ink}`) close every page rhythm — testimonial bands, "how can we help?" prelude, and the footer - Light + dark modes both supported; dark mode is the primary operational mode for SOC analysts
- Section rhythm: utility-strip → top nav → white body → cloud-band → ink slab → cloud-band → ink footer
---
## Colors ## Colors
> **No Interaction sub-section.** Hover colors are silently filtered. Allowed sub-sections: Brand & Accent, Surface, Text, Semantic.
### Brand & Accent ### Brand & Accent
- **HP Electric Blue** (`{colors.primary}``#024ad8`): the system's lone signal — primary CTA fill, link color, chevron-decoration fill, active sub-nav indicator. Reserved. - **Electric Blue** (`{colors.primary}``#024ad8`): primary CTA fill, active nav indicator, focus ring. Never used as a section background.
- **Bright Blue** (`{colors.primary-bright}``#296ef9`): a slightly lighter variant used inside dark slabs (testimonial-card buttons, dark-band CTA links) where the deeper blue would muddy. - **Bright Blue** (`{colors.primary-bright}``#296ef9`): CTA on dark slab surfaces where `#024ad8` muddles.
- **Deep Navy** (`{colors.primary-deep}``#0e3191`): pressed state for the primary CTA and the visited-link color. - **Deep Navy** (`{colors.primary-deep}``#0e3191`): pressed state for primary CTA.
- **Soft Blue** (`{colors.primary-soft}``#c9e0fc`): pale-blue surface used inside customer-story cards and selection chips. - **Soft Blue** (`{colors.primary-soft}``#c9e0fc`): selection highlight, chip background on light surfaces.
### Surface ### Surface
- **Canvas** (`{colors.canvas}``#ffffff`): the universal page background. White, full opacity. - **Canvas** (`{colors.canvas}``#f3f5f8` light / `#111827` dark): universal page background. In light mode, canvas is tinted while paper stays pure white so cards lift without shadow or radius, preserving brutalism.
- **Paper** (`{colors.paper}``#ffffff`): card surfaces — same white as canvas, with hairline borders or shadows providing the lift. - **Paper** (`{colors.paper}``#ffffff` light / `#1f2937` dark): card and panel surfaces.
- **Cloud** (`{colors.cloud}``#f7f7f7`): the lightest gray section band, used for alternating-row backgrounds and product-feature card groups. - **Cloud** (`{colors.cloud}``#f7f7f7` light / `#1f2937` dark): alternating section band, table row zebra.
- **Fog** (`{colors.fog}``#e8e8e8`): a slightly darker gray surface band, used for FAQ outer panels and the "Trending laptops" header strip. - **Fog** (`{colors.fog}``#e8e8e8` light / `#374151` dark): secondary section band, input background on dense panels.
- **Steel** (`{colors.steel}``#c2c2c2`): hairline border used on outlined elements with stronger emphasis (focus states, active filter). - **Steel** (`{colors.steel}``#c2c2c2` light / `#4b5563` dark): hairline borders, disabled states.
- **Bloom Coral / Bloom Rose** (`{colors.bloom-coral}` / `{colors.bloom-rose}``#ff5050`, `#f9d4d2`): the "Get 25% off" sale-tag chip + soft pink lifestyle accent on the sale hero. - **Hairline** (`{colors.hairline}`): 1px divider between cells, panels, table rows.
- **Storm Mist / Sea / Deep** (`{colors.storm-mist}`, `{colors.storm-sea}`, `{colors.storm-deep}``#8ebdce`, `#7fadbe`, `#356373`): the teal-storm tones reserved for the printer-plan illustration backdrop and supporting infographic accents. - **Slab** (`{colors.slab}``#111827`): fixed-dark surface — utility strip, footer, dark bands. Does NOT invert in dark mode.
### Text ### Text
- **Ink** (`{colors.ink}``#1a1a1a`): the universal text color on white surfaces — headlines, body, button labels, navigation. - **Ink** (`{colors.ink}``#1a1a1a` light / `#f9fafb` dark): universal body text color.
- **Ink Deep** (`{colors.ink-deep}``#000000`): pure black used for the wordmark and 1px hairline strokes around badge outlines. - **Ink Deep** (`{colors.ink-deep}``#000000` light / `#ffffff` dark): maximum contrast for headings.
- **Ink Soft** (`{colors.ink-soft}``#292929`): an alternate near-black used inside dark-navy slabs as a subtle textural shift. - **Ink Soft** (`{colors.ink-soft}``#292929` light / `#e5e7eb` dark): muted body, secondary labels.
- **On Ink** (`{colors.on-ink}``#ffffff`): pure white used for headline and body text on every dark-navy slab. - **On Ink** (`{colors.ink-on}``#ffffff` light / `#111827` dark): text on slab surfaces.
- **Charcoal** (`{colors.charcoal}``#3d3d3d`): muted body color on white surfaces — secondary descriptions, fine-print disclaimers. - **Charcoal** (`{colors.charcoal}``#3d3d3d` light / `#d1d5db` dark): secondary descriptions, captions.
- **Graphite** (`{colors.graphite}``#636363`): smaller-print color, used for legal lines and timestamp metadata. - **Graphite** (`{colors.graphite}``#636363` light / `#9ca3af` dark): timestamps, metadata, footnotes.
### Semantic ### Semantic (Status Signals)
- **Bloom Deep** (`{colors.bloom-deep}``#b3262b`) + **Bloom Wine** (`{colors.bloom-wine}``#5a1313`): error and discount-emphasis colors. The deep brick reads as "sale" or "destructive" depending on placement. - **Success** (`{colors.success}``#16a34a` light / `#22c55e` dark): confirmed detections, done status, positive metrics. Background softened to `{colors.success-soft}` (`#dcfce7` light / `#14532d` dark) for badge fills.
- **Storm Deep** (`{colors.storm-deep}``#356373`): used as a neutral status accent (e.g., printer-plan tier "Versatile" tier color). - **Warn** (`{colors.warn}``#d97706` light / `#f59e0b` dark): pending review, caution states, partial detection. Background softened to `{colors.warn-soft}` (`#fef3c7` light / `#78350f` dark) for badge fills.
- **Bloom Deep** (`{colors.bloom-deep}``#b3262b`): error, destructive action, failed detection. Bloom family covers red-spectrum signals.
- **Bloom Wine** (`{colors.bloom-wine}``#5a1313`): darkened destructive emphasis.
- **Bloom Coral** (`{colors.bloom-coral}``#ff5050`): alert highlight on dark surfaces.
---
## Typography ## Typography
### Font Family ### Font Families
- **Inter Variable** (`{fontFamily.sans}`): body text, labels, headers, navigation, form fields. The universal surface font.
- **JetBrains Mono Variable** (`{fontFamily.mono}`): data cells ONLY — engagement IDs, simulation IDs, ISO 8601 dates, execution commands, terminal output, MITRE technique codes (T1059.001), numeric metrics, usernames-as-identifiers. Never used for prose, headings, or labels.
The voice is **single-family**: Forma DJR Micro (HP's bespoke geometric grotesque, fallback Arial) across every surface — display, body, button, caption. Forma DJR Micro is a wide, slightly rounded grotesque designed at small optical sizes to stay legible at UI-chrome scale. HP runs it at weight 400 for body, 500 for display headlines, 600/700 for emphasis and button labels. Both fonts are bundled locally via `@fontsource-variable/inter` and `@fontsource-variable/jetbrains-mono`. Zero CDN loading at runtime.
The 16/14/12-px caption tier carries the catalog metadata — model numbers, spec rows, fine print — at weight 400 with a 1.41.5 line-height. Button labels lift to weight 600/700 with positive 0.51.1px letter-spacing and uppercase transform — the only place the system tracks letters.
### Hierarchy ### Hierarchy
| Token | Size | Weight | Line Height | Letter Spacing | Use | | Token | Size | Weight | Line Height | Use |
|---|---|---|---|---|---| |---|---|---|---|---|
| `{typography.display-xxl}` | 72px | 500 | 1.0 | 0 | Hero headline (homepage, laptop hub) | | `{typography.display-xxl}` | 40px | 500 | 1.1 | Page-level hero (rare — dashboard title) |
| `{typography.display-xl}` | 56px | 500 | 1.0 | 0 | Section headlines on landing pages | | `{typography.display-xl}` | 32px | 500 | 1.1 | Section title (engagement name, modal header) |
| `{typography.display-lg}` | 44px | 500 | 1.0 | 0 | Sub-section headlines on shop pages | | `{typography.display-lg}` | 28px | 500 | 1.1 | Sub-section header, panel title |
| `{typography.display-md}` | 32px | 500 | 1.0 | 0 | Promo strip headlines, FAQ section headers | | `{typography.display-md}` | 24px | 500 | 1.1 | Card title, table header group |
| `{typography.display-sm}` | 24px | 500 | 1.17 | 0 | Card titles, pricing-tier names | | `{typography.display-sm}` | 20px | 500 | 1.1 | Item title, form section header |
| `{typography.display-xs}` | 20px | 500 | 1.0 | 0 | Inline list headers, accordion labels | | `{typography.display-xs}` | 16px | 600 | 1.1 | Compact section header, sidebar label |
| `{typography.body-lg}` | 18px | 400 | 1.33 | 0 | Lead paragraphs | | `{typography.body-lg}` | 18px | 400 | 1.4 | Lead paragraph |
| `{typography.body-md}` | 16px | 400 | 1.38 | 0 | Default body | | `{typography.body-md}` | 16px | 400 | 1.4 | Default body, form labels |
| `{typography.body-emphasis}` | 16px | 500 | 1.38 | 0 | Bolded run-in copy | | `{typography.body-emphasis}` | 16px | 500 | 1.4 | Bolded inline copy, table column headers |
| `{typography.caption-md}` | 14px | 400 | 1.5 | 0 | Specs, metadata, captions | | `{typography.caption-md}` | 14px | 400 | 1.5 | Secondary metadata, captions, table cells (non-data) |
| `{typography.caption-bold}` | 14px | 700 | 1.3 | 0 | Sale tags, in-card highlights | | `{typography.caption-bold}` | 14px | 700 | 1.3 | Status labels, tag text |
| `{typography.caption-sm}` | 12px | 400 | 1.33 | 0 | Footnotes, legal lines | | `{typography.caption-sm}` | 12px | 400 | 1.33 | Footnotes, legal fine-print |
| `{typography.link-md}` | 16px | 500 | 1.38 | 0 | Inline link emphasis | | `{typography.button-md}` | 14px | 600 | 1.4 | Button labels (uppercase) |
| `{typography.button-md}` | 14px | 600 | 1.4 | 0.7px | Primary/secondary button labels (uppercase) | | `{typography.button-sm}` | 12.6px | 700 | 1.0 | Compact button in tight cells |
| `{typography.button-sm}` | 12.6px | 700 | 1.0 | 0.126px | Compact button labels in tight cells |
| `{typography.price-md}` | 24px | 500 | 1.17 | 0 | Tier and product price stamps |
### Principles ### Monospace Discipline
The typographic decision worth flagging: HP runs **weight 500 for every display size**, including the largest 72px hero headline. Most editorial systems jump to 600/700 at hero scale; HP doesn't. The result feels open and approachable rather than commanding — appropriate for a brand that sells across consumer, SMB, and enterprise audiences in the same catalog. JetBrains Mono carries data that must be scanned without typographic noise. Apply `font-mono` (Tailwind) or `font-family: var(--font-mono)` to:
Forma DJR Micro's rounded-grotesque shapes do most of the warmth. There's no italic in the system except inside legal disclaimers; emphasis is carried by weight (500 → body-emphasis, 700 → caption-bold) instead. - Engagement IDs, simulation IDs (any UUID or integer identifier)
- ISO 8601 dates and timestamps (`2024-06-09T14:32:00`)
- MITRE technique codes (`T1059.001`, `TA0002`)
- Execution commands and command fields
- Terminal / execution output
- Numeric metrics (counts, durations)
- Usernames displayed as record identifiers (not greeting text)
### Note on Font Substitutes Never apply `font-mono` to: page titles, section headers, body prose, navigation, form labels, button text.
Forma DJR Micro is proprietary (Commercial Type / Mark Caneso). Closest open-source substitutes: ---
- **Inter** at weights 400 / 500 / 600 / 700 — slightly narrower than Forma DJR Micro; bump font-size by ~3% to compensate
- **Manrope** at weights 400 / 500 / 600 / 700 — closer in proportion, gentler curves; use directly with no metric adjustment
- **Roboto** at weights 400 / 500 / 700 — flatter character; use as last-resort fallback
When swapping, set body line-height to 1.4 and display line-height to 1.0 explicitly — the Forma DJR Micro line-height numbers are tight, and most substitutes default looser.
## Layout ## Layout
### Spacing System ### Spacing System
- **Base unit**: 8px. Smaller half-step at 4px. The scale is gentle — most card padding lands at 16px or 24px; section gap at 80px. - **Base unit**: 8px. Half-step 4px.
- **Tokens (front matter)**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 80px - **Tokens**: `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 16px · `{spacing.lg}` 20px · `{spacing.xl}` 24px · `{spacing.xxl}` 32px · `{spacing.section}` 48px
- **Section padding**: `{spacing.section}` (80px) vertical between major bands on desktop; collapses to ~48px on mobile. - **Section padding**: 48px vertical between major bands (desktop); ~24px on mobile.
- **Card internal padding**: `{spacing.xl}` (24px) for product cards; `{spacing.xxl}` (32px) for promo strips and feature cards; `{spacing.md}` (16px) for compact article tiles. - **Card padding**: 16px on dense panels; 24px on standard cards.
- **Gutter**: `{spacing.xl}` (24px) between grid columns at desktop; `{spacing.md}` (16px) on tablet/mobile. - **Gutter**: 24px between grid columns on desktop; 16px on tablet/mobile.
The 80px section gap is the universal rhythm constant — it appears between every major homepage band, between the hero and the comparison table on the printer-plan page, and between feature rows on the laptop-shop page.
### Grid & Container ### Grid & Container
- **Desktop max-width**: 1366px content container with full-bleed-on-canvas section backgrounds. - **Max-width**: 1366px content container, full-bleed on canvas.
- **Hero**: a single full-width photo card (homepage and laptop-hub hero) with the headline overlay positioned upper-left or upper-right. - **List pages**: full-width table with 1px hairline borders, no card wrap.
- **Product family grid**: 4 columns at >1200px, 3 at 10241199px, 2 at 7681023px, 1 below 768px. - **Detail pages**: 2-column split (60/40) on desktop, stacked on mobile.
- **Pricing tiers**: 4 columns at >1024px, 2x2 grid at 7681023px, single-column accordion below 768px. - **Form pages**: single-column centered at 640px max-width.
- **Footer**: 5-column link grid at >1024px, collapsing to 2-column then accordion on mobile.
### Whitespace Philosophy ### Whitespace Philosophy
Whitespace is **commercial-clean** — generous around hero photography, tight around catalog spec rows. Product cards leave breathing room above and below the photo (≥32px) so the laptop or printer reads as a hero shot rather than a thumbnail. The fine-print disclaimer regions (legal, footnote rows) tighten line-height to 1.3 and shrink type to 1112px so the bulk of fine print stays compact. Dense but not cramped. Table rows at 44px height (WCAG touch target). Card padding minimum 12px. No whitespace used as decoration — every gap serves alignment or grouping. Editorial breathing room (80px sections, hero-scale photography) does not apply here.
## Elevation & Depth ---
| Level | Treatment | Use |
|---|---|---|
| 0 — Flat | No border, no shadow. | Section bands (white, cloud, fog), full-bleed photo heroes |
| 1 — Hairline | 1px solid `{colors.hairline}` (`#e8e8e8`) border, no shadow. | Outlined buttons, comparison-table cells, FAQ accordion outers |
| 2 — Soft Lift | `0 2px 8px rgba(26, 26, 26, 0.08)`. | Product cards, pricing-tier columns, customer-story tiles |
| 3 — Floating Modal | `0 8px 24px rgba(26, 26, 26, 0.12)`. | Add-to-cart drawer, mobile-nav sheet, image zoom modal |
The system is mostly flat — depth is communicated by **color contrast** (cloud-band vs. white card on the same band) rather than shadow elevation. The Soft Lift level is the workhorse for the catalog — every product tile and pricing column gets it; nothing else does. Modal-floating is rare and reserved for transient overlays.
### Decorative Depth
The system's most distinctive depth gesture is the **HP blue chevron pair** — two angular `{colors.primary}` slashes (no radius, no shadow) that sit on the left and right of the homepage hero card and the laptop-shop hero. They're not decorative noise; they're a literal echo of the HP wordmark's two parallel slashes, scaled up to architectural size. Treat them as a brand artifact, not a generic geometric flourish.
Photography on the homepage and laptop-shop pages frames product imagery inside `{rounded.xl}` (16px) containers with a soft 1px hairline. Lifestyle photography (testimonials, "How HP works for X") sits full-bleed inside dark-navy slabs without rounding.
## Shapes ## Shapes
### Border Radius Scale ### Border Radius
| Token | Value | Use | | Token | Value | Use |
|---|---|---| |---|---|---|
| `{rounded.none}` | 0px | Hero chevron decorations, full-bleed photo heroes, marquee strips | | `{rounded.none}` | 0px | **Default for everything**: buttons, cards, modals, inputs, dropdowns, panels, tags, tables |
| `{rounded.xs}` | 2px | Secondary chip backgrounds, sale-tag pills | | `{rounded.pill}` | 9999px | **Status pills only** (`StatusBadge`, `SimulationStatusBadge`) and circular avatars |
| `{rounded.sm}` | 3px | Default secondary CTA radius (small touch zones) |
| `{rounded.md}` | 4px | Primary buttons, secondary buttons, text inputs |
| `{rounded.lg}` | 8px | Badge pills, category-icon cards, FAQ row containers |
| `{rounded.xl}` | 16px | Product cards, pricing tiers, customer-story tiles, photo frames |
| `{rounded.pill}` | 9999px | Category sub-nav tabs, search-pill input, filter chips |
The system maintains a clear two-tier philosophy: **buttons stay sharp** (4px, almost rectilinear) while **cards and photo frames stay soft** (16px). This split is the visual signature — sharp interactive elements against softer container surfaces. No intermediate radius values (`xs`, `sm`, `md`, `lg`, `xl`) are used on visible surfaces. The brutalist rule: if it's not a status pill or avatar, `border-radius: 0`.
### Photography Geometry ---
Hero photography sits in `{rounded.xl}` (16px) frames with no border. Product family thumbnails inside the laptop-grid are 1:1 (square) on a `{colors.canvas}` background, padded so the laptop is shown at ~70% of the frame. Customer-story photography uses 16:9 inside the same `{rounded.xl}` frame. There are no full-bleed circular avatars; testimonial avatars are 4px-rounded squares. ## Elevation
No shadows on interactive surfaces. Elevation is communicated by **border contrast** (1px hairline on paper over canvas) not box-shadow.
| Level | Treatment | Use |
|---|---|---|
| 0 — Flat | No border, no shadow | Page background, full-bleed bands |
| 1 — Hairline | `1px solid {colors.hairline}` | Cards, panels, table cells, input borders |
| 2 — Modal overlay | `rgba(0,0,0,0.6)` backdrop | Modal dialogs — backdrop only, frame stays hairline |
Shadows (`box-shadow`) are not used anywhere.
---
## Components ## Components
> **No hover states documented.** Every component spec below documents only Default and Active/Pressed states. Variants live as separate front-matter entries. > Every component spec below: border-radius **0** unless noted. No `transition-*` on any interactive surface. Hover is instantaneous.
### Buttons ### Buttons
**`button-primary`** — the lone HP Electric Blue CTA **`.btn-primary`** — Electric Blue CTA
- Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.button-md}` (uppercase, 0.7px tracking), padding `{spacing.sm} {spacing.xl}` (12 × 24), height 44px, rounded `{rounded.md}` - Background `{colors.primary}`, text white, uppercase, 14px/600, `h-11`, padding `12px 24px`, `rounded-none`
- Pressed state `button-primary-pressed` background `{colors.primary-deep}`, same text - Hover: background `{colors.primary-deep}` — no transition
- Disabled state `button-primary-disabled` background `{colors.steel}`, white text - Disabled: background `{colors.steel}`, cursor not-allowed
- Used for: "Buy now", "Shop now", "Get a printer", primary form submit
**`button-ink`** — black filled CTA **`.btn-ink`** — Fixed-dark filled CTA
- Background `{colors.ink}`, text `{colors.on-primary}`, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}`, type `{typography.button-md}` - Background `{colors.slab}`, text `{colors.slab-text}`, uppercase, same metrics as btn-primary
- Used for: "Buy now" on dark photo overlays, secondary primary actions where the blue would clash with imagery - Used on dark band surfaces where blue would clash
**`button-outline`** — blue-text outlined CTA **`.btn-outline`** — Outlined blue CTA
- Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}` - Background `{colors.canvas}`, text `{colors.primary}`, 1px `{colors.primary}` border, `rounded-none`
- Used for: "Compare", "Customize", "Learn more" — secondary actions on white surfaces - Hover: background `{colors.primary-soft}`
**`button-outline-ink`** — black-text outlined CTA **`.btn-outline-ink`** — Outlined neutral CTA
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, padding `{spacing.sm} {spacing.xl}`, height 44px, rounded `{rounded.md}` - Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, `rounded-none`
- Used for: "View" buttons inside product family card grids — neutral against the blue primary - Hover: background `{colors.cloud}`
**`button-text-link`** — inline blue link with underline **`.btn-text-link`** — Inline text link
- Background `{colors.canvas}`, text `{colors.primary}`, type `{typography.link-md}`, padding `{spacing.xxs} 0` - Text `{colors.primary}`, no background, no border
- Used for: "See details", "Read more" inside cards and disclaimer rows - Hover: underline, no color change
### Cards & Containers
**`card-product`** — the workhorse product tile
- Background `{colors.canvas}`, rounded `{rounded.xl}` (16px), padding `{spacing.xl}` (24px), Soft Lift shadow
- Layout: hero photo (1:1 ratio) on top, title in `{typography.display-xs}`, spec rows in `{typography.caption-md}`, price in `{typography.price-md}`, CTA pinned to bottom
- Used for: laptop catalog cards, desktop catalog cards
**`card-product-feature`** — full-row feature card with photo + copy
- Background `{colors.cloud}`, rounded `{rounded.xl}`, padding `{spacing.xxl}` (32px)
- Layout: photo on the left (50% width), copy on the right with section eyebrow + title + body + CTA pair
- Used for: "Trending laptops" feature rows, "Shop these must haves"
**`card-pricing-tier`** + **`card-pricing-tier-featured`**
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xl}`, Soft Lift shadow
- Tier name in `{typography.display-sm}`, monthly price in `{typography.display-md}` with `{typography.caption-md}` cadence, page count caption, full feature list, primary CTA
- Featured tier carries `{colors.primary}` text accent on the price-stamp + a `{colors.primary}` thin top border instead of a colored card background — never inverted to dark
**`card-customer-story`** — the three-up testimonial tile
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.md}` (16px), Soft Lift shadow
- 16:9 photo at top in `{rounded.xl}` frame, quote excerpt in `{typography.body-md}`, attribution row at the bottom
- Used in the "See what our customers say" homepage section
**`card-article-tile`** — the four-up "Latest from HP" tile
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.md}`, Soft Lift shadow
- 16:9 thumbnail at top, date eyebrow in `{typography.caption-sm}`, title in `{typography.body-emphasis}`, "Read more" link
**`card-category-icon`** — the small icon-and-label card in the homepage "Our Products" row
- Background `{colors.canvas}`, rounded `{rounded.lg}` (8px), padding `{spacing.md}`
- 48px icon at top, label in `{typography.body-emphasis}` below
- Used for: Laptops, Desktops, Printers, Computer Tools, Accessories, Enterprise Solutions
**`hero-promo-card`** — the homepage hero card with chevron decorations
- Background `{colors.canvas}`, rounded `{rounded.xl}`, padding `{spacing.xxl}` (32px)
- Photography occupies left half; copy block (eyebrow + headline + price stamp + CTA pair) occupies right half
- Flanked by `chevron-decoration` blue slashes outside the card's bounding box on left and right edges
**`promo-strip-dark`** — the inline dark navy promo block
- Background `{colors.ink}`, text `{colors.on-ink}`, rounded `{rounded.xl}`, padding `{spacing.xxl} 48px`
- Used for: "When did work start getting in the way of work?" mid-page promo, the SMB testimonial slab
### Inputs & Forms ### Inputs & Forms
**`text-input`** + **`text-input-focused`** **`.text-input`** — Standard text field
- Background `{colors.canvas}`, text `{colors.ink}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 44px - Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.steel}` border, `rounded-none`, `h-11`
- 1px `{colors.steel}` border in default; gains 1px `{colors.ink}` border on focus (no halo) - Focus: border becomes 1px `{colors.primary}`, no halo, no transition
**`text-input-search`** — pill search in the top nav **`textarea.text-input`** — Multiline variant
- Background `{colors.canvas}`, rounded `{rounded.md}`, padding `{spacing.sm} {spacing.md}`, height 40px, 1px `{colors.steel}` border, magnifying-glass icon at right - Same as text-input, `min-h-[88px]`, height auto
**`badge-pill-ink`** — filled tag pill **`select.text-input`** — Dropdown select
- Background `{colors.ink}`, text `{colors.on-primary}`, rounded `{rounded.lg}`, padding 6px 12px, type `{typography.body-md}` - Same surface as text-input, caret via OS or custom SVG
- Used inline next to product titles to mark "New" or featured indicators
**`badge-pill-outline`** — outlined tag pill ### Cards & Panels
- Background `{colors.canvas}`, text `{colors.ink}`, 1px `{colors.ink}` border, rounded `{rounded.lg}`, padding 6px 12px
**`badge-sale-coral`** — the sale price-stamp **`.card-product`** — Standard content card
- Background `{colors.bloom-coral}`, text `{colors.on-primary}`, rounded `{rounded.sm}`, padding `{spacing.xxs} {spacing.xs}`, type `{typography.caption-bold}` - Background `{colors.paper}`, 1px `{colors.hairline}` border, `rounded-none`, padding `{spacing.md}` (16px)
- Used for: "Save $200", "25% off" overlay tags on hero promo cards - No shadow. Dense surfaces use padding `{spacing.sm}` (12px).
**`.modal-backdrop`** — Modal overlay backdrop
- `background-color: rgba(0,0,0,0.6)` — fixed, never themed
- Modal frame: `{colors.paper}` background, 1px `{colors.hairline}` border, `rounded-none`
### Badges & Tags
**`.badge-pill-*`** — Status pills (StatusBadge, SimulationStatusBadge)
- `rounded-pill` (9999px) — THE ONLY rounded surfaces on status badges
- Semantic fill: planned→warn-soft/warn, active→primary-soft/primary, closed→cloud/graphite
- done→success-soft/success, review→warn-soft/warn, pending→cloud/graphite, in-progress→primary-soft/primary
**MITRE technique tags** — NOT pills
- Angular (`rounded-none`), 1px `{colors.hairline}` border, `{colors.cloud}` background, caption-md size
- They are labels, not status signals — no pill shape
### Navigation ### Navigation
**`utility-strip`** — the top-of-page utility bar **`.utility-strip`** — Top utility bar
- Background `{colors.ink}`, text `{colors.on-primary}`, height 36px, padding 0 {spacing.xl}, type `{typography.caption-md}` - Background `{colors.slab}`, text `{colors.slab-text}`, height 36px, `font-mono` for user role/username
- Holds: country/locale picker, "For Business / For Home" toggle, "Sign in" link, cart link
**`nav-bar-top`** — desktop top nav (sits below utility strip) **`.nav-bar-top`** — Main navigation
- Background `{colors.canvas}`, height 64px, padding 0 32px - Background `{colors.slab}` (fixed dark — does not invert), height 56px
- Layout: HP wordmark logo flush left → middle category list (Laptops / Desktops / Printers / Accessories / Solutions / Support) → right slot with Search field, Sign-in link, Cart icon - Active link: 2px `{colors.primary}` bottom border
- 1px `{colors.hairline}` bottom border separates nav from page
**`nav-link`** **`.nav-link`** — Navigation anchor
- Background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-md}`, padding `{spacing.xs} {spacing.md}` - Text `{colors.slab-text}`, caption-md, `rounded-none`
- Active page draws a 2px `{colors.primary}` underline below the text baseline - Active state: 2px primary bottom border
**Top Nav (Mobile)** ### Data Tables
- Same height, hamburger icon replaces the middle category list, Search and Cart stay visible
- Drawer expands as a full-canvas sheet with `{typography.body-lg}` link list and a sticky Sign-in CTA at bottom
**`category-tab`** + **`category-tab-active`** — the pill sub-nav - `table-layout: fixed`, `word-break: break-word`
- Default: background `{colors.canvas}`, text `{colors.ink}`, type `{typography.body-emphasis}`, rounded `{rounded.pill}`, padding `{spacing.xs} {spacing.lg}` - Header row: background `{colors.cloud}`, `body-emphasis` text, 1px `{colors.hairline}` bottom border
- Active: background `{colors.ink}`, text `{colors.on-primary}`, same rounding - Data cells: 44px min-height, 1px `{colors.hairline}` bottom border
- Used on the laptop-shop page for "All / Trending / On Sale" filtering, and on the homepage "How can we help?" closing band - ID / date / technique columns: `font-mono`
- Zebra striping optional — use `{colors.cloud}` for odd rows if table is wide
### Signature Components ### Toast Notifications
**`chevron-decoration`** — the geometric blue slash motif - Angular (`rounded-none`), 4px left border strip in semantic color (success/warn/bloom-deep/primary)
- Background `{colors.primary}`, rounded `{rounded.none}`, no shadow - Background `{colors.paper}`, 1px `{colors.hairline}` border
- Renders as a sharp parallelogram cut at ~60° angle, sized to the height of the hero card it flanks
- Reserved for hero bands and full-page banners — never decorative noise inside cards
**`faq-row`** — the accordion row on the printer-plan FAQ ---
- Background `{colors.canvas}`, rounded `{rounded.lg}`, padding `{spacing.lg} {spacing.xl}`, type `{typography.body-emphasis}`
- 1px `{colors.hairline}` divider between rows; chevron-down icon on the right collapsed, chevron-up when expanded
- Body answer renders inside the same row container in `{typography.body-md}` after expansion
**`help-band-dark`** — the closing "How can we help?" prelude band
- Background `{colors.ink}`, text `{colors.on-primary}`, padding 64px {spacing.xl}
- Layout: large lifestyle photograph as the band background (low-opacity) with chip-style category tabs centered: Browse Topics / Live Chat / Contact / Diagnose / Order Status
**`footer-dark`**
- Background `{colors.ink}`, text `{colors.on-primary}`, type `{typography.body-md}`, padding 64px {spacing.xl}
- 5-column link grid (Company / Shop / Support / Resources / Connect) with `{typography.body-emphasis}` headers and `{typography.caption-md}` link rows
- Bottom strip carries social icons, language picker, and legal lines in `{typography.caption-sm}` muted to `{colors.steel}`
## Do's and Don'ts ## Do's and Don'ts
### Do ### Do
- Reserve `{colors.primary}` for the primary CTA, link color, and `chevron-decoration` motif — at most twice per viewport - `rounded-none` on every container, button, input, modal, dropdown, panel, tag
- Set every headline in Forma DJR Micro at weight 500 with line-height 1.0 — resist the urge to bump weight at hero scale - `font-mono` on IDs, ISO dates, MITRE codes, commands, output, metrics
- Use `{rounded.xl}` (16px) for cards and photo frames; `{rounded.md}` (4px) for buttons and inputs — keep the two-tier split sharp - Semantic color for status: success green, warn amber, bloom-deep for errors
- Pair white `{colors.canvas}` body bands with `{colors.cloud}` (`#f7f7f7`) alternating bands; let the gray do the breathing - 1px hairline borders for panel separation — never shadow
- Close every page rhythm with a dark-navy `{colors.ink}` slab — the "How can we help?" prelude + footer - Instant hover (no `transition-*`)
- Set button labels in uppercase with `{typography.button-md}` (0.7px tracking) — the only place the system tracks letters - Sharp focus ring: `outline: 2px solid {colors.primary}; outline-offset: 0`
- Use Soft Lift shadow exclusively for product cards and pricing tiers — leave section bands flat - Keep Inter for all headers, labels, prose, navigation, button text
- Frame product photography inside `{rounded.xl}` containers; never use full-bleed circular masks
### Don't ### Don't
- Don't introduce secondary saturated colors outside `{colors.primary}` family + the `bloom-coral` sale-tag and `storm` printer-plan accents - Don't round containers — not even `2px`. If it's not a status pill or avatar, `rounded-none`
- Don't apply heavy material shadows — depth is via color contrast (cloud vs. white) and Soft Lift only - Don't use `font-mono` for headers, labels, prose, or button text
- Don't round buttons above `{rounded.md}` (4px); a soft 8px+ button reads as a different brand - Don't add `transition-*` on any interactive element
- Don't run Forma DJR Micro below 12px — small caption at 11px is the floor - Don't use shadows — hairline borders only
- Don't use the chevron decoration as inline noise; it is a hero-only architectural element tied to the wordmark - Don't use `{colors.primary}` as a background for sections
- Don't drop ink text opacity to create hierarchy — switch surface or shift to `{colors.charcoal}` / `{colors.graphite}` instead - Don't drop opacity on ink text — use `{colors.charcoal}` or `{colors.graphite}` for hierarchy
- Don't replace the HP wordmark with a generic sans lockup; the wordmark is a custom mark with its own ratio - Don't use animated spinners with rounded tracks — a simple spinning line or text indicator fits the terminal aesthetic better
- Don't apply `rounded-pill` to anything that is not a STATUS BADGE or AVATAR
## Responsive Behavior ---
### Breakpoints
| Name | Width | Key Changes |
|---|---|---|
| Mobile | < 480px | Single-column stack; hamburger nav; section padding drops to ~48px; hero serif scales to ~36px |
| Mobile-Large | 480767px | Same column count; hero scales to ~44px; pricing tiers stack vertically |
| Tablet | 7681023px | 2-column product grid; pricing 2x2; nav still full text labels |
| Desktop | 10241279px | 3-column product grid; 4-column pricing; full nav |
| Desktop-Large | ≥ 1280px | 4-column product grid; 1366px content max-width with full-bleed bands |
### Touch Targets
Every interactive element clears 44×44px on mobile. `button-primary` at 44px height + 24px horizontal padding meets WCAG-AAA touch target. `category-tab` at 8px 20px padding bumps to 12px 24px on touch screens. Nav-link tap areas extend invisibly beyond the text run to the full 44px row height. Sticky cart/sign-in icons in the top nav use 44×44 invisible hit boxes around their visible 24×24 glyph.
### Collapsing Strategy
- **Utility strip**: stays visible on every breakpoint; dropdowns collapse into a single "Account" icon below 768px
- **Top nav**: middle category list collapses into a hamburger drawer below 1024px; the right-side Search + Sign-in + Cart stay visible
- **Hero**: stays single-column at every breakpoint; chevron decorations shrink to ~60% size on tablet and disappear entirely on mobile
- **Product family grid**: 4 → 3 → 2 → 1 column as breakpoints shrink; cards keep `{rounded.xl}` corners at every size
- **Pricing comparison table**: 4-column grid on desktop collapses to 2x2 on tablet, then stacks into individual accordion-style cards on mobile
- **Footer**: 5-column link grid → 2-column tablet → single-column accordion on mobile; HP wordmark stays flush left
### Image Behavior
Hero photography uses `{rounded.xl}` containers at every breakpoint. The chevron decorations vanish on mobile; the underlying photo card centers in the viewport. Lifestyle photography in the testimonial and "how-can-we-help" bands maintains 16:9 ratio with horizontal cropping rather than letterboxing on mobile. There are no art-direction crop swaps between desktop and mobile — the same image is used at every size.
## Iteration Guide ## Iteration Guide
1. Focus on ONE component at a time; resist refactoring an entire section in one pass 1. One component at a time — no section refactors in one pass
2. Reference component names and tokens directly (`{colors.primary}`, `{typography.display-xxl}`, `{rounded.xl}`, `card-product`) — do not paraphrase to hex/px in prose 2. Reference tokens by name (`{colors.primary}`, `font-mono`, `rounded-none`) — not hex/px in prose
3. Run `npx @google/design.md lint DESIGN.md` after edits — `broken-ref`, `contrast-ratio`, and `orphaned-tokens` warnings flag issues automatically 3. When adding a new data field: ask "is this a datum (ID, date, code, metric)?" → `font-mono`. If no → Inter
4. Add new variants as separate component entries (`-pressed`, `-disabled`, `-focused`); never bury state inside prose 4. New status signals: map to existing semantic tokens (success/warn/bloom-deep/primary). No ad-hoc colors
5. Default body to `{typography.body-md}`; reach for `{typography.body-emphasis}` for run-in bolds; keep display sizes for true heading roles 5. For new badge-like elements: pill ONLY if it's a status indicator with semantic color. Otherwise angular tag
6. Keep `{colors.primary}` scarce — at most two flame elements per viewport (one CTA + one chevron decoration). Three flame items in one viewport is over-saturation
7. When introducing a new section band, choose from `{colors.canvas}` / `{colors.cloud}` / `{colors.fog}` / `{colors.ink}` — six pre-defined surface modes is the entire surface vocabulary

View File

@@ -3,16 +3,20 @@ IMAGE ?= mimic:latest
CONTAINER ?= mimic CONTAINER ?= mimic
VOLUME ?= mimic-data VOLUME ?= mimic-data
.PHONY: build start stop restart update logs create-admin update-mitre test-backend test-frontend test-e2e clean # Container engine: auto-detect docker first, fall back to podman.
# 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 open-pr
build: build:
docker build -f docker/Dockerfile -t $(IMAGE) . $(CONTAINER_CMD) build -f docker/Dockerfile -t $(IMAGE) .
start: start:
docker run -d --name $(CONTAINER) -p $(PORT):5000 -v $(VOLUME):/data --env-file .env $(IMAGE) $(CONTAINER_CMD) run -d --name $(CONTAINER) -p $(PORT):5000 -v $(VOLUME):/data --env-file .env $(IMAGE)
stop: stop:
docker stop $(CONTAINER) && docker rm $(CONTAINER) $(CONTAINER_CMD) stop $(CONTAINER) && $(CONTAINER_CMD) rm $(CONTAINER)
restart: restart:
$(MAKE) stop && $(MAKE) start $(MAKE) stop && $(MAKE) start
@@ -21,7 +25,7 @@ update:
git pull && $(MAKE) build && $(MAKE) restart git pull && $(MAKE) build && $(MAKE) restart
logs: logs:
docker logs -f $(CONTAINER) $(CONTAINER_CMD) logs -f $(CONTAINER)
create-admin: create-admin:
ifndef USER ifndef USER
@@ -30,7 +34,7 @@ endif
ifndef PASS ifndef PASS
$(error PASS is required: make create-admin USER=alice PASS=p4ssw0rd) $(error PASS is required: make create-admin USER=alice PASS=p4ssw0rd)
endif endif
docker exec $(CONTAINER) flask create-admin $(USER) $(PASS) $(CONTAINER_CMD) exec $(CONTAINER) flask create-admin $(USER) $(PASS)
MITRE_URL ?= https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json MITRE_URL ?= https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json
@@ -38,13 +42,13 @@ update-mitre:
@mkdir -p backend/data/mitre @mkdir -p backend/data/mitre
@curl -fsSL "$(MITRE_URL)" -o backend/data/mitre/enterprise-attack.json @curl -fsSL "$(MITRE_URL)" -o backend/data/mitre/enterprise-attack.json
@echo "MITRE bundle updated" @echo "MITRE bundle updated"
@if docker ps --format '{{.Names}}' | grep -q "^$(CONTAINER)$$"; then \ @if $(CONTAINER_CMD) ps --format '{{.Names}}' | grep -q "^$(CONTAINER)$$"; then \
echo "Restarting $(CONTAINER) to reload MITRE bundle..."; \ echo "Restarting $(CONTAINER) to reload MITRE bundle..."; \
docker restart $(CONTAINER); \ $(CONTAINER_CMD) restart $(CONTAINER); \
fi fi
test-backend: test-backend:
docker exec $(CONTAINER) pytest -q backend/tests/ $(CONTAINER_CMD) exec $(CONTAINER) pytest -q backend/tests/
test-frontend: test-frontend:
cd frontend && npm run test -- --run cd frontend && npm run test -- --run
@@ -53,6 +57,18 @@ test-e2e:
cd e2e && npx playwright test cd e2e && npx playwright test
clean: clean:
-docker rm -f $(CONTAINER) 2>/dev/null -$(CONTAINER_CMD) rm -f $(CONTAINER) 2>/dev/null
-docker volume rm $(VOLUME) 2>/dev/null -$(CONTAINER_CMD) volume rm $(VOLUME) 2>/dev/null
rm -rf backend/__pycache__ frontend/node_modules frontend/dist 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. **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 2Simulations + MITRE ATT&CK**. The Purple Team workflow (RedTeam fills test → marks for review → SOC documents detection → closes) is now end-to-end testable in the UI, with MITRE technique autocomplete for TTP tagging. > Status: **Sprint 6Engagement export**. Admin/redteam can now export an engagement to Markdown, CSV, or PDF in one click from `EngagementDetailPage`. The export contains the engagement header and all simulations with both Red Team and SOC fields — closing the "replace the shared Excel" loop. CSV cells are defused against spreadsheet formula injection. SOC has no access to the export.
--- ---
@@ -98,7 +98,7 @@ mimic/
| Target | What it does | | Target | What it does |
|---|---| |---|---|
| `make build` | Build the `mimic:latest` Docker image (multistage: Node → Python) | | `make build` | Build the `mimic:latest` container image (multistage: Node → Python). Uses `docker` if installed, otherwise `podman` — override with `make build CONTAINER_CMD=podman` |
| `make start` | Start the container (port from `PORT`, default 5000; mounts `mimic-data` volume) | | `make start` | Start the container (port from `PORT`, default 5000; mounts `mimic-data` volume) |
| `make stop` | Stop and remove the container | | `make stop` | Stop and remove the container |
| `make restart` | `make stop && make start` — preserves the SQLite volume | | `make restart` | `make stop && make start` — preserves the SQLite volume |
@@ -110,6 +110,7 @@ mimic/
| `make test-frontend` | `npm run test -- --run` in `frontend/` | | `make test-frontend` | `npm run test -- --run` in `frontend/` |
| `make test-e2e` | Playwright acceptance suite (container must be running) | | `make test-e2e` | Playwright acceptance suite (container must be running) |
| `make clean` | Remove container + volume + Python/Node caches | | `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: Tests:
```bash ```bash
cd backend && pytest -q # 131 tests cd backend && pytest -q # 253 tests
cd frontend && npm run test -- --run # 63 tests cd frontend && npm run test -- --run # 136 tests
cd e2e && npx playwright test # 68 tests (needs container up) cd e2e && npx playwright test # 223 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
``` ```
--- ---

107
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 : Une simulation est composée des champs suivants :
* Partie RedTeam : * Partie RedTeam :
- Nom du test - 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 - Description
- Commandes exécutés (liste) - Commandes exécutés (liste)
- Pré-requis (champs texte) - Pré-requis (champs texte)
@@ -25,17 +25,104 @@ 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. 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 : Le workflow se mettra à jour de la manière suivante :
- Création de la simulation : pending - 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. - 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.
## Templates de simulations
Un **template de simulation** est une simulation pré-remplie côté redteam (name + description + commandes + pré-requis + techniques MITRE + tactiques MITRE) qui sert de point de départ pour instancier rapidement des simulations dans un engagement. Le template ne contient PAS de partie SOC, ni de date d'exécution, ni de résultat d'exécution — ces champs restent par-instance.
L'instanciation d'un template dans un engagement crée une **nouvelle simulation indépendante** : le template et l'instance sont décorrélés, l'édition de l'un n'affecte pas l'autre. Aucune référence (FK `template_id`) n'est conservée sur la simulation instanciée.
**RBAC templates = ressource Red Team uniquement** : admin et redteam les gèrent (CRUD). SOC n'a aucun accès (pas de nav link, tous endpoints templates retournent 403). Les nouveaux noms de templates sont uniques pour la clarté UX du dropdown d'instanciation.
## Export d'engagement
Un engagement peut être exporté à tout moment dans 3 formats au choix : **Markdown** (handoff narratif), **CSV** (machine-readable, intégration tableurs), **PDF** (livrable client). Endpoint unique : `GET /api/engagements/<id>/export?format=md|csv|pdf`. Réservé aux rôles admin et redteam (livrable RedTeam, cohérent avec le RBAC Templates). Filename normalisé : `engagement-<id>-<slug>-YYYYMMDD.<ext>`.
**Schéma fixe à 7 colonnes** (en-têtes français) pour tous les formats — une ligne par simulation :
| # | Colonne | Source |
|---|---|---|
| 1 | Scénario | `simulation.name` |
| 2 | Test | `simulation.description` |
| 3 | Source de log | `simulation.log_source` |
| 4 | Commentaires SOC | `simulation.soc_comment` |
| 5 | Exécution | concat multi-ligne sans labels, ordre fixe : `executed_at``commands``execution_result` |
| 6 | Logs remontés au SIEM | `simulation.logs` |
| 7 | Cyber incident | `simulation.incident_number` |
CSV : exactement 1 ligne d'en-tête + 1 ligne par simulation. Markdown : en-tête engagement (name, dates, status, created_by) puis une table de 7 colonnes. PDF : même structure que le Markdown rendue via HTML→PDF (WeasyPrint). Le statut de la simulation, les techniques/tactiques MITRE, les prerequisites et les métadonnées (id, created_at) ne sont PAS exportés — l'export est un handoff focalisé RT↔SOC, pas un dump complet.
Prévoir un module d'authentification : dans un premier temps local à la bdd. Prévoir un module d'authentification : dans un premier temps local à la bdd.
Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests. Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests.
Dans un second temps, après que la V1 soit terminée, nous ajouterons une couche permettant de se connecter à un C2 (mythic ou maison) afin d'exécuter des simulation au travers du C2. Dans un second temps, après que la V1 soit terminée, nous ajouterons une couche permettant de se connecter à un C2 (mythic ou maison) afin d'exécuter des simulation au travers du C2.
## Intégration C2 (Sprint 8+)
Couche d'intégration C2 permettant d'exécuter les commandes d'une simulation à travers un Command & Control distant, suivre l'avancement des tâches en quasi-temps réel, et importer l'historique d'exécutions existant. **Implémentation de référence : Mythic 3.x**, derrière une interface `C2Adapter` mince qui ne ferme pas la porte à un C2 maison ultérieur.
**RBAC C2 = ressource Red Team uniquement** (précédent Templates + Export) : admin et redteam ont accès complet (config + exécution + import). SOC retourne 403 sur tous les endpoints C2 (pas de nav link, pas d'affichage du panneau C2).
**Configuration par engagement** : chaque engagement possède au plus une `c2_config` (URL Mythic + API token + flag `verify_tls`). Le token est **chiffré au repos** via `cryptography.Fernet` ; la clé est dérivée de l'env var `MIMIC_ENCRYPTION_KEY` (variable obligatoire pour activer la fonctionnalité C2 — jamais hardcodée, conforme à la règle OPSEC zero-secret-in-code). Le token n'est jamais renvoyé en clair par l'API — `GET /api/engagements/<id>/c2-config` retourne `has_token: bool` uniquement. Mise à jour via `PUT` ; suppression via `DELETE`. La suppression d'un engagement supprime en cascade sa `c2_config`.
**Sélection d'adapter** via l'env var `MIMIC_C2_ADAPTER` :
- `mythic` (défaut) : adapter Mythic réel (GraphQL via Hasura).
- `fake` : adapter en mémoire déterministe utilisé pour la validation Playwright et le dev local sans instance Mythic.
**Modèle de données — additions** :
`c2_config` (1 ligne par engagement au max) :
| Colonne | Type | Notes |
|---|---|---|
| `id` | int PK | |
| `engagement_id` | int FK `engagements.id` ON DELETE CASCADE, **UNIQUE** | |
| `url` | text | endpoint Mythic, ex. `https://lab.internal:7443` |
| `api_token_encrypted` | text | Fernet ciphertext, jamais en clair |
| `verify_tls` | bool, défaut `true` | `false` autorisé pour labs auto-signés |
| `created_at`, `updated_at` | datetime | |
`c2_task` (lien simulation ↔ tâche Mythic) :
| Colonne | Type | Notes |
|---|---|---|
| `id` | int PK | |
| `simulation_id` | int FK `simulations.id` ON DELETE CASCADE | |
| `mythic_task_display_id` | int | identifiant côté Mythic |
| `callback_display_id` | int | callback Mythic sur lequel la tâche tourne |
| `command` | text | commande envoyée |
| `params` | text nullable | paramètres associés |
| `status` | text | statut brut Mythic (`submitted`, `completed`, `error`, …) |
| `completed` | bool | `true` quand la tâche est terminée |
| `output` | text nullable | sortie décodée (base64 → utf-8 ; binaire → préfixe `<binary>` + hex) |
| `source` | enum `mimic` \| `import` | tâche lancée depuis Mimic ou importée a posteriori |
| `created_at` | datetime | |
| `completed_at` | datetime nullable | timestamp de complétion |
**Endpoints C2** (tous admin+redteam ; SOC = 403) :
- `GET /api/engagements/<id>/c2-config``{has_token, url, verify_tls}` (jamais le token en clair).
- `PUT /api/engagements/<id>/c2-config``{url, api_token?, verify_tls}`.
- `DELETE /api/engagements/<id>/c2-config`.
- `POST /api/engagements/<id>/c2-config/test` — test de connectivité via l'adapter, renvoie `{ok, error?}`.
- `GET /api/engagements/<id>/c2/callbacks` — callbacks actifs de l'instance Mythic configurée.
- `POST /api/simulations/<id>/c2/execute` `{callback_display_id, commands: [str]}` — une tâche Mythic par commande, stockées dans `c2_task` (source=`mimic`). **Auto-transition** : si la simulation est `pending`, elle passe à `in_progress` (même règle que l'édition manuelle RT — cf. § Fonctionnement).
- `GET /api/simulations/<id>/c2/tasks` — poll-on-read : à la lecture, rafraîchit le statut et l'output des `c2_task` non terminées depuis Mythic, applique le mapping de sortie (voir ci-dessous) à la simulation pour chaque tâche qui vient de se terminer (idempotent — appliqué une seule fois par tâche).
- `GET /api/engagements/<id>/c2/callbacks/<cid>/history?page=` — historique paginé des tâches d'un callback, pour l'import.
- `POST /api/simulations/<id>/c2/import` `{task_display_ids: [int]}` — import sélectif de tâches (source=`import`) + mapping de sortie.
**Mapping de sortie vers la simulation** (appliqué une fois par tâche, lors de la complétion ou de l'import) :
- `simulation.execution_result` reçoit en append le bloc `\n$ <command>\n<output>\n` (préserve l'existant, jamais d'écrasement).
- `simulation.executed_at` est renseigné depuis le timestamp de la première tâche complétée si le champ est vide ; sinon non modifié.
- `simulation.commands` reçoit en append la commande si elle n'y figure pas déjà (déduplication ligne par ligne).
**Suivi temps réel** : polling court — le frontend re-fetch `GET /api/simulations/<id>/c2/tasks` toutes les **2 500 ms** via TanStack Query `refetchInterval` tant qu'une tâche attachée n'est pas terminée ; le polling s'arrête automatiquement quand toutes les tâches sont `completed`. Pas d'infrastructure ajoutée côté serveur (pas de WebSocket, pas de scheduler).
**UI** : les contrôles C2 vivent dans la carte Red Team de l'écran simulation — bouton `[Execute via C2]` ouvrant une modale (picker de callback + textarea de commandes pré-remplie depuis `commands`), panneau des tâches attachées sous la carte, et modale d'import historique. Configuration C2 visible/éditable depuis l'écran de détail/édition d'engagement.
**Validation** : MVP entièrement mocké — pytest utilise un adapter mocké (zéro HTTP live), Playwright utilise l'adapter `fake` (déterministe). Le branchement contre une instance Mythic réelle est repoussé au premier usage opérationnel et peut nécessiter un patch mineur du contrat GraphQL.
## Stacks techniques ## Stacks techniques
* **FrontEnd** : WebUI * **FrontEnd** : WebUI
- Stacks standard : ReactJS, Vite, TailWind etc... - Stacks standard : ReactJS, Vite, TailWind etc...
@@ -51,8 +138,11 @@ Dans un second temps, après que la V1 soit terminée, nous ajouterons une couch
## Workflows ## Workflows
* Découpage en sprint * Découpage en sprint
* Chaque sprint doit apporter une nouvelle fonctionnalité à tester sur l'UI * 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). * 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 # Team
@@ -157,6 +247,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`). * **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`. * 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). * 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 ## Stack technique précisée
* **Backend** : Python 3.12, Flask, SQLAlchemy, Alembic, pytest, ruff, mypy. Auth via `PyJWT` + middleware decorator. * **Backend** : Python 3.12, Flask, SQLAlchemy, Alembic, pytest, ruff, mypy. Auth via `PyJWT` + middleware decorator.

View File

@@ -6,7 +6,15 @@ from pathlib import Path
from flask import Flask, jsonify, send_from_directory from flask import Flask, jsonify, send_from_directory
from backend.app.api import auth_bp, engagements_bp, simulations_bp, users_bp from backend.app.api import (
auth_bp,
c2_bp,
engagements_bp,
sims_c2_bp,
simulations_bp,
templates_bp,
users_bp,
)
from backend.app.cli import register_cli from backend.app.cli import register_cli
from backend.app.config import Config, TestConfig from backend.app.config import Config, TestConfig
from backend.app.errors import register_error_handlers from backend.app.errors import register_error_handlers
@@ -37,6 +45,9 @@ def create_app(config_object: object | None = None) -> Flask:
app.register_blueprint(users_bp) app.register_blueprint(users_bp)
app.register_blueprint(engagements_bp) app.register_blueprint(engagements_bp)
app.register_blueprint(simulations_bp) app.register_blueprint(simulations_bp)
app.register_blueprint(templates_bp)
app.register_blueprint(c2_bp)
app.register_blueprint(sims_c2_bp)
from backend.app.services import mitre as mitre_svc from backend.app.services import mitre as mitre_svc
mitre_svc.load_bundle() mitre_svc.load_bundle()

View File

@@ -1,7 +1,17 @@
"""API blueprints.""" """API blueprints."""
from backend.app.api.auth import auth_bp from backend.app.api.auth import auth_bp
from backend.app.api.c2 import c2_bp, sims_c2_bp
from backend.app.api.engagements import engagements_bp from backend.app.api.engagements import engagements_bp
from backend.app.api.simulations import simulations_bp from backend.app.api.simulations import simulations_bp
from backend.app.api.templates import templates_bp
from backend.app.api.users import users_bp from backend.app.api.users import users_bp
__all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp"] __all__ = [
"auth_bp",
"c2_bp",
"sims_c2_bp",
"users_bp",
"engagements_bp",
"simulations_bp",
"templates_bp",
]

517
backend/app/api/c2.py Normal file
View File

@@ -0,0 +1,517 @@
"""C2 endpoints — config CRUD and execution.
All endpoints:
- Require admin or redteam role (SOC → 403).
- Return 503 when MIMIC_ENCRYPTION_KEY is not set.
- Never include the cleartext API token in any response.
- Adapter errors → 502 with sanitized message (no URL or token in body).
"""
from __future__ import annotations
from datetime import UTC, datetime
from urllib.parse import urlparse
from flask import Blueprint, jsonify, request
from backend.app.auth import role_required
from backend.app.extensions import db
from backend.app.models import Engagement
from backend.app.models.c2_config import C2Config
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.factory import get_adapter
from backend.app.services.c2.mapping import apply_task_to_simulation
from backend.app.services.crypto import C2Disabled, decrypt, encrypt
from backend.app.services.simulation_workflow import promote_to_in_progress
c2_bp = Blueprint("c2", __name__, url_prefix="/api/engagements")
sims_c2_bp = Blueprint("sims_c2", __name__, url_prefix="/api/simulations")
_503_BODY = {"error": "C2 disabled: MIMIC_ENCRYPTION_KEY not set"}
def _crypto_guard():
"""Return a 503 Response when crypto key is absent, else None."""
try:
# Attempt a dummy operation to test key availability.
encrypt("probe")
return None
except C2Disabled:
return jsonify(_503_BODY), 503
@c2_bp.get("/<int:eid>/c2-config")
@role_required("admin", "redteam")
def get_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return jsonify({"error": "C2 config not found"}), 404
return jsonify({
"has_token": bool(cfg.api_token_encrypted),
"url": cfg.url,
"verify_tls": cfg.verify_tls,
}), 200
@c2_bp.put("/<int:eid>/c2-config")
@role_required("admin", "redteam")
def upsert_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
data = request.get_json(silent=True) or {}
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "url is required"}), 400
parsed = urlparse(url)
if parsed.scheme != "https":
return jsonify({"error": "url must use https"}), 400
if not parsed.hostname:
return jsonify({"error": "url must contain a hostname"}), 400
verify_tls = data.get("verify_tls", True)
if not isinstance(verify_tls, bool):
return jsonify({"error": "verify_tls must be a boolean"}), 400
cfg: C2Config | None = engagement.c2_config
if cfg is None:
# New row — api_token is required on creation.
raw_token = data.get("api_token") or ""
if not raw_token:
return jsonify({"error": "api_token is required when creating a config"}), 400
encrypted = encrypt(raw_token)
cfg = C2Config(
engagement_id=eid,
url=url,
api_token_encrypted=encrypted,
verify_tls=verify_tls,
)
db.session.add(cfg)
else:
# Update — omitting api_token keeps the existing ciphertext.
cfg.url = url
cfg.verify_tls = verify_tls
cfg.updated_at = datetime.now(UTC)
raw_token = data.get("api_token") or ""
if raw_token:
cfg.api_token_encrypted = encrypt(raw_token)
db.session.commit()
return jsonify({
"has_token": True,
"url": cfg.url,
"verify_tls": cfg.verify_tls,
}), 200
@c2_bp.delete("/<int:eid>/c2-config")
@role_required("admin", "redteam")
def delete_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return jsonify({"error": "C2 config not found"}), 404
db.session.delete(cfg)
db.session.commit()
return "", 204
@c2_bp.post("/<int:eid>/c2-config/test")
@role_required("admin", "redteam")
def test_c2_config(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return jsonify({"error": "C2 config not found"}), 404
try:
api_token = decrypt(cfg.api_token_encrypted)
except ValueError:
return jsonify({"ok": False, "error": "Stored token is corrupt"}), 200
adapter = get_adapter(
url=cfg.url,
api_token=api_token,
verify_tls=cfg.verify_tls,
)
health = adapter.test_connection()
return jsonify({"ok": health.ok, "error": health.error}), 200
# ---------------------------------------------------------------------------
# M2 — callbacks listing + execute
# ---------------------------------------------------------------------------
def _load_adapter_for_engagement(engagement: Engagement):
"""Decrypt token and return adapter, or return a (response, status) error tuple."""
cfg: C2Config | None = engagement.c2_config
if cfg is None:
return None, (jsonify({"error": "C2 config not found"}), 404)
try:
api_token = decrypt(cfg.api_token_encrypted)
except ValueError:
return None, (jsonify({"error": "Stored token is corrupt"}), 500)
adapter = get_adapter(url=cfg.url, api_token=api_token, verify_tls=cfg.verify_tls)
return adapter, None
@c2_bp.get("/<int:eid>/c2/callbacks")
@role_required("admin", "redteam")
def list_callbacks(eid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
try:
callbacks = adapter.list_callbacks()
except C2Error as exc:
return jsonify({"error": str(exc)}), 502
return jsonify({
"callbacks": [
{
"display_id": cb.display_id,
"active": cb.active,
"host": cb.host,
"user": cb.user,
"domain": cb.domain,
"last_checkin": cb.last_checkin,
}
for cb in callbacks
]
}), 200
@sims_c2_bp.post("/<int:sid>/c2/execute")
@role_required("admin", "redteam")
def execute_simulation(sid: int):
guard = _crypto_guard()
if guard is not None:
return guard
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
# Done is terminal — block execution.
if sim.status == SimulationStatus.DONE:
return jsonify({"error": "simulation is done — reopen first"}), 409
data = request.get_json(silent=True) or {}
callback_display_id = data.get("callback_display_id")
commands = data.get("commands")
if not isinstance(callback_display_id, int):
return jsonify({"error": "callback_display_id must be an integer"}), 400
if not isinstance(commands, list) or len(commands) == 0:
return jsonify({"error": "commands must be a non-empty list"}), 400
for cmd in commands:
if not isinstance(cmd, str):
return jsonify({"error": "each command must be a string"}), 400
engagement = db.session.get(Engagement, sim.engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
created_tasks = []
try:
for command in commands:
mythic_id = adapter.create_task(
callback_display_id=callback_display_id,
command=command,
)
task = C2Task(
simulation_id=sid,
mythic_task_display_id=mythic_id,
callback_display_id=callback_display_id,
command=command,
params=None,
status="submitted",
completed=False,
source=C2TaskSource.MIMIC,
created_at=datetime.now(UTC),
)
db.session.add(task)
created_tasks.append(task)
except C2Error as exc:
db.session.rollback()
return jsonify({"error": str(exc)}), 502
# Auto-transition pending → in_progress (no-op for other statuses).
promote_to_in_progress(sim)
db.session.commit()
return jsonify({
"tasks": [
{
"id": t.id,
"mythic_task_display_id": t.mythic_task_display_id,
"command": t.command,
"status": t.status,
"completed": t.completed,
}
for t in created_tasks
]
}), 200
# ---------------------------------------------------------------------------
# M3 — poll-on-read task listing
# ---------------------------------------------------------------------------
@sims_c2_bp.get("/<int:sid>/c2/tasks")
@role_required("admin", "redteam")
def list_simulation_tasks(sid: int):
guard = _crypto_guard()
if guard is not None:
return guard
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
engagement = db.session.get(Engagement, sim.engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
tasks: list[C2Task] = C2Task.query.filter_by(simulation_id=sid).all()
for task in tasks:
if task.completed:
continue
try:
status = adapter.get_task(task.mythic_task_display_id)
except C2Error:
# Best-effort refresh — skip this task if the adapter fails.
continue
task.status = status.status
task.completed = status.completed
if status.completed:
task.completed_at = status.completed_at or datetime.now(UTC)
try:
task.output = adapter.get_task_output(task.mythic_task_display_id)
except C2Error:
task.output = ""
apply_task_to_simulation(task, sim)
db.session.commit()
return jsonify({
"tasks": [
{
"id": t.id,
"mythic_task_display_id": t.mythic_task_display_id,
"callback_display_id": t.callback_display_id,
"command": t.command,
"params": t.params,
"status": t.status,
"completed": t.completed,
"output": t.output,
"source": t.source.value,
"mapping_applied": t.mapping_applied,
"created_at": t.created_at.isoformat() if t.created_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_at else None,
}
for t in tasks
]
}), 200
# ---------------------------------------------------------------------------
# M4 — callback history + task import
# ---------------------------------------------------------------------------
@c2_bp.get("/<int:eid>/c2/callbacks/<int:cid>/history")
@role_required("admin", "redteam")
def list_callback_history(eid: int, cid: int):
guard = _crypto_guard()
if guard is not None:
return guard
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
# Validate pagination params.
try:
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 25))
except (ValueError, TypeError):
return jsonify({"error": "page and page_size must be integers"}), 400
if page < 1 or page_size < 1:
return jsonify({"error": "page and page_size must be >= 1"}), 400
if page_size > 100:
return jsonify({"error": "page_size must be <= 100"}), 400
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
try:
page_result = adapter.list_callback_tasks(
callback_display_id=cid,
page=page,
page_size=page_size,
)
except C2Error as exc:
return jsonify({"error": str(exc)}), 502
return jsonify({
"tasks": [
{
"display_id": t.display_id,
"command": t.command,
"params": t.params,
"status": t.status,
"completed": t.completed,
"timestamp": t.timestamp,
}
for t in page_result.items
],
"total": page_result.total,
"page": page_result.page,
"page_size": page_result.page_size,
}), 200
@sims_c2_bp.post("/<int:sid>/c2/import")
@role_required("admin", "redteam")
def import_tasks(sid: int):
guard = _crypto_guard()
if guard is not None:
return guard
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
if sim.status == SimulationStatus.DONE:
return jsonify({"error": "simulation is done — reopen first"}), 409
data = request.get_json(silent=True) or {}
callback_display_id = data.get("callback_display_id")
task_display_ids = data.get("task_display_ids")
if not isinstance(callback_display_id, int):
return jsonify({"error": "callback_display_id must be an integer"}), 400
if not isinstance(task_display_ids, list) or len(task_display_ids) == 0:
return jsonify({"error": "task_display_ids must be a non-empty list"}), 400
for tid in task_display_ids:
if not isinstance(tid, int):
return jsonify({"error": "each task_display_id must be an integer"}), 400
engagement = db.session.get(Engagement, sim.engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
adapter, err = _load_adapter_for_engagement(engagement)
if err is not None:
return err
imported_count = 0
skipped_count = 0
try:
for task_display_id in task_display_ids:
# Idempotency: skip if already imported for this simulation.
existing = C2Task.query.filter_by(
simulation_id=sid,
mythic_task_display_id=task_display_id,
).first()
if existing is not None:
skipped_count += 1
continue
status = adapter.get_task(task_display_id)
task = C2Task(
simulation_id=sid,
mythic_task_display_id=task_display_id,
callback_display_id=callback_display_id,
command=status.command or "",
params=None,
status=status.status,
completed=status.completed,
source=C2TaskSource.IMPORT,
created_at=datetime.now(UTC),
mapping_applied=False,
)
if status.completed:
task.completed_at = status.completed_at or datetime.now(UTC)
try:
task.output = adapter.get_task_output(task_display_id)
except C2Error:
task.output = ""
db.session.add(task)
db.session.flush()
apply_task_to_simulation(task, sim)
else:
db.session.add(task)
imported_count += 1
except C2Error as exc:
db.session.rollback()
return jsonify({"error": str(exc)}), 502
# Auto-transition pending → in_progress when at least one task was imported.
if imported_count > 0:
promote_to_in_progress(sim)
db.session.commit()
return jsonify({"imported": imported_count, "skipped": skipped_count}), 200

View File

@@ -3,12 +3,19 @@ from __future__ import annotations
from datetime import date from datetime import date
from flask import Blueprint, g, jsonify, request from flask import Blueprint, Response, g, jsonify, request
from backend.app.auth import login_required, role_required from backend.app.auth import login_required, role_required
from backend.app.extensions import db from backend.app.extensions import db
from backend.app.models import Engagement, EngagementStatus from backend.app.models import Engagement, EngagementStatus
from backend.app.models.simulation import Simulation
from backend.app.serializers import serialize_engagement from backend.app.serializers import serialize_engagement
from backend.app.services.export import (
_export_filename,
render_engagement_csv,
render_engagement_markdown,
render_engagement_pdf,
)
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements") engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
@@ -156,3 +163,48 @@ def delete_engagement(engagement_id: int):
db.session.delete(engagement) db.session.delete(engagement)
db.session.commit() db.session.commit()
return "", 204 return "", 204
@engagements_bp.get("/<int:eid>/export")
@role_required("admin", "redteam")
def export_engagement(eid: int):
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
fmt = request.args.get("format", "").strip().lower()
if fmt not in ("md", "csv", "pdf"):
return jsonify({"error": "format must be one of: md, csv, pdf"}), 400
simulations = (
Simulation.query.filter_by(engagement_id=eid)
.order_by(Simulation.id.asc())
.all()
)
if fmt == "md":
body = render_engagement_markdown(engagement, simulations)
filename = _export_filename(engagement, "md")
return Response(
body,
mimetype="text/markdown; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
if fmt == "csv":
body = render_engagement_csv(engagement, simulations)
filename = _export_filename(engagement, "csv")
return Response(
body,
mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# pdf
body_bytes = render_engagement_pdf(engagement, simulations)
filename = _export_filename(engagement, "pdf")
return Response(
body_bytes,
mimetype="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View File

@@ -43,9 +43,16 @@ def create_simulation(eid: int):
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip() name = (data.get("name") or "").strip()
if not name: template_id = data.get("template_id")
return jsonify({"error": "name is required"}), 400
if template_id is not None:
from backend.app.models.simulation_template import SimulationTemplate
tmpl = db.session.get(SimulationTemplate, template_id)
if tmpl is None:
return jsonify({"error": "Template not found"}), 404
if not name:
name = tmpl.name
sim = Simulation( sim = Simulation(
engagement_id=eid, engagement_id=eid,
name=name, name=name,
@@ -53,6 +60,22 @@ def create_simulation(eid: int):
created_at=datetime.now(UTC), created_at=datetime.now(UTC),
created_by_id=g.current_user.id, created_by_id=g.current_user.id,
) )
sim.description = tmpl.description
sim.commands = tmpl.commands
sim.prerequisites = tmpl.prerequisites
sim.techniques = list(tmpl.techniques or [])
sim.tactic_ids = list(tmpl.tactic_ids or [])
else:
if not name:
return jsonify({"error": "name is required"}), 400
sim = Simulation(
engagement_id=eid,
name=name,
status=SimulationStatus.PENDING,
created_at=datetime.now(UTC),
created_by_id=g.current_user.id,
)
db.session.add(sim) db.session.add(sim)
db.session.commit() db.session.commit()
return jsonify(serialize_simulation(sim)), 201 return jsonify(serialize_simulation(sim)), 201
@@ -121,7 +144,7 @@ def transition_simulation(sid: int):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MITRE autocomplete # MITRE autocomplete + matrix
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -136,3 +159,14 @@ def mitre_techniques():
q = request.args.get("q", "").strip() q = request.args.get("q", "").strip()
results = mitre_svc.search(q) results = mitre_svc.search(q)
return jsonify(results), 200 return jsonify(results), 200
@simulations_bp.get("/api/mitre/matrix")
@login_required
def mitre_matrix():
from backend.app.services import mitre as mitre_svc
if not mitre_svc.mitre_loaded:
return jsonify({"error": "mitre bundle not loaded"}), 503
return jsonify(mitre_svc.get_matrix()), 200

View File

@@ -0,0 +1,151 @@
"""SimulationTemplate CRUD endpoints — admin and redteam only."""
from __future__ import annotations
from datetime import UTC, datetime
import sqlalchemy.exc
from flask import Blueprint, g, jsonify, request
from backend.app.auth import role_required
from backend.app.extensions import db
from backend.app.models.simulation_template import SimulationTemplate
from backend.app.serializers import serialize_template
from backend.app.services import mitre as mitre_svc
from backend.app.services.simulation_workflow import (
_resolve_tactic_ids,
_resolve_technique_ids,
)
templates_bp = Blueprint("templates", __name__)
_MUTABLE_FIELDS = {"name", "description", "commands", "prerequisites", "technique_ids", "tactic_ids"}
@templates_bp.get("/api/templates")
@role_required("admin", "redteam")
def list_templates():
items = SimulationTemplate.query.order_by(SimulationTemplate.name).all()
return jsonify([serialize_template(t) for t in items]), 200
@templates_bp.post("/api/templates")
@role_required("admin", "redteam")
def create_template():
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
techniques: list[dict] = []
tactic_ids_val: list[str] = []
if "technique_ids" in data:
if not isinstance(data["technique_ids"], list):
return jsonify({"error": "technique_ids must be a list"}), 400
if not mitre_svc.mitre_loaded:
return jsonify({"error": "mitre bundle not loaded"}), 503
resolved, err = _resolve_technique_ids(data["technique_ids"])
if err is not None:
return err
techniques = resolved or []
if "tactic_ids" in data:
if not isinstance(data["tactic_ids"], list):
return jsonify({"error": "tactic_ids must be a list"}), 400
resolved_ta, err = _resolve_tactic_ids(data["tactic_ids"])
if err is not None:
return err
tactic_ids_val = resolved_ta or []
tmpl = SimulationTemplate(
name=name,
description=data.get("description"),
commands=data.get("commands"),
prerequisites=data.get("prerequisites"),
techniques=techniques,
tactic_ids=tactic_ids_val,
created_at=datetime.now(UTC),
created_by_id=g.current_user.id,
)
db.session.add(tmpl)
try:
db.session.commit()
except sqlalchemy.exc.IntegrityError:
db.session.rollback()
return jsonify({"error": "template name already exists"}), 409
return jsonify(serialize_template(tmpl)), 201
@templates_bp.get("/api/templates/<int:tid>")
@role_required("admin", "redteam")
def get_template(tid: int):
tmpl = db.session.get(SimulationTemplate, tid)
if tmpl is None:
return jsonify({"error": "Template not found"}), 404
return jsonify(serialize_template(tmpl)), 200
@templates_bp.patch("/api/templates/<int:tid>")
@role_required("admin", "redteam")
def update_template(tid: int):
tmpl = db.session.get(SimulationTemplate, tid)
if tmpl is None:
return jsonify({"error": "Template not found"}), 404
data = request.get_json(silent=True) or {}
unknown = set(data.keys()) - _MUTABLE_FIELDS
if unknown:
return jsonify({"error": f"unknown fields: {sorted(unknown)}"}), 400
if not data:
return jsonify(serialize_template(tmpl)), 200
if "name" in data:
name = (data["name"] or "").strip()
if not name:
return jsonify({"error": "name cannot be empty"}), 400
tmpl.name = name
for field in ("description", "commands", "prerequisites"):
if field in data:
setattr(tmpl, field, data[field])
if "technique_ids" in data:
if not isinstance(data["technique_ids"], list):
return jsonify({"error": "technique_ids must be a list"}), 400
if not mitre_svc.mitre_loaded:
return jsonify({"error": "mitre bundle not loaded"}), 503
resolved, err = _resolve_technique_ids(data["technique_ids"])
if err is not None:
return err
tmpl.techniques = resolved
if "tactic_ids" in data:
if not isinstance(data["tactic_ids"], list):
return jsonify({"error": "tactic_ids must be a list"}), 400
resolved_ta, err = _resolve_tactic_ids(data["tactic_ids"])
if err is not None:
return err
tmpl.tactic_ids = resolved_ta
tmpl.updated_at = datetime.now(UTC)
try:
db.session.commit()
except sqlalchemy.exc.IntegrityError:
db.session.rollback()
return jsonify({"error": "template name already exists"}), 409
return jsonify(serialize_template(tmpl)), 200
@templates_bp.delete("/api/templates/<int:tid>")
@role_required("admin", "redteam")
def delete_template(tid: int):
tmpl = db.session.get(SimulationTemplate, tid)
if tmpl is None:
return jsonify({"error": "Template not found"}), 404
db.session.delete(tmpl)
db.session.commit()
return "", 204

View File

@@ -1,6 +1,20 @@
"""SQLAlchemy models.""" """SQLAlchemy models."""
from backend.app.models.c2_config import C2Config
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.engagement import Engagement, EngagementStatus from backend.app.models.engagement import Engagement, EngagementStatus
from backend.app.models.simulation import Simulation, SimulationStatus from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.models.simulation_template import SimulationTemplate
from backend.app.models.user import User, UserRole from backend.app.models.user import User, UserRole
__all__ = ["User", "UserRole", "Engagement", "EngagementStatus", "Simulation", "SimulationStatus"] __all__ = [
"User",
"UserRole",
"Engagement",
"EngagementStatus",
"Simulation",
"SimulationStatus",
"SimulationTemplate",
"C2Config",
"C2Task",
"C2TaskSource",
]

View File

@@ -0,0 +1,34 @@
"""C2Config model — per-engagement Mythic connection settings."""
from __future__ import annotations
from datetime import UTC, datetime
from backend.app.extensions import db
class C2Config(db.Model): # type: ignore[name-defined]
__tablename__ = "c2_config"
id = db.Column(db.Integer, primary_key=True)
engagement_id = db.Column(
db.Integer,
db.ForeignKey("engagements.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
)
url = db.Column(db.Text, nullable=False)
api_token_encrypted = db.Column(db.Text, nullable=False)
verify_tls = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)
updated_at = db.Column(db.DateTime, nullable=True)
engagement = db.relationship(
"Engagement",
backref=db.backref("c2_config", uselist=False, cascade="all, delete-orphan"),
)
def __repr__(self) -> str:
return f"<C2Config engagement_id={self.engagement_id}>"

View File

@@ -0,0 +1,48 @@
"""C2Task model — link between a Mimic simulation and a Mythic task."""
from __future__ import annotations
import enum
from datetime import UTC, datetime
from backend.app.extensions import db
class C2TaskSource(str, enum.Enum):
MIMIC = "mimic"
IMPORT = "import"
class C2Task(db.Model): # type: ignore[name-defined]
__tablename__ = "c2_task"
id = db.Column(db.Integer, primary_key=True)
simulation_id = db.Column(
db.Integer,
db.ForeignKey("simulations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
mythic_task_display_id = db.Column(db.Integer, nullable=False)
callback_display_id = db.Column(db.Integer, nullable=False)
command = db.Column(db.Text, nullable=False)
params = db.Column(db.Text, nullable=True)
status = db.Column(db.Text, nullable=False)
completed = db.Column(db.Boolean, nullable=False, default=False)
output = db.Column(db.Text, nullable=True)
source = db.Column(
db.Enum(C2TaskSource, name="c2task_source"),
nullable=False,
)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)
completed_at = db.Column(db.DateTime, nullable=True)
mapping_applied = db.Column(db.Boolean, nullable=False, default=False)
simulation = db.relationship(
"Simulation",
backref=db.backref("c2_tasks", cascade="all, delete-orphan", lazy="dynamic"),
)
def __repr__(self) -> str:
return f"<C2Task simulation_id={self.simulation_id} mythic_id={self.mythic_task_display_id}>"

View File

@@ -25,8 +25,8 @@ class Simulation(db.Model): # type: ignore[name-defined]
index=True, index=True,
) )
name = db.Column(db.String(255), nullable=False) name = db.Column(db.String(255), nullable=False)
mitre_technique_id = db.Column(db.String(32), nullable=True) techniques = db.Column(db.JSON, nullable=False, default=list)
mitre_technique_name = db.Column(db.String(255), nullable=True) tactic_ids = db.Column(db.JSON, nullable=False, default=list)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
commands = db.Column(db.Text, nullable=True) commands = db.Column(db.Text, nullable=True)
prerequisites = db.Column(db.Text, nullable=True) prerequisites = db.Column(db.Text, nullable=True)

View File

@@ -0,0 +1,32 @@
"""SimulationTemplate model."""
from __future__ import annotations
from datetime import UTC, datetime
from backend.app.extensions import db
class SimulationTemplate(db.Model): # type: ignore[name-defined]
__tablename__ = "simulation_templates"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
description = db.Column(db.Text, nullable=True)
commands = db.Column(db.Text, nullable=True)
prerequisites = db.Column(db.Text, nullable=True)
techniques = db.Column(db.JSON, nullable=False, default=list)
tactic_ids = db.Column(db.JSON, nullable=False, default=list)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)
updated_at = db.Column(db.DateTime, nullable=True)
created_by_id = db.Column(
db.Integer,
db.ForeignKey("users.id", ondelete="RESTRICT"),
nullable=False,
)
created_by = db.relationship("User", lazy="joined")
def __repr__(self) -> str:
return f"<SimulationTemplate {self.id} {self.name!r}>"

View File

@@ -5,6 +5,7 @@ from typing import Any
from backend.app.models import Engagement, User from backend.app.models import Engagement, User
from backend.app.models.simulation import Simulation from backend.app.models.simulation import Simulation
from backend.app.models.simulation_template import SimulationTemplate
def serialize_user(user: User) -> dict[str, Any]: def serialize_user(user: User) -> dict[str, Any]:
@@ -20,13 +21,37 @@ def serialize_user_brief(user: User) -> dict[str, Any]:
return {"id": user.id, "username": user.username} return {"id": user.id, "username": user.username}
def _enrich_techniques(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Attach tactics to each {id, name} snapshot from the MITRE service."""
from backend.app.services import mitre as mitre_svc
return [
{"id": t["id"], "name": t["name"], "tactics": mitre_svc.get_tactics(t["id"])}
for t in (raw or [])
]
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]: def serialize_simulation(simulation: Simulation) -> dict[str, Any]:
return { return {
"id": simulation.id, "id": simulation.id,
"engagement_id": simulation.engagement_id, "engagement_id": simulation.engagement_id,
"name": simulation.name, "name": simulation.name,
"mitre_technique_id": simulation.mitre_technique_id, "techniques": _enrich_techniques(simulation.techniques or []),
"mitre_technique_name": simulation.mitre_technique_name, "tactics": _enrich_tactics(simulation.tactic_ids or []),
"description": simulation.description, "description": simulation.description,
"commands": simulation.commands, "commands": simulation.commands,
"prerequisites": simulation.prerequisites, "prerequisites": simulation.prerequisites,
@@ -45,6 +70,23 @@ def serialize_simulation(simulation: Simulation) -> dict[str, Any]:
} }
def serialize_template(t: SimulationTemplate) -> dict[str, Any]:
return {
"id": t.id,
"name": t.name,
"description": t.description,
"commands": t.commands,
"prerequisites": t.prerequisites,
"techniques": _enrich_techniques(t.techniques or []),
"tactics": _enrich_tactics(t.tactic_ids or []),
"created_at": t.created_at.isoformat() if t.created_at else None,
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
"created_by": serialize_user_brief(t.created_by) # type: ignore[arg-type]
if t.created_by
else None,
}
def serialize_engagement(engagement: Engagement) -> dict[str, Any]: def serialize_engagement(engagement: Engagement) -> dict[str, Any]:
return { return {
"id": engagement.id, "id": engagement.id,

View File

@@ -0,0 +1,22 @@
"""C2 adapter package. Import the factory from here."""
from backend.app.services.c2.adapter import (
C2Adapter,
C2Callback,
C2Error,
C2Health,
C2TaskPage,
C2TaskStatus,
decode_response_text,
)
from backend.app.services.c2.factory import get_adapter
__all__ = [
"C2Adapter",
"C2Callback",
"C2Error",
"C2Health",
"C2TaskPage",
"C2TaskStatus",
"decode_response_text",
"get_adapter",
]

View File

@@ -0,0 +1,117 @@
"""Abstract C2 adapter interface and shared dataclasses."""
from __future__ import annotations
import base64
import binascii
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
class C2Error(Exception):
"""Raised by adapters when the C2 returns an application-level error."""
@dataclass
class C2Health:
ok: bool
error: str | None = None
@dataclass
class C2Callback:
display_id: int
active: bool
host: str
user: str
domain: str
last_checkin: str # ISO-8601 string
@dataclass
class C2TaskStatus:
display_id: int
status: str
completed: bool
completed_at: datetime | None = field(default=None)
# command_name is populated by get_task() so import doesn't need a second round-trip.
command: str | None = field(default=None)
@dataclass
class C2HistoricalTask:
"""A task entry from callback history (carries command + params, unlike C2TaskStatus)."""
display_id: int
command: str
params: str | None
status: str
completed: bool
timestamp: str | None # ISO-8601 or None
@dataclass
class C2TaskPage:
items: list[C2HistoricalTask]
total: int
page: int
page_size: int
def decode_response_text(raw: str) -> str:
"""Decode a base64-encoded Mythic response_text field.
On binascii.Error (binary payload) returns "<binary> " + hex string
so execution_result never silently corrupts.
"""
try:
return base64.b64decode(raw).decode("utf-8")
except binascii.Error:
return "<binary> " + raw.encode().hex()
except UnicodeDecodeError:
raw_bytes = base64.b64decode(raw)
return "<binary> " + raw_bytes.hex()
class C2Adapter(ABC):
"""Thin interface over a C2 backend (Mythic or custom)."""
@abstractmethod
def test_connection(self) -> C2Health:
"""Verify that the C2 is reachable and the token is valid."""
...
@abstractmethod
def list_callbacks(self) -> list[C2Callback]:
"""Return active callbacks visible to this API token."""
...
@abstractmethod
def create_task(
self,
callback_display_id: int,
command: str,
params: str | None = None,
) -> int:
"""Issue a task and return its Mythic display_id."""
...
@abstractmethod
def get_task(self, task_display_id: int) -> C2TaskStatus:
"""Return current status of a task."""
...
@abstractmethod
def get_task_output(self, task_display_id: int) -> str:
"""Return decoded, concatenated output for a completed task."""
...
@abstractmethod
def list_callback_tasks(
self,
callback_display_id: int,
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
"""Return a paginated history of tasks for a callback."""
...

View File

@@ -0,0 +1,19 @@
"""Factory that resolves the C2Adapter implementation from MIMIC_C2_ADAPTER env."""
from __future__ import annotations
import os
from backend.app.services.c2.adapter import C2Adapter
def get_adapter(url: str, api_token: str, verify_tls: bool = True) -> C2Adapter:
"""Return the correct C2Adapter based on MIMIC_C2_ADAPTER (default: mythic)."""
adapter_name = os.environ.get("MIMIC_C2_ADAPTER", "mythic").lower()
if adapter_name == "fake":
from backend.app.services.c2.fake import FakeAdapter
return FakeAdapter()
# Default: real Mythic adapter
from backend.app.services.c2.mythic import MythicAdapter
return MythicAdapter(url=url, api_token=api_token, verify_tls=verify_tls)

View File

@@ -0,0 +1,176 @@
"""Deterministic in-memory C2 adapter — used when MIMIC_C2_ADAPTER=fake.
Intended for integration tests and local development without a live Mythic instance.
Task state is per-instance so parallel tests don't interfere with each other.
"""
from __future__ import annotations
from backend.app.services.c2.adapter import (
C2Adapter,
C2Callback,
C2Error,
C2Health,
C2HistoricalTask,
C2TaskPage,
C2TaskStatus,
)
# Frozen base timestamp — all fake history tasks share this prefix for determinism.
_BASE_TS = "2026-06-10T00:00:00Z"
# Deterministic history for list_callback_tasks:
# callback 1 → 12 tasks, callback 2 → 0 tasks, callback 3 → 5 tasks.
# Commands cycle through a fixed set; even-indexed tasks are completed.
_HISTORY_COMMANDS = ["whoami", "hostname", "id", "ipconfig", "net user", "pwd"]
_FAKE_HISTORY: dict[int, list[C2HistoricalTask]] = {
1: [
C2HistoricalTask(
display_id=100 + i,
command=_HISTORY_COMMANDS[i % len(_HISTORY_COMMANDS)],
params=None,
status="completed" if i % 2 == 0 else "submitted",
completed=i % 2 == 0,
timestamp=_BASE_TS if i % 2 == 0 else None,
)
for i in range(12)
],
2: [],
3: [
C2HistoricalTask(
display_id=200 + i,
command=_HISTORY_COMMANDS[i % len(_HISTORY_COMMANDS)],
params=None,
status="completed" if i % 2 == 0 else "submitted",
completed=i % 2 == 0,
timestamp=_BASE_TS if i % 2 == 0 else None,
)
for i in range(5)
],
}
# Three fixed callbacks the test suite can pin against.
_FAKE_CALLBACKS = [
C2Callback(
display_id=1,
active=True,
host="WORKSTATION-01",
user="jdoe",
domain="LAB",
last_checkin="2026-06-10T00:00:00Z",
),
C2Callback(
display_id=2,
active=True,
host="SERVER-DC01",
user="svc_backup",
domain="LAB",
last_checkin="2026-06-10T00:01:00Z",
),
C2Callback(
display_id=3,
active=True,
host="LAPTOP-RT",
user="admin",
domain="LAB",
last_checkin="2026-06-10T00:02:00Z",
),
]
class FakeAdapter(C2Adapter):
"""In-memory adapter with deterministic behaviour.
Each instance starts with an empty task store and display_ids from 1000.
get_task() state progression per task (keyed by display_id):
- First call after create_task → submitted, completed=False
- Second and subsequent calls → completed=True, status="completed"
"""
def __init__(self) -> None:
self._tasks: dict[int, dict] = {}
self._next_task_id = 1000
# Tracks how many times get_task has been called per display_id.
self._get_task_calls: dict[int, int] = {}
def test_connection(self) -> C2Health:
return C2Health(ok=True)
def list_callbacks(self) -> list[C2Callback]:
return list(_FAKE_CALLBACKS)
def create_task(
self,
callback_display_id: int,
command: str,
params: str | None = None,
) -> int:
tid = self._next_task_id
self._next_task_id += 1
self._tasks[tid] = {
"display_id": tid,
"callback_display_id": callback_display_id,
"command": command,
"params": params,
"status": "submitted",
"completed": False,
"output": None,
}
return tid
def get_task(self, task_display_id: int) -> C2TaskStatus:
"""Deterministic state progression: first call → submitted, second+ → completed.
Tracks call count regardless of whether the task was created by this instance,
so the endpoint poll-on-read flow works across separate adapter instantiations.
"""
call_count = self._get_task_calls.get(task_display_id, 0) + 1
self._get_task_calls[task_display_id] = call_count
task = self._tasks.get(task_display_id)
if call_count >= 2:
completed = True
status = "completed"
if task is not None:
task["status"] = "completed"
task["completed"] = True
else:
completed = False
status = task["status"] if task is not None else "submitted"
return C2TaskStatus(
display_id=task_display_id,
status=status,
completed=completed,
command=task["command"] if task is not None else None,
)
def get_task_output(self, task_display_id: int) -> str:
"""Returns deterministic output once task is completed; raises C2Error before that."""
# Check call count — completed if get_task was called at least twice.
if self._get_task_calls.get(task_display_id, 0) < 2:
# Also allow tasks in _tasks that were explicitly set to completed.
task = self._tasks.get(task_display_id)
if task is None or not task.get("completed", False):
raise C2Error("task not completed")
task = self._tasks.get(task_display_id)
command = task["command"] if task is not None else "unknown"
return f"output for task {task_display_id}: {command}\n"
def list_callback_tasks(
self,
callback_display_id: int,
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
all_items = _FAKE_HISTORY.get(callback_display_id, [])
start = (page - 1) * page_size
return C2TaskPage(
items=all_items[start : start + page_size],
total=len(all_items),
page=page,
page_size=page_size,
)

View File

@@ -0,0 +1,53 @@
"""C2 task → Simulation output mapping.
apply_task_to_simulation() implements the full §0.11 contract:
1. execution_result — append "$ <command>\n<output>\n" block.
2. executed_at — set from task.completed_at when currently null.
3. commands — append task.command deduplicated line-by-line.
Caller is responsible for committing the session.
"""
from __future__ import annotations
from datetime import UTC, datetime
from backend.app.models.c2_task import C2Task
from backend.app.models.simulation import Simulation
def apply_task_to_simulation(task: C2Task, simulation: Simulation) -> None:
"""Apply completed task data to simulation fields per §0.11.
Idempotent: no-op when task.mapping_applied is already True.
Always sets mapping_applied = True on exit so the task is never re-processed.
"""
if task.mapping_applied:
return
output = (task.output or "").strip()
# 1) execution_result — "$ <command>\n<output>\n" block, only when output is non-empty.
if output:
block = f"$ {task.command}\n{output}\n"
existing = simulation.execution_result or ""
if existing:
sep = "" if existing.endswith("\n") else "\n"
simulation.execution_result = existing + sep + block
else:
simulation.execution_result = block
# 2) executed_at — set once from the first completed task's timestamp.
if simulation.executed_at is None and task.completed_at is not None:
simulation.executed_at = task.completed_at
# 3) commands — append deduplicated line.
if task.command:
existing_cmds = (simulation.commands or "").splitlines()
if task.command.strip() not in (line.strip() for line in existing_cmds):
if simulation.commands:
simulation.commands = simulation.commands + "\n" + task.command
else:
simulation.commands = task.command
simulation.updated_at = datetime.now(UTC)
task.mapping_applied = True

View File

@@ -0,0 +1,293 @@
# Contract pinned from MythicMeta/Mythic_Scripting master @ 2026-06-10 (raw.githubusercontent.com/MythicMeta/Mythic_Scripting/master/mythic/mythic.py)
"""Mythic 3.x C2 adapter.
Transport: POST https://<host>:7443/graphql
Header: apitoken: <token>
Backend: Hasura-proxied Postgres behind nginx.
M1: test_connection()
M2: list_callbacks(), create_task()
M3: get_task(), get_task_output()
M4: list_callback_tasks()
"""
from __future__ import annotations
from datetime import datetime
import requests
from backend.app.services.c2.adapter import (
C2Adapter,
C2Callback,
C2Error,
C2Health,
C2HistoricalTask,
C2TaskPage,
C2TaskStatus,
decode_response_text,
)
_HEALTH_QUERY = "{ __typename }"
_CALLBACKS_QUERY = """
query {
callback(order_by: {id: asc}, where: {active: {_eq: true}}) {
id
display_id
active
host
user
domain
last_checkin
}
}
"""
_CREATE_TASK_MUTATION = """
mutation CreateTask($callback_id: Int!, $command: String!, $params: String!) {
createTask(
callback_id: $callback_id,
command: $command,
params: $params,
tasking_location: "command_line"
) {
id
display_id
error
}
}
"""
_GET_TASK_QUERY = """
query GetTask($display_id: Int!) {
task(where: {display_id: {_eq: $display_id}}) {
display_id
command_name
status
completed
timestamp
}
}
"""
_LIST_CALLBACK_TASKS_QUERY = """
query ListCallbackTasks($callback_display_id: Int!, $limit: Int!, $offset: Int!) {
task(
where: {callback: {display_id: {_eq: $callback_display_id}}}
order_by: {id: desc}
limit: $limit
offset: $offset
) {
display_id
command_name
params
status
completed
timestamp
}
}
"""
_COUNT_CALLBACK_TASKS_QUERY = """
query CountCallbackTasks($callback_display_id: Int!) {
task_aggregate(where: {callback: {display_id: {_eq: $callback_display_id}}}) {
aggregate {
count
}
}
}
"""
_GET_TASK_OUTPUT_QUERY = """
query GetTaskOutput($display_id: Int!) {
response(
where: {task: {display_id: {_eq: $display_id}}}
order_by: {id: asc}
) {
response_text
}
}
"""
class MythicAdapter(C2Adapter):
"""Real Mythic 3.x adapter using GraphQL over HTTP."""
def __init__(self, url: str, api_token: str, verify_tls: bool = True) -> None:
self._url = url.rstrip("/") + "/graphql"
self._token = api_token
self._verify = verify_tls
def _headers(self) -> dict[str, str]:
return {
"Content-Type": "application/json",
"apitoken": self._token,
}
def _post(self, body: dict) -> dict:
resp = requests.post(
self._url,
json=body,
headers=self._headers(),
verify=self._verify,
timeout=10,
allow_redirects=False,
)
resp.raise_for_status()
return resp.json()
def test_connection(self) -> C2Health:
"""POST a trivial introspection query to verify reachability and token validity."""
try:
resp = requests.post(
self._url,
json={"query": _HEALTH_QUERY},
headers=self._headers(),
verify=self._verify,
timeout=10,
allow_redirects=False,
)
if resp.status_code == 200:
return C2Health(ok=True)
return C2Health(ok=False, error=f"HTTP {resp.status_code}")
except requests.RequestException as exc:
return C2Health(ok=False, error=str(exc))
def list_callbacks(self) -> list[C2Callback]:
"""Return active callbacks from Mythic (filtered server-side: active=true)."""
try:
data = self._post({"query": _CALLBACKS_QUERY})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
callbacks_raw = data.get("data", {}).get("callback", [])
return [
C2Callback(
display_id=cb["display_id"],
active=cb["active"],
host=cb.get("host") or "",
user=cb.get("user") or "",
domain=cb.get("domain") or "",
last_checkin=cb.get("last_checkin") or "",
)
for cb in callbacks_raw
]
def create_task(
self,
callback_display_id: int,
command: str,
params: str | None = None,
) -> int:
"""Issue a task on a callback; return Mythic task display_id."""
try:
data = self._post({
"query": _CREATE_TASK_MUTATION,
"variables": {
"callback_id": callback_display_id,
"command": command,
"params": params or "",
},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
task_data = data.get("data", {}).get("createTask", {})
error_msg = task_data.get("error")
if error_msg:
raise C2Error(error_msg)
return int(task_data["display_id"])
def get_task(self, task_display_id: int) -> C2TaskStatus:
"""Return current task status from Mythic."""
try:
data = self._post({
"query": _GET_TASK_QUERY,
"variables": {"display_id": task_display_id},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
rows = data.get("data", {}).get("task", [])
if not rows:
raise C2Error(f"task {task_display_id} not found in Mythic")
row = rows[0]
completed_at: datetime | None = None
if row.get("completed") and row.get("timestamp"):
try:
completed_at = datetime.fromisoformat(
row["timestamp"].replace("Z", "+00:00")
)
except ValueError:
completed_at = None
return C2TaskStatus(
display_id=row["display_id"],
status=row["status"],
completed=bool(row.get("completed", False)),
completed_at=completed_at,
command=row.get("command_name") or None,
)
def get_task_output(self, task_display_id: int) -> str:
"""Return decoded, concatenated output for a task."""
try:
data = self._post({
"query": _GET_TASK_OUTPUT_QUERY,
"variables": {"display_id": task_display_id},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
rows = data.get("data", {}).get("response", [])
return "".join(
decode_response_text(r["response_text"])
for r in rows
if r.get("response_text")
)
def list_callback_tasks(
self,
callback_display_id: int,
page: int = 1,
page_size: int = 25,
) -> C2TaskPage:
"""Return a paginated, most-recent-first history of tasks for a callback."""
offset = (page - 1) * page_size
try:
data = self._post({
"query": _LIST_CALLBACK_TASKS_QUERY,
"variables": {
"callback_display_id": callback_display_id,
"limit": page_size,
"offset": offset,
},
})
count_data = self._post({
"query": _COUNT_CALLBACK_TASKS_QUERY,
"variables": {"callback_display_id": callback_display_id},
})
except requests.RequestException as exc:
raise C2Error(f"C2 transport error: {type(exc).__name__}") from exc
rows = data.get("data", {}).get("task", [])
total: int = (
count_data.get("data", {})
.get("task_aggregate", {})
.get("aggregate", {})
.get("count", 0)
)
items = [
C2HistoricalTask(
display_id=r["display_id"],
command=r.get("command_name") or "",
params=r.get("params") or None,
status=r.get("status") or "",
completed=bool(r.get("completed", False)),
timestamp=r.get("timestamp") or None,
)
for r in rows
]
return C2TaskPage(items=items, total=total, page=page, page_size=page_size)

View File

@@ -0,0 +1,40 @@
"""Fernet-based encryption service for sensitive fields.
Key is read from the MIMIC_ENCRYPTION_KEY env var (Fernet base64-urlsafe 32-byte key).
When the key is absent the service raises C2Disabled so callers can return 503.
The key is never logged or returned in any response.
"""
from __future__ import annotations
import os
from cryptography.fernet import Fernet, InvalidToken
class C2Disabled(Exception):
"""Raised when MIMIC_ENCRYPTION_KEY is not set."""
def _get_fernet() -> Fernet:
key = os.environ.get("MIMIC_ENCRYPTION_KEY")
if not key:
raise C2Disabled("C2 disabled: MIMIC_ENCRYPTION_KEY not set")
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt(plaintext: str) -> str:
"""Encrypt *plaintext* and return a Fernet token (str)."""
f = _get_fernet()
return f.encrypt(plaintext.encode()).decode()
def decrypt(ciphertext: str) -> str:
"""Decrypt a Fernet token and return the plaintext string."""
f = _get_fernet()
try:
return f.decrypt(ciphertext.encode()).decode()
except InvalidToken as exc:
raise ValueError("Invalid ciphertext") from exc
__all__ = ["C2Disabled", "encrypt", "decrypt"]

View File

@@ -0,0 +1,277 @@
"""Engagement export renderers — Markdown, CSV, PDF."""
from __future__ import annotations
import csv
import io
import re
import unicodedata
from datetime import date
from html import escape as _html_escape
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from backend.app.models.engagement import Engagement
from backend.app.models.simulation import Simulation
def _export_filename(engagement: Engagement, ext: str) -> str:
name = engagement.name or ""
normalized = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode()
slug = re.sub(r"[^a-z0-9]+", "-", normalized.lower()).strip("-")[:60] or "unnamed"
today = date.today().strftime("%Y%m%d")
return f"engagement-{engagement.id}-{slug}-{today}.{ext}"
def _creator(obj: object) -> str:
"""Return username string from an ORM object with a created_by relationship."""
cb = getattr(obj, "created_by", None)
if cb is None:
return ""
return getattr(cb, "username", "") or ""
# ---------------------------------------------------------------------------
# CSV formula-injection defense (defined early — used by _format_execution_csv)
# ---------------------------------------------------------------------------
# \t and \r included: Excel auto-trims leading whitespace, so a tab/CR prefix still
# reaches the formula parser in some sheet versions.
_CSV_FORMULA_TRIGGERS = ("=", "+", "-", "@", "\t", "\r")
def _csv_safe(value: object) -> object:
"""Defuse spreadsheet formula injection by prefixing user-controlled cells.
Excel / LibreOffice / Google Sheets interpret cells starting with =, +, -, @,
\\t or \\r as formulas. Since this CSV is the engagement handoff to SOC and is
explicitly opened in a spreadsheet app, an authenticated red-team user could
craft a simulation field that executes on the SOC analyst's machine. Prefixing
with a single apostrophe forces the spreadsheet to treat the cell as text.
"""
if isinstance(value, str) and value and value[0] in _CSV_FORMULA_TRIGGERS:
return "'" + value
return value
# ---------------------------------------------------------------------------
# Execution cell helpers
# ---------------------------------------------------------------------------
def _format_execution_text(sim: Simulation) -> str:
"""Canonical 3-part execution concat for Markdown and PDF (no CSV sanitization)."""
parts = [
sim.executed_at.isoformat() if sim.executed_at else "",
sim.commands or "",
sim.execution_result or "",
]
return "\n".join(parts)
def _format_execution_csv(sim: Simulation) -> str:
"""Execution concat for CSV: each user-controlled component is formula-defused
before joining so that inner lines starting with =, +, -, @ are safe."""
parts = [
sim.executed_at.isoformat() if sim.executed_at else "",
str(_csv_safe(sim.commands or "")),
str(_csv_safe(sim.execution_result or "")),
]
return "\n".join(parts)
# ---------------------------------------------------------------------------
# Markdown
# ---------------------------------------------------------------------------
_MD_HEADERS = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
def render_engagement_markdown(
engagement: Engagement, simulations: list[Simulation]
) -> str:
lines: list[str] = []
lines.append(f"# {engagement.name}")
lines.append("")
if engagement.description:
lines.append(engagement.description)
lines.append("")
lines.append(f"**Status**: {engagement.status.value}")
lines.append(
f"**Start date**: {engagement.start_date.isoformat() if engagement.start_date else 'N/A'}"
)
lines.append(
f"**End date**: {engagement.end_date.isoformat() if engagement.end_date else 'N/A'}"
)
lines.append(f"**Created by**: {_creator(engagement)}")
lines.append(
f"**Created at**: {engagement.created_at.isoformat() if engagement.created_at else 'N/A'}"
)
lines.append("")
if not simulations:
return "\n".join(lines)
lines.append("---")
lines.append("")
lines.append("## Simulations")
lines.append("")
header_row = "| " + " | ".join(_MD_HEADERS) + " |"
separator = "| " + " | ".join("---" for _ in _MD_HEADERS) + " |"
lines.append(header_row)
lines.append(separator)
for sim in simulations:
def _cell(value: str | None) -> str:
# Escape HTML (including quotes) first to prevent stored XSS in MD renderers
# that interpret inline HTML, then escape pipe (GFM table syntax),
# then fold newlines to <br/> (our own safe markup, inserted after escape).
s = _html_escape(value or "")
s = s.replace("|", "\\|")
s = s.replace("\n", "<br/>")
return s
execution = _format_execution_text(sim)
row = "| " + " | ".join([
_cell(sim.name),
_cell(sim.description),
_cell(sim.log_source),
_cell(sim.soc_comment),
_cell(execution),
_cell(sim.logs),
_cell(sim.incident_number),
]) + " |"
lines.append(row)
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CSV
# ---------------------------------------------------------------------------
_CSV_HEADERS = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
def render_engagement_csv(
_engagement: Engagement, simulations: list[Simulation]
) -> str:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(_CSV_HEADERS)
for sim in simulations:
execution = _format_execution_csv(sim)
writer.writerow([
_csv_safe(sim.name or ""),
_csv_safe(sim.description or ""),
_csv_safe(sim.log_source or ""),
_csv_safe(sim.soc_comment or ""),
_csv_safe(execution), # belt-and-braces: outer check covers empty executed_at case
_csv_safe(sim.logs or ""),
_csv_safe(sim.incident_number or ""),
])
return buf.getvalue()
# ---------------------------------------------------------------------------
# HTML (internal, used by PDF renderer)
# ---------------------------------------------------------------------------
_CSS = """
@page { size: A4 landscape; margin: 20mm; }
body { font-family: sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
h1 { font-size: 20px; border-bottom: 2px solid #333; padding-bottom: 6px; }
h2 { font-size: 15px; margin-top: 32px; color: #333; }
table { border-collapse: collapse; width: 100%; margin-bottom: 12px; table-layout: fixed; }
th, td { border: 1px solid #ccc; padding: 3px 6px; text-align: left; vertical-align: top; white-space: pre-wrap; word-break: break-word; }
th { background: #e0e0e0; }
.meta { color: #555; margin-bottom: 16px; }
"""
_HTML_HEADERS = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
def _render_engagement_html(
engagement: Engagement, simulations: list[Simulation]
) -> str:
h = _html_escape
parts: list[str] = []
parts.append("<!DOCTYPE html><html><head><meta charset='utf-8'>")
parts.append(f"<style>{_CSS}</style></head><body>")
parts.append(f"<h1>{h(engagement.name)}</h1>")
parts.append("<div class='meta'>")
if engagement.description:
parts.append(f"<p>{h(engagement.description)}</p>")
parts.append(f"<p><strong>Status:</strong> {h(engagement.status.value)}</p>")
sd = engagement.start_date.isoformat() if engagement.start_date else "N/A"
ed = engagement.end_date.isoformat() if engagement.end_date else "N/A"
parts.append(f"<p><strong>Dates:</strong> {h(sd)}{h(ed)}</p>")
parts.append(f"<p><strong>Created by:</strong> {h(_creator(engagement))}</p>")
ca = engagement.created_at.isoformat() if engagement.created_at else "N/A"
parts.append(f"<p><strong>Created at:</strong> {h(ca)}</p>")
parts.append("</div>")
if simulations:
parts.append("<h2>Simulations</h2>")
thead = "<thead><tr>" + "".join(f"<th>{h(col)}</th>" for col in _HTML_HEADERS) + "</tr></thead>"
parts.append(f"<table>{thead}<tbody>")
for sim in simulations:
execution_html = h(_format_execution_text(sim)).replace("\n", "<br/>")
cells = [
h(sim.name or ""),
h(sim.description or ""),
h(sim.log_source or ""),
h(sim.soc_comment or ""),
execution_html,
h(sim.logs or ""),
h(sim.incident_number or ""),
]
row = "<tr>" + "".join(f"<td>{c}</td>" for c in cells) + "</tr>"
parts.append(row)
parts.append("</tbody></table>")
parts.append("</body></html>")
return "".join(parts)
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
def render_engagement_pdf(
engagement: Engagement, simulations: list[Simulation]
) -> bytes:
from weasyprint import HTML
html = _render_engagement_html(engagement, simulations)
return HTML(string=html).write_pdf()

View File

@@ -8,11 +8,64 @@ from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Absolute path to the committed bundle.
_BUNDLE_PATH = Path(__file__).parent.parent.parent / "data" / "mitre" / "enterprise-attack.json" _BUNDLE_PATH = Path(__file__).parent.parent.parent / "data" / "mitre" / "enterprise-attack.json"
# Canonical Enterprise tactic order (12 tactics).
_TACTIC_ORDER = [
"initial-access",
"execution",
"persistence",
"privilege-escalation",
"defense-evasion",
"credential-access",
"discovery",
"lateral-movement",
"collection",
"command-and-control",
"exfiltration",
"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",
"persistence": "Persistence",
"privilege-escalation": "Privilege Escalation",
"defense-evasion": "Defense Evasion",
"credential-access": "Credential Access",
"discovery": "Discovery",
"lateral-movement": "Lateral Movement",
"collection": "Collection",
"command-and-control": "Command and Control",
"exfiltration": "Exfiltration",
"impact": "Impact",
}
mitre_loaded: bool = False mitre_loaded: bool = False
_index: list[dict[str, Any]] = [] _index: list[dict[str, Any]] = []
_tactics_by_technique: dict[str, list[str]] = {}
_name_by_id: dict[str, str] = {}
# matrix: list of tactic dicts (built once at load time)
_matrix: list[dict[str, Any]] = []
def _extract_tactics(obj: dict[str, Any]) -> list[str]: def _extract_tactics(obj: dict[str, Any]) -> list[str]:
@@ -20,7 +73,7 @@ def _extract_tactics(obj: dict[str, Any]) -> list[str]:
return [ return [
p["phase_name"] p["phase_name"]
for p in phases for p in phases
if isinstance(p, dict) and "phase_name" in p if isinstance(p, dict) and "phase_name" in p and p.get("kill_chain_name") == "mitre-attack"
] ]
@@ -31,9 +84,67 @@ def _get_external_id(obj: dict[str, Any]) -> str | None:
return None return None
def _is_subtechnique(tech_id: str) -> bool:
return "." in tech_id
def _parent_id(sub_id: str) -> str:
return sub_id.split(".")[0]
def _build_matrix(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Build the tactic → techniques → subtechniques tree."""
# Group top-level techniques by tactic.
tactic_techs: dict[str, list[dict[str, Any]]] = {t: [] for t in _TACTIC_ORDER}
for entry in entries:
if _is_subtechnique(entry["id"]):
continue
for tactic in entry["tactics"]:
if tactic in tactic_techs:
tactic_techs[tactic].append(entry)
# Attach sub-techniques to their parents.
parent_subs: dict[str, list[dict[str, Any]]] = {}
for entry in entries:
if not _is_subtechnique(entry["id"]):
continue
pid = _parent_id(entry["id"])
parent_subs.setdefault(pid, []).append({"id": entry["id"], "name": entry["name"]})
# Sort subs alphabetically by name.
for subs in parent_subs.values():
subs.sort(key=lambda x: x["name"])
matrix: list[dict[str, Any]] = []
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(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": ta_id,
"tactic_name": tactic_name,
"techniques": [
{
"id": t["id"],
"name": t["name"],
"subtechniques": parent_subs.get(t["id"], []),
}
for t in techs_sorted
],
}
)
return matrix
def load_bundle(path: Path | None = None) -> None: def load_bundle(path: Path | None = None) -> None:
"""Load the MITRE bundle into memory. Called once at app boot.""" """Load the MITRE bundle into memory. Called once at app boot."""
global mitre_loaded, _index global mitre_loaded, _index, _tactics_by_technique, _name_by_id, _matrix
bundle_path = path or _BUNDLE_PATH bundle_path = path or _BUNDLE_PATH
try: try:
@@ -49,6 +160,9 @@ def load_bundle(path: Path | None = None) -> None:
return return
entries: list[dict[str, Any]] = [] entries: list[dict[str, Any]] = []
tactics_map: dict[str, list[str]] = {}
name_map: dict[str, str] = {}
for obj in data.get("objects") or []: for obj in data.get("objects") or []:
if not isinstance(obj, dict): if not isinstance(obj, dict):
continue continue
@@ -59,19 +173,51 @@ def load_bundle(path: Path | None = None) -> None:
ext_id = _get_external_id(obj) ext_id = _get_external_id(obj)
if not ext_id: if not ext_id:
continue continue
entries.append( tactics = _extract_tactics(obj)
{ name = obj.get("name", "")
"id": ext_id, entries.append({"id": ext_id, "name": name, "tactics": tactics})
"name": obj.get("name", ""), tactics_map[ext_id] = tactics
"tactics": _extract_tactics(obj), name_map[ext_id] = name
}
)
_index = entries _index = entries
_tactics_by_technique = tactics_map
_name_by_id = name_map
_matrix = _build_matrix(entries)
mitre_loaded = True mitre_loaded = True
logger.info("MITRE bundle loaded: %d techniques", len(_index)) logger.info("MITRE bundle loaded: %d techniques", len(_index))
def get_tactics(technique_id: str) -> list[str]:
"""Return tactic list for a technique id; empty list if unknown."""
return _tactics_by_technique.get(technique_id, [])
def lookup_name(technique_id: str) -> str | None:
"""Return the name for a technique id, or None if not in the bundle."""
return _name_by_id.get(technique_id)
def get_matrix() -> list[dict[str, Any]]:
"""Return the full tactic → techniques → subtechniques tree."""
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]]: def search(query: str, limit: int = 20) -> list[dict[str, Any]]:
"""Return up to `limit` techniques matching `query`. """Return up to `limit` techniques matching `query`.

View File

@@ -10,11 +10,10 @@ from backend.app.extensions import db
from backend.app.models import User from backend.app.models import User
from backend.app.models.simulation import Simulation, SimulationStatus from backend.app.models.simulation import Simulation, SimulationStatus
# Fields only admin/redteam may write (excluding technique_ids/tactic_ids handled separately).
REDTEAM_FIELDS = frozenset( REDTEAM_FIELDS = frozenset(
{ {
"name", "name",
"mitre_technique_id",
"mitre_technique_name",
"description", "description",
"commands", "commands",
"prerequisites", "prerequisites",
@@ -25,8 +24,6 @@ REDTEAM_FIELDS = frozenset(
SOC_FIELDS = frozenset({"log_source", "logs", "soc_comment", "incident_number"}) SOC_FIELDS = frozenset({"log_source", "logs", "soc_comment", "incident_number"})
# Transitions allowed via POST /transition endpoint (manual only).
# auto pending→in_progress is handled in apply_patch, not here.
_ALLOWED_TRANSITIONS: dict[str, dict[str, set[str]]] = { _ALLOWED_TRANSITIONS: dict[str, dict[str, set[str]]] = {
"review_required": { "review_required": {
"from": {"pending", "in_progress"}, "from": {"pending", "in_progress"},
@@ -48,6 +45,72 @@ def _is_non_empty(value: Any) -> bool:
return not (isinstance(value, list) and len(value) == 0) return not (isinstance(value, list) and len(value) == 0)
def _resolve_technique_ids(
technique_ids: list[str],
) -> tuple[list[dict[str, str]] | None, tuple[Any, int] | None]:
"""Validate and resolve technique IDs to [{id, name}] snapshots.
Returns (resolved_list, None) on success or (None, error_tuple) on failure.
Deduplicates while preserving order.
"""
from backend.app.services import mitre as mitre_svc
if not mitre_svc.mitre_loaded:
return None, (jsonify({"error": "mitre bundle not loaded"}), 503)
seen: dict[str, None] = dict.fromkeys(technique_ids)
resolved: list[dict[str, str]] = []
for tid in seen:
name = mitre_svc.lookup_name(tid)
if name is None:
return None, (jsonify({"error": f"unknown technique id: {tid}"}), 400)
resolved.append({"id": tid, "name": name})
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 promote_to_in_progress(simulation: Simulation) -> None:
"""Transition simulation pending → in_progress if it is currently pending.
Also advances the engagement planned → active via _maybe_activate_engagement.
No-op when the simulation is already in any other status.
Caller must commit.
"""
if simulation.status == SimulationStatus.PENDING:
simulation.status = SimulationStatus.IN_PROGRESS
simulation.updated_at = datetime.now(UTC)
_maybe_activate_engagement(simulation)
def apply_patch( def apply_patch(
simulation: Simulation, payload: dict[str, Any], user: User simulation: Simulation, payload: dict[str, Any], user: User
) -> tuple[Any, int] | None: ) -> tuple[Any, int] | None:
@@ -56,18 +119,23 @@ def apply_patch(
Returns a (response, status_code) tuple on error, or None on success Returns a (response, status_code) tuple on error, or None on success
(caller is responsible for committing). (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 role = user.role.value
if role == "soc": if role == "soc":
# SOC can only patch when status allows it.
if simulation.status not in ( if simulation.status not in (
SimulationStatus.REVIEW_REQUIRED, SimulationStatus.REVIEW_REQUIRED,
SimulationStatus.DONE, SimulationStatus.DONE,
): ):
return jsonify({"error": "simulation not ready for SOC review"}), 403 return jsonify({"error": "simulation not ready for SOC review"}), 403
# SOC must not send redteam fields. # SOC must not send redteam fields, technique_ids, or tactic_ids.
redteam_keys_in_payload = REDTEAM_FIELDS & payload.keys() redteam_keys_in_payload = (
REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}
) & payload.keys()
if redteam_keys_in_payload: if redteam_keys_in_payload:
return jsonify({"error": "soc cannot edit redteam fields"}), 403 return jsonify({"error": "soc cannot edit redteam fields"}), 403
@@ -76,10 +144,10 @@ def apply_patch(
setattr(simulation, field, payload[field]) setattr(simulation, field, payload[field])
else: else:
# admin / redteam: apply all fields present. # admin / redteam path.
redteam_keys_present = REDTEAM_FIELDS & payload.keys() redteam_keys_present = REDTEAM_FIELDS & payload.keys()
# Validate executed_at before any writes so a bad value causes no partial mutation. # Validate executed_at upfront before any writes.
executed_at_value: datetime | None = None executed_at_value: datetime | None = None
if "executed_at" in redteam_keys_present: if "executed_at" in redteam_keys_present:
val = payload["executed_at"] val = payload["executed_at"]
@@ -91,22 +159,57 @@ def apply_patch(
except ValueError: except ValueError:
return jsonify({"error": "invalid executed_at"}), 400 return jsonify({"error": "invalid executed_at"}), 400
# Validate and resolve technique_ids upfront.
resolved_techniques: list[dict[str, str]] | None = None
if "technique_ids" in payload:
raw_ids = payload["technique_ids"]
if not isinstance(raw_ids, list):
return jsonify({"error": "technique_ids must be a list"}), 400
resolved_techniques, err = _resolve_technique_ids(raw_ids)
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: for field in redteam_keys_present:
if field == "executed_at": if field == "executed_at":
simulation.executed_at = executed_at_value simulation.executed_at = executed_at_value
else: else:
setattr(simulation, field, payload[field]) setattr(simulation, field, payload[field])
# Apply resolved techniques.
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: for field in SOC_FIELDS:
if field in payload: if field in payload:
setattr(simulation, field, payload[field]) setattr(simulation, field, payload[field])
# Auto-transition pending → in_progress: at least one redteam field with # Auto-transition pending → in_progress.
# a non-empty value in the *incoming payload*. # Triggers when any redteam scalar has a non-empty value, technique_ids or tactic_ids non-empty.
if simulation.status == SimulationStatus.PENDING and any( auto_trigger = any(_is_non_empty(payload[k]) for k in redteam_keys_present)
_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 simulation.status = SimulationStatus.IN_PROGRESS
_maybe_activate_engagement(simulation)
simulation.updated_at = datetime.now(UTC) simulation.updated_at = datetime.now(UTC)
return None return None
@@ -116,6 +219,13 @@ def transition(
simulation: Simulation, to_status: str, user: User simulation: Simulation, to_status: str, user: User
) -> tuple[Any, int] | None: ) -> tuple[Any, int] | None:
"""Attempt a manual transition. Returns error tuple or None on success.""" """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) rule = _ALLOWED_TRANSITIONS.get(to_status)
if rule is None: if rule is None:
return jsonify({"error": "invalid transition"}), 409 return jsonify({"error": "invalid transition"}), 409

View File

@@ -0,0 +1,73 @@
"""replace scalar MITRE columns with techniques JSON array
Revision ID: 0003
Revises: 0002
Create Date: 2026-05-27 00:00:00.000000
"""
import json
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import text
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
# 1. Add techniques column (nullable while we backfill).
op.add_column("simulations", sa.Column("techniques", sa.Text(), nullable=True))
# 2. Backfill: scalar → JSON array.
rows = bind.execute(
text("SELECT id, mitre_technique_id, mitre_technique_name FROM simulations")
).fetchall()
for row in rows:
if row[1]: # mitre_technique_id is not null
val = json.dumps([{"id": row[1], "name": row[2] or ""}])
else:
val = "[]"
bind.execute(
text("UPDATE simulations SET techniques = :v WHERE id = :id"),
{"v": val, "id": row[0]},
)
# 3. Make NOT NULL now that every row has a value.
with op.batch_alter_table("simulations") as batch_op:
batch_op.alter_column("techniques", existing_type=sa.Text(), nullable=False)
# 4. Drop old scalar columns.
with op.batch_alter_table("simulations") as batch_op:
batch_op.drop_column("mitre_technique_id")
batch_op.drop_column("mitre_technique_name")
def downgrade():
bind = op.get_bind()
# 1. Re-add scalar columns.
with op.batch_alter_table("simulations") as batch_op:
batch_op.add_column(sa.Column("mitre_technique_id", sa.String(length=32), nullable=True))
batch_op.add_column(sa.Column("mitre_technique_name", sa.String(length=255), nullable=True))
# 2. Back-fill: take first element of techniques array.
rows = bind.execute(text("SELECT id, techniques FROM simulations")).fetchall()
for row in rows:
techniques = json.loads(row[1] or "[]")
if techniques:
first = techniques[0]
bind.execute(
text(
"UPDATE simulations SET mitre_technique_id = :tid, mitre_technique_name = :tname WHERE id = :id"
),
{"tid": first.get("id"), "tname": first.get("name"), "id": row[0]},
)
# 3. Drop techniques column.
with op.batch_alter_table("simulations") as batch_op:
batch_op.drop_column("techniques")

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,40 @@
"""create simulation_templates table
Revision ID: 0005
Revises: 0004
Create Date: 2026-05-28 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"simulation_templates",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False, unique=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("commands", sa.Text(), nullable=True),
sa.Column("prerequisites", sa.Text(), nullable=True),
sa.Column("techniques", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
sa.Column("tactic_ids", sa.JSON(), nullable=False, server_default=sa.text("'[]'")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column(
"created_by_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="RESTRICT"),
nullable=False,
),
)
op.create_index("ix_simulation_templates_name", "simulation_templates", ["name"])
def downgrade() -> None:
op.drop_index("ix_simulation_templates_name", "simulation_templates")
op.drop_table("simulation_templates")

View File

@@ -0,0 +1,67 @@
"""create c2_config and c2_task tables
Revision ID: 0006
Revises: 0005
Create Date: 2026-06-10 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "0006"
down_revision = "0005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"c2_config",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"engagement_id",
sa.Integer(),
sa.ForeignKey("engagements.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("url", sa.Text(), nullable=False),
sa.Column("api_token_encrypted", sa.Text(), nullable=False),
sa.Column("verify_tls", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
op.create_index("ix_c2_config_engagement_id", "c2_config", ["engagement_id"])
op.create_table(
"c2_task",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"simulation_id",
sa.Integer(),
sa.ForeignKey("simulations.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("mythic_task_display_id", sa.Integer(), nullable=False),
sa.Column("callback_display_id", sa.Integer(), nullable=False),
sa.Column("command", sa.Text(), nullable=False),
sa.Column("params", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False),
sa.Column("completed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("output", sa.Text(), nullable=True),
sa.Column(
"source",
sa.Enum("mimic", "import", name="c2task_source"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("completed_at", sa.DateTime(), nullable=True),
)
def downgrade() -> None:
op.drop_table("c2_task")
op.drop_index("ix_c2_config_engagement_id", "c2_config")
op.drop_table("c2_config")
# Remove the enum type (no-op on SQLite, required on Postgres)
sa.Enum(name="c2task_source").drop(op.get_bind(), checkfirst=True)

View File

@@ -0,0 +1,30 @@
"""add mapping_applied column to c2_task
Revision ID: 0007
Revises: 0006
Create Date: 2026-06-10 00:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("c2_task") as batch_op:
batch_op.add_column(
sa.Column(
"mapping_applied",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table("c2_task") as batch_op:
batch_op.drop_column("mapping_applied")

View File

@@ -3,6 +3,11 @@ Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7 Flask-Migrate==4.0.7
PyJWT==2.9.0 PyJWT==2.9.0
argon2-cffi==23.1.0 argon2-cffi==23.1.0
weasyprint>=60.0
cryptography==44.0.0
requests==2.32.3
pytest==8.3.3 pytest==8.3.3
ruff==0.6.9 ruff==0.6.9
mypy==1.11.2 mypy==1.11.2
types-requests==2.32.0.20240914
requests-mock==1.12.1

View File

@@ -0,0 +1,30 @@
"""Tests for the FakeAdapter deterministic in-memory implementation."""
from __future__ import annotations
from backend.app.services.c2.adapter import C2Health
from backend.app.services.c2.fake import FakeAdapter
class TestFakeAdapterTestConnection:
def test_returns_ok_true(self):
adapter = FakeAdapter()
health = adapter.test_connection()
assert isinstance(health, C2Health)
assert health.ok is True
assert health.error is None
def test_list_callbacks_returns_list(self):
adapter = FakeAdapter()
callbacks = adapter.list_callbacks()
assert isinstance(callbacks, list)
assert len(callbacks) >= 1
def test_list_callbacks_fields(self):
adapter = FakeAdapter()
cb = adapter.list_callbacks()[0]
assert hasattr(cb, "display_id")
assert hasattr(cb, "active")
assert hasattr(cb, "host")
assert hasattr(cb, "user")
assert hasattr(cb, "domain")
assert hasattr(cb, "last_checkin")

View File

@@ -0,0 +1,62 @@
"""FakeAdapter M2 tests — list_callbacks shape, create_task monotonicity."""
from __future__ import annotations
from backend.app.services.c2.fake import FakeAdapter
class TestFakeAdapterListCallbacks:
def test_returns_three_callbacks(self):
adapter = FakeAdapter()
callbacks = adapter.list_callbacks()
assert len(callbacks) == 3
def test_all_active(self):
adapter = FakeAdapter()
for cb in adapter.list_callbacks():
assert cb.active is True
def test_display_ids_are_1_2_3(self):
adapter = FakeAdapter()
ids = [cb.display_id for cb in adapter.list_callbacks()]
assert ids == [1, 2, 3]
def test_pinned_last_checkin_format(self):
adapter = FakeAdapter()
for cb in adapter.list_callbacks():
assert cb.last_checkin.startswith("2026-06-10")
def test_callbacks_have_host_user_domain(self):
adapter = FakeAdapter()
for cb in adapter.list_callbacks():
assert cb.host
assert cb.user
assert cb.domain
class TestFakeAdapterCreateTask:
def test_returns_monotonic_ids_from_1000(self):
adapter = FakeAdapter()
id1 = adapter.create_task(1, "whoami")
id2 = adapter.create_task(1, "ipconfig")
assert id1 == 1000
assert id2 == 1001
def test_separate_instances_start_at_1000_independently(self):
a1 = FakeAdapter()
a2 = FakeAdapter()
assert a1.create_task(1, "cmd") == 1000
assert a2.create_task(1, "cmd") == 1000
def test_stores_command_and_callback(self):
adapter = FakeAdapter()
tid = adapter.create_task(callback_display_id=2, command="ls", params="-la")
task = adapter._tasks[tid]
assert task["command"] == "ls"
assert task["params"] == "-la"
assert task["callback_display_id"] == 2
def test_initial_status_submitted(self):
adapter = FakeAdapter()
tid = adapter.create_task(1, "hostname")
assert adapter._tasks[tid]["status"] == "submitted"
assert adapter._tasks[tid]["completed"] is False

View File

@@ -0,0 +1,110 @@
"""FakeAdapter M3 state-progression tests — get_task and get_task_output."""
from __future__ import annotations
import pytest
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.fake import FakeAdapter
@pytest.fixture()
def adapter() -> FakeAdapter:
return FakeAdapter()
@pytest.fixture()
def adapter_with_task(adapter: FakeAdapter) -> tuple[FakeAdapter, int]:
tid = adapter.create_task(callback_display_id=1, command="whoami")
return adapter, tid
class TestFakeAdapterGetTaskProgression:
def test_first_call_returns_submitted(self, adapter_with_task):
a, tid = adapter_with_task
status = a.get_task(tid)
assert status.status == "submitted"
assert status.completed is False
def test_second_call_returns_completed(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid) # first call
status = a.get_task(tid) # second call
assert status.status == "completed"
assert status.completed is True
def test_subsequent_calls_stay_completed(self, adapter_with_task):
a, tid = adapter_with_task
for _ in range(5):
a.get_task(tid)
status = a.get_task(tid)
assert status.completed is True
def test_unknown_task_id_returns_submitted_on_first_call(self, adapter):
"""A task ID not created by this instance still goes through submitted→completed."""
status = adapter.get_task(9999)
assert status.display_id == 9999
assert status.status == "submitted"
assert status.completed is False
def test_call_counters_are_per_task(self, adapter):
"""Two tasks have independent state — completing one does not affect the other."""
t1 = adapter.create_task(callback_display_id=1, command="whoami")
t2 = adapter.create_task(callback_display_id=1, command="ipconfig")
# Advance t1 to completed via two calls.
adapter.get_task(t1)
adapter.get_task(t1)
# t2 first call should still be submitted.
s2 = adapter.get_task(t2)
assert s2.status == "submitted"
assert s2.completed is False
def test_instances_are_isolated(self):
"""Per-instance counters — different FakeAdapter instances don't share state."""
a1 = FakeAdapter()
a2 = FakeAdapter()
t1 = a1.create_task(1, "cmd")
t2 = a2.create_task(1, "cmd")
a1.get_task(t1)
a1.get_task(t1) # a1's task is now completed
# a2's task with same display_id (both start at 1000) should be independent.
assert t1 == t2 == 1000
s2 = a2.get_task(t2)
assert s2.status == "submitted"
class TestFakeAdapterGetTaskOutput:
def test_raises_before_completed(self, adapter_with_task):
a, tid = adapter_with_task
with pytest.raises(C2Error, match="task not completed"):
a.get_task_output(tid)
def test_raises_after_first_get_task_call_only(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid) # first call — still submitted
with pytest.raises(C2Error, match="task not completed"):
a.get_task_output(tid)
def test_returns_output_after_completed(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid)
a.get_task(tid) # now completed
output = a.get_task_output(tid)
assert "whoami" in output
assert str(tid) in output
def test_output_format(self, adapter):
tid = adapter.create_task(callback_display_id=2, command="ipconfig /all")
adapter.get_task(tid)
adapter.get_task(tid)
output = adapter.get_task_output(tid)
assert output == f"output for task {tid}: ipconfig /all\n"
def test_unknown_task_raises_c2error(self, adapter):
"""Task ID never created and never polled — not completed → C2Error."""
with pytest.raises(C2Error, match="task not completed"):
adapter.get_task_output(9999)

View File

@@ -0,0 +1,75 @@
"""FakeAdapter M4 tests — list_callback_tasks pagination."""
from __future__ import annotations
import pytest
from backend.app.services.c2.adapter import C2HistoricalTask
from backend.app.services.c2.fake import FakeAdapter
@pytest.fixture()
def adapter() -> FakeAdapter:
return FakeAdapter()
class TestFakeAdapterListCallbackTasks:
def test_callback_1_returns_12_total(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert page.total == 12
def test_callback_2_returns_0_tasks(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=2, page=1, page_size=25)
assert page.total == 0
assert page.items == []
def test_callback_3_returns_5_tasks(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=3, page=1, page_size=25)
assert page.total == 5
assert len(page.items) == 5
def test_items_are_c2_historical_task_instances(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
for item in page.items:
assert isinstance(item, C2HistoricalTask)
def test_pagination_page1(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
assert len(page.items) == 5
assert page.page == 1
assert page.page_size == 5
def test_pagination_page2(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=5)
assert len(page.items) == 5
assert page.page == 2
def test_pagination_last_page_partial(self, adapter):
# 12 tasks, page_size=5 → page 3 has 2 items.
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=5)
assert len(page.items) == 2
assert page.total == 12
def test_pagination_beyond_range_returns_empty(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=99, page_size=25)
assert len(page.items) == 0
assert page.total == 12
def test_history_is_deterministic_across_instances(self):
a1 = FakeAdapter()
a2 = FakeAdapter()
p1 = a1.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
p2 = a2.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert [t.display_id for t in p1.items] == [t.display_id for t in p2.items]
def test_completed_and_submitted_mix(self, adapter):
"""Callback 1 has alternating completed/submitted tasks (even=completed)."""
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=12)
completed = [t for t in page.items if t.completed]
submitted = [t for t in page.items if not t.completed]
assert len(completed) == 6
assert len(submitted) == 6
def test_unknown_callback_returns_empty(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=999, page=1, page_size=25)
assert page.total == 0
assert page.items == []

View File

@@ -0,0 +1,151 @@
"""MythicAdapter unit tests — mocked HTTP with requests-mock."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
class TestMythicAdapterListCallbacks:
def test_returns_callbacks_from_graphql(self, adapter):
payload = {
"data": {
"callback": [
{
"id": 1,
"display_id": 1,
"active": True,
"host": "HOST-01",
"user": "jdoe",
"domain": "LAB",
"last_checkin": "2026-06-10T00:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
callbacks = adapter.list_callbacks()
assert len(callbacks) == 1
assert callbacks[0].display_id == 1
assert callbacks[0].host == "HOST-01"
assert callbacks[0].user == "jdoe"
def test_sends_apitoken_header(self, adapter):
payload = {"data": {"callback": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.list_callbacks()
sent_headers = m.last_request.headers
assert sent_headers.get("apitoken") == _TOKEN
def test_verify_tls_flag_passed(self):
"""Adapter with verify_tls=True should pass verify=True to requests."""
adapter_tls = MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=True)
payload = {"data": {"callback": []}}
# requests-mock intercepts before TLS — just confirm no error path triggered.
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
callbacks = adapter_tls.list_callbacks()
assert isinstance(callbacks, list)
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("connection refused"))
with pytest.raises(C2Error):
adapter.list_callbacks()
def test_http_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=500, text="Internal Server Error")
with pytest.raises(C2Error):
adapter.list_callbacks()
class TestMythicAdapterCreateTask:
def test_returns_display_id_on_success(self, adapter):
payload = {
"data": {
"createTask": {
"id": 42,
"display_id": 7,
"error": None,
}
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
tid = adapter.create_task(callback_display_id=1, command="whoami")
assert tid == 7
def test_sends_apitoken_header(self, adapter):
payload = {"data": {"createTask": {"id": 1, "display_id": 1, "error": None}}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.create_task(1, "cmd")
sent_headers = m.last_request.headers
assert sent_headers.get("apitoken") == _TOKEN
def test_error_field_raises_c2error(self, adapter):
payload = {
"data": {
"createTask": {
"id": None,
"display_id": None,
"error": "callback not found",
}
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
with pytest.raises(C2Error, match="callback not found"):
adapter.create_task(1, "whoami")
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.Timeout("timeout"))
with pytest.raises(C2Error):
adapter.create_task(1, "whoami")
class TestMythicAdapterErrorSanitization:
def test_connection_error_message_does_not_contain_url(self, adapter):
"""C2Error message must not expose the configured Mythic URL."""
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError(
f"HTTPSConnectionPool(host='{_BASE_URL}', port=7443): Max retries exceeded"
))
with pytest.raises(C2Error) as exc_info:
adapter.list_callbacks()
assert _BASE_URL not in str(exc_info.value)
assert "ConnectionError" in str(exc_info.value)
class TestMythicAdapterNoRedirects:
def test_does_not_follow_redirect(self, adapter):
"""Adapter must not follow HTTP redirects (allow_redirects=False)."""
with rm_module.Mocker() as m:
# Simulate a redirect response; requests-mock won't auto-follow it.
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/graphql"})
# With allow_redirects=False the 301 is treated as a non-2xx → raise_for_status raises.
with pytest.raises(C2Error):
adapter.list_callbacks()
# Exactly one request was made — no follow-up to Location.
assert len(m.request_history) == 1

View File

@@ -0,0 +1,188 @@
"""MythicAdapter M3 tests — get_task and get_task_output, mocked HTTP."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
class TestMythicAdapterGetTask:
def test_returns_status_for_incomplete_task(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"status": "processing",
"completed": False,
"timestamp": None,
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.display_id == 7
assert status.status == "processing"
assert status.completed is False
assert status.completed_at is None
def test_returns_completed_at_for_completed_task(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.completed is True
assert status.completed_at is not None
assert status.completed_at.year == 2026
def test_raises_when_task_not_found(self, adapter):
payload = {"data": {"task": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
with pytest.raises(C2Error, match="not found"):
adapter.get_task(999)
def test_sends_apitoken_header(self, adapter):
payload = {
"data": {
"task": [
{"display_id": 1, "status": "submitted", "completed": False, "timestamp": None}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.get_task(1)
assert m.last_request.headers.get("apitoken") == _TOKEN
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("refused"))
with pytest.raises(C2Error):
adapter.get_task(1)
def test_no_redirect_followed(self, adapter):
"""get_task must not follow HTTP redirects."""
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.get_task(1)
assert len(m.request_history) == 1
def test_invalid_timestamp_does_not_crash(self, adapter):
"""A malformed timestamp field falls back to completed_at=None without raising."""
payload = {
"data": {
"task": [
{
"display_id": 5,
"status": "completed",
"completed": True,
"timestamp": "not-a-date",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(5)
assert status.completed is True
assert status.completed_at is None
class TestMythicAdapterGetTaskOutput:
def test_returns_decoded_output(self, adapter):
import base64
encoded = base64.b64encode(b"Administrator\r\n").decode()
payload = {
"data": {
"response": [{"response_text": encoded}]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert "Administrator" in output
def test_concatenates_multiple_responses(self, adapter):
import base64
r1 = base64.b64encode(b"line one\n").decode()
r2 = base64.b64encode(b"line two\n").decode()
payload = {
"data": {
"response": [{"response_text": r1}, {"response_text": r2}]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert "line one" in output
assert "line two" in output
def test_returns_empty_string_when_no_responses(self, adapter):
payload = {"data": {"response": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert output == ""
def test_skips_empty_response_text(self, adapter):
import base64
encoded = base64.b64encode(b"real output").decode()
payload = {
"data": {
"response": [
{"response_text": ""},
{"response_text": encoded},
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert output == "real output"
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.Timeout("timeout"))
with pytest.raises(C2Error):
adapter.get_task_output(7)
def test_no_redirect_followed(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=302, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.get_task_output(1)
assert len(m.request_history) == 1

View File

@@ -0,0 +1,167 @@
"""MythicAdapter M4 tests — list_callback_tasks, mocked HTTP."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error, C2HistoricalTask
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
def _task_list_payload(tasks: list[dict]) -> dict:
return {"data": {"task": tasks}}
def _count_payload(count: int) -> dict:
return {"data": {"task_aggregate": {"aggregate": {"count": count}}}}
class TestMythicAdapterListCallbackTasks:
def test_returns_tasks_from_graphql(self, adapter):
tasks_payload = _task_list_payload([
{
"display_id": 7,
"command_name": "whoami",
"params": "",
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
])
count_payload = _count_payload(1)
with rm_module.Mocker() as m:
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert page.total == 1
assert len(page.items) == 1
item = page.items[0]
assert isinstance(item, C2HistoricalTask)
assert item.display_id == 7
assert item.command == "whoami"
assert item.completed is True
def test_pagination_offset_calculation(self, adapter):
"""page=2, page_size=10 → offset=10 must be sent to Mythic."""
tasks_payload = _task_list_payload([])
count_payload = _count_payload(0)
with rm_module.Mocker() as m:
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=10)
# First request is the task list; check variables.
first_body = m.request_history[0].json()
variables = first_body.get("variables", {})
assert variables.get("offset") == 10
assert variables.get("limit") == 10
def test_sends_apitoken_header(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(0)},
])
adapter.list_callback_tasks(callback_display_id=1)
for req in m.request_history:
assert req.headers.get("apitoken") == _TOKEN
def test_empty_task_list(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(0)},
])
page = adapter.list_callback_tasks(callback_display_id=1)
assert page.total == 0
assert page.items == []
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("refused"))
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
def test_http_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=500, text="error")
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
def test_no_redirect_followed(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
# Both requests (tasks + count) should each only make one attempt.
for req in m.request_history:
assert req.method == "POST"
def test_page_and_page_size_in_response(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(50)},
])
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=10)
assert page.page == 3
assert page.page_size == 10
assert page.total == 50
class TestMythicAdapterGetTaskCommandField:
"""Ensure command_name is surfaced via get_task() C2TaskStatus.command."""
def test_get_task_returns_command(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"command_name": "shell",
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.command == "shell"
def test_get_task_command_none_when_missing(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"command_name": None,
"status": "submitted",
"completed": False,
"timestamp": None,
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.command is None

View File

@@ -0,0 +1,142 @@
"""Tests for GET /api/engagements/<id>/c2/callbacks."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask.testing import FlaskClient
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
class TestGetCallbacksHappyPath:
def test_returns_3_callbacks_with_fake_adapter(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 200
body = resp.get_json()
assert "callbacks" in body
assert len(body["callbacks"]) == 3
def test_callback_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
cb = resp.get_json()["callbacks"][0]
assert "display_id" in cb
assert "active" in cb
assert "host" in cb
assert "user" in cb
assert "domain" in cb
assert "last_checkin" in cb
def test_redteam_allowed(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(redteam_token),
)
assert resp.status_code == 200
class TestGetCallbacksErrorCases:
def test_404_when_no_config(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 404
def test_404_engagement_not_found(self, client: FlaskClient, admin_token: str) -> None:
resp = client.get(
"/api/engagements/9999/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 404
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(soc_token),
)
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 503
def test_502_when_adapter_raises(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self):
raise C2Error("mythic unreachable")
monkeypatch.setattr(fake_mod.FakeAdapter, "list_callbacks", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/c2/callbacks",
headers=_h(admin_token),
)
assert resp.status_code == 502
assert "mythic unreachable" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,367 @@
"""Tests for C2 config CRUD endpoints.
Covers:
- GET 404 when no config exists
- PUT create (api_token required)
- PUT update with omitted token keeps old ciphertext
- GET 200 returns has_token=True, never cleartext
- DELETE 204
- Cascade delete when engagement is deleted
- RBAC: admin OK / redteam OK / SOC 403 on all 4 endpoints
- 503 guard when MIMIC_ENCRYPTION_KEY is unset
- POST /test with fake adapter
"""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.models.c2_config import C2Config
from backend.tests.conftest import auth_headers as _h
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
"""Default: key is present. Individual tests can override."""
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _put_config(
client: FlaskClient,
token: str,
eid: int,
*,
url: str = "https://c2.internal:7443",
api_token: str | None = "s3cr3t",
verify_tls: bool = True,
) -> dict:
payload: dict = {"url": url, "verify_tls": verify_tls}
if api_token is not None:
payload["api_token"] = api_token
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json=payload,
)
return resp
# ---------------------------------------------------------------------------
# GET — 404 when no config
# ---------------------------------------------------------------------------
def test_get_config_not_found(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.status_code == 404
def test_get_config_engagement_not_found(client: FlaskClient, admin_token: str) -> None:
resp = client.get("/api/engagements/9999/c2-config", headers=_h(admin_token))
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PUT — create
# ---------------------------------------------------------------------------
def test_put_rejects_http_scheme(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, admin_token, eng["id"], url="http://c2.internal:7443")
assert resp.status_code == 400
assert "https" in resp.get_json().get("error", "").lower()
def test_put_rejects_missing_hostname(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
# urlparse("https://:7443") produces an empty hostname
resp = _put_config(client, admin_token, eng["id"], url="https://:7443")
assert resp.status_code == 400
assert "hostname" in resp.get_json().get("error", "").lower()
def test_put_creates_config(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, admin_token, eng["id"])
assert resp.status_code == 200
body = resp.get_json()
assert body["has_token"] is True
assert body["url"] == "https://c2.internal:7443"
assert body["verify_tls"] is True
def test_put_create_requires_api_token(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, admin_token, eng["id"], api_token=None)
assert resp.status_code == 400
def test_put_create_requires_url(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.put(
f"/api/engagements/{eng['id']}/c2-config",
headers=_h(admin_token),
json={"api_token": "tok", "verify_tls": True},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# PUT — update, omitting api_token preserves old ciphertext
# ---------------------------------------------------------------------------
def test_put_update_omits_token_keeps_old(
app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"], api_token="original-token")
# Read ciphertext from DB before update.
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
old_cipher = cfg.api_token_encrypted
# Update URL, omit api_token.
resp = _put_config(
client, admin_token, eng["id"],
url="https://new.internal:7443", api_token=None,
)
assert resp.status_code == 200
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
assert cfg.api_token_encrypted == old_cipher
assert cfg.url == "https://new.internal:7443"
def test_put_update_with_token_replaces_ciphertext(
app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"], api_token="original-token")
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
old_cipher = cfg.api_token_encrypted
_put_config(client, admin_token, eng["id"], api_token="new-token")
with app.app_context():
cfg = C2Config.query.filter_by(engagement_id=eng["id"]).first()
assert cfg is not None
assert cfg.api_token_encrypted != old_cipher
# ---------------------------------------------------------------------------
# GET — 200, has_token=True, never cleartext
# ---------------------------------------------------------------------------
def test_get_config_returns_has_token_not_cleartext(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"], api_token="s3cr3t")
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.status_code == 200
body = resp.get_json()
assert body["has_token"] is True
assert "api_token" not in body
assert "api_token_encrypted" not in body
assert "s3cr3t" not in str(body)
def test_get_config_verify_tls_default_true(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp.get_json()["verify_tls"] is True
# ---------------------------------------------------------------------------
# DELETE — 204
# ---------------------------------------------------------------------------
def test_delete_config_204(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.delete(
f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)
)
assert resp.status_code == 204
# Subsequent GET returns 404.
resp2 = client.get(f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token))
assert resp2.status_code == 404
def test_delete_config_not_found(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.delete(
f"/api/engagements/{eng['id']}/c2-config", headers=_h(admin_token)
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# CASCADE — delete engagement removes config
# ---------------------------------------------------------------------------
def test_cascade_delete_engagement_removes_config(
app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
with app.app_context():
assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 1
client.delete(f"/api/engagements/{eng['id']}", headers=_h(admin_token))
with app.app_context():
assert C2Config.query.filter_by(engagement_id=eng["id"]).count() == 0
# ---------------------------------------------------------------------------
# RBAC matrix
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("method,path_suffix", [
("GET", "/c2-config"),
("PUT", "/c2-config"),
("DELETE", "/c2-config"),
("POST", "/c2-config/test"),
])
def test_soc_gets_403(
client: FlaskClient, admin_token: str, soc_token: str,
method: str, path_suffix: str,
) -> None:
eng = _make_engagement(client, admin_token)
url = f"/api/engagements/{eng['id']}{path_suffix}"
resp = getattr(client, method.lower())(url, headers=_h(soc_token), json={})
assert resp.status_code == 403
@pytest.mark.parametrize("method,path_suffix", [
("GET", "/c2-config"),
("DELETE", "/c2-config"),
("POST", "/c2-config/test"),
])
def test_redteam_gets_allowed(
client: FlaskClient, admin_token: str, redteam_token: str,
method: str, path_suffix: str,
) -> None:
eng = _make_engagement(client, admin_token)
# Ensure config exists for GET/DELETE/test.
_put_config(client, admin_token, eng["id"])
url = f"/api/engagements/{eng['id']}{path_suffix}"
resp = getattr(client, method.lower())(url, headers=_h(redteam_token), json={})
# Not 403 and not 401.
assert resp.status_code not in (401, 403)
def test_redteam_can_put_config(
client: FlaskClient, admin_token: str, redteam_token: str,
) -> None:
eng = _make_engagement(client, admin_token)
resp = _put_config(client, redteam_token, eng["id"])
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# 503 guard when MIMIC_ENCRYPTION_KEY is unset
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("method,path_suffix", [
("GET", "/c2-config"),
("PUT", "/c2-config"),
("DELETE", "/c2-config"),
("POST", "/c2-config/test"),
])
def test_503_when_key_unset(
monkeypatch,
client: FlaskClient,
admin_token: str,
method: str,
path_suffix: str,
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
url = f"/api/engagements/{eng['id']}{path_suffix}"
resp = getattr(client, method.lower())(url, headers=_h(admin_token), json={
"url": "https://c2", "api_token": "tok", "verify_tls": True,
})
assert resp.status_code == 503
assert "MIMIC_ENCRYPTION_KEY" in resp.get_json().get("error", "")
# ---------------------------------------------------------------------------
# POST /test — connectivity check via fake adapter
# ---------------------------------------------------------------------------
def test_post_test_returns_ok_true(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = client.post(
f"/api/engagements/{eng['id']}/c2-config/test",
headers=_h(admin_token),
json={},
)
assert resp.status_code == 200
body = resp.get_json()
assert body["ok"] is True
assert body["error"] is None
def test_post_test_no_config_returns_404(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.post(
f"/api/engagements/{eng['id']}/c2-config/test",
headers=_h(admin_token),
json={},
)
assert resp.status_code == 404

View File

@@ -0,0 +1,324 @@
"""Tests for POST /api/simulations/<id>/c2/execute."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _execute(
client: FlaskClient,
token: str,
sid: int,
commands: list,
callback_display_id: int = 1,
):
return client.post(
f"/api/simulations/{sid}/c2/execute",
headers=_h(token),
json={"callback_display_id": callback_display_id, "commands": commands},
)
def _advance_to_in_progress(client: FlaskClient, token: str, sid: int) -> None:
client.patch(
f"/api/simulations/{sid}",
headers=_h(token),
json={"name": "Sim Alpha"},
)
def _advance_to_review_required(client: FlaskClient, token: str, sid: int) -> None:
_advance_to_in_progress(client, token, sid)
client.post(
f"/api/simulations/{sid}/transition",
headers=_h(token),
json={"to": "review_required"},
)
def _advance_to_done(client: FlaskClient, redteam_token: str, soc_token: str, sid: int) -> None:
_advance_to_review_required(client, redteam_token, sid)
client.post(
f"/api/simulations/{sid}/transition",
headers=_h(soc_token),
json={"to": "done"},
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestExecuteHappyPath:
def test_two_commands_create_two_tasks(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami", "ipconfig"])
assert resp.status_code == 200
body = resp.get_json()
assert len(body["tasks"]) == 2
assert body["tasks"][0]["command"] == "whoami"
assert body["tasks"][1]["command"] == "ipconfig"
with app.app_context():
rows = C2Task.query.filter_by(simulation_id=sim["id"]).all()
assert len(rows) == 2
def test_task_response_shape(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["hostname"])
task = resp.get_json()["tasks"][0]
assert "id" in task
assert "mythic_task_display_id" in task
assert "command" in task
assert "status" in task
assert "completed" in task
def test_pending_sim_transitions_to_in_progress(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
assert sim["status"] == "pending"
_execute(client, admin_token, sim["id"], ["whoami"])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_already_in_progress_stays_in_progress(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_in_progress(client, admin_token, sim["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 200
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_review_required_sim_still_allowed(
self, app: Flask, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_review_required(client, admin_token, sim["id"])
resp = _execute(client, admin_token, sim["id"], ["net use"])
assert resp.status_code == 200
# Status stays review_required — no regression to in_progress.
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.REVIEW_REQUIRED
def test_redteam_can_execute(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, redteam_token, sim["id"], ["whoami"])
assert resp.status_code == 200
def test_mythic_task_display_id_stored(
self, app: Flask, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mythic_task_display_id == 1000 # FakeAdapter starts at 1000
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
class TestExecuteValidation:
def test_400_empty_commands(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], [])
assert resp.status_code == 400
def test_400_non_string_command(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/execute",
headers=_h(admin_token),
json={"callback_display_id": 1, "commands": [42]},
)
assert resp.status_code == 400
def test_400_missing_callback_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/execute",
headers=_h(admin_token),
json={"commands": ["whoami"]},
)
assert resp.status_code == 400
def test_409_done_sim(
self,
client: FlaskClient,
admin_token: str,
soc_token: str,
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_done(client, admin_token, soc_token, sim["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 409
assert "done" in resp.get_json().get("error", "").lower()
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _execute(client, admin_token, 9999, ["whoami"])
assert resp.status_code == 404
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 404
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, soc_token, sim["id"], ["whoami"])
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 503
def test_502_adapter_error(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, callback_display_id, command, params=None):
raise C2Error("task queue full")
monkeypatch.setattr(fake_mod.FakeAdapter, "create_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _execute(client, admin_token, sim["id"], ["whoami"])
assert resp.status_code == 502
assert "task queue full" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,215 @@
"""Tests for GET /api/engagements/<id>/c2/callbacks/<cid>/history."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask.testing import FlaskClient
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _history(client: FlaskClient, token: str, eid: int, cid: int, **params):
return client.get(
f"/api/engagements/{eid}/c2/callbacks/{cid}/history",
headers=_h(token),
query_string=params,
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestHistoryHappyPath:
def test_returns_200(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 200
def test_response_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
body = resp.get_json()
assert "tasks" in body
assert "total" in body
assert "page" in body
assert "page_size" in body
def test_task_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
task = resp.get_json()["tasks"][0]
for field in ("display_id", "command", "params", "status", "completed", "timestamp"):
assert field in task, f"missing field: {field}"
def test_default_page_is_1(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["page"] == 1
def test_default_page_size_is_25(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["page_size"] == 25
def test_callback_1_has_12_total(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["total"] == 12
def test_callback_2_has_0_tasks(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 2)
body = resp.get_json()
assert body["total"] == 0
assert body["tasks"] == []
def test_pagination_page_size_applied(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=1, page_size=5)
body = resp.get_json()
assert len(body["tasks"]) == 5
assert body["page_size"] == 5
def test_redteam_can_view_history(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, redteam_token, eng["id"], 1)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
class TestHistoryValidation:
def test_400_page_size_too_large(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=101)
assert resp.status_code == 400
def test_400_page_zero(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=0)
assert resp.status_code == 400
def test_400_page_size_zero(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=0)
assert resp.status_code == 400
def test_400_page_negative(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=-1)
assert resp.status_code == 400
def test_400_page_size_100_is_ok(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=100)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Authorization / error cases
# ---------------------------------------------------------------------------
class TestHistoryErrors:
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, soc_token, eng["id"], 1)
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 503
def test_404_engagement_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _history(client, admin_token, 9999, 1)
assert resp.status_code == 404
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 404
def test_502_adapter_error(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, callback_display_id, page=1, page_size=25):
raise C2Error("upstream error")
monkeypatch.setattr(fake_mod.FakeAdapter, "list_callback_tasks", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 502
assert "upstream error" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,437 @@
"""Tests for POST /api/simulations/<id>/c2/import."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error, C2TaskStatus
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _import(client: FlaskClient, token: str, sid: int, task_display_ids: list, callback_display_id: int = 1):
return client.post(
f"/api/simulations/{sid}/c2/import",
headers=_h(token),
json={"callback_display_id": callback_display_id, "task_display_ids": task_display_ids},
)
def _make_completed_get_task(monkeypatch, command: str = "whoami"):
"""Patch FakeAdapter.get_task to return completed=True with a command."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
command=command,
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
def _output(self, task_display_id: int) -> str:
return f"output for {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
def _advance_to_review_required(client, token, sid):
client.patch(f"/api/simulations/{sid}", headers=_h(token), json={"name": "Sim Alpha"})
client.post(f"/api/simulations/{sid}/transition", headers=_h(token), json={"to": "review_required"})
def _advance_to_done(client, admin_token, soc_token, sid):
_advance_to_review_required(client, admin_token, sid)
client.post(f"/api/simulations/{sid}/transition", headers=_h(soc_token), json={"to": "done"})
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestImportHappyPath:
def test_imports_two_completed_tasks(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch, command="whoami")
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100, 101])
assert resp.status_code == 200
body = resp.get_json()
assert body["imported"] == 2
assert body["skipped"] == 0
with app.app_context():
rows = C2Task.query.filter_by(simulation_id=sim["id"]).all()
assert len(rows) == 2
def test_imported_tasks_have_source_import(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.source == C2TaskSource.IMPORT
def test_completed_tasks_get_mapping_applied(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mapping_applied is True
def test_idempotent_import_counts_skipped(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
# First import.
_import(client, admin_token, sim["id"], [100, 101])
# Second import with one overlap.
resp = _import(client, admin_token, sim["id"], [100, 102])
body = resp.get_json()
assert body["imported"] == 1
assert body["skipped"] == 1
def test_auto_transition_pending_to_in_progress(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
assert sim["status"] == "pending"
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_no_transition_when_already_in_progress(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
# Advance to in_progress manually.
client.patch(
f"/api/simulations/{sim['id']}",
headers=_h(admin_token),
json={"name": "Sim Alpha"},
)
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_no_transition_when_review_required(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_review_required(client, admin_token, sim["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.REVIEW_REQUIRED
def test_incomplete_task_stored_without_mapping(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""An incomplete task is stored as-is; mapping_applied stays False."""
from backend.app.services.c2 import fake as fake_mod
def _submitted(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="submitted",
completed=False,
command="shell",
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _submitted)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [200])
assert resp.status_code == 200
assert resp.get_json()["imported"] == 1
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.completed is False
assert task.mapping_applied is False
assert task.output is None
def test_command_stored_from_get_task(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Command field on the stored row comes from adapter.get_task().command."""
_make_completed_get_task(monkeypatch, command="net user /domain")
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.command == "net user /domain"
def test_redteam_can_import(
self, monkeypatch, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, redteam_token, sim["id"], [100])
assert resp.status_code == 200
def test_source_field_is_import_in_tasks_listing(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Imported tasks appear with source='import' in GET /c2/tasks response."""
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
resp = client.get(
f"/api/simulations/{sim['id']}/c2/tasks",
headers=_h(admin_token),
)
assert resp.status_code == 200
task = resp.get_json()["tasks"][0]
assert task["source"] == "import"
def test_no_transition_when_all_skipped(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""If imported=0 (all skipped), do not transition pending→in_progress."""
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100]) # first import
_import(client, admin_token, sim["id"], []) # empty — should 400 before this matters
# Reset to pending state via a fresh sim (can't undo, just verify the 0-skipped case).
# We test: importing same task again = skipped=1, imported=0 → no double-transition.
resp = _import(client, admin_token, sim["id"], [100])
body = resp.get_json()
assert body["imported"] == 0
assert body["skipped"] == 1
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
class TestImportValidation:
def test_400_empty_task_display_ids(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [])
assert resp.status_code == 400
def test_400_non_int_task_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/import",
headers=_h(admin_token),
json={"callback_display_id": 1, "task_display_ids": ["not-an-int"]},
)
assert resp.status_code == 400
def test_400_missing_callback_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/import",
headers=_h(admin_token),
json={"task_display_ids": [100]},
)
assert resp.status_code == 400
def test_409_done_simulation(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_done(client, admin_token, soc_token, sim["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 409
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _import(client, admin_token, 9999, [100])
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Authorization / error cases
# ---------------------------------------------------------------------------
class TestImportErrors:
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, soc_token, sim["id"], [100])
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 503
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 404
def test_502_adapter_error_on_get_task(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, task_display_id: int) -> C2TaskStatus:
raise C2Error("Mythic unreachable")
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 502
assert "Mythic unreachable" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,208 @@
"""Unit tests for apply_task_to_simulation() mapping helper — §0.11 contract."""
from __future__ import annotations
from datetime import UTC, datetime
from unittest.mock import MagicMock
from backend.app.services.c2.mapping import apply_task_to_simulation
def _make_task(
command: str = "whoami",
output: str | None = "root",
mapping_applied: bool = False,
completed_at: datetime | None = None,
) -> MagicMock:
task = MagicMock()
task.command = command
task.output = output
task.mapping_applied = mapping_applied
task.completed_at = completed_at
return task
def _make_sim(
execution_result: str | None = None,
executed_at: datetime | None = None,
commands: str | None = None,
) -> MagicMock:
sim = MagicMock()
sim.execution_result = execution_result
sim.executed_at = executed_at
sim.commands = commands
sim.updated_at = None
return sim
class TestExecutionResult:
def test_first_task_produces_command_block(self):
task = _make_task(command="whoami", output="root")
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ whoami\nroot\n"
def test_second_task_appended_with_block_separator(self):
"""Two tasks → two '$ command\noutput\n' blocks separated by a single newline."""
sim = _make_sim()
t1 = _make_task(command="whoami", output="root")
t2 = _make_task(command="hostname", output="lab-1")
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.execution_result == "$ whoami\nroot\n$ hostname\nlab-1\n"
def test_no_double_blank_line_when_existing_ends_with_newline(self):
"""If existing result already ends with \n, no extra blank line is inserted."""
sim = _make_sim(execution_result="$ id\nuid=0\n")
task = _make_task(command="hostname", output="lab-1")
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ id\nuid=0\n$ hostname\nlab-1\n"
def test_empty_output_skips_block_but_marks_applied(self):
task = _make_task(output="")
sim = _make_sim(execution_result="$ id\nuid=0\n")
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ id\nuid=0\n"
assert task.mapping_applied is True
def test_none_output_skips_block_but_marks_applied(self):
task = _make_task(output=None)
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert sim.execution_result is None
assert task.mapping_applied is True
def test_command_with_empty_string_produces_dollar_header(self):
"""Empty command → block header is '$ \n<output>\n' (consistent, not suppressed)."""
task = _make_task(command="", output="some output")
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert sim.execution_result == "$ \nsome output\n" or sim.execution_result == "$ \nsome output\n"
class TestExecutedAt:
def test_sets_executed_at_from_task_when_null(self):
ts = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC)
task = _make_task(completed_at=ts)
sim = _make_sim(executed_at=None)
apply_task_to_simulation(task, sim)
assert sim.executed_at == ts
def test_does_not_overwrite_existing_executed_at(self):
original_ts = datetime(2026, 6, 1, 0, 0, 0, tzinfo=UTC)
later_ts = datetime(2026, 6, 10, 12, 0, 0, tzinfo=UTC)
task = _make_task(completed_at=later_ts)
sim = _make_sim(executed_at=original_ts)
apply_task_to_simulation(task, sim)
assert sim.executed_at == original_ts
def test_executed_at_stays_null_when_task_completed_at_is_none(self):
task = _make_task(completed_at=None)
sim = _make_sim(executed_at=None)
apply_task_to_simulation(task, sim)
assert sim.executed_at is None
def test_first_task_sets_executed_at_second_does_not_overwrite(self):
ts1 = datetime(2026, 6, 10, 10, 0, 0, tzinfo=UTC)
ts2 = datetime(2026, 6, 10, 11, 0, 0, tzinfo=UTC)
t1 = _make_task(command="whoami", output="root", completed_at=ts1)
t2 = _make_task(command="hostname", output="lab-1", completed_at=ts2)
sim = _make_sim(executed_at=None)
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.executed_at == ts1
class TestCommandsDedup:
def test_appends_command_to_empty_commands(self):
task = _make_task(command="whoami", output="root")
sim = _make_sim(commands=None)
apply_task_to_simulation(task, sim)
assert sim.commands == "whoami"
def test_appends_second_distinct_command(self):
sim = _make_sim(commands=None)
t1 = _make_task(command="whoami", output="root")
t2 = _make_task(command="hostname", output="lab-1")
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.commands == "whoami\nhostname"
def test_deduplicates_repeated_command(self):
sim = _make_sim(commands=None)
t1 = _make_task(command="whoami", output="root")
t2 = _make_task(command="whoami", output="root2")
apply_task_to_simulation(t1, sim)
apply_task_to_simulation(t2, sim)
assert sim.commands == "whoami"
def test_dedup_is_case_and_whitespace_stripped(self):
sim = _make_sim(commands="whoami")
task = _make_task(command=" whoami ", output="root")
apply_task_to_simulation(task, sim)
# " whoami ".strip() == "whoami" which is already present → no append.
assert sim.commands == "whoami"
def test_empty_command_not_appended(self):
task = _make_task(command="", output="output")
sim = _make_sim(commands=None)
apply_task_to_simulation(task, sim)
# task.command is falsy → commands block skipped.
assert sim.commands is None
class TestIdempotency:
def test_no_op_when_mapping_already_applied(self):
task = _make_task(output="root", mapping_applied=True)
sim = _make_sim(execution_result="existing")
apply_task_to_simulation(task, sim)
assert sim.execution_result == "existing"
def test_always_marks_mapping_applied(self):
task = _make_task(output="root")
sim = _make_sim()
apply_task_to_simulation(task, sim)
assert task.mapping_applied is True
def test_updated_at_is_set(self):
task = _make_task(output="root")
sim = _make_sim()
before = datetime.now(UTC)
apply_task_to_simulation(task, sim)
assert sim.updated_at is not None
assert sim.updated_at >= before

View File

@@ -0,0 +1,375 @@
"""Tests for GET /api/simulations/<id>/c2/tasks — poll-on-read endpoint."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task
from backend.app.models.simulation import Simulation
from backend.app.services.c2.adapter import C2Error, C2TaskStatus
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _execute(client: FlaskClient, token: str, sid: int, commands: list, callback_display_id: int = 1):
return client.post(
f"/api/simulations/{sid}/c2/execute",
headers=_h(token),
json={"callback_display_id": callback_display_id, "commands": commands},
)
def _list_tasks(client: FlaskClient, token: str, sid: int):
return client.get(
f"/api/simulations/{sid}/c2/tasks",
headers=_h(token),
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestListTasksHappyPath:
def test_returns_empty_list_when_no_tasks(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
assert resp.get_json()["tasks"] == []
def test_returns_task_after_execute(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
tasks = resp.get_json()["tasks"]
assert len(tasks) == 1
assert tasks[0]["command"] == "whoami"
def test_task_shape(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["hostname"])
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
for field in ("id", "mythic_task_display_id", "callback_display_id",
"command", "params", "status", "completed", "output",
"source", "mapping_applied", "created_at", "completed_at"):
assert field in task, f"missing field: {field}"
assert task["source"] == "mimic"
def test_first_poll_returns_submitted(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
# First GET — FakeAdapter.get_task() first call → submitted.
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
assert task["status"] == "submitted"
assert task["completed"] is False
def test_poll_marks_completed_when_adapter_returns_completed(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""When adapter.get_task returns completed=True the task is updated in DB."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
assert task["completed"] is True
assert task["status"] == "completed"
def test_output_populated_after_completion(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Output is fetched and stored when task transitions to completed."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
def _output(self, task_display_id: int) -> str:
return f"whoami result for task {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, admin_token, sim["id"])
task = resp.get_json()["tasks"][0]
assert task["output"] is not None
assert "whoami" in task["output"]
def test_mapping_applied_set_after_completion(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
_list_tasks(client, admin_token, sim["id"])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mapping_applied is True
def test_execution_result_updated_on_simulation(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
def _output(self, task_display_id: int) -> str:
return f"WORKSTATION-01\\whoami output {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
_list_tasks(client, admin_token, sim["id"])
with app.app_context():
updated_sim = db.session.get(Simulation, sim["id"])
assert updated_sim is not None
assert updated_sim.execution_result is not None
assert "whoami" in updated_sim.execution_result
def test_completed_task_not_re_polled(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Once task.completed=True in DB, subsequent GETs skip polling (no re-poll)."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
call_count = {"n": 0}
def _completed(self, task_display_id: int) -> C2TaskStatus:
call_count["n"] += 1
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
_list_tasks(client, admin_token, sim["id"]) # 1st GET — marks task completed (1 call)
first_count = call_count["n"]
_list_tasks(client, admin_token, sim["id"]) # 2nd GET — task already completed, skip poll
# get_task should NOT have been called again on the 2nd GET.
assert call_count["n"] == first_count, "completed task should not be re-polled"
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
task = resp.get_json()["tasks"][0]
assert task["completed"] is True
def test_redteam_can_list_tasks(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
resp = _list_tasks(client, redteam_token, sim["id"])
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Error cases
# ---------------------------------------------------------------------------
class TestListTasksErrors:
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _list_tasks(client, admin_token, 9999)
assert resp.status_code == 404
def test_403_soc_forbidden(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, soc_token, sim["id"])
assert resp.status_code == 403
def test_503_no_encryption_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 503
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 404
def test_adapter_error_during_poll_is_tolerated(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""If get_task raises C2Error during poll, the task is skipped (best-effort)."""
from backend.app.services.c2 import fake as fake_mod
def _boom(self, task_display_id: int):
raise C2Error("upstream unavailable")
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_execute(client, admin_token, sim["id"], ["whoami"])
# Should still return 200 with the task (un-refreshed status).
resp = _list_tasks(client, admin_token, sim["id"])
assert resp.status_code == 200
tasks = resp.get_json()["tasks"]
assert len(tasks) == 1
# Status is stale (not updated due to error) — still "submitted".
assert tasks[0]["status"] == "submitted"

View File

@@ -0,0 +1,52 @@
"""Tests for the Fernet crypto service."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from backend.app.services.crypto import C2Disabled, decrypt, encrypt
@pytest.fixture()
def fernet_key(monkeypatch) -> str:
key = Fernet.generate_key().decode()
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", key)
return key
@pytest.fixture()
def no_key(monkeypatch):
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
class TestEncryptDecrypt:
def test_round_trip(self, fernet_key):
plaintext = "s3cr3t-api-token"
ciphertext = encrypt(plaintext)
assert ciphertext != plaintext
assert decrypt(ciphertext) == plaintext
def test_different_tokens_for_same_input(self, fernet_key):
# Fernet tokens are non-deterministic (random IV).
t1 = encrypt("same")
t2 = encrypt("same")
assert t1 != t2
assert decrypt(t1) == decrypt(t2) == "same"
def test_decrypt_invalid_ciphertext(self, fernet_key):
with pytest.raises(ValueError):
decrypt("not-valid-fernet-token")
class TestKeyAbsent:
def test_encrypt_raises_c2disabled(self, no_key):
with pytest.raises(C2Disabled):
encrypt("anything")
def test_decrypt_raises_c2disabled(self, no_key):
with pytest.raises(C2Disabled):
decrypt("anything")
def test_c2disabled_message(self, no_key):
with pytest.raises(C2Disabled, match="MIMIC_ENCRYPTION_KEY"):
encrypt("x")

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

@@ -0,0 +1,270 @@
"""Endpoint tests for GET /api/engagements/<eid>/export."""
from __future__ import annotations
from datetime import date
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models import Engagement, EngagementStatus, User
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.export import _export_filename
from backend.tests.conftest import auth_headers as _h
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str, name: str = "Op Alpha") -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": name, "start_date": "2026-06-01"},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _make_sim(client: FlaskClient, token: str, eid: int, name: str = "Sim One") -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": name},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _export(client: FlaskClient, token: str, eid: int, fmt: str):
return client.get(
f"/api/engagements/{eid}/export?format={fmt}",
headers=_h(token),
)
# ---------------------------------------------------------------------------
# RBAC
# ---------------------------------------------------------------------------
def test_export_markdown_admin_returns_md_with_engagement_header_and_all_simulations(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_make_sim(client, admin_token, eng["id"], "Lateral Movement")
_make_sim(client, admin_token, eng["id"], "Persistence Check")
resp = _export(client, admin_token, eng["id"], "md")
assert resp.status_code == 200
assert "text/markdown" in resp.content_type
body = resp.data.decode()
assert "Op Alpha" in body
# Both simulation names appear as cells in the 7-column table
assert "Lateral Movement" in body
assert "Persistence Check" in body
# Table uses French column headers
assert "Scénario" in body
def test_export_markdown_redteam_ok(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
resp = _export(client, redteam_token, eng["id"], "md")
assert resp.status_code == 200
def test_export_markdown_soc_403(
client: FlaskClient, soc_token: str, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _export(client, soc_token, eng["id"], "md")
assert resp.status_code == 403
def test_export_unauthenticated_401(client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(f"/api/engagements/{eng['id']}/export?format=md")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# CSV
# ---------------------------------------------------------------------------
def test_export_csv_returns_csv_with_one_row_per_simulation(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_make_sim(client, admin_token, eng["id"], "S1")
_make_sim(client, admin_token, eng["id"], "S2")
resp = _export(client, admin_token, eng["id"], "csv")
assert resp.status_code == 200
assert "text/csv" in resp.content_type
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
# 1 header + 2 simulations
assert len(rows) == 3
def test_export_csv_columns_match_contract(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _export(client, admin_token, eng["id"], "csv")
assert resp.status_code == 200
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(resp.data.decode())))
expected_headers = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
assert rows[0] == expected_headers
def test_export_csv_escapes_special_characters(
client: FlaskClient, admin_token: str, app
) -> None:
eng = _make_engagement(client, admin_token)
with app.app_context():
admin = User.query.filter_by(username="admin1").first()
sim = Simulation(
engagement_id=eng["id"],
name='Sim "quoted"',
commands='cmd1, cmd2\nnewline "here"',
status=SimulationStatus.PENDING,
created_by_id=admin.id,
)
db.session.add(sim)
db.session.commit()
resp = _export(client, admin_token, eng["id"], "csv")
assert resp.status_code == 200
body = resp.data.decode()
# csv.writer must have quoted the fields — no raw unquoted double-quotes breaking rows
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(body)))
assert len(rows) == 2 # header + 1 sim
name_col = rows[1][0] # col 0 = Scénario
assert "quoted" in name_col
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
def test_export_pdf_returns_pdf_magic_bytes_and_non_empty(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_make_sim(client, admin_token, eng["id"], "S1")
resp = _export(client, admin_token, eng["id"], "pdf")
assert resp.status_code == 200
assert resp.content_type == "application/pdf"
assert resp.data[:4] == b"%PDF"
assert len(resp.data) > 1024
# ---------------------------------------------------------------------------
# 400 / 404
# ---------------------------------------------------------------------------
def test_export_unknown_format_400(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/export?format=xml",
headers=_h(admin_token),
)
assert resp.status_code == 400
assert "format must be one of" in resp.get_json()["error"]
def test_export_missing_format_400(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.get(
f"/api/engagements/{eng['id']}/export",
headers=_h(admin_token),
)
assert resp.status_code == 400
assert "format must be one of" in resp.get_json()["error"]
def test_export_unknown_engagement_404(
client: FlaskClient, admin_token: str
) -> None:
resp = client.get(
"/api/engagements/99999/export?format=md",
headers=_h(admin_token),
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
def test_export_engagement_with_zero_simulations_renders_header_only(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token, "Empty Engagement")
resp_md = _export(client, admin_token, eng["id"], "md")
assert resp_md.status_code == 200
assert "Empty Engagement" in resp_md.data.decode()
resp_csv = _export(client, admin_token, eng["id"], "csv")
assert resp_csv.status_code == 200
import csv as csv_mod
import io
rows = list(csv_mod.reader(io.StringIO(resp_csv.data.decode())))
assert len(rows) == 1 # header only
resp_pdf = _export(client, admin_token, eng["id"], "pdf")
assert resp_pdf.status_code == 200
assert resp_pdf.data[:4] == b"%PDF"
def test_export_filename_slugifies_name_and_carries_date(app, admin_user: User) -> None:
with app.app_context():
eng = Engagement(
name="Opération Spéciale!",
start_date=date(2026, 6, 1),
status=EngagementStatus.PLANNED,
created_by_id=admin_user.id,
)
db.session.add(eng)
db.session.commit()
fname = _export_filename(eng, "md")
from datetime import date as _date
today = _date.today().strftime("%Y%m%d")
assert fname.startswith(f"engagement-{eng.id}-")
assert "operation-speciale" in fname
assert fname.endswith(f"-{today}.md")

View File

@@ -0,0 +1,317 @@
"""Unit tests for render functions in backend.app.services.export."""
from __future__ import annotations
import csv as _csv
import io as _io
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from backend.app.services.export import (
render_engagement_csv,
render_engagement_markdown,
render_engagement_pdf,
)
# ---------------------------------------------------------------------------
# Fixtures / factories
# ---------------------------------------------------------------------------
def _make_engagement(**kw) -> Any:
from datetime import date
defaults: dict[str, Any] = {
"id": 1,
"name": "Test Engagement",
"description": "A purple team exercise",
"start_date": date(2026, 6, 1),
"end_date": date(2026, 6, 30),
"status": SimpleNamespace(value="active"),
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
"created_by": SimpleNamespace(username="alice"),
}
defaults.update(kw)
return SimpleNamespace(**defaults)
def _make_sim(sid: int = 1, name: str = "Sim Alpha", **kw) -> Any:
defaults: dict[str, Any] = {
"id": sid,
"name": name,
"status": SimpleNamespace(value="pending"),
"description": "Execute a script",
"commands": "whoami",
"executed_at": None,
"execution_result": None,
"log_source": None,
"logs": None,
"soc_comment": None,
"incident_number": None,
"created_at": datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
"updated_at": None,
"created_by": SimpleNamespace(username="bob"),
}
defaults.update(kw)
return SimpleNamespace(**defaults)
# ---------------------------------------------------------------------------
# Shared constants
# ---------------------------------------------------------------------------
_FR_HEADERS = [
"Scénario",
"Test",
"Source de log",
"Commentaires SOC",
"Exécution",
"Logs remontés au SIEM",
"Cyber incident",
]
# ---------------------------------------------------------------------------
# Markdown tests
# ---------------------------------------------------------------------------
def test_render_engagement_markdown_includes_header_fields(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_markdown(eng, [])
assert "Test Engagement" in result
assert "2026-06-01" in result
assert "2026-06-30" in result
assert "active" in result
assert "alice" in result
def test_render_engagement_markdown_has_seven_column_table_headers(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
result = render_engagement_markdown(eng, [sim])
for header in _FR_HEADERS:
assert header in result, f"Expected French header '{header}' in markdown table"
def test_render_engagement_markdown_lists_all_simulations_in_order(app) -> None:
with app.app_context():
eng = _make_engagement()
sims = [_make_sim(1, "First Sim"), _make_sim(2, "Second Sim")]
result = render_engagement_markdown(eng, sims)
first_pos = result.index("First Sim")
second_pos = result.index("Second Sim")
assert first_pos < second_pos
def test_render_engagement_markdown_no_simulations_has_no_table(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_markdown(eng, [])
assert "Scénario" not in result
assert "## Simulations" not in result
def test_render_engagement_markdown_execution_cell_uses_br_separator(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
commands="whoami",
execution_result="admin@host",
)
result = render_engagement_markdown(eng, [sim])
assert "<br/>" in result
assert "whoami" in result
assert "admin@host" in result
def test_render_engagement_markdown_pipe_in_cell_is_escaped(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="Name | with pipe")
result = render_engagement_markdown(eng, [sim])
assert "Name \\| with pipe" in result
# ---------------------------------------------------------------------------
# CSV tests
# ---------------------------------------------------------------------------
def _parse_csv(csv_text: str) -> list[list[str]]:
return list(_csv.reader(_io.StringIO(csv_text)))
def test_render_engagement_csv_has_header_row(app) -> None:
with app.app_context():
eng = _make_engagement()
result = render_engagement_csv(eng, [])
rows = _parse_csv(result)
assert rows[0] == _FR_HEADERS
def test_render_engagement_csv_has_one_row_per_simulation(app) -> None:
with app.app_context():
eng = _make_engagement()
sims = [_make_sim(1, "S1"), _make_sim(2, "S2")]
result = render_engagement_csv(eng, sims)
rows = _parse_csv(result)
assert len(rows) == 3 # header + 2 sims
def test_render_engagement_csv_columns_are_seven(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
result = render_engagement_csv(eng, [sim])
rows = _parse_csv(result)
assert len(rows[0]) == 7
assert len(rows[1]) == 7
def test_render_engagement_csv_execution_column_contains_commands(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
commands="net user /domain",
execution_result="success",
)
result = render_engagement_csv(eng, [sim])
rows = _parse_csv(result)
exec_cell = rows[1][4] # col index 4 = Exécution
assert "2026-06-01" in exec_cell
assert "net user /domain" in exec_cell
assert "success" in exec_cell
# ---------------------------------------------------------------------------
# CSV formula injection defense
# ---------------------------------------------------------------------------
def _parse_csv_data_row(csv_text: str, row_index: int = 1) -> list[str]:
return _parse_csv(csv_text)[row_index]
def test_render_engagement_csv_escapes_formula_injection_in_scenario(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="=cmd|'/c calc'!A1")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# col 0 = Scénario
assert cells[0] == "'=cmd|'/c calc'!A1"
def test_render_engagement_csv_escapes_formula_injection_in_execution(app) -> None:
with app.app_context():
eng = _make_engagement()
# executed_at=None so concat is "\ncommand\n" — leading \n is not a trigger.
# Use a formula-triggering execution_result to test the final concat.
sim = _make_sim(execution_result="=HYPERLINK(\"http://evil\")")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
# col 4 = Exécution; concat starts with "\n" (empty executed_at) so not triggered
# but the execution_result value is embedded — verify it's present
assert "HYPERLINK" in cells[4]
def test_render_engagement_csv_defuses_formula_in_inner_execution_lines(app) -> None:
"""When executed_at is set, the cell starts with a safe date, but commands
line may inject formulas. Each user-controlled component must be defused."""
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
executed_at=datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
commands="=cmd|'/c calc'!A1",
execution_result="@SUM(1)",
)
result = render_engagement_csv(eng, [sim])
cells = list(_csv.reader(_io.StringIO(result)))[1]
execution_cell = cells[4] # Exécution column
assert "'=cmd|'/c calc'!A1" in execution_cell
assert "'@SUM(1)" in execution_cell
def test_render_engagement_csv_does_not_alter_safe_strings(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim(name="Mimikatz LSASS Dump", commands="whoami /all")
result = render_engagement_csv(eng, [sim])
cells = _parse_csv_data_row(result)
assert cells[0] == "Mimikatz LSASS Dump"
assert "whoami /all" in cells[4]
def test_render_engagement_markdown_escapes_html_in_table_cells(app) -> None:
"""User content in table cells must be HTML-escaped to prevent stored XSS
when the .md is opened in a renderer that interprets inline HTML."""
with app.app_context():
eng = _make_engagement()
sim = _make_sim(
name="<script>alert(1)</script>",
commands='<img src=x onerror="alert(1)">',
)
result = render_engagement_markdown(eng, [sim])
assert "<script>" not in result
assert 'onerror="alert' not in result
assert "&lt;script&gt;" in result
assert "&lt;img" in result
# double-quotes in attribute values are also escaped
assert "&quot;" in result
# ---------------------------------------------------------------------------
# PDF tests
# ---------------------------------------------------------------------------
def test_render_engagement_pdf_starts_with_pdf_magic(app) -> None:
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
result = render_engagement_pdf(eng, [sim])
assert isinstance(result, bytes)
assert result[:4] == b"%PDF"
def test_render_engagement_pdf_contains_simulation_table(app) -> None:
from backend.app.services.export import _render_engagement_html
with app.app_context():
eng = _make_engagement()
sim = _make_sim()
html = _render_engagement_html(eng, [sim])
assert "<table>" in html
for header in _FR_HEADERS:
assert header in html, f"Expected French header '{header}' in HTML"
def test_render_engagement_html_has_landscape_page_rule(app) -> None:
from backend.app.services.export import _render_engagement_html
with app.app_context():
eng = _make_engagement()
html = _render_engagement_html(eng, [])
assert "landscape" in html, "HTML must include A4 landscape @page rule for PDF output"
# ---------------------------------------------------------------------------
# Defense-in-depth: filename header injection
# ---------------------------------------------------------------------------
def test_export_filename_never_contains_quote_or_crlf() -> None:
"""Defense-in-depth: even with malicious engagement names, the filename
used in Content-Disposition must never contain header-injection chars."""
from backend.app.services.export import _export_filename
evil = SimpleNamespace(id=42, name='evil"name\r\nX-Injected: yes')
fname = _export_filename(evil, "md")
assert '"' not in fname
assert '\r' not in fname
assert '\n' not in fname

View File

@@ -0,0 +1,199 @@
"""Migration round-trip test for 0006_c2_layer.
Verifies that upgrade() creates c2_config and c2_task with the expected schema,
and that downgrade() removes both tables cleanly.
Uses the resolved-path pattern (derives path from __file__) to avoid the
hardcoded-path regression documented in lessons.md Sprint 4.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect, text
def _load_migration():
versions_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
path = versions_dir / "0006_c2_layer.py"
spec = importlib.util.spec_from_file_location("migration_0006", path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod
def _fresh_engine():
"""In-memory SQLite with the tables that 0006 depends on already present."""
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
created_at DATETIME NOT NULL
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE engagements (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
start_date DATE NOT NULL,
end_date DATE,
status TEXT NOT NULL DEFAULT 'planned',
created_at DATETIME NOT NULL,
created_by_id INTEGER NOT NULL REFERENCES users(id)
)
"""
)
)
conn.execute(
text(
"""
CREATE TABLE simulations (
id INTEGER PRIMARY KEY,
engagement_id INTEGER NOT NULL REFERENCES engagements(id),
name TEXT NOT NULL,
techniques JSON NOT NULL DEFAULT '[]',
tactic_ids JSON NOT NULL DEFAULT '[]',
description TEXT,
commands TEXT,
prerequisites TEXT,
executed_at DATETIME,
execution_result TEXT,
log_source TEXT,
logs TEXT,
soc_comment TEXT,
incident_number TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
updated_at DATETIME,
created_by_id INTEGER NOT NULL REFERENCES users(id)
)
"""
)
)
return engine
def _run_upgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.upgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
def _run_downgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.downgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
class TestMigration0006Upgrade:
def test_c2_config_table_created(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
assert "c2_config" in insp.get_table_names()
def test_c2_task_table_created(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
assert "c2_task" in insp.get_table_names()
def test_c2_config_columns(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_config")}
assert {"id", "engagement_id", "url", "api_token_encrypted",
"verify_tls", "created_at", "updated_at"} <= cols
def test_c2_config_unique_constraint_on_engagement_id(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
# Insert a user and engagement first.
with engine.begin() as conn:
conn.execute(text(
"INSERT INTO users (id, username, password_hash, role, created_at) "
"VALUES (1, 'u', 'h', 'admin', '2026-01-01')"
))
conn.execute(text(
"INSERT INTO engagements (id, name, start_date, status, created_at, created_by_id) "
"VALUES (1, 'Op', '2026-01-01', 'planned', '2026-01-01', 1)"
))
conn.execute(text(
"INSERT INTO c2_config (engagement_id, url, api_token_encrypted, verify_tls, created_at) "
"VALUES (1, 'https://c2', 'tok', 1, '2026-01-01')"
))
# Second insert on same engagement_id must fail.
try:
conn.execute(text(
"INSERT INTO c2_config (engagement_id, url, api_token_encrypted, verify_tls, created_at) "
"VALUES (1, 'https://c2b', 'tok2', 1, '2026-01-01')"
))
raised = False
except Exception:
raised = True
assert raised, "UNIQUE constraint on c2_config.engagement_id must be enforced"
def test_c2_task_columns(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert {"id", "simulation_id", "mythic_task_display_id", "callback_display_id",
"command", "params", "status", "completed", "output", "source",
"created_at", "completed_at"} <= cols
class TestMigration0006Downgrade:
def test_downgrade_removes_c2_config(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
assert "c2_config" not in insp.get_table_names()
def test_downgrade_removes_c2_task(self):
engine = _fresh_engine()
mod = _load_migration()
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
assert "c2_task" not in insp.get_table_names()

View File

@@ -0,0 +1,124 @@
"""Migration round-trip test for 0007_c2_task_mapping_applied.
Verifies that upgrade() adds the mapping_applied column and downgrade() removes it.
Uses the resolved-path pattern per lessons.md Sprint 4.
"""
from __future__ import annotations
import importlib.util
from pathlib import Path
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect, text
def _load_migration(name: str):
versions_dir = Path(__file__).resolve().parent.parent / "migrations" / "versions"
path = versions_dir / name
spec = importlib.util.spec_from_file_location(name.removesuffix(".py"), path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod
def _fresh_engine_with_c2_task():
"""In-memory SQLite with c2_task already created (as left by 0006 upgrade)."""
engine = create_engine("sqlite:///:memory:")
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE c2_task (
id INTEGER PRIMARY KEY,
simulation_id INTEGER NOT NULL,
mythic_task_display_id INTEGER NOT NULL,
callback_display_id INTEGER NOT NULL,
command TEXT NOT NULL,
params TEXT,
status TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT 0,
output TEXT,
source TEXT NOT NULL,
created_at DATETIME NOT NULL,
completed_at DATETIME
)
"""))
return engine
def _run_upgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.upgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
def _run_downgrade(engine, migration_mod):
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
ops = Operations(ctx)
ops._install_proxy() # type: ignore[attr-defined]
try:
migration_mod.downgrade()
finally:
ops._remove_proxy() # type: ignore[attr-defined]
class TestMigration0007Upgrade:
def test_mapping_applied_column_added(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
_run_upgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert "mapping_applied" in cols
def test_mapping_applied_defaults_to_false(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
# Insert a row before upgrading (no mapping_applied column yet).
with engine.begin() as conn:
conn.execute(text(
"INSERT INTO c2_task "
"(simulation_id, mythic_task_display_id, callback_display_id, "
"command, status, completed, source, created_at) "
"VALUES (1, 1000, 1, 'whoami', 'submitted', 0, 'mimic', '2026-01-01')"
))
_run_upgrade(engine, mod)
with engine.begin() as conn:
row = conn.execute(
text("SELECT mapping_applied FROM c2_task WHERE id = 1")
).fetchone()
assert row is not None
# SQLite stores booleans as 0/1.
assert row[0] == 0 or row[0] is False
class TestMigration0007Downgrade:
def test_downgrade_removes_mapping_applied(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert "mapping_applied" not in cols
def test_downgrade_does_not_drop_other_columns(self):
engine = _fresh_engine_with_c2_task()
mod = _load_migration("0007_c2_task_mapping_applied.py")
_run_upgrade(engine, mod)
_run_downgrade(engine, mod)
insp = inspect(engine)
cols = {c["name"] for c in insp.get_columns("c2_task")}
assert {"id", "simulation_id", "command", "status", "completed"} <= cols

View File

@@ -33,6 +33,14 @@ _FIXTURE_BUNDLE = {
], ],
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}], "kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
}, },
{
"type": "attack-pattern",
"name": "Python",
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1059.006"}
],
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
},
{ {
"type": "attack-pattern", "type": "attack-pattern",
"name": "Phishing", "name": "Phishing",
@@ -62,6 +70,14 @@ _FIXTURE_BUNDLE = {
], ],
"kill_chain_phases": [], "kill_chain_phases": [],
}, },
{
"type": "attack-pattern",
"name": "Application Layer Protocol",
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1071"}
],
"kill_chain_phases": [{"phase_name": "command-and-control", "kill_chain_name": "mitre-attack"}],
},
{ {
# Not an attack-pattern — must be ignored. # Not an attack-pattern — must be ignored.
"type": "relationship", "type": "relationship",
@@ -76,9 +92,15 @@ def _reset_mitre():
"""Reset the MITRE service state between tests.""" """Reset the MITRE service state between tests."""
original_loaded = mitre_svc.mitre_loaded original_loaded = mitre_svc.mitre_loaded
original_index = list(mitre_svc._index) 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 yield
mitre_svc.mitre_loaded = original_loaded mitre_svc.mitre_loaded = original_loaded
mitre_svc._index = original_index 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() @pytest.fixture()
@@ -96,7 +118,7 @@ def bundle_file(tmp_path: pathlib.Path) -> pathlib.Path:
def test_load_bundle_success(bundle_file: pathlib.Path) -> None: def test_load_bundle_success(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file) mitre_svc.load_bundle(bundle_file)
assert mitre_svc.mitre_loaded is True assert mitre_svc.mitre_loaded is True
assert len(mitre_svc._index) == 4 # 5 objects minus 1 revoked = 4 assert len(mitre_svc._index) == 6 # 7 attack-patterns minus 1 revoked = 6
def test_load_bundle_missing_file() -> None: def test_load_bundle_missing_file() -> None:
@@ -245,3 +267,137 @@ def test_mitre_endpoint_includes_tactics(
phishing = next((r for r in data if r["id"] == "T1566"), None) phishing = next((r for r in data if r["id"] == "T1566"), None)
assert phishing is not None assert phishing is not None
assert "initial-access" in phishing["tactics"] assert "initial-access" in phishing["tactics"]
# ---------------------------------------------------------------------------
# Sprint 3: get_tactics, lookup_name, get_matrix
# ---------------------------------------------------------------------------
def test_get_tactics_known(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
tactics = mitre_svc.get_tactics("T1078")
assert "initial-access" in tactics
assert "persistence" in tactics
def test_get_tactics_unknown_returns_empty(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
assert mitre_svc.get_tactics("T0000") == []
def test_lookup_name_known(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
assert mitre_svc.lookup_name("T1059") == "Command and Scripting Interpreter"
def test_lookup_name_subtechnique(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
assert mitre_svc.lookup_name("T1059.001") == "PowerShell"
def test_lookup_name_unknown_returns_none(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
assert mitre_svc.lookup_name("T0000") is None
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]
# 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"] == "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"]]
assert "T1059.001" in sub_ids
assert "T1059.006" in sub_ids
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"] == "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)
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"] == "TA0001")
names = [t["name"] for t in ia_tactic["techniques"]]
assert names == sorted(names)
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"] == "TA0001")
phishing = next((t for t in ia_tactic["techniques"] if t["id"] == "T1566"), None)
assert phishing is not None
assert phishing["subtechniques"] == []
def test_matrix_endpoint_ok(
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
) -> None:
mitre_svc.load_bundle(bundle_file)
resp = client.get("/api/mitre/matrix", headers=_h(redteam_token))
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, list)
tactic_ids = [t["tactic_id"] for t in data]
assert "TA0001" in tactic_ids # initial-access
assert "TA0002" in tactic_ids # execution
def test_matrix_endpoint_503_when_not_loaded(
client: FlaskClient, redteam_token: str
) -> None:
mitre_svc.mitre_loaded = False
resp = client.get("/api/mitre/matrix", headers=_h(redteam_token))
assert resp.status_code == 503
def test_matrix_endpoint_requires_auth(client: FlaskClient) -> None:
resp = client.get("/api/mitre/matrix")
assert resp.status_code == 401
def test_matrix_endpoint_all_roles(
client: FlaskClient,
redteam_token: str,
soc_token: str,
admin_token: str,
bundle_file: pathlib.Path,
) -> None:
mitre_svc.load_bundle(bundle_file)
for token in (redteam_token, soc_token, admin_token):
resp = client.get("/api/mitre/matrix", headers=_h(token))
assert resp.status_code == 200
def test_get_matrix_command_and_control_display_name(bundle_file: pathlib.Path) -> None:
"""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"] == "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,289 @@
"""SimulationTemplate CRUD: list, create, get, patch, delete + RBAC + dedup."""
from __future__ import annotations
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models import User
from backend.app.models.simulation_template import SimulationTemplate
from backend.tests.conftest import auth_headers as _h # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_template(client: FlaskClient, token: str, **kw) -> dict:
payload = {"name": "Template Alpha", **kw}
resp = client.post("/api/templates", headers=_h(token), json=payload)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
def test_list_templates_empty(client: FlaskClient, admin_token: str) -> None:
resp = client.get("/api/templates", headers=_h(admin_token))
assert resp.status_code == 200
assert resp.get_json() == []
def test_list_templates_soc_forbidden(client: FlaskClient, soc_token: str) -> None:
resp = client.get("/api/templates", headers=_h(soc_token))
assert resp.status_code == 403
def test_list_templates_unauthenticated(client: FlaskClient) -> None:
resp = client.get("/api/templates")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
def test_create_template_as_admin(
client: FlaskClient, admin_user: User, admin_token: str
) -> None:
body = _make_template(
client,
admin_token,
description="desc",
commands="cmd",
prerequisites="prereq",
)
assert body["name"] == "Template Alpha"
assert body["description"] == "desc"
assert body["commands"] == "cmd"
assert body["prerequisites"] == "prereq"
assert body["techniques"] == []
assert body["tactics"] == []
assert body["created_by"] == {"id": admin_user.id, "username": "admin1"}
assert body["id"] is not None
def test_create_template_as_redteam(
client: FlaskClient, redteam_user: User, redteam_token: str
) -> None:
body = _make_template(client, redteam_token)
assert body["created_by"]["username"] == "redteam1"
def test_create_template_soc_forbidden(client: FlaskClient, soc_token: str) -> None:
resp = client.post(
"/api/templates", headers=_h(soc_token), json={"name": "T"}
)
assert resp.status_code == 403
def test_create_template_missing_name(client: FlaskClient, admin_token: str) -> None:
resp = client.post("/api/templates", headers=_h(admin_token), json={})
assert resp.status_code == 400
assert "name" in resp.get_json()["error"]
def test_create_template_duplicate_name_409(
client: FlaskClient, admin_token: str
) -> None:
_make_template(client, admin_token)
resp = client.post(
"/api/templates", headers=_h(admin_token), json={"name": "Template Alpha"}
)
assert resp.status_code == 409
assert "already exists" in resp.get_json()["error"]
def test_create_template_unknown_technique_id_400(
client: FlaskClient, admin_token: str
) -> None:
resp = client.post(
"/api/templates",
headers=_h(admin_token),
json={"name": "T", "technique_ids": ["T9999.999"]},
)
assert resp.status_code == 400
assert "unknown technique id" in resp.get_json()["error"]
def test_create_template_unknown_tactic_id_400(
client: FlaskClient, admin_token: str
) -> None:
resp = client.post(
"/api/templates",
headers=_h(admin_token),
json={"name": "T", "tactic_ids": ["TA9999"]},
)
assert resp.status_code == 400
assert "unknown tactic id" in resp.get_json()["error"]
# ---------------------------------------------------------------------------
# Get single
# ---------------------------------------------------------------------------
def test_get_template(client: FlaskClient, admin_token: str) -> None:
created = _make_template(client, admin_token)
resp = client.get(f"/api/templates/{created['id']}", headers=_h(admin_token))
assert resp.status_code == 200
assert resp.get_json()["id"] == created["id"]
def test_get_template_not_found(client: FlaskClient, admin_token: str) -> None:
resp = client.get("/api/templates/9999", headers=_h(admin_token))
assert resp.status_code == 404
def test_get_template_soc_forbidden(
client: FlaskClient, admin_token: str, soc_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.get(f"/api/templates/{created['id']}", headers=_h(soc_token))
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Patch
# ---------------------------------------------------------------------------
def test_patch_template_name(client: FlaskClient, admin_token: str) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"name": "Renamed"},
)
assert resp.status_code == 200
assert resp.get_json()["name"] == "Renamed"
assert resp.get_json()["updated_at"] is not None
def test_patch_template_empty_name_rejected(
client: FlaskClient, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"name": ""},
)
assert resp.status_code == 400
def test_patch_template_unknown_field_rejected(
client: FlaskClient, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"bogus_field": "x"},
)
assert resp.status_code == 400
assert "unknown fields" in resp.get_json()["error"]
def test_patch_template_duplicate_name_409(
client: FlaskClient, admin_token: str
) -> None:
_make_template(client, admin_token, name="T1")
t2 = _make_template(client, admin_token, name="T2")
resp = client.patch(
f"/api/templates/{t2['id']}",
headers=_h(admin_token),
json={"name": "T1"},
)
assert resp.status_code == 409
def test_patch_template_soc_forbidden(
client: FlaskClient, admin_token: str, soc_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(soc_token),
json={"name": "X"},
)
assert resp.status_code == 403
def test_patch_template_not_found(client: FlaskClient, admin_token: str) -> None:
resp = client.patch(
"/api/templates/9999", headers=_h(admin_token), json={"name": "X"}
)
assert resp.status_code == 404
def test_patch_template_unknown_technique_id_400(
client: FlaskClient, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"technique_ids": ["T9999.999"]},
)
assert resp.status_code == 400
assert "unknown technique id" in resp.get_json()["error"]
def test_patch_template_unknown_tactic_id_400(
client: FlaskClient, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"tactic_ids": ["TA9999"]},
)
assert resp.status_code == 400
assert "unknown tactic id" in resp.get_json()["error"]
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
def test_delete_template(
client: FlaskClient, app, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.delete(f"/api/templates/{created['id']}", headers=_h(admin_token))
assert resp.status_code == 204
with app.app_context():
assert db.session.get(SimulationTemplate, created["id"]) is None
def test_delete_template_not_found(client: FlaskClient, admin_token: str) -> None:
resp = client.delete("/api/templates/9999", headers=_h(admin_token))
assert resp.status_code == 404
def test_delete_template_soc_forbidden(
client: FlaskClient, admin_token: str, soc_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.delete(f"/api/templates/{created['id']}", headers=_h(soc_token))
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# List returns ordered by name
# ---------------------------------------------------------------------------
def test_list_templates_ordered_by_name(
client: FlaskClient, admin_token: str
) -> None:
for name in ("Zebra", "Alpha", "Midpoint"):
_make_template(client, admin_token, name=name)
body = client.get("/api/templates", headers=_h(admin_token)).get_json()
names = [t["name"] for t in body]
assert names == sorted(names)

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

@@ -0,0 +1,209 @@
"""Tests for creating simulations from a template (POST /api/engagements/<eid>/simulations)."""
from __future__ import annotations
from pathlib import Path
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
from flask.testing import FlaskClient
from sqlalchemy import create_engine, text
from backend.tests.conftest import auth_headers as _h
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Bravo", "start_date": "2026-06-01"},
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _make_template(client: FlaskClient, token: str, **kw) -> dict:
payload = {"name": "Base Template", **kw}
resp = client.post("/api/templates", headers=_h(token), json=payload)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
def _make_sim(client: FlaskClient, token: str, eid: int, **kw) -> dict:
payload = {"name": "Sim From Template", **kw}
resp = client.post(
f"/api/engagements/{eid}/simulations", headers=_h(token), json=payload
)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
# ---------------------------------------------------------------------------
# Instantiation
# ---------------------------------------------------------------------------
def test_create_simulation_from_template_copies_fields(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
tmpl = _make_template(
client,
admin_token,
description="template desc",
commands="template cmd",
prerequisites="template prereq",
)
sim = _make_sim(client, admin_token, eng["id"], template_id=tmpl["id"])
assert sim["description"] == "template desc"
assert sim["commands"] == "template cmd"
assert sim["prerequisites"] == "template prereq"
assert sim["techniques"] == []
assert sim["tactics"] == []
assert sim["status"] == "pending"
def test_create_simulation_name_overrides_template(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
tmpl = _make_template(client, admin_token)
sim = _make_sim(
client, admin_token, eng["id"], name="Custom Name", template_id=tmpl["id"]
)
assert sim["name"] == "Custom Name"
def test_create_simulation_name_falls_back_to_template_name(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
tmpl = _make_template(client, admin_token, name="Recon Template")
resp = client.post(
f"/api/engagements/{eng['id']}/simulations",
headers=_h(admin_token),
json={"template_id": tmpl["id"]},
)
assert resp.status_code == 201
assert resp.get_json()["name"] == "Recon Template"
def test_create_simulation_template_not_found(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = client.post(
f"/api/engagements/{eng['id']}/simulations",
headers=_h(admin_token),
json={"name": "S", "template_id": 9999},
)
assert resp.status_code == 404
assert "Template not found" in resp.get_json()["error"]
def test_create_simulation_without_template_unaffected(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
assert sim["description"] is None
assert sim["commands"] is None
assert sim["prerequisites"] is None
def test_create_simulation_from_template_status_is_pending(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
tmpl = _make_template(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"], template_id=tmpl["id"])
assert sim["status"] == "pending"
def test_delete_template_does_not_cascade_to_simulations(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
tmpl = _make_template(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"], template_id=tmpl["id"])
sid = sim["id"]
# Delete the template.
del_resp = client.delete(
f"/api/templates/{tmpl['id']}", headers=_h(admin_token)
)
assert del_resp.status_code == 204
# Simulation must still be retrievable.
get_resp = client.get(f"/api/simulations/{sid}", headers=_h(admin_token))
assert get_resp.status_code == 200
assert get_resp.get_json()["id"] == sid
# ---------------------------------------------------------------------------
# Migration round-trip
# ---------------------------------------------------------------------------
def test_migration_0005_round_trip() -> None:
engine = create_engine("sqlite:///:memory:")
migration_file = (
Path(__file__).parent.parent
/ "migrations"
/ "versions"
/ "0005_simulation_templates.py"
)
import importlib.util
spec = importlib.util.spec_from_file_location("m0005", migration_file)
assert spec is not None
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module) # type: ignore[union-attr]
with engine.begin() as conn:
ctx = MigrationContext.configure(conn)
import alembic.op as op_module
op_module._proxy = Operations(ctx) # type: ignore[attr-defined]
# Create users table (FK dependency).
conn.execute(
text(
"CREATE TABLE users ("
"id INTEGER PRIMARY KEY, "
"username TEXT NOT NULL, "
"password_hash TEXT NOT NULL, "
"role TEXT NOT NULL DEFAULT 'redteam', "
"created_at DATETIME"
")"
)
)
module.upgrade()
tables_after = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
table_names = {r[0] for r in tables_after}
assert "simulation_templates" in table_names
cols = conn.execute(
text("PRAGMA table_info(simulation_templates)")
).fetchall()
col_names = {c[1] for c in cols}
for expected in ("id", "name", "techniques", "tactic_ids", "created_by_id"):
assert expected in col_names, f"missing column: {expected}"
module.downgrade()
tables_after_down = conn.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
).fetchall()
table_names_down = {r[0] for r in tables_after_down}
assert "simulation_templates" not in table_names_down

View File

@@ -230,9 +230,10 @@ def test_soc_can_patch_when_review_required(
assert body["incident_number"] == "INC-001" 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 client: FlaskClient, redteam_token: str, soc_token: str
) -> None: ) -> None:
"""Done is terminal — PATCH is rejected for ALL roles (AC-18.1)."""
eng = _make_engagement(client, redteam_token) eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"]) sim = _make_sim(client, redteam_token, eng["id"])
client.post( client.post(
@@ -247,7 +248,8 @@ def test_soc_can_patch_when_done(
) )
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "Final note"}) 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( 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

@@ -0,0 +1,430 @@
"""Sprint 3 — multi-technique simulation tests (AC-13)."""
from __future__ import annotations
import json
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
# ---------------------------------------------------------------------------
# Minimal STIX fixture (reused from test_mitre.py pattern)
# ---------------------------------------------------------------------------
_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"}],
},
{
"type": "attack-pattern",
"name": "PowerShell",
"external_references": [{"source_name": "mitre-attack", "external_id": "T1059.001"}],
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
},
{
"type": "attack-pattern",
"name": "Valid Accounts",
"external_references": [{"source_name": "mitre-attack", "external_id": "T1078"}],
"kill_chain_phases": [
{"phase_name": "initial-access", "kill_chain_name": "mitre-attack"},
{"phase_name": "persistence", "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:
p = tmp_path / "enterprise-attack.json"
p.write_text(json.dumps(_FIXTURE_BUNDLE), encoding="utf-8")
return p
@pytest.fixture()
def loaded_bundle(bundle_file: pathlib.Path) -> pathlib.Path:
mitre_svc.load_bundle(bundle_file)
return bundle_file
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Sprint3", "start_date": "2026-06-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": "Technique Test"},
)
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)
# ---------------------------------------------------------------------------
# AC-13.1 — new simulation has techniques = []
# ---------------------------------------------------------------------------
def test_new_simulation_has_empty_techniques(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
assert sim["techniques"] == []
# ---------------------------------------------------------------------------
# AC-13.3 — serializer enriches techniques with tactics
# ---------------------------------------------------------------------------
def test_techniques_enriched_with_tactics(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_patch(client, redteam_token, sim["id"], {"technique_ids": ["T1078"]})
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(redteam_token))
assert resp.status_code == 200
techs = resp.get_json()["techniques"]
assert len(techs) == 1
assert techs[0]["id"] == "T1078"
assert "initial-access" in techs[0]["tactics"]
assert "persistence" in techs[0]["tactics"]
def test_techniques_with_unknown_id_returns_empty_tactics(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
"""If a technique was removed from the bundle after save, tactics gracefully = []."""
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
# Bypass service, write directly an id not in the bundle.
from backend.app.extensions import db
from backend.app.models.simulation import Simulation
with client.application.app_context():
s = db.session.get(Simulation, sim["id"])
s.techniques = [{"id": "T0000", "name": "Removed Technique"}]
db.session.commit()
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(redteam_token))
techs = resp.get_json()["techniques"]
assert techs[0]["tactics"] == []
# ---------------------------------------------------------------------------
# AC-13.4 — PATCH technique_ids
# ---------------------------------------------------------------------------
def test_patch_technique_ids_sets_techniques(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059", "T1078"]})
assert resp.status_code == 200
techs = resp.get_json()["techniques"]
assert len(techs) == 2
ids = [t["id"] for t in techs]
assert "T1059" in ids
assert "T1078" in ids
def test_patch_technique_ids_resolves_name(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
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 == 200
tech = resp.get_json()["techniques"][0]
assert tech["name"] == "Command and Scripting Interpreter"
def test_patch_technique_ids_unknown_returns_400(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T9999"]})
assert resp.status_code == 400
assert "unknown technique id: T9999" in resp.get_json()["error"]
def test_patch_technique_ids_partial_unknown_rejected(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
# One valid, one unknown — whole request rejected.
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059", "T9999"]})
assert resp.status_code == 400
def test_patch_technique_ids_includes_subtechnique(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059.001"]})
assert resp.status_code == 200
techs = resp.get_json()["techniques"]
assert techs[0]["id"] == "T1059.001"
assert techs[0]["name"] == "PowerShell"
def test_patch_technique_ids_replaces_list(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]})
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1078"]})
assert resp.status_code == 200
ids = [t["id"] for t in resp.get_json()["techniques"]]
assert ids == ["T1078"]
def test_patch_technique_ids_empty_clears_list(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]})
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": []})
assert resp.status_code == 200
assert resp.get_json()["techniques"] == []
def test_patch_technique_ids_not_list_returns_400(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
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 == 400
# ---------------------------------------------------------------------------
# Dedup (spec-reviewer note: AC-13.4)
# ---------------------------------------------------------------------------
def test_patch_technique_ids_deduplicates(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(
client, redteam_token, sim["id"], {"technique_ids": ["T1059", "T1078", "T1059"]}
)
assert resp.status_code == 200
techs = resp.get_json()["techniques"]
assert len(techs) == 2
# Order preserved: T1059 first.
assert techs[0]["id"] == "T1059"
assert techs[1]["id"] == "T1078"
# ---------------------------------------------------------------------------
# AC-13.5 — auto-transition on technique_ids
# ---------------------------------------------------------------------------
def test_technique_ids_non_empty_triggers_auto_transition(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> 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"], {"technique_ids": ["T1059"]})
assert resp.status_code == 200
assert resp.get_json()["status"] == "in_progress"
def test_technique_ids_empty_does_not_trigger_auto_transition(
client: FlaskClient, redteam_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": []})
assert resp.status_code == 200
assert resp.get_json()["status"] == "pending"
# ---------------------------------------------------------------------------
# Bundle not loaded — 503 on technique_ids PATCH
# ---------------------------------------------------------------------------
def test_patch_technique_ids_bundle_not_loaded_returns_503(
client: FlaskClient, redteam_token: str
) -> None:
"""When MITRE bundle is absent, PATCH with technique_ids must return 503."""
mitre_svc.mitre_loaded = False
mitre_svc._index = []
mitre_svc._name_by_id = {}
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
assert resp.get_json()["error"] == "mitre bundle not loaded"
# ---------------------------------------------------------------------------
# SOC cannot patch technique_ids (it's a redteam field)
# ---------------------------------------------------------------------------
def test_soc_cannot_patch_technique_ids(
client: FlaskClient, redteam_token: str, soc_token: str, loaded_bundle
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
# Advance to review_required so SOC can touch the simulation at all.
client.post(
f"/api/simulations/{sim['id']}/transition",
headers=_h(redteam_token),
json={"to": "review_required"},
)
resp = _patch(client, soc_token, sim["id"], {"technique_ids": ["T1059"]})
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Migration backfill test (inline, no Alembic runner needed)
# ---------------------------------------------------------------------------
def test_migration_backfill_logic() -> None:
"""Verify the backfill logic used in upgrade(): scalar → [{id, name}]."""
import json as _json
def _backfill(tech_id, tech_name):
if tech_id:
return _json.loads(_json.dumps([{"id": tech_id, "name": tech_name or ""}]))
return []
assert _backfill("T1059", "Command and Scripting Interpreter") == [
{"id": "T1059", "name": "Command and Scripting Interpreter"}
]
assert _backfill(None, None) == []
assert _backfill("T1059", None) == [{"id": "T1059", "name": ""}]
def test_migration_0003_techniques_not_null_after_upgrade() -> None:
"""Run migration 0003 upgrade() against a real SQLite DB and assert techniques is NOT NULL."""
import importlib
import json as _json
import sqlalchemy as _sa
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
engine = _sa.create_engine("sqlite:///:memory:")
with engine.begin() as conn:
# Create the pre-migration schema (0002 state).
conn.execute(_sa.text(
"CREATE TABLE simulations ("
" id INTEGER PRIMARY KEY,"
" mitre_technique_id VARCHAR(32),"
" mitre_technique_name VARCHAR(255)"
")"
))
conn.execute(_sa.text(
"INSERT INTO simulations (id, mitre_technique_id, mitre_technique_name)"
" VALUES (1, 'T1059', 'Command and Scripting Interpreter')"
))
conn.execute(_sa.text(
"INSERT INTO simulations (id, mitre_technique_id, mitre_technique_name)"
" VALUES (2, NULL, NULL)"
))
# Run upgrade() via Alembic Operations context.
with engine.begin() as conn:
ctx = MigrationContext.configure(conn, opts={"as_sql": False})
ops = Operations(ctx)
# Patch the module-level proxy so the migration's op.* calls work.
import alembic.op as _op_module
_op_module._proxy = ops # type: ignore[attr-defined]
_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]
mig.upgrade()
# Verify schema: techniques column exists and is NOT NULL.
insp = _sa.inspect(engine)
cols = {c["name"]: c for c in insp.get_columns("simulations")}
assert "techniques" in cols, "techniques column must exist after upgrade"
assert cols["techniques"]["nullable"] is False, "techniques must be NOT NULL after upgrade"
assert "mitre_technique_id" not in cols
assert "mitre_technique_name" not in cols
# Verify data was backfilled correctly.
with engine.connect() as conn:
rows = conn.execute(_sa.text("SELECT id, techniques FROM simulations ORDER BY id")).fetchall()
assert _json.loads(rows[0][1]) == [{"id": "T1059", "name": "Command and Scripting Interpreter"}]
assert _json.loads(rows[1][1]) == []

View File

@@ -150,16 +150,18 @@ def test_transition_unknown_status_rejected(
assert resp.status_code == 409 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 client: FlaskClient, redteam_token: str
) -> None: ) -> None:
"""done → review_required is the Reopen path, now allowed (AC-18.2)."""
eng = _make_engagement(client, redteam_token) eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"]) sim = _make_sim(client, redteam_token, eng["id"])
_transition(client, redteam_token, sim["id"], "review_required") _transition(client, redteam_token, sim["id"], "review_required")
_transition(client, redteam_token, sim["id"], "done") _transition(client, redteam_token, sim["id"], "done")
resp = _transition(client, redteam_token, sim["id"], "review_required") 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

@@ -9,6 +9,14 @@ RUN npm run build
# Stage 2: python runtime # Stage 2: python runtime
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libcairo2 \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libfontconfig1 \
shared-mime-info \
&& rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt ./backend/ COPY backend/requirements.txt ./backend/
RUN pip install --no-cache-dir -r backend/requirements.txt RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend/ ./backend/ COPY backend/ ./backend/

View File

@@ -19,7 +19,15 @@ import { adminToken, deleteUserByUsername, login, makeClient } from '../fixtures
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker'; function detectRuntime(): string {
try {
execSync('command -v docker', { stdio: 'ignore' });
return 'docker';
} catch {
return 'podman';
}
}
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? detectRuntime();
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic'; const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
function runCreateAdmin(user: string, pass: string): { function runCreateAdmin(user: string, pass: string): {
@@ -100,10 +108,11 @@ test.describe('US-1 — bootstrap first admin', () => {
const token = await adminToken(); const token = await adminToken();
expect(token).toBeTruthy(); expect(token).toBeTruthy();
// Defence-in-depth: assert the Makefile target literally invokes // Defence-in-depth: assert the Makefile target wraps an `exec … flask create-admin`
// `docker exec … flask create-admin`. This is a contract check. // through the container engine. Sprint 2 made the engine configurable via
// $(CONTAINER_CMD) (auto-detects docker or podman), so we assert the variable form.
const makefilePath = resolve(__dirname, '../..', 'Makefile'); const makefilePath = resolve(__dirname, '../..', 'Makefile');
const content = readFileSync(makefilePath, 'utf8'); const content = readFileSync(makefilePath, 'utf8');
expect(content).toMatch(/docker exec .+ flask create-admin/); expect(content).toMatch(/\$\(CONTAINER_CMD\) exec .+ flask create-admin/);
}); });
}); });

View File

@@ -128,7 +128,7 @@ test.describe('US-10 — MITRE autocomplete', () => {
expect(subtech.name).toBeTruthy(); expect(subtech.name).toBeTruthy();
}); });
test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection fills both fields', async ({ test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection appends tag', async ({
page, page,
context, context,
}) => { }) => {
@@ -137,12 +137,14 @@ test.describe('US-10 — MITRE autocomplete', () => {
await seedTokenInStorage(context, redteamToken); await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// 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 }); const picker = page.getByRole('combobox', { name: /mitre technique/i });
await expect(picker).toBeVisible(); await expect(picker).toBeVisible();
// Type a query — after debounce (200ms) the dropdown opens with results // Type a query — after debounce (200ms) the dropdown opens with results
await picker.fill('T1059'); await picker.fill('T1059');
// Wait for dropdown to appear (debounce + network)
const listbox = page.getByRole('listbox', { name: /mitre techniques/i }); const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
await expect(listbox).toBeVisible({ timeout: 5_000 }); await expect(listbox).toBeVisible({ timeout: 5_000 });
@@ -157,13 +159,14 @@ test.describe('US-10 — MITRE autocomplete', () => {
await picker.press('ArrowDown'); await picker.press('ArrowDown');
await picker.press('Enter'); await picker.press('Enter');
// After selection the dropdown closes and input shows the selected value // 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(listbox).not.toBeVisible();
const inputValue = await picker.inputValue(); await expect(page.getByTestId('techniques-tag-list')).toBeVisible({ timeout: 5_000 });
expect(inputValue).toMatch(/T1059/); await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059');
expect(inputValue).toMatch(/—/);
// Escape closes the dropdown // Escape closes the dropdown (re-open picker via inline placeholder)
await page.getByText(/search technique/i).click();
await picker.fill('T1'); await picker.fill('T1');
await expect(listbox).toBeVisible({ timeout: 5_000 }); await expect(listbox).toBeVisible({ timeout: 5_000 });
await picker.press('Escape'); await picker.press('Escape');

View File

@@ -127,11 +127,12 @@ test.describe('US-11 — workflow transitions', () => {
expect(rSOC.status).toBe(200); expect(rSOC.status).toBe(200);
expect(rSOC.data.status).toBe('done'); 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`, { const rBack = await rtClient.post(`/simulations/${simRT.id}/transition`, {
to: 'review_required', 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, simRT.id);
await deleteSimulation(redteamToken, simSOC.id); await deleteSimulation(redteamToken, simSOC.id);
@@ -167,7 +168,7 @@ test.describe('US-11 — workflow transitions', () => {
}) => { }) => {
const rtClient = makeClient(redteamToken); const rtClient = makeClient(redteamToken);
// pending → "Marquer en revue" visible for redteam; "Clôturer" hidden // pending → "Mark for review" visible for redteam; "Close" hidden
const simPending = await createSimulation( const simPending = await createSimulation(
redteamToken, redteamToken,
engagementId, engagementId,
@@ -175,34 +176,34 @@ test.describe('US-11 — workflow transitions', () => {
); );
await seedTokenInStorage(context, redteamToken); await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${simPending.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${simPending.id}/edit`);
await expect(page.getByRole('button', { name: /marquer en revue/i })).toBeVisible(); await expect(page.getByRole('button', { name: /mark for review/i })).toBeVisible();
await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /^close$/i })).toHaveCount(0);
// in_progress → "Marquer en revue" visible // in_progress → "Mark for review" visible
const simIP = await createSimulation(redteamToken, engagementId, 'AC-11.4 in_progress UI'); const simIP = await createSimulation(redteamToken, engagementId, 'AC-11.4 in_progress UI');
await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' }); await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' });
await page.goto(`/engagements/${engagementId}/simulations/${simIP.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${simIP.id}/edit`);
await expect(page.getByRole('button', { name: /marquer en revue/i })).toBeVisible(); await expect(page.getByRole('button', { name: /mark for review/i })).toBeVisible();
await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /^close$/i })).toHaveCount(0);
// review_required → "Clôturer" visible for redteam; "Marquer en revue" hidden // review_required → "Close" visible for redteam; "Mark for review" hidden
const simRR = await createSimulation(redteamToken, engagementId, 'AC-11.4 review UI'); const simRR = await createSimulation(redteamToken, engagementId, 'AC-11.4 review UI');
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'review_required' }); await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'review_required' });
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
await expect(page.getByRole('button', { name: /clôturer/i })).toBeVisible(); await expect(page.getByRole('button', { name: /^close$/i })).toBeVisible();
await expect(page.getByRole('button', { name: /marquer en revue/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /mark for review/i })).toHaveCount(0);
// review_required → "Clôturer" also visible for SOC // review_required → "Close" also visible for SOC
await seedTokenInStorage(context, socToken); await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
await expect(page.getByRole('button', { name: /clôturer/i })).toBeVisible(); await expect(page.getByRole('button', { name: /^close$/i })).toBeVisible();
// done → both buttons hidden // done → both buttons hidden
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'done' }); await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'done' });
await seedTokenInStorage(context, redteamToken); await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
await expect(page.getByRole('button', { name: /marquer en revue/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /mark for review/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /clôturer/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /^close$/i })).toHaveCount(0);
await deleteSimulation(redteamToken, simPending.id); await deleteSimulation(redteamToken, simPending.id);
await deleteSimulation(redteamToken, simIP.id); await deleteSimulation(redteamToken, simIP.id);
@@ -223,14 +224,14 @@ test.describe('US-11 — workflow transitions', () => {
const badge = page.getByTestId('simulation-status-badge'); const badge = page.getByTestId('simulation-status-badge');
await expect(badge).toHaveAttribute('data-status', 'pending'); await expect(badge).toHaveAttribute('data-status', 'pending');
// Click "Marquer en revue" // Click "Mark for review"
await page.getByRole('button', { name: /marquer en revue/i }).click(); await page.getByRole('button', { name: /mark for review/i }).click();
// Badge updates to review_required without page reload // Badge updates to review_required without page reload
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 }); await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
// "Clôturer" now visible; click it // "Close" now visible; click it
await page.getByRole('button', { name: /clôturer/i }).click(); await page.getByRole('button', { name: /^close$/i }).click();
await expect(badge).toHaveAttribute('data-status', 'done', { timeout: 5_000 }); await expect(badge).toHaveAttribute('data-status', 'done', { timeout: 5_000 });
// Verify list is also updated: navigate to engagement detail and check badge there // Verify list is also updated: navigate to engagement detail and check badge there

View File

@@ -123,26 +123,26 @@ test.describe('US-12 — simulation delete', () => {
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Delete button is visible for redteam // Delete button is visible for redteam
const deleteBtn = page.getByRole('button', { name: /supprimer/i }); const deleteBtn = page.getByRole('button', { name: /^delete$/i });
await expect(deleteBtn).toBeVisible(); await expect(deleteBtn).toBeVisible();
// SOC should NOT see delete button // SOC should NOT see delete button
await seedTokenInStorage(context, socToken); await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
await expect(page.getByRole('button', { name: /supprimer/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
// Back to redteam — click delete, confirm modal appears // Back to redteam — click delete, confirm modal appears
await seedTokenInStorage(context, redteamToken); await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
await page.getByRole('button', { name: /supprimer/i }).click(); await page.getByRole('button', { name: /^delete$/i }).click();
// Confirmation dialog must appear // Confirmation dialog must appear
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible(); await expect(dialog).toBeVisible();
await expect(dialog.getByText(/supprimer la simulation/i)).toBeVisible(); await expect(dialog.getByText(/delete simulation/i)).toBeVisible();
// Confirm deletion // Confirm deletion
await dialog.getByRole('button', { name: /supprimer/i }).click(); await dialog.getByRole('button', { name: /^delete$/i }).click();
// Should navigate back to engagement detail // Should navigate back to engagement detail
await page.waitForURL(new RegExp(`/engagements/${engagementId}$`)); await page.waitForURL(new RegExp(`/engagements/${engagementId}$`));

View File

@@ -0,0 +1,195 @@
/**
* US-13 — redteam selects multiple MITRE techniques per simulation.
* Covers AC-13.1 → AC-13.5 (API / data contract focus).
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
makeClient,
} from '../fixtures/api';
const REDTEAM_USER = 'us13-redteam';
const SOC_USER = 'us13-soc';
const PASS = 'us13-pass-strong';
interface Simulation {
id: number;
status: string;
techniques: { id: string; name: string; tactics: string[] }[];
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-13 sim',
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.post(`/engagements/${engagementId}/simulations`, { name });
if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`);
return r.data as Simulation;
}
async function deleteSimulation(token: string, simId: number): Promise<void> {
await makeClient(token).delete(`/simulations/${simId}`);
}
test.describe('US-13 — multi-technique simulations', () => {
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-13 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-13.1 — simulation serialisation has techniques array, not scalar MITRE fields', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.1 sim');
expect(Array.isArray(sim.techniques)).toBe(true);
expect(sim.techniques).toHaveLength(0);
expect(sim).not.toHaveProperty('mitre_technique_id');
expect(sim).not.toHaveProperty('mitre_technique_name');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-13.2 — migration: new simulations start with techniques = []', async () => {
// Migration is tested implicitly: every new simulation created via POST must
// return techniques: [] (no scalar columns present).
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.2 migration sim');
expect(sim.techniques).toEqual([]);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-13.3 — serialisation enriches each technique entry with tactics from bundle', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.3 sim');
const client = makeClient(redteamToken);
const r = await client.patch(`/simulations/${sim.id}`, {
technique_ids: ['T1059', 'T1078'],
});
expect(r.status).toBe(200);
const techniques: { id: string; name: string; tactics: string[] }[] = r.data.techniques;
expect(techniques).toHaveLength(2);
// Each entry has id, name, and tactics (derived from bundle at serialize time)
for (const t of techniques) {
expect(t).toHaveProperty('id');
expect(t).toHaveProperty('name');
expect(Array.isArray(t.tactics)).toBe(true);
expect(t.tactics.length).toBeGreaterThan(0);
}
const t1059 = techniques.find((t) => t.id === 'T1059');
expect(t1059).toBeTruthy();
expect(t1059!.name).toBe('Command and Scripting Interpreter');
expect(t1059!.tactics).toContain('execution');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-13.4 — PATCH technique_ids: valid IDs stored, unknown ID → 400', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.4 sim');
const client = makeClient(redteamToken);
// Valid IDs
const rOk = await client.patch(`/simulations/${sim.id}`, {
technique_ids: ['T1059', 'T1078', 'T1566'],
});
expect(rOk.status).toBe(200);
const ids = (rOk.data.techniques as { id: string }[]).map((t) => t.id);
expect(ids).toContain('T1059');
expect(ids).toContain('T1078');
expect(ids).toContain('T1566');
// Unknown ID → 400
const rBad = await client.patch(`/simulations/${sim.id}`, {
technique_ids: ['T9999'],
});
expect(rBad.status).toBe(400);
expect(rBad.data.error).toMatch(/unknown technique id.*T9999/i);
// Dedup: sending T1059 twice keeps only one entry in order
const rDedup = await client.patch(`/simulations/${sim.id}`, {
technique_ids: ['T1059', 'T1078', 'T1059'],
});
expect(rDedup.status).toBe(200);
const dedupIds = (rDedup.data.techniques as { id: string }[]).map((t) => t.id);
expect(dedupIds).toEqual(['T1059', 'T1078']);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-13.4 — SOC PATCH technique_ids → 403', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.4 soc block');
// Advance to review_required so SOC can attempt a patch
const rtClient = makeClient(redteamToken);
await rtClient.patch(`/simulations/${sim.id}`, { name: 'trigger' });
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
const socClient = makeClient(socToken);
const r = await socClient.patch(`/simulations/${sim.id}`, {
technique_ids: ['T1059'],
});
expect(r.status).toBe(403);
expect(r.data.error).toMatch(/soc cannot edit redteam fields/i);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-13.5 — auto-transition pending→in_progress triggered by non-empty technique_ids', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.5 auto-transition');
expect(sim.status).toBe('pending');
const client = makeClient(redteamToken);
// Non-empty technique_ids → triggers auto-transition
const r = await client.patch(`/simulations/${sim.id}`, {
technique_ids: ['T1059'],
});
expect(r.status).toBe(200);
expect(r.data.status).toBe('in_progress');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-13.5 — empty technique_ids does NOT trigger auto-transition', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-13.5 no-trigger');
expect(sim.status).toBe('pending');
const client = makeClient(redteamToken);
const r = await client.patch(`/simulations/${sim.id}`, {
technique_ids: [],
});
expect(r.status).toBe(200);
expect(r.data.status).toBe('pending');
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,261 @@
/**
* US-14 — redteam views and removes techniques as tags.
* Covers AC-14.1 → AC-14.5 (UI tags + auto-save).
*/
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 = 'us14-redteam';
const SOC_USER = 'us14-soc';
const PASS = 'us14-pass-strong';
interface Simulation {
id: number;
status: string;
techniques: { id: string; name: string; tactics: string[] }[];
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-14 sim',
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.post(`/engagements/${engagementId}/simulations`, { name });
if (r.status !== 201) throw new Error(`create sim: ${r.status}`);
return r.data as Simulation;
}
async function patchTechniques(
token: string,
simId: number,
techniqueIds: string[],
): Promise<void> {
const client = makeClient(token);
const r = await client.patch(`/simulations/${simId}`, { technique_ids: techniqueIds });
if (r.status !== 200) throw new Error(`patch techniques: ${r.status}`);
}
async function deleteSimulation(token: string, simId: number): Promise<void> {
await makeClient(token).delete(`/simulations/${simId}`);
}
test.describe('US-14 — technique tags UI', () => {
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-14 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-14.1 — MitreTechniquesField shows empty state, matrix icon + inline search input', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.1 empty');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Empty state message visible when no techniques
await expect(page.getByText(/no techniques selected/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);
});
test('AC-14.1 — tags show id + name + × button', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.1 tags');
await patchTechniques(redteamToken, sim.id, ['T1059', 'T1078']);
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
const tagList = page.getByTestId('techniques-tag-list');
await expect(tagList).toBeVisible();
// Both techniques appear as tags
const tags = tagList.getByTestId('mitre-technique-tag');
await expect(tags).toHaveCount(2);
// First tag contains T1059
await expect(tagList).toContainText('T1059');
await expect(tagList).toContainText('T1078');
// × buttons present for redteam
await expect(page.getByRole('button', { name: /remove T1059/i })).toBeVisible();
await expect(page.getByRole('button', { name: /remove T1078/i })).toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-14.2 — removing a tag triggers auto-save PATCH (toast + tag disappears)', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.2 remove tag');
await patchTechniques(redteamToken, sim.id, ['T1059', 'T1078']);
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Wait for tags to render
await expect(page.getByTestId('techniques-tag-list')).toBeVisible();
await expect(page.getByRole('button', { name: /remove T1059/i })).toBeVisible();
// Click × on T1059
await page.getByRole('button', { name: /remove T1059/i }).click();
// Toast "Techniques updated" should appear
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
// T1059 tag is gone, T1078 remains
await expect(page.getByTestId('techniques-tag-list')).not.toContainText('T1059');
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1078');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-14.2 — inline search: selecting technique appends as tag + auto-save', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.2 quick search');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// 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');
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
await expect(listbox).toBeVisible({ timeout: 5_000 });
await picker.press('ArrowDown');
await picker.press('Enter');
// Tag appears and auto-save toast
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1059');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-14.3 — SimulationList MITRE column shows first id + +N counter', async ({
page,
context,
}) => {
const simEmpty = await createSimulation(redteamToken, engagementId, 'AC-14.3 empty');
const simOne = await createSimulation(redteamToken, engagementId, 'AC-14.3 one');
await patchTechniques(redteamToken, simOne.id, ['T1059']);
const simThree = await createSimulation(redteamToken, engagementId, 'AC-14.3 three');
await patchTechniques(redteamToken, simThree.id, ['T1059', 'T1078', 'T1566']);
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
// Empty → shows "—"
const rowEmpty = page.getByRole('row', { name: /AC-14.3 empty/i });
await expect(rowEmpty).toContainText('—');
// One technique → shows id only
const rowOne = page.getByRole('row', { name: /AC-14.3 one/i });
await expect(rowOne).toContainText('T1059');
// Three techniques → shows "T1059 +2"
const rowThree = page.getByRole('row', { name: /AC-14.3 three/i });
await expect(rowThree).toContainText('T1059');
await expect(rowThree).toContainText('+2');
await deleteSimulation(redteamToken, simEmpty.id);
await deleteSimulation(redteamToken, simOne.id);
await deleteSimulation(redteamToken, simThree.id);
});
test('AC-14.4 — order of tags preserved between read and write', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.4 order');
// Patch with specific order T1566 → T1059 → T1078
await patchTechniques(redteamToken, sim.id, ['T1566', 'T1059', 'T1078']);
// Verify via API
const r = await makeClient(redteamToken).get(`/simulations/${sim.id}`);
const ids = (r.data.techniques as { id: string }[]).map((t) => t.id);
expect(ids).toEqual(['T1566', 'T1059', 'T1078']);
// Verify via UI — tags appear in insertion order
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
const tags = page.getByTestId('mitre-technique-tag');
await expect(tags).toHaveCount(3);
await expect(tags.nth(0)).toContainText('T1566');
await expect(tags.nth(1)).toContainText('T1059');
await expect(tags.nth(2)).toContainText('T1078');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-14.5 — tags styled with DESIGN.md tokens (bg-primary-soft, rounded-full)', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-14.5 style');
await patchTechniques(redteamToken, sim.id, ['T1059']);
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
const tag = page.getByTestId('mitre-technique-tag').first();
await expect(tag).toBeVisible();
// Verify styling classes are present on the tag element
const cls = await tag.getAttribute('class');
expect(cls).toMatch(/bg-primary-soft/);
expect(cls).toMatch(/rounded-full/);
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,507 @@
/**
* US-15 — redteam explores and selects techniques via the MITRE ATT&CK matrix modal.
* Covers AC-15.1 → AC-15.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 = 'us15-redteam';
const PASS = 'us15-pass-strong';
interface Simulation {
id: number;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-15 sim',
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.post(`/engagements/${engagementId}/simulations`, { name });
if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`);
return r.data as Simulation;
}
async function deleteSimulation(token: string, simId: number): Promise<void> {
await makeClient(token).delete(`/simulations/${simId}`);
}
test.describe('US-15 — MITRE matrix 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-15 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-15.1 — GET /api/mitre/matrix returns tactic tree with correct structure', async () => {
const client = makeClient(redteamToken);
const r = await client.get('/mitre/matrix');
expect(r.status).toBe(200);
expect(Array.isArray(r.data)).toBe(true);
// At least the 12 canonical MITRE Enterprise tactics
expect(r.data.length).toBeGreaterThanOrEqual(12);
const first = r.data[0];
expect(first).toHaveProperty('tactic_id');
expect(first).toHaveProperty('tactic_name');
expect(Array.isArray(first.techniques)).toBe(true);
// First tactic must be "Initial Access" (canonical order)
expect(first.tactic_name).toBe('Initial Access');
// Each technique has id, name, subtechniques array
const tech = first.techniques[0];
expect(tech).toHaveProperty('id');
expect(tech).toHaveProperty('name');
expect(Array.isArray(tech.subtechniques)).toBe(true);
// A technique with known sub-techniques: T1059 is in Execution
const execTactic = (r.data as { tactic_name: string; techniques: { id: string; subtechniques: { id: string; name: string }[] }[] }[]).find(
(t) => t.tactic_name === 'Execution',
);
expect(execTactic).toBeTruthy();
const t1059 = execTactic!.techniques.find((t) => t.id === 'T1059');
expect(t1059).toBeTruthy();
expect(t1059!.subtechniques.length).toBeGreaterThan(0);
// T1059.001 should be a known sub-technique
const sub = t1059!.subtechniques.find((s) => s.id === 'T1059.001');
expect(sub).toBeTruthy();
expect(sub!.name).toBeTruthy();
});
test('AC-15.1 — tactic canonical order is correct (Initial Access first, Impact last)', async () => {
const client = makeClient(redteamToken);
const r = await client.get('/mitre/matrix');
expect(r.status).toBe(200);
const tacticNames = (r.data as { tactic_name: string }[]).map((t) => t.tactic_name);
expect(tacticNames[0]).toBe('Initial Access');
expect(tacticNames[tacticNames.length - 1]).toBe('Impact');
// Verify Exfiltration appears before Impact
const exfilIdx = tacticNames.indexOf('Exfiltration');
const impactIdx = tacticNames.indexOf('Impact');
expect(exfilIdx).toBeGreaterThan(-1);
expect(exfilIdx).toBeLessThan(impactIdx);
});
test('AC-15.2 — modal layout: columns per tactic, tactic header, technique cells', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 layout');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Open the matrix modal via "Add technique"
await page.getByLabel(/open mitre matrix/i).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10_000 });
// Modal title
await expect(dialog.getByRole('heading', { name: /mitre att&?ck matrix/i })).toBeVisible();
// Search / filter input present and focused
const searchInput = dialog.getByLabel(/filter techniques/i);
await expect(searchInput).toBeVisible();
// At least one tactic column visible — check for "Initial Access" and "Execution"
await expect(dialog).toContainText('Initial Access');
await expect(dialog).toContainText('Execution');
// T1059 technique cell visible in Execution column
await expect(dialog).toContainText('T1059');
// Cancel button present
await expect(dialog.getByRole('button', { name: /^cancel$/i })).toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.2 — selecting technique updates Apply button counter', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 select');
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 });
// Use search to isolate T1059 so there's only the label button visible
// The chevron has aria-label "Expand T1059"; we use filter to get the label button
const searchInput = dialog.getByLabel(/filter techniques/i);
await searchInput.fill('T1059');
// Wait for filter to apply — only T1059 and its subtechniques should be visible
await expect(dialog).toContainText('Command and Scripting Interpreter');
// The label button (selection) is the one containing the technique name text
// Filter explicitly excludes the chevron (aria-label="Expand T1059")
const techLabelBtn = dialog
.getByRole('button', { name: /command and scripting interpreter/i })
.first();
await techLabelBtn.click();
// Apply button should now show count = 1
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+ item/i })).not.toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.2 — subtechnique expand/collapse via chevron', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 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 button (▸ Expand T1059)
const expandBtn = dialog.getByRole('button', { name: /expand T1059/i });
await expect(expandBtn).toBeVisible();
await expandBtn.click();
// Sub-technique T1059.001 should now be visible
await expect(dialog).toContainText('T1059.001');
// Collapse it
const collapseBtn = dialog.getByRole('button', { name: /collapse T1059/i });
await collapseBtn.click();
await expect(dialog).not.toContainText('T1059.001');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.2 — search filters techniques, auto-expands parent when sub matches', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 search');
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 });
const searchInput = dialog.getByLabel(/filter techniques/i);
// Search by sub-technique ID — parent should auto-expand
await searchInput.fill('T1059.001');
await expect(dialog).toContainText('T1059.001');
// Search by name (case-insensitive)
await searchInput.fill('powershell');
await expect(dialog).toContainText('PowerShell');
// Clear search — techniques come back
await searchInput.fill('');
await expect(dialog).toContainText('T1059');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.2 — tactic header shows selected count when techniques selected', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.2 counter');
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 });
// Initially no "selected" counter visible
// 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);
await searchInput.fill('T1059');
await expect(dialog).toContainText('Command and Scripting Interpreter');
// The label button contains the technique name; the chevron has aria-label="Expand T1059"
await dialog
.getByRole('button', { name: /command and scripting interpreter/i })
.first()
.click();
// Sprint 4: tactic header shows "1 sel." (truncated) due to tight column width
await expect(dialog).toContainText('1 sel');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.3 — Apply auto-saves techniques and closes modal', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 apply');
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 });
const searchInput = dialog.getByLabel(/filter techniques/i);
// Select T1059 via label button (not chevron) — filter to isolate
await searchInput.fill('T1059');
await expect(dialog).toContainText('Command and Scripting Interpreter');
await dialog
.getByRole('button', { name: /command and scripting interpreter/i })
.first()
.click();
// Select T1566 (Phishing) — no subtechniques, so only one button
await searchInput.fill('T1566');
await expect(dialog).toContainText('T1566');
await dialog.getByRole('button', { name: /phishing/i }).first().click();
// Apply (2 techniques selected)
const applyBtn = dialog.getByRole('button', { name: /apply \d+ item/i });
await expect(applyBtn).toBeVisible();
await applyBtn.click();
// Modal closes
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
// Auto-save toast appears
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
// Tags appear in the tag list
const tagList = page.getByTestId('techniques-tag-list');
await expect(tagList).toContainText('T1059');
await expect(tagList).toContainText('T1566');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.3 — modal receives current selection as initial state', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 initial');
// Seed T1059 via API before opening the UI
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
technique_ids: ['T1059'],
});
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 });
// Apply button should already show 1 technique (from initial selection)
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
// Cancel to discard
await dialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(dialog).not.toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.3 — Cancel discards local changes (no PATCH fired)', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.3 cancel');
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 });
// Select a technique via label button (filter to avoid hitting chevron)
const searchInput = dialog.getByLabel(/filter techniques/i);
await searchInput.fill('T1059');
await expect(dialog).toContainText('Command and Scripting Interpreter');
await dialog
.getByRole('button', { name: /command and scripting interpreter/i })
.first()
.click();
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
// Cancel instead of Apply
await dialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(dialog).not.toBeVisible();
// No toast, no PATCH fired — empty state message still visible (0 techniques)
await expect(page.getByText(/techniques updated/i)).not.toBeVisible();
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.4 — Escape key closes modal (Cancel behaviour)', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 escape');
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 });
// Select something to confirm Cancel semantics on Escape
const searchInput = dialog.getByLabel(/filter techniques/i);
await searchInput.fill('T1059');
await expect(dialog).toContainText('Command and Scripting Interpreter');
await dialog
.getByRole('button', { name: /command and scripting interpreter/i })
.first()
.click();
await expect(dialog.getByRole('button', { name: /apply 1 item/i })).toBeVisible();
await page.keyboard.press('Escape');
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
// No PATCH fired — empty state message still visible (no techniques added)
await expect(page.getByText(/no techniques selected/i)).toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.4 — backdrop click closes modal (Cancel behaviour)', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.4 backdrop');
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 });
// Click outside the modal container (top-left corner of viewport, which is the backdrop)
await page.mouse.click(5, 5);
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.5 — a11y: role=dialog + aria-labelledby, search input focused on open', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-15.5 a11y');
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 });
// role="dialog" is set (getByRole('dialog') already asserts this)
// aria-modal attribute
const ariaModal = await dialog.getAttribute('aria-modal');
expect(ariaModal).toBe('true');
// aria-labelledby points to the modal title
const labelledBy = await dialog.getAttribute('aria-labelledby');
expect(labelledBy).toBeTruthy();
const titleEl = page.locator(`#${labelledBy}`);
await expect(titleEl).toContainText(/mitre att&?ck matrix/i);
// Search input is focused immediately after open
const searchInput = dialog.getByLabel(/filter techniques/i);
await expect(searchInput).toBeFocused({ timeout: 2_000 });
await deleteSimulation(redteamToken, sim.id);
});
test('AC-15.5 — a11y: Tab wraps within modal (focus trap)', 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 });
// Tab through enough elements to hit the wrap point
// (we don't know exact count, but Shift+Tab from the first focused element
// should stay inside the modal — not land outside)
const searchInput = dialog.getByLabel(/filter techniques/i);
await expect(searchInput).toBeFocused({ timeout: 2_000 });
// Shift+Tab from the first element (search) should wrap to Cancel or Apply
await page.keyboard.press('Shift+Tab');
// The focused element must still be inside the dialog
const focusedOutsideDialog = await page.evaluate(() => {
const dialog = document.querySelector('[role="dialog"]');
return dialog ? !dialog.contains(document.activeElement) : true;
});
expect(focusedOutsideDialog).toBe(false);
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,282 @@
/**
* US-16 — regression: sprint 2 features still work under the sprint 3 model.
* Covers AC-16.1 → AC-16.3.
*
* This file re-exercises critical sprint 2 ACs that are most likely to break
* due to the scalar→array MITRE migration:
* - Auto-transition pending→in_progress (AC-8.2 / AC-13.5)
* - Manual workflow transitions + badge update (AC-11.x)
* - SOC field-level RBAC (AC-9.x)
* - MitreTechniquePicker still accessible via Quick Search (AC-16.2)
*/
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 = 'us16-redteam';
const SOC_USER = 'us16-soc';
const PASS = 'us16-pass-strong';
interface Simulation {
id: number;
status: string;
techniques: { id: string; name: string; tactics: string[] }[];
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-16 sim',
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.post(`/engagements/${engagementId}/simulations`, { name });
if (r.status !== 201) throw new Error(`create sim: ${r.status} ${JSON.stringify(r.data)}`);
return r.data as Simulation;
}
async function deleteSimulation(token: string, simId: number): Promise<void> {
await makeClient(token).delete(`/simulations/${simId}`);
}
test.describe('US-16 — sprint 2 regression', () => {
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-16 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-16.1 — workflow sprint 2: auto-transition, manual transitions, SOC RBAC
test('AC-16.1 — auto-transition pending→in_progress triggered by PATCH with non-empty technique_ids', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 auto-transition');
expect(sim.status).toBe('pending');
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
technique_ids: ['T1059'],
});
expect(r.status).toBe(200);
expect(r.data.status).toBe('in_progress');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-16.1 — auto-transition triggered by non-technique redteam PATCH (name)', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 auto-name');
expect(sim.status).toBe('pending');
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
name: 'trigger by name',
});
expect(r.status).toBe(200);
expect(r.data.status).toBe('in_progress');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-16.1 — manual transition in_progress→review_required→closed', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 workflow');
const rtClient = makeClient(redteamToken);
// Trigger in_progress
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 workflow' });
// in_progress → review_required
const r1 = await rtClient.post(`/simulations/${sim.id}/transition`, {
to: 'review_required',
});
expect(r1.status).toBe(200);
expect(r1.data.status).toBe('review_required');
// review_required → done
const r2 = await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'done' });
expect(r2.status).toBe(200);
expect(r2.data.status).toBe('done');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-16.1 — SOC cannot PATCH technique_ids (403)', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc block');
const rtClient = makeClient(redteamToken);
// Advance to review_required so SOC has access
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 soc block' });
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, {
technique_ids: ['T1059'],
});
expect(r.status).toBe(403);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-16.1 — SOC can PATCH soc_comment without affecting status', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc comment');
const rtClient = makeClient(redteamToken);
// Advance to review_required
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 soc comment' });
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
const r = await makeClient(socToken).patch(`/simulations/${sim.id}`, {
soc_comment: 'Looks good, close it.',
});
expect(r.status).toBe(200);
expect(r.data.status).toBe('review_required');
expect(r.data.soc_comment).toBe('Looks good, close it.');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-16.1 — SOC cannot transition pending simulation', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 soc transition');
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-16.1 — workflow badge updates in UI without page reload', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.1 badge');
const rtClient = makeClient(redteamToken);
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-16.1 badge' });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
const badge = page.getByTestId('simulation-status-badge');
await expect(badge).toBeVisible();
await expect(badge).toHaveAttribute('data-status', 'in_progress');
// Trigger transition via button
await page.getByRole('button', { name: /mark for review/i }).click();
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
await deleteSimulation(redteamToken, sim.id);
});
// AC-16.2 — MitreTechniquePicker still accessible via Quick Search (clean rewrite onSelect)
test('AC-16.2 — MitreTechniquePicker accessible via inline search, appends tag on selection', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.2 picker');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// 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();
// Type to search
await picker.fill('T1078');
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
await expect(listbox).toBeVisible({ timeout: 5_000 });
// Keyboard select
await picker.press('ArrowDown');
await picker.press('Enter');
// Tag appears (onSelect one-shot mode — appends to list)
await expect(page.getByTestId('techniques-tag-list')).toContainText('T1078', {
timeout: 5_000,
});
// Auto-save toast
await expect(page.getByText(/techniques updated/i)).toBeVisible({ timeout: 5_000 });
await deleteSimulation(redteamToken, sim.id);
});
// AC-16.3 — no sprint 1/2 e2e broken: spot-check key assertions with new model
test('AC-16.3 — simulation serialisation has techniques array (not scalar MITRE fields)', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.3 schema');
// New schema: techniques array
expect(Array.isArray(sim.techniques)).toBe(true);
expect(sim).not.toHaveProperty('mitre_technique_id');
expect(sim).not.toHaveProperty('mitre_technique_name');
// PATCH technique_ids → techniques array in response
const r = await makeClient(redteamToken).patch(`/simulations/${sim.id}`, {
technique_ids: ['T1059', 'T1078'],
});
expect(r.status).toBe(200);
expect(Array.isArray(r.data.techniques)).toBe(true);
expect(r.data.techniques).toHaveLength(2);
expect(r.data.techniques[0].id).toBe('T1059');
expect(r.data.techniques[0].name).toBeTruthy();
expect(Array.isArray(r.data.techniques[0].tactics)).toBe(true);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-16.3 — edit form Red Team section still has name, description, commands fields', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-16.3 form fields');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Red Team section
await expect(page.getByRole('heading', { name: /red team/i })).toBeVisible();
// Core fields still present
await expect(page.locator('#sim-name')).toBeVisible();
await expect(page.locator('#sim-description')).toBeVisible();
await expect(page.locator('#sim-commands')).toBeVisible();
// Sprint 4: "Save Red Team" renamed to "Save"
await expect(page.getByRole('button', { name: /^save$/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

@@ -0,0 +1,372 @@
/**
* US-26 — Admin/redteam creates and manages simulation templates.
* Covers AC-26.3 → AC-26.8 (API CRUD + UI).
* AC-26.1/2 (model + migration) tested implicitly via API assertions.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
deleteUserByUsername,
ensureUser,
login,
makeClient,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const REDTEAM_USER = 'us26-redteam';
const SOC_USER = 'us26-soc';
const PASS = 'us26-pass-strong';
interface Template {
id: number;
name: string;
description: string | null;
commands: string | null;
prerequisites: string | null;
techniques: { id: string; name: string }[];
tactics: { id: string; name: string }[];
created_at: string;
updated_at: string | null;
created_by: { id: number; username: string };
}
async function createTemplate(
token: string,
payload: { name: string; description?: string; commands?: string; technique_ids?: string[]; tactic_ids?: string[] },
): Promise<Template> {
// Delete first if already exists (idempotent for retry safety)
const list = await makeClient(token).get('/templates');
if (list.status === 200) {
const existing = (list.data as Template[]).find((t) => t.name === payload.name);
if (existing) await makeClient(token).delete(`/templates/${existing.id}`);
}
const r = await makeClient(token).post('/templates', payload);
if (r.status !== 201) throw new Error(`create template: ${r.status} ${JSON.stringify(r.data)}`);
return r.data as Template;
}
async function deleteTemplate(token: string, id: number): Promise<void> {
await makeClient(token).delete(`/templates/${id}`);
}
test.describe('US-26 — templates CRUD', () => {
let redteamToken: string;
let socToken: string;
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;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
for (const u of [REDTEAM_USER, SOC_USER]) await deleteUserByUsername(tok, u);
} catch { /* noop */ }
});
// AC-26.3 — GET /api/templates
test('AC-26.3 — GET /api/templates returns list sorted by name ASC', async () => {
const t1 = await createTemplate(redteamToken, { name: 'US26 Zebra template' });
const t2 = await createTemplate(redteamToken, { name: 'US26 Alpha template' });
const r = await makeClient(redteamToken).get('/templates');
expect(r.status).toBe(200);
expect(Array.isArray(r.data)).toBe(true);
const names = (r.data as Template[])
.filter((t) => ['US26 Zebra template', 'US26 Alpha template'].includes(t.name))
.map((t) => t.name);
expect(names).toEqual(['US26 Alpha template', 'US26 Zebra template']);
await deleteTemplate(redteamToken, t1.id);
await deleteTemplate(redteamToken, t2.id);
});
test('AC-26.3 — GET /api/templates serializes techniques + tactics', async () => {
const t = await createTemplate(redteamToken, {
name: 'US26 mitre template',
tactic_ids: ['TA0007'],
});
const r = await makeClient(redteamToken).get('/templates');
expect(r.status).toBe(200);
const found = (r.data as Template[]).find((x) => x.id === t.id);
expect(found).toBeTruthy();
expect(Array.isArray(found!.techniques)).toBe(true);
expect(Array.isArray(found!.tactics)).toBe(true);
expect(found!.tactics[0].id).toBe('TA0007');
expect(found!.tactics[0].name).toBe('Discovery');
expect(found!.created_by.username).toBe(REDTEAM_USER);
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.3 — SOC GET /api/templates → 403', async () => {
const r = await makeClient(socToken).get('/templates');
expect(r.status).toBe(403);
});
// AC-26.4 — POST /api/templates
test('AC-26.4 — POST creates template with all fields', async () => {
const r = await makeClient(redteamToken).post('/templates', {
name: 'US26 full template',
description: 'test desc',
commands: 'cmd1\ncmd2',
prerequisites: 'prereq1',
tactic_ids: ['TA0001'],
});
expect(r.status).toBe(201);
expect(r.data.name).toBe('US26 full template');
expect(r.data.description).toBe('test desc');
expect(r.data.commands).toBe('cmd1\ncmd2');
expect(r.data.prerequisites).toBe('prereq1');
expect(r.data.tactics).toHaveLength(1);
expect(r.data.tactics[0].id).toBe('TA0001');
expect(r.data.updated_at).toBeNull();
await deleteTemplate(redteamToken, r.data.id as number);
});
test('AC-26.4 — POST name empty → 400', async () => {
const r = await makeClient(redteamToken).post('/templates', { name: '' });
expect(r.status).toBe(400);
expect(r.data.error).toMatch(/name/i);
});
test('AC-26.4 — POST duplicate name → 409', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 dup name' });
const r = await makeClient(redteamToken).post('/templates', { name: 'US26 dup name' });
expect(r.status).toBe(409);
expect(r.data.error).toMatch(/template name already exists/i);
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.4 — POST unknown tactic_id → 400', async () => {
const r = await makeClient(redteamToken).post('/templates', {
name: 'US26 bad tactic',
tactic_ids: ['TA9999'],
});
expect(r.status).toBe(400);
expect(r.data.error).toMatch(/unknown tactic id.*TA9999/i);
});
test('AC-26.4 — POST technique_ids as string (not list) → 400 (isinstance guard)', async () => {
const r = await makeClient(redteamToken).post('/templates', {
name: 'US26 bad technique_ids type',
technique_ids: 'T1059',
});
expect(r.status).toBe(400);
expect(r.data.error).toMatch(/technique_ids must be a list/i);
});
test('AC-26.4 — SOC POST → 403', async () => {
const r = await makeClient(socToken).post('/templates', { name: 'soc template attempt' });
expect(r.status).toBe(403);
});
// AC-26.5 — GET /api/templates/<tid>
test('AC-26.5 — GET /api/templates/:id returns 200 with full data', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 get single' });
const r = await makeClient(redteamToken).get(`/templates/${t.id}`);
expect(r.status).toBe(200);
expect(r.data.id).toBe(t.id);
expect(r.data.name).toBe('US26 get single');
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.5 — GET /api/templates/:id unknown → 404', async () => {
const r = await makeClient(redteamToken).get('/templates/999999');
expect(r.status).toBe(404);
});
test('AC-26.5 — SOC GET /api/templates/:id → 403', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 soc get single' });
const r = await makeClient(socToken).get(`/templates/${t.id}`);
expect(r.status).toBe(403);
await deleteTemplate(redteamToken, t.id);
});
// AC-26.6 — PATCH /api/templates/<tid>
test('AC-26.6 — PATCH updates fields and sets updated_at', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 patch me' });
expect(t.updated_at).toBeNull();
const r = await makeClient(redteamToken).patch(`/templates/${t.id}`, {
description: 'patched desc',
commands: 'new cmd',
tactic_ids: ['TA0007'],
});
expect(r.status).toBe(200);
expect(r.data.description).toBe('patched desc');
expect(r.data.commands).toBe('new cmd');
expect(r.data.tactics[0].id).toBe('TA0007');
expect(r.data.updated_at).toBeTruthy();
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.6 — PATCH name conflict → 409', async () => {
const t1 = await createTemplate(redteamToken, { name: 'US26 conflict A' });
const t2 = await createTemplate(redteamToken, { name: 'US26 conflict B' });
const r = await makeClient(redteamToken).patch(`/templates/${t2.id}`, { name: 'US26 conflict A' });
expect(r.status).toBe(409);
expect(r.data.error).toMatch(/template name already exists/i);
await deleteTemplate(redteamToken, t1.id);
await deleteTemplate(redteamToken, t2.id);
});
test('AC-26.6 — PATCH same name (no-op rename) → 200', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 same name' });
const r = await makeClient(redteamToken).patch(`/templates/${t.id}`, { name: 'US26 same name' });
expect(r.status).toBe(200);
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.6 — SOC PATCH → 403', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 soc patch target' });
const r = await makeClient(socToken).patch(`/templates/${t.id}`, { description: 'hacked' });
expect(r.status).toBe(403);
await deleteTemplate(redteamToken, t.id);
});
// AC-26.7 — DELETE /api/templates/<tid>
test('AC-26.7 — DELETE returns 204 and template no longer GETable', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 delete me' });
const del = await makeClient(redteamToken).delete(`/templates/${t.id}`);
expect(del.status).toBe(204);
const r = await makeClient(redteamToken).get(`/templates/${t.id}`);
expect(r.status).toBe(404);
});
test('AC-26.7 — SOC DELETE → 403', async () => {
const t = await createTemplate(redteamToken, { name: 'US26 soc delete target' });
const r = await makeClient(socToken).delete(`/templates/${t.id}`);
expect(r.status).toBe(403);
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.7 — DELETE template does NOT cascade to instantiated simulations', async () => {
const tok = await adminToken();
// Create engagement
const engR = await makeClient(tok).post('/engagements', {
name: 'US26 cascade eng',
start_date: '2026-01-01',
});
expect(engR.status).toBe(201);
const engId = engR.data.id as number;
// Create template with distinct RT fields
const tmpl = await createTemplate(redteamToken, {
name: 'US26 cascade template',
description: 'cascade test desc',
commands: 'cascade cmd',
tactic_ids: ['TA0007'],
});
// Instantiate simulation from template
const simR = await makeClient(redteamToken).post(`/engagements/${engId}/simulations`, {
template_id: tmpl.id,
});
expect(simR.status).toBe(201);
const simId = simR.data.id as number;
// Delete the template
const del = await makeClient(redteamToken).delete(`/templates/${tmpl.id}`);
expect(del.status).toBe(204);
// Simulation must still exist with RT fields copied at instantiation time
const simCheck = await makeClient(redteamToken).get(`/simulations/${simId}`);
expect(simCheck.status).toBe(200);
expect(simCheck.data.name).toBe('US26 cascade template');
expect(simCheck.data.description).toBe('cascade test desc');
expect(simCheck.data.commands).toBe('cascade cmd');
// Cleanup
await makeClient(tok).delete(`/simulations/${simId}`);
await makeClient(tok).delete(`/engagements/${engId}`);
});
// AC-26.8 — UI /admin/templates page
test('AC-26.8 — /admin/templates page is accessible to redteam, shows table + New button', async ({
page,
context,
}) => {
const t = await createTemplate(redteamToken, { name: 'US26 UI list template' });
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates');
// Page title or heading
await expect(page.getByRole('heading', { name: /templates/i })).toBeVisible({ timeout: 5_000 });
// Table with template row
await expect(page.getByRole('row', { name: /US26 UI list template/i })).toBeVisible();
// "New" link in header (list is non-empty — empty state link says "New template")
const newLink = page.getByRole('link', { name: /^new$/i }).first();
await expect(newLink).toBeVisible();
// Edit button on the row
await expect(page.getByRole('link', { name: /edit/i }).first()).toBeVisible();
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.8 — /admin/templates shows Delete button per row', async ({
page,
context,
}) => {
const t = await createTemplate(redteamToken, { name: 'US26 UI delete btn' });
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates');
await expect(page.getByRole('row', { name: /US26 UI delete btn/i })).toBeVisible();
await expect(page.getByRole('button', { name: /delete/i }).first()).toBeVisible();
await deleteTemplate(redteamToken, t.id);
});
test('AC-26.8 — SOC cannot access /admin/templates (redirected)', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socToken);
await page.goto('/admin/templates');
// ProtectedRoute redirects SOC to /engagements
await expect(page).toHaveURL(/\/engagements/, { timeout: 5_000 });
});
test('AC-26.8 — "New" link navigates to /admin/templates/new', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates');
// Header "New" link (when list is non-empty)
await page.getByRole('link', { name: /^new$/i }).first().click();
await expect(page).toHaveURL(/\/admin\/templates\/new/);
});
test('AC-26.8 — TemplateFormPage saves template and redirects to edit', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates/new');
await page.locator('#tpl-name').fill('US26 form create');
await page.getByRole('button', { name: /save/i }).click();
// Redirects to /admin/templates/:id/edit after creation
await expect(page).toHaveURL(/\/admin\/templates\/\d+\/edit/, { timeout: 5_000 });
// Clean up — get id from URL
const url = page.url();
const match = url.match(/\/admin\/templates\/(\d+)\/edit/);
if (match) await deleteTemplate(redteamToken, parseInt(match[1]));
});
});

View File

@@ -0,0 +1,396 @@
/**
* US-27 — Redteam instantiates a template into an engagement.
* Covers AC-27.1 → AC-27.7.
* AC-27.3 (decoupling) tested via API: modifying instance ≠ modifying template and vice-versa.
*/
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 = 'us27-redteam';
const SOC_USER = 'us27-soc';
const PASS = 'us27-pass-strong';
interface Template { id: number; name: string; techniques: unknown[]; tactics: unknown[]; [k: string]: unknown; }
interface Simulation { id: number; name: string; status: string; techniques: unknown[]; tactic_ids?: unknown[]; [k: string]: unknown; }
async function createTemplate(token: string, payload: Record<string, unknown>): Promise<Template> {
const r = await makeClient(token).post('/templates', payload);
if (r.status !== 201) throw new Error(`create template: ${r.status} ${JSON.stringify(r.data)}`);
return r.data as Template;
}
async function deleteTemplate(token: string, id: number): Promise<void> {
await makeClient(token).delete(`/templates/${id}`);
}
async function deleteSimulation(token: string, id: number): Promise<void> {
await makeClient(token).delete(`/simulations/${id}`);
}
test.describe('US-27 — instantiate from template', () => {
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-27 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-27.1 — POST with template_id copies all RT fields
test('AC-27.1 — POST with template_id copies name, description, commands, prerequisites, techniques, tactic_ids', async () => {
const tmpl = await createTemplate(redteamToken, {
name: 'US27 full template',
description: 'template desc',
commands: 'cmd1\ncmd2',
prerequisites: 'prereq',
tactic_ids: ['TA0007'],
});
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
});
expect(r.status).toBe(201);
const sim = r.data as Simulation;
expect(sim.name).toBe('US27 full template');
expect(sim.description).toBe('template desc');
expect(sim.commands).toBe('cmd1\ncmd2');
expect(sim.prerequisites).toBe('prereq');
expect(sim.status).toBe('pending');
expect(sim.executed_at).toBeNull();
expect(Array.isArray(sim.tactics)).toBe(true);
expect((sim.tactics as { id: string }[])[0]?.id).toBe('TA0007');
// SOC fields null
expect(sim.soc_comment).toBeNull();
await deleteSimulation(redteamToken, sim.id);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.2 — name override in body wins over template name
test('AC-27.2 — POST with template_id + name override: body name wins', async () => {
const tmpl = await createTemplate(redteamToken, { name: 'US27 override base' });
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
name: 'Custom override name',
});
expect(r.status).toBe(201);
expect(r.data.name).toBe('Custom override name');
await deleteSimulation(redteamToken, r.data.id as number);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.2 — POST without template_id keeps original blank-create behavior
test('AC-27.2 — POST without template_id creates blank simulation (name required)', async () => {
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
name: 'Blank sim no template',
});
expect(r.status).toBe(201);
expect(r.data.name).toBe('Blank sim no template');
expect(r.data.techniques).toHaveLength(0);
await deleteSimulation(redteamToken, r.data.id as number);
});
// AC-27.1 — template_id inexistant → 404
test('AC-27.1 — POST with unknown template_id → 404', async () => {
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
name: 'orphan',
template_id: 999999,
});
expect(r.status).toBe(404);
expect(r.data.error).toMatch(/template not found/i);
});
// AC-27.3 — decoupling: modifying instance does not touch template
test('AC-27.3 — modifying instance does NOT affect template', async () => {
const tmpl = await createTemplate(redteamToken, {
name: 'US27 decouple template',
description: 'original desc',
});
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
});
const sim = r.data as Simulation;
// Modify the instance
await makeClient(redteamToken).patch(`/simulations/${sim.id}`, { description: 'changed on instance' });
// Template unchanged
const tCheck = await makeClient(redteamToken).get(`/templates/${tmpl.id}`);
expect(tCheck.data.description).toBe('original desc');
await deleteSimulation(redteamToken, sim.id);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.3 — decoupling: modifying template does not touch existing instance
test('AC-27.3 — modifying template does NOT affect already-created instance', async () => {
const tmpl = await createTemplate(redteamToken, {
name: 'US27 decouple template 2',
commands: 'original cmd',
});
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
});
const sim = r.data as Simulation;
// Modify the template
await makeClient(redteamToken).patch(`/templates/${tmpl.id}`, { commands: 'modified cmd' });
// Instance unchanged
const sCheck = await makeClient(redteamToken).get(`/simulations/${sim.id}`);
expect(sCheck.data.commands).toBe('original cmd');
await deleteSimulation(redteamToken, sim.id);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.4 — auto-transition NOT triggered on creation from template
test('AC-27.4 — creation from template with techniques does NOT auto-transition (stays pending)', async () => {
const tmpl = await createTemplate(redteamToken, {
name: 'US27 no auto-transition',
tactic_ids: ['TA0007'],
});
const r = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
template_id: tmpl.id,
});
expect(r.status).toBe(201);
expect(r.data.status).toBe('pending');
await deleteSimulation(redteamToken, r.data.id as number);
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.5 — engagement auto-status NOT triggered by instantiation
test('AC-27.5 — engagement stays planned after instantiation from template', async () => {
const eng = await createEngagement(redteamToken, {
name: 'US27 auto-status check',
start_date: '2026-01-01',
});
const tmpl = await createTemplate(redteamToken, {
name: 'US27 auto-status template',
tactic_ids: ['TA0001'],
});
const r = await makeClient(redteamToken).post(`/engagements/${eng.id}/simulations`, {
template_id: tmpl.id,
});
expect(r.status).toBe(201);
// Engagement status must still be planned
const engCheck = await makeClient(redteamToken).get(`/engagements/${eng.id}`);
expect(engCheck.data.status).toBe('planned');
await deleteSimulation(redteamToken, r.data.id as number);
await deleteTemplate(redteamToken, tmpl.id);
const tok = await adminToken();
await deleteEngagement(tok, eng.id);
});
// AC-27.6 — UI: dropdown + TemplatePickerModal
test('AC-27.6 — EngagementDetailPage: dropdown toggle shows Blank + From template… options', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
// Main "New" button visible (direct Blank action)
const newBtn = page.getByTestId('new-simulation-btn');
await expect(newBtn).toBeVisible({ timeout: 5_000 });
// Chevron toggle opens dropdown
await page.getByTestId('new-simulation-dropdown-toggle').click();
await expect(page.getByRole('menuitem', { name: /blank/i })).toBeVisible();
await expect(page.getByRole('menuitem', { name: /from template/i })).toBeVisible();
});
test('AC-27.6 — clicking Blank navigates to /engagements/:eid/simulations/new', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-btn').click();
await expect(page).toHaveURL(/\/engagements\/\d+\/simulations\/new/);
});
test('AC-27.6 — "From template…" opens TemplatePickerModal', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
await page.getByTestId('from-template-btn').click();
// Modal appears
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog).toContainText(/from template/i);
});
test('AC-27.6 — TemplatePickerModal empty state when no templates exist', async ({
page,
context,
}) => {
// Delete ALL templates (cross-suite leftovers included) to get a clean empty state
const tok = await adminToken();
const allTmpl = await makeClient(tok).get('/templates');
for (const t of allTmpl.data as Template[]) {
await deleteTemplate(tok, t.id);
}
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
await page.getByTestId('from-template-btn').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
await expect(dialog).toContainText(/no templates available/i);
});
test('AC-27.6 — selecting template from picker creates simulation and navigates to edit page', async ({
page,
context,
}) => {
const tmpl = await createTemplate(redteamToken, { name: 'US27 picker select template' });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
await page.getByTestId('from-template-btn').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Click the template row
await dialog.getByTestId(`template-row-${tmpl.id}`).click();
// Modal closes, redirect to /engagements/:eid/simulations/:sid/edit
await expect(dialog).not.toBeVisible({ timeout: 5_000 });
await expect(page).toHaveURL(
new RegExp(`/engagements/${engagementId}/simulations/\\d+/edit`),
{ timeout: 8_000 },
);
// Simulation name matches template name (use heading to avoid strict mode with toast)
await expect(page.getByRole('heading', { name: 'US27 picker select template' })).toBeVisible();
// Clean up: extract sim id from URL
const url = page.url();
const m = url.match(/\/simulations\/(\d+)\/edit/);
if (m) await deleteSimulation(redteamToken, parseInt(m[1]));
await deleteTemplate(redteamToken, tmpl.id);
});
// AC-27.7 — SOC has no access to the new simulation button
test('AC-27.7 — SOC does not see the New simulation dropdown on EngagementDetailPage', async ({
page,
context,
}) => {
// Advance a sim to review_required so SOC can access the engagement page
const sim = await makeClient(redteamToken).post(`/engagements/${engagementId}/simulations`, {
name: 'US27 soc visibility sim',
});
const simId = sim.data.id as number;
await makeClient(redteamToken).patch(`/simulations/${simId}`, { name: 'US27 soc vis' });
await makeClient(redteamToken).post(`/simulations/${simId}/transition`, { to: 'review_required' });
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}`);
// No new-simulation button for SOC
await expect(page.getByTestId('new-simulation-btn')).not.toBeVisible();
await expect(page.getByTestId('new-simulation-dropdown-toggle')).not.toBeVisible();
await deleteSimulation(redteamToken, simId);
});
// NIT 1 — Dropdown closes on Escape key and on outside click
test('NIT-1 — dropdown closes on Escape key press', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
// Menu is open
await expect(page.getByTestId('from-template-btn')).toBeVisible({ timeout: 3_000 });
// Press Escape
await page.keyboard.press('Escape');
await expect(page.getByTestId('from-template-btn')).not.toBeVisible({ timeout: 3_000 });
});
test('NIT-1 — dropdown closes when clicking outside', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
await page.getByTestId('new-simulation-dropdown-toggle').click();
await expect(page.getByTestId('from-template-btn')).toBeVisible({ timeout: 3_000 });
// Click somewhere outside the dropdown (page heading)
await page.getByRole('heading').first().click({ force: true });
await expect(page.getByTestId('from-template-btn')).not.toBeVisible({ timeout: 3_000 });
});
// NIT 2 — Empty-engagement SimulationList still shows dropdown
test('NIT-2 — engagement with 0 simulations still shows New simulation dropdown', async ({
page,
context,
}) => {
// Create a fresh engagement with no simulations
const eng = await createEngagement(redteamToken, {
name: 'US27 empty eng dropdown',
start_date: '2026-01-01',
});
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${eng.id}`);
// Primary button visible even in empty state
await expect(page.getByTestId('new-simulation-btn')).toBeVisible({ timeout: 5_000 });
// Chevron also visible and functional
await page.getByTestId('new-simulation-dropdown-toggle').click();
await expect(page.getByTestId('from-template-btn')).toBeVisible({ timeout: 3_000 });
const tok = await adminToken();
await deleteEngagement(tok, eng.id);
});
});

View File

@@ -0,0 +1,136 @@
/**
* US-28 — Admin/redteam access templates from the nav.
* Covers AC-28.1 (Templates link in topbar), AC-28.2 (ProtectedRoute SOC redirect),
* AC-28.3 (page is always edit-capable, no read-only mode).
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
deleteUserByUsername,
ensureUser,
login,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const REDTEAM_USER = 'us28-redteam';
const SOC_USER = 'us28-soc';
const PASS = 'us28-pass-strong';
test.describe('US-28 — templates nav', () => {
let redteamToken: string;
let socToken: string;
let adminTok: string;
test.beforeAll(async () => {
adminTok = await adminToken();
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;
});
test.afterAll(async () => {
try {
const tok = await adminToken();
for (const u of [REDTEAM_USER, SOC_USER]) await deleteUserByUsername(tok, u);
} catch { /* noop */ }
});
// AC-28.1 — Templates nav link visible to admin + redteam
test('AC-28.1 — redteam sees "Templates" link in topbar nav', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/engagements');
const link = page.getByRole('link', { name: /^templates$/i });
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('href', '/admin/templates');
});
test('AC-28.1 — admin sees "Templates" link in topbar nav', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto('/engagements');
await expect(page.getByRole('link', { name: /^templates$/i })).toBeVisible();
});
test('AC-28.1 — SOC does NOT see "Templates" link in topbar nav', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socToken);
await page.goto('/engagements');
// Wait for page to fully load before asserting absence
await page.waitForLoadState('networkidle');
await expect(page.getByRole('link', { name: /^templates$/i })).not.toBeVisible();
});
test('AC-28.1 — clicking Templates link navigates to /admin/templates', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/engagements');
await page.getByRole('link', { name: /^templates$/i }).click();
await expect(page).toHaveURL(/\/admin\/templates/);
});
// AC-28.2 — SOC typing /admin/templates URL directly → redirected
test('AC-28.2 — SOC direct URL /admin/templates → redirected to /engagements', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socToken);
await page.goto('/admin/templates');
await expect(page).toHaveURL(/\/engagements/, { timeout: 5_000 });
});
test('AC-28.2 — SOC direct URL /admin/templates/new → redirected to /engagements', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socToken);
await page.goto('/admin/templates/new');
await expect(page).toHaveURL(/\/engagements/, { timeout: 5_000 });
});
// AC-28.3 — Page assumes canEdit=true (form inputs are never disabled for admin/redteam)
test('AC-28.3 — TemplateFormPage for redteam: name input is editable (no read-only mode)', async ({
page,
context,
}) => {
await seedTokenInStorage(context, redteamToken);
await page.goto('/admin/templates/new');
const nameInput = page.getByLabel(/name/i).first();
await expect(nameInput).toBeVisible();
await expect(nameInput).toBeEnabled();
// Should be able to type
await nameInput.fill('AC-28.3 editability test');
await expect(nameInput).toHaveValue('AC-28.3 editability test');
});
test('AC-28.3 — admin has same edit access on /admin/templates', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto('/admin/templates');
// Page loads (not redirected)
await expect(page).toHaveURL(/\/admin\/templates/);
await expect(page.getByRole('heading', { name: 'Templates', exact: true })).toBeVisible({ timeout: 5_000 });
// "New" link in header when templates exist, "New template" in empty state — either is fine
await expect(page.getByRole('link', { name: /new( template)?/i }).first()).toBeVisible();
});
});

View File

@@ -0,0 +1,334 @@
/**
* US-29 — Admin/redteam exports an engagement in Markdown, CSV, PDF.
*
* Strategy: seed one engagement with 2 simulations via the API, then drive the
* ExportEngagementButton dropdown in Chromium. Downloads are captured via
* page.waitForEvent('download') and read back with fs.readFile.
*
* AC covered: 29.1 — 29.6
*/
import * as fs from 'fs/promises';
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
makeClient,
type Engagement,
} from '../fixtures/api';
const CSV_HEADER_COLS = [
'Scénario',
'Test',
'Source de log',
'Commentaires SOC',
'Exécution',
'Logs remontés au SIEM',
'Cyber incident',
];
import { seedTokenInStorage } from '../fixtures/auth';
const ADMIN_USER = 'us29-admin';
const REDTEAM_USER = 'us29-redteam';
const PASS = 'us29-pass-strong!';
interface Simulation {
id: number;
name: string;
}
/**
* RFC-4180 row counter.
* Walks char-by-char tracking quoting so that newlines inside quoted cells
* don't count as row breaks. Returns the total row count (including header)
* plus helper strings for assertions.
*/
function countCsvRows(csv: string): {
count: number;
headerLine: string;
dataText: string;
} {
let inQuote = false;
let rowCount = 0;
let lineStart = 0;
let headerLine = '';
for (let i = 0; i < csv.length; i++) {
const ch = csv[i];
if (ch === '"') {
if (inQuote && csv[i + 1] === '"') {
i++; // escaped double-quote inside quoted cell
} else {
inQuote = !inQuote;
}
} else if ((ch === '\n' || ch === '\r') && !inQuote) {
if (ch === '\r' && csv[i + 1] === '\n') i++;
const line = csv.slice(lineStart, i).trim();
if (line.length > 0) {
if (rowCount === 0) headerLine = line;
rowCount++;
}
lineStart = i + 1;
}
}
// trailing row without final newline
const tail = csv.slice(lineStart).trim();
if (tail.length > 0) {
if (rowCount === 0) headerLine = tail;
rowCount++;
}
const dataText = rowCount > 1 ? csv.slice(headerLine.length + 1) : '';
return { count: rowCount, headerLine, dataText };
}
async function createSimulation(
token: string,
engagementId: number,
name: string,
): Promise<Simulation> {
const r = await makeClient(token).post(`/engagements/${engagementId}/simulations`, { name });
if (r.status !== 201) {
throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`);
}
return r.data as Simulation;
}
test.describe('US-29 — Export formats (admin + redteam)', () => {
let adminTok: string;
let redteamTok: string;
let engagement: Engagement;
test.beforeAll(async () => {
await ensureUser(ADMIN_USER, PASS, 'admin');
await ensureUser(REDTEAM_USER, PASS, 'redteam');
adminTok = (await login(ADMIN_USER, PASS)).token;
redteamTok = (await login(REDTEAM_USER, PASS)).token;
engagement = await createEngagement(adminTok, {
name: 'US29 Export Engagement',
description: 'Export test engagement',
start_date: '2026-01-15',
status: 'active',
});
await createSimulation(adminTok, engagement.id, 'US29 Sim Alpha');
await createSimulation(adminTok, engagement.id, 'US29 Sim Beta');
});
test.afterAll(async () => {
try {
await deleteEngagement(adminTok, engagement.id);
const rootTok = await adminToken();
for (const u of [ADMIN_USER, REDTEAM_USER]) await deleteUserByUsername(rootTok, u);
} catch { /* noop */ }
});
// AC-29.1 — Export dropdown opens
test('AC-29.1 — admin: Export dropdown opens with 3 format items', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
// Click the Export button to open dropdown
await dropdownWrapper.locator('button').first().click();
await expect(page.getByRole('menuitem', { name: /markdown/i }).or(
page.locator('[role="menuitem"]').filter({ hasText: /markdown/i })
).first()).toBeVisible({ timeout: 5_000 });
await expect(page.getByText(/csv/i).first()).toBeVisible();
await expect(page.getByText(/pdf/i).first()).toBeVisible();
});
// AC-29.2 — Markdown download
test('AC-29.2 — admin: Markdown download contains engagement name and simulation names', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
]);
const filePath = await download.path();
expect(filePath).toBeTruthy();
const content = await fs.readFile(filePath!, 'utf-8');
// Must contain engagement name and start date in the header section
expect(content).toContain('US29 Export Engagement');
expect(content).toContain('2026-01-15');
// Must use the 7-column GFM table layout
expect(content).toContain('| Scénario |');
// Simulation names appear in the Scénario column
expect(content).toContain('US29 Sim Alpha');
expect(content).toContain('US29 Sim Beta');
// Suggested filename from Content-Disposition must end in .md
const suggestedName = download.suggestedFilename();
expect(suggestedName).toMatch(/\.md$/);
});
// AC-29.3 — CSV download: N+1 rows (1 header + N simulations)
test('AC-29.3 — admin: CSV download has N+1 rows (header + 2 sim rows)', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(),
]);
const filePath = await download.path();
expect(filePath).toBeTruthy();
const raw = await fs.readFile(filePath!, 'utf-8');
// Count RFC-4180 rows: walk char-by-char, track quoting state so that
// newlines embedded inside quoted cells don't count as row breaks.
const rows = countCsvRows(raw);
// 1 header + 2 simulation rows
expect(rows.count).toBe(3);
// Header must be exactly the 7 FR columns
const headerCells = rows.headerLine.split(',').map((c) => c.trim().replace(/^"|"$/g, ''));
expect(headerCells).toEqual(CSV_HEADER_COLS);
// Scénario column (index 0) contains simulation names
expect(rows.dataText).toContain('US29 Sim Alpha');
expect(rows.dataText).toContain('US29 Sim Beta');
const suggestedName = download.suggestedFilename();
expect(suggestedName).toMatch(/\.csv$/);
});
// AC-29.4 — PDF download
test('AC-29.4 — admin: PDF download has %PDF magic bytes and size > 1 KB', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(),
]);
const filePath = await download.path();
expect(filePath).toBeTruthy();
const buf = await fs.readFile(filePath!);
// Magic bytes: %PDF
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
// Size > 1 KB
expect(buf.byteLength).toBeGreaterThan(1024);
const suggestedName = download.suggestedFilename();
expect(suggestedName).toMatch(/\.pdf$/);
});
// AC-29.5 — Redteam: all 3 formats work
test('AC-29.5 — redteam: Markdown download works', async ({ page, context }) => {
await seedTokenInStorage(context, redteamTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
]);
const filePath = await download.path();
const content = await fs.readFile(filePath!, 'utf-8');
expect(content).toContain('US29 Export Engagement');
});
test('AC-29.5 — redteam: CSV download works', async ({ page, context }) => {
await seedTokenInStorage(context, redteamTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^csv$/i }).first().click(),
]);
const filePath = await download.path();
const raw = await fs.readFile(filePath!, 'utf-8');
const rows = countCsvRows(raw);
expect(rows.count).toBeGreaterThanOrEqual(3);
});
test('AC-29.5 — redteam: PDF download works', async ({ page, context }) => {
await seedTokenInStorage(context, redteamTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^pdf$/i }).first().click(),
]);
const filePath = await download.path();
const buf = await fs.readFile(filePath!);
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
});
// AC-29.6 — Filename convention: engagement-<id>-<slug>-YYYYMMDD.{ext}
test('AC-29.6 — filename matches engagement-<id>-<slug>-YYYYMMDD pattern', async ({
page,
context,
}) => {
await seedTokenInStorage(context, adminTok);
await page.goto(`/engagements/${engagement.id}`);
const dropdownWrapper = page.locator('[data-testid="export-dropdown"]');
await expect(dropdownWrapper).toBeVisible({ timeout: 10_000 });
await dropdownWrapper.locator('button').first().click();
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator('[role="menuitem"]').filter({ hasText: /^markdown$/i }).first().click(),
]);
const suggestedName = download.suggestedFilename();
// Pattern: engagement-<id>-<slug>-YYYYMMDD.md
const filenamePattern = new RegExp(
`^engagement-${engagement.id}-[a-z0-9-]+-\\d{8}\\.md$`,
);
expect(suggestedName).toMatch(filenamePattern);
});
});

View File

@@ -0,0 +1,90 @@
/**
* US-30 — SOC role has zero access to the export feature.
*
* AC-30.1: SOC login → Export button absent from DOM (not just hidden).
* AC-30.2: Direct API call with SOC Bearer → 403.
* AC-30.3: Direct API call without token → 401.
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
type Engagement,
} from '../fixtures/api';
import { seedTokenInStorage } from '../fixtures/auth';
const SOC_USER = 'us30-soc';
const ADMIN_USER = 'us30-admin';
const PASS = 'us30-pass-strong!';
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
test.describe('US-30 — SOC zero access to export', () => {
let socTok: string;
let adminTok: string;
let engagement: Engagement;
test.beforeAll(async () => {
await ensureUser(SOC_USER, PASS, 'soc');
await ensureUser(ADMIN_USER, PASS, 'admin');
socTok = (await login(SOC_USER, PASS)).token;
adminTok = (await login(ADMIN_USER, PASS)).token;
engagement = await createEngagement(adminTok, {
name: 'US30 RBAC Engagement',
start_date: '2026-01-01',
});
});
test.afterAll(async () => {
try {
await deleteEngagement(adminTok, engagement.id);
const rootTok = await adminToken();
for (const u of [SOC_USER, ADMIN_USER]) await deleteUserByUsername(rootTok, u);
} catch { /* noop */ }
});
// AC-30.1 — SOC: Export button absent from DOM
test('AC-30.1 — SOC login: Export dropdown is NOT attached to the DOM', async ({
page,
context,
}) => {
await seedTokenInStorage(context, socTok);
await page.goto(`/engagements/${engagement.id}`);
// Wait for the page to fully load (engagement header should be visible)
await expect(page.locator('h1, h2').first()).toBeVisible({ timeout: 10_000 });
// The export dropdown wrapper must not be in the DOM at all
await expect(
page.locator('[data-testid="export-dropdown"]'),
).not.toBeAttached();
});
// AC-30.2 — SOC Bearer token → 403
test('AC-30.2 — SOC Bearer: GET /api/engagements/<id>/export?format=md → 403', async ({
request,
}) => {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
{
headers: { Authorization: `Bearer ${socTok}` },
},
);
expect(response.status()).toBe(403);
});
// AC-30.3 — No token → 401
test('AC-30.3 — No token: GET /api/engagements/<id>/export?format=md → 401', async ({
request,
}) => {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
);
expect(response.status()).toBe(401);
});
});

View File

@@ -0,0 +1,188 @@
/**
* US-31 — Export robustness: format validation and edge cases.
*
* All 4 ACs use direct API calls (no UI needed) for speed and determinism.
*
* AC-31.1: missing ?format → 400 friendly message
* AC-31.2: ?format=xml → 400 friendly message
* AC-31.3: engagement 99999 → 404
* AC-31.4: engagement with 0 simulations → export OK (CSV = 1 header row only)
*/
import { test, expect } from '@playwright/test';
import {
adminToken,
createEngagement,
deleteEngagement,
deleteUserByUsername,
ensureUser,
login,
} from '../fixtures/api';
const CSV_HEADER_COLS = [
'Scénario',
'Test',
'Source de log',
'Commentaires SOC',
'Exécution',
'Logs remontés au SIEM',
'Cyber incident',
];
const ADMIN_USER = 'us31-admin';
const PASS = 'us31-pass-strong!';
const BASE_URL = process.env.MIMIC_BASE_URL ?? 'http://localhost:5000';
/**
* RFC-4180-aware row counter — same logic as us29 helper.
* Embedded newlines inside quoted cells are not counted as row breaks.
*/
function countCsvRows(csv: string): number {
let inQuote = false;
let rowCount = 0;
let lineStart = 0;
for (let i = 0; i < csv.length; i++) {
const ch = csv[i];
if (ch === '"') {
if (inQuote && csv[i + 1] === '"') {
i++;
} else {
inQuote = !inQuote;
}
} else if ((ch === '\n' || ch === '\r') && !inQuote) {
if (ch === '\r' && csv[i + 1] === '\n') i++;
const line = csv.slice(lineStart, i).trim();
if (line.length > 0) rowCount++;
lineStart = i + 1;
}
}
const tail = csv.slice(lineStart).trim();
if (tail.length > 0) rowCount++;
return rowCount;
}
test.describe('US-31 — Export robustness', () => {
let adminTok: string;
test.beforeAll(async () => {
await ensureUser(ADMIN_USER, PASS, 'admin');
adminTok = (await login(ADMIN_USER, PASS)).token;
});
test.afterAll(async () => {
try {
const rootTok = await adminToken();
await deleteUserByUsername(rootTok, ADMIN_USER);
} catch { /* noop */ }
});
// AC-31.1 — missing format → 400
test('AC-31.1 — GET /export without format → 400 with friendly error', async ({
request,
}) => {
// Need a valid engagement id — use admin to create one transiently
const engagement = await createEngagement(adminTok, {
name: 'US31 missing format eng',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(400);
const body = await response.json();
expect(body).toHaveProperty('error');
expect(body.error).toMatch(/format/i);
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
// AC-31.2 — unknown format → 400
test('AC-31.2 — GET /export?format=xml → 400 with friendly error', async ({
request,
}) => {
const engagement = await createEngagement(adminTok, {
name: 'US31 bad format eng',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=xml`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(400);
const body = await response.json();
expect(body).toHaveProperty('error');
expect(body.error).toMatch(/format/i);
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
// AC-31.3 — unknown engagement → 404
test('AC-31.3 — GET /engagements/99999/export?format=md → 404', async ({
request,
}) => {
const response = await request.get(
`${BASE_URL}/api/engagements/99999/export?format=md`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(404);
});
// AC-31.4 — engagement with 0 simulations: export OK
test('AC-31.4 — engagement with 0 simulations: Markdown export OK (header only)', async ({
request,
}) => {
const engagement = await createEngagement(adminTok, {
name: 'US31 empty engagement',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=md`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(200);
const text = await response.text();
// Engagement header section present
expect(text).toContain('US31 empty engagement');
// With 0 simulations the GFM table is absent (no rows to render)
expect(text).not.toContain('| Scénario |');
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
test('AC-31.4 — engagement with 0 simulations: CSV export has only 1 header row', async ({
request,
}) => {
const engagement = await createEngagement(adminTok, {
name: 'US31 empty CSV engagement',
start_date: '2026-01-01',
});
try {
const response = await request.get(
`${BASE_URL}/api/engagements/${engagement.id}/export?format=csv`,
{ headers: { Authorization: `Bearer ${adminTok}` } },
);
expect(response.status()).toBe(200);
const text = await response.text();
// Count rows via RFC-4180-aware counter (handles embedded newlines in quoted cells)
const rowCount = countCsvRows(text);
expect(rowCount).toBe(1);
// The single row is the header with exactly the 7 FR columns
const headerCells = text.trim().split(',').map((c) => c.trim().replace(/^"|"$/g, ''));
expect(headerCells).toEqual(CSV_HEADER_COLS);
} finally {
await deleteEngagement(adminTok, engagement.id);
}
});
});

View File

@@ -198,8 +198,8 @@ test.describe('US-4 — engagement CRUD', () => {
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await expect(row.getByText(REDTEAM_USER)).toBeVisible(); await expect(row.getByText(REDTEAM_USER)).toBeVisible();
// Redteam sees the action buttons. // Redteam sees the action buttons. Sprint 4: "New engagement" renamed to "New".
await expect(page.getByRole('link', { name: /new engagement/i })).toBeVisible(); await expect(page.getByRole('link', { name: /^new$/i })).toBeVisible();
await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible(); await expect(row.getByRole('link', { name: /^edit$/i })).toBeVisible();
await expect(row.getByRole('button', { name: /^delete$/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'); await page.goto('/engagements');
const rowAsSoc = page.getByRole('row', { name: /UI list sample/i }); const rowAsSoc = page.getByRole('row', { name: /UI list sample/i });
await expect(rowAsSoc).toBeVisible(); 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('link', { name: /^edit$/i })).toHaveCount(0);
await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0); await expect(rowAsSoc.getByRole('button', { name: /^delete$/i })).toHaveCount(0);
}); });
@@ -265,9 +265,7 @@ test.describe('US-4 — engagement CRUD', () => {
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible();
// Sprint 2 replaced the placeholder with the real SimulationList — covered by AC-7.5. // Sprint 2 replaced the placeholder with the real SimulationList — covered by AC-7.5.
await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible();
// admin/redteam see the "Nouvelle simulation" button // Sprint 5: "New" is now a split-button dropdown (data-testid="new-simulation-btn")
await expect( await expect(page.getByTestId('new-simulation-btn')).toBeVisible();
page.getByRole('link', { name: /nouvelle simulation/i }),
).toBeVisible();
}); });
}); });

View File

@@ -58,12 +58,12 @@ test.describe('US-5 — DESIGN.md fidelity, responsive, states', () => {
.evaluate((el) => window.getComputedStyle(el).backgroundColor); .evaluate((el) => window.getComputedStyle(el).backgroundColor);
expect(chevronBg.replace(/\s/g, '')).toBe('rgb(2,74,216)'); 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 const utilityBg = await page
.locator('div.bg-ink.text-ink-on') .locator('div.bg-slab.text-slab-text')
.first() .first()
.evaluate((el) => window.getComputedStyle(el).backgroundColor); .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 // 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). // wired through tailwind.config.ts and not ad-hoc hex values).

View File

@@ -24,7 +24,15 @@ import {
waitForHealth, waitForHealth,
} from '../fixtures/api'; } from '../fixtures/api';
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? 'docker'; function detectRuntime(): string {
try {
execSync('command -v docker', { stdio: 'ignore' });
return 'docker';
} catch {
return 'podman';
}
}
const RUNTIME = process.env.MIMIC_CONTAINER_CMD ?? detectRuntime();
const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic'; const CONTAINER = process.env.MIMIC_CONTAINER ?? 'mimic';
const IMAGE = process.env.MIMIC_IMAGE ?? 'mimic:latest'; const IMAGE = process.env.MIMIC_IMAGE ?? 'mimic:latest';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));

View File

@@ -156,15 +156,13 @@ test.describe('US-7 — simulation create', () => {
// The created simulation row is visible // The created simulation row is visible
await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible(); await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible();
// "Nouvelle simulation" button visible for redteam // Sprint 5: "New" is now a split-button dropdown (data-testid="new-simulation-btn")
await expect( await expect(page.getByTestId('new-simulation-btn')).toBeVisible();
page.getByRole('link', { name: /nouvelle simulation/i }),
).toBeVisible();
// SOC should NOT see "Nouvelle simulation" button // SOC should NOT see "New simulation" dropdown
await seedTokenInStorage(context, socToken); await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}`); await page.goto(`/engagements/${engagementId}`);
await expect(page.getByRole('link', { name: /nouvelle simulation/i })).toHaveCount(0); await expect(page.getByTestId('new-simulation-btn')).not.toBeVisible();
await deleteSimulation(redteamToken, sim.id); await deleteSimulation(redteamToken, sim.id);
}); });

View File

@@ -79,8 +79,7 @@ test.describe('US-8 — redteam fill simulation details', () => {
const patch = { const patch = {
name: 'Updated name', name: 'Updated name',
mitre_technique_id: 'T1059', technique_ids: ['T1059'],
mitre_technique_name: 'Command and Scripting Interpreter',
description: 'Some description', description: 'Some description',
commands: 'cmd /c whoami\ncmd /c ipconfig', commands: 'cmd /c whoami\ncmd /c ipconfig',
prerequisites: 'Admin shell', prerequisites: 'Admin shell',
@@ -90,8 +89,10 @@ test.describe('US-8 — redteam fill simulation details', () => {
const r = await client.patch(`/simulations/${sim.id}`, patch); const r = await client.patch(`/simulations/${sim.id}`, patch);
expect(r.status).toBe(200); expect(r.status).toBe(200);
expect(r.data.name).toBe('Updated name'); expect(r.data.name).toBe('Updated name');
expect(r.data.mitre_technique_id).toBe('T1059'); // sprint 3: techniques array replaces scalar scalars
expect(r.data.mitre_technique_name).toBe('Command and Scripting Interpreter'); expect(Array.isArray(r.data.techniques)).toBe(true);
expect(r.data.techniques[0].id).toBe('T1059');
expect(r.data.techniques[0].name).toBe('Command and Scripting Interpreter');
expect(r.data.description).toBe('Some description'); expect(r.data.description).toBe('Some description');
expect(r.data.commands).toBe('cmd /c whoami\ncmd /c ipconfig'); expect(r.data.commands).toBe('cmd /c whoami\ncmd /c ipconfig');
expect(r.data.prerequisites).toBe('Admin shell'); expect(r.data.prerequisites).toBe('Admin shell');
@@ -192,14 +193,15 @@ test.describe('US-8 — redteam fill simulation details', () => {
await expect(nameField).toBeEnabled(); await expect(nameField).toBeEnabled();
// Clear the name, try to save → client validation error // Clear the name, try to save → client validation error
// Sprint 4: "Save Red Team" button renamed to "Save"
await nameField.fill(''); 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 expect(page.getByText(/name is required/i)).toBeVisible();
await deleteSimulation(redteamToken, sim.id); await deleteSimulation(redteamToken, sim.id);
}); });
test('AC-8.6 — MITRE technique picker is present on the edit form', async ({ test('AC-8.6 — MITRE technique picker accessible via inline search on the edit form', async ({
page, page,
context, context,
}) => { }) => {
@@ -208,6 +210,8 @@ test.describe('US-8 — redteam fill simulation details', () => {
await seedTokenInStorage(context, redteamToken); await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`); await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Sprint 4: picker opens by clicking the inline placeholder text
await page.getByText(/search technique/i).click();
// MitreTechniquePicker renders an input with combobox role // MitreTechniquePicker renders an input with combobox role
await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible(); await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible();

View File

@@ -178,7 +178,7 @@ test.describe('US-9 — SOC restricted edit', () => {
// Banner must be visible // Banner must be visible
await expect(page.getByTestId('soc-blocked-banner')).toBeVisible(); await expect(page.getByTestId('soc-blocked-banner')).toBeVisible();
await expect( await expect(
page.getByText(/simulation pas encore en revue/i), page.getByText(/simulation not yet ready for review/i),
).toBeVisible(); ).toBeVisible();
// SOC fields are disabled // SOC fields are disabled

View File

@@ -8,8 +8,10 @@
"name": "mimic-frontend", "name": "mimic-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"lucide-react": "^1.16.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.27.0" "react-router-dom": "^6.27.0"
@@ -1012,6 +1014,15 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -5083,6 +5094,15 @@
"yallist": "^3.0.2" "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": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@@ -12,8 +12,10 @@
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tanstack/react-query": "^5.59.0", "@tanstack/react-query": "^5.59.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"lucide-react": "^1.16.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.27.0" "react-router-dom": "^6.27.0"

View File

@@ -8,6 +8,8 @@ import { EngagementFormPage } from '@/pages/EngagementFormPage';
import { EngagementDetailPage } from '@/pages/EngagementDetailPage'; import { EngagementDetailPage } from '@/pages/EngagementDetailPage';
import { UsersAdminPage } from '@/pages/UsersAdminPage'; import { UsersAdminPage } from '@/pages/UsersAdminPage';
import { SimulationFormPage } from '@/pages/SimulationFormPage'; import { SimulationFormPage } from '@/pages/SimulationFormPage';
import { TemplatesListPage } from '@/pages/TemplatesListPage';
import { TemplateFormPage } from '@/pages/TemplateFormPage';
/** /**
* Router. Auth + role gates handled by <ProtectedRoute />. * Router. Auth + role gates handled by <ProtectedRoute />.
@@ -43,6 +45,13 @@ export function App(): JSX.Element {
<Route element={<ProtectedRoute roles={['admin']} />}> <Route element={<ProtectedRoute roles={['admin']} />}>
<Route path="/admin/users" element={<UsersAdminPage />} /> <Route path="/admin/users" element={<UsersAdminPage />} />
</Route> </Route>
{/* admin + redteam routes */}
<Route element={<ProtectedRoute roles={['admin', 'redteam']} />}>
<Route path="/admin/templates" element={<TemplatesListPage />} />
<Route path="/admin/templates/new" element={<TemplateFormPage />} />
<Route path="/admin/templates/:id/edit" element={<TemplateFormPage />} />
</Route>
</Route> </Route>
</Route> </Route>

94
frontend/src/api/c2.ts Normal file
View File

@@ -0,0 +1,94 @@
import { apiClient } from './client';
import type {
C2CallbackHistoryResponse,
C2Config,
C2ConfigInput,
C2TestResult,
C2CallbacksResponse,
C2ExecuteInput,
C2ExecuteResponse,
C2ImportInput,
C2ImportResponse,
C2TasksResponse,
} from './types';
export async function getC2Config(engagementId: number): Promise<C2Config | null> {
try {
const { data } = await apiClient.get<C2Config>(`/engagements/${engagementId}/c2-config`);
return data;
} catch (err: unknown) {
const e = err as { response?: { status?: number } };
if (e?.response?.status === 404) return null;
throw err;
}
}
export async function putC2Config(
engagementId: number,
input: C2ConfigInput,
): Promise<C2Config> {
const { data } = await apiClient.put<C2Config>(
`/engagements/${engagementId}/c2-config`,
input,
);
return data;
}
export async function deleteC2Config(engagementId: number): Promise<void> {
await apiClient.delete(`/engagements/${engagementId}/c2-config`);
}
export async function testC2Config(engagementId: number): Promise<C2TestResult> {
const { data } = await apiClient.post<C2TestResult>(
`/engagements/${engagementId}/c2-config/test`,
);
return data;
}
export async function listCallbacks(engagementId: number): Promise<C2CallbacksResponse> {
const { data } = await apiClient.get<C2CallbacksResponse>(
`/engagements/${engagementId}/c2/callbacks`,
);
return data;
}
export async function executeC2(
simulationId: number,
input: C2ExecuteInput,
): Promise<C2ExecuteResponse> {
const { data } = await apiClient.post<C2ExecuteResponse>(
`/simulations/${simulationId}/c2/execute`,
input,
);
return data;
}
export async function getC2Tasks(simulationId: number): Promise<C2TasksResponse> {
const { data } = await apiClient.get<C2TasksResponse>(
`/simulations/${simulationId}/c2/tasks`,
);
return data;
}
export async function listCallbackHistory(
engagementId: number,
callbackDisplayId: number,
params: { page: number; pageSize: number },
): Promise<C2CallbackHistoryResponse> {
const { data } = await apiClient.get<C2CallbackHistoryResponse>(
`/engagements/${engagementId}/c2/callbacks/${callbackDisplayId}/history`,
{ params: { page: params.page, page_size: params.pageSize } },
);
return data;
}
export async function importC2(
simulationId: number,
input: C2ImportInput,
): Promise<C2ImportResponse> {
const { data } = await apiClient.post<C2ImportResponse>(
`/simulations/${simulationId}/c2/import`,
input,
);
return data;
}

View File

@@ -0,0 +1,56 @@
import axios from 'axios';
import { apiClient } from './client';
export type ExportFormat = 'md' | 'csv' | 'pdf';
export function parseContentDispositionFilename(header: string | undefined): string | null {
if (!header) return null;
const match = header.match(/filename="([^"]+)"/);
return match ? match[1] : null;
}
async function parseBlobError(err: unknown): Promise<string> {
if (axios.isAxiosError(err) && err.response?.data instanceof Blob) {
try {
const text = await (err.response.data as Blob).text();
const parsed = JSON.parse(text) as { error?: string };
if (parsed.error) return parsed.error;
} catch {
// fall through to default
}
}
if (err instanceof Error) return err.message;
return 'Export failed';
}
export async function downloadEngagementExport(
engagementId: number,
format: ExportFormat,
): Promise<void> {
try {
const response = await apiClient.get(`/engagements/${engagementId}/export`, {
params: { format },
responseType: 'blob',
});
let filename = parseContentDispositionFilename(
response.headers['content-disposition'] as string | undefined,
);
if (!filename) {
const ext = format === 'md' ? 'md' : format === 'csv' ? 'csv' : 'pdf';
filename = `engagement-${engagementId}.${ext}`;
}
const url = URL.createObjectURL(response.data as Blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
} catch (err) {
const message = await parseBlobError(err);
throw new Error(message);
}
}

View File

@@ -1,5 +1,5 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { MitreTechnique } from './types'; import type { MitreTactic, MitreTechnique } from './types';
export async function searchMitreTechniques(query: string): Promise<MitreTechnique[]> { export async function searchMitreTechniques(query: string): Promise<MitreTechnique[]> {
const { data } = await apiClient.get<MitreTechnique[]>('/mitre/techniques', { const { data } = await apiClient.get<MitreTechnique[]>('/mitre/techniques', {
@@ -7,3 +7,8 @@ export async function searchMitreTechniques(query: string): Promise<MitreTechniq
}); });
return data; return data;
} }
export async function getMitreMatrix(): Promise<MitreTactic[]> {
const { data } = await apiClient.get<MitreTactic[]>('/mitre/matrix');
return data;
}

View File

@@ -0,0 +1,35 @@
import { apiClient } from './client';
import type {
SimulationTemplate,
SimulationTemplateCreateInput,
SimulationTemplatePatchInput,
} from './types';
export async function listTemplates(): Promise<SimulationTemplate[]> {
const { data } = await apiClient.get<SimulationTemplate[]>('/templates');
return data;
}
export async function getTemplate(id: number): Promise<SimulationTemplate> {
const { data } = await apiClient.get<SimulationTemplate>(`/templates/${id}`);
return data;
}
export async function createTemplate(
input: SimulationTemplateCreateInput,
): Promise<SimulationTemplate> {
const { data } = await apiClient.post<SimulationTemplate>('/templates', input);
return data;
}
export async function updateTemplate(
id: number,
patch: SimulationTemplatePatchInput,
): Promise<SimulationTemplate> {
const { data } = await apiClient.patch<SimulationTemplate>(`/templates/${id}`, patch);
return data;
}
export async function deleteTemplate(id: number): Promise<void> {
await apiClient.delete(`/templates/${id}`);
}

View File

@@ -61,12 +61,34 @@ export interface MitreTechnique {
tactics: string[]; tactics: string[];
} }
export interface MitreMatrixSubtechnique {
id: string;
name: string;
}
export interface MitreMatrixTechnique {
id: string;
name: string;
subtechniques: MitreMatrixSubtechnique[];
}
export interface MitreTactic {
tactic_id: string;
tactic_name: string;
techniques: MitreMatrixTechnique[];
}
export interface MitreTacticRef {
id: string;
name: string;
}
export interface Simulation { export interface Simulation {
id: number; id: number;
engagement_id: number; engagement_id: number;
name: string; name: string;
mitre_technique_id: string | null; techniques: MitreTechnique[];
mitre_technique_name: string | null; tactics: MitreTacticRef[];
description: string | null; description: string | null;
commands: string | null; commands: string | null;
prerequisites: string | null; prerequisites: string | null;
@@ -82,14 +104,46 @@ export interface Simulation {
created_by: { id: number; username: string }; created_by: { id: number; username: string };
} }
export interface SimulationTemplate {
id: number;
name: string;
description: string | null;
commands: string | null;
prerequisites: string | null;
techniques: MitreTechnique[];
tactics: MitreTacticRef[];
created_at: string;
updated_at: string | null;
created_by: { id: number; username: string };
}
export interface SimulationTemplateCreateInput {
name: string;
description?: string | null;
commands?: string | null;
prerequisites?: string | null;
technique_ids?: string[];
tactic_ids?: string[];
}
export interface SimulationTemplatePatchInput {
name?: string;
description?: string | null;
commands?: string | null;
prerequisites?: string | null;
technique_ids?: string[];
tactic_ids?: string[];
}
export interface SimulationCreateInput { export interface SimulationCreateInput {
name: string; name: string;
template_id?: number;
} }
export interface SimulationPatchInput { export interface SimulationPatchInput {
name?: string; name?: string;
mitre_technique_id?: string | null; technique_ids?: string[];
mitre_technique_name?: string | null; tactic_ids?: string[];
description?: string | null; description?: string | null;
commands?: string | null; commands?: string | null;
prerequisites?: string | null; prerequisites?: string | null;
@@ -100,3 +154,101 @@ export interface SimulationPatchInput {
soc_comment?: string | null; soc_comment?: string | null;
incident_number?: string | null; incident_number?: string | null;
} }
// C2 types
export interface C2Config {
has_token: boolean;
url: string;
verify_tls: boolean;
}
export interface C2ConfigInput {
url: string;
api_token?: string;
verify_tls: boolean;
}
export interface C2TestResult {
ok: boolean;
error: string | null;
}
export interface C2Callback {
display_id: number;
active: boolean;
host: string;
user: string;
domain: string;
last_checkin: string;
}
export interface C2CallbacksResponse {
callbacks: C2Callback[];
}
// Thin shape returned by the execute endpoint
export interface C2ExecuteTask {
id: number;
mythic_task_display_id: number;
command: string;
status: string;
completed: boolean;
}
export interface C2ExecuteInput {
callback_display_id: number;
commands: string[];
}
export interface C2ExecuteResponse {
tasks: C2ExecuteTask[];
}
// Full shape returned by the tasks list endpoint (M3)
export interface C2TaskListItem {
id: number;
mythic_task_display_id: number;
callback_display_id: number;
command: string;
params: string | null;
status: string;
completed: boolean;
output: string | null;
mapping_applied: boolean;
source: 'mimic' | 'import';
created_at: string;
completed_at: string | null;
}
export interface C2TasksResponse {
tasks: C2TaskListItem[];
}
// Callback history (M4)
export interface C2HistoryTask {
display_id: number;
command: string;
status: string;
completed: boolean;
completed_at: string | null;
created_at: string;
}
export interface C2CallbackHistoryResponse {
tasks: C2HistoryTask[];
total: number;
page: number;
page_size: number;
}
// Import (M4)
export interface C2ImportInput {
callback_display_id: number;
task_display_ids: number[];
}
export interface C2ImportResponse {
imported: number;
skipped: number;
}

View File

@@ -0,0 +1,95 @@
import { extractApiError } from '@/api/client';
import type { C2Callback } from '@/api/types';
interface C2CallbackPickerProps {
callbacks: C2Callback[];
isLoading: boolean;
isError: boolean;
error: unknown;
selectedId: number | null;
onSelect: (id: number) => void;
rowTestId?: string;
}
export function C2CallbackPicker({
callbacks,
isLoading,
isError,
error,
selectedId,
onSelect,
rowTestId = 'c2-callback-row',
}: C2CallbackPickerProps): JSX.Element {
if (isLoading) {
return <p className="text-[14px] text-graphite">Loading callbacks</p>;
}
if (isError) {
return (
<p className="text-[14px] text-bloom-deep">
Could not load callbacks: {extractApiError(error, 'Unknown error')}
</p>
);
}
if (callbacks.length === 0) {
return <p className="text-[14px] text-graphite">No callbacks available.</p>;
}
return (
<div className="border border-hairline overflow-x-auto">
<table className="w-full text-[14px]">
<thead>
<tr className="bg-cloud border-b border-hairline">
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th>
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
<th className="px-md py-sm text-left font-medium text-ink">User</th>
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
</tr>
</thead>
<tbody>
{callbacks.map((cb) => {
const isSelected = selectedId === cb.display_id;
return (
<tr
key={cb.display_id}
data-testid={rowTestId}
onClick={() => onSelect(cb.display_id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(cb.display_id);
}
}}
tabIndex={0}
role="button"
className={`cursor-pointer border-b border-hairline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
isSelected ? 'bg-primary-soft' : 'hover:bg-cloud'
}`}
>
<td className="px-md py-sm font-mono">{cb.display_id}</td>
<td className="px-md py-sm">
<span
className={`inline-flex items-center rounded-pill px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${
cb.active
? 'bg-primary-soft text-primary-deep'
: 'bg-cloud text-graphite border border-hairline'
}`}
>
{cb.active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-md py-sm font-mono">{cb.host}</td>
<td className="px-md py-sm font-mono">{cb.user}</td>
<td className="px-md py-sm font-mono">{cb.domain}</td>
<td className="px-md py-sm font-mono">{cb.last_checkin}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More