Compare commits

..

9 Commits

Author SHA1 Message Date
868097d78a Merge pull request 'sprint/2-simulations' (#3) from sprint/2-simulations into main
Reviewed-on: #3
2026-05-26 10:14:35 +00:00
Knacky
9ace9ac0d8 docs: sprint 2 wrap-up — README + CHANGELOG + lessons + plan final
- README: status bump to sprint 2, blueprints + workflow + MITRE section, test counts refreshed (131/63/68)
- CHANGELOG: sprint 2 entry under [Unreleased]; sprint 1 moved to its own [Sprint 1] section
- tasks/lessons.md: 5 lessons captured (3 frontend testing gotchas, agent-reuse via SendMessage, e2e refresh on placeholder supersession)
- 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-26 11:41:23 +02:00
Knacky
54e90f78bb test(e2e): refresh us4 AC-4.9 — placeholder replaced by SimulationList (sprint 2)
The sprint 2 SimulationList component replaced the "Simulations à venir au
Sprint 2" placeholder. AC-4.9 now asserts the Simulations heading and the
"Nouvelle simulation" button are visible for redteam, in line with AC-7.5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:37:51 +02:00
Knacky
da905cc0a0 test(e2e): sprint 2 acceptance tests — US-7 through US-12
Covers AC-7.1→AC-7.6, AC-8.1→AC-8.6, AC-9.1→AC-9.4, AC-10.1→AC-10.5,
AC-11.1→AC-11.5, AC-12.1→AC-12.4 (32 new tests, all passing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:35:29 +02:00
Knacky
cf0e8a8a6b fix(frontend): sprint 2 review fixes — MitrePicker input retention + SPA navigation
- MitreTechniquePicker: use hasHydratedFromProps ref so onChange(null,null) on
  keystrokes does not propagate back and wipe inputValue mid-stroke
- SimulationList: replace window.location.href with useNavigate(); drop
  redundant stopPropagation on inner Link
- SimulationFormPage: hoist canSaveSoc flag; replace duplicated ternary
  expressions at onSubmit and button visibility guard
- SimulationFormPage: drop dead .replace(' ', 'T') on executed_at (isoformat
  always emits 'T')
- Tests: add regression for MitrePicker input retention and SimulationList
  SPA navigation (63 tests total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:22:48 +02:00
Knacky
c9032a9057 fix(frontend): post-review fixes sprint 2
- MitreTechniquePicker: guard sync effect while dropdown open to prevent
  mid-stroke wipe when onChange(null,null) propagates back as null props
- SimulationList: replace window.location.href with navigate() to keep
  TanStack Query cache intact on row click
- SimulationFormPage: simplify redundant ternary on SOC form onSubmit
- SimulationFormPage: remove dead .replace(' ','T') on executed_at
  (backend already returns ISO 8601 with T separator)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:22:05 +02:00
Knacky
83bf60fb30 fix(backend): post-review fixes sprint 2
- test_simulations_patch: remove false dict return annotation on _patch helper
- simulation_workflow: validate executed_at upfront before any setattr (prevents partial mutation on bad payload)
- api/simulations: remove unreachable role check in update_simulation (all valid roles are admin/redteam/soc)
- Dockerfile: remove redundant COPY backend/data/ (already covered by COPY backend/)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:21:32 +02:00
Knacky
765bb5a1a4 feat(frontend): sprint 2 — simulations UI + MITRE picker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:13:14 +02:00
Knacky
006c4c2c5f feat(backend): sprint 2 — simulations + MITRE ATT&CK
- Simulation model with full field set (redteam + SOC sides) and cascade delete
- Alembic migration 0002 for simulations table
- simulation_workflow service: PATCH RBAC field-level + auto-transition pending→in_progress + state machine
- mitre service: STIX bundle loader (boot-safe) + ranked search (exact-id > prefix-id > name)
- 7 new API endpoints: list/create/get/patch/delete simulations, transition, MITRE autocomplete
- serialize_simulation added to serializers.py
- Makefile update-mitre target with real curl + optional docker restart
- Dockerfile updated to copy backend/data/ into image
- MITRE enterprise-attack.json bundle committed (~45 MB)
- 67 new tests (total 130 passing), ruff clean, mypy introduces no new errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 10:59:14 +02:00
43 changed files with 799970 additions and 276 deletions

View File

@@ -6,7 +6,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [Unreleased]
### Added — Sprint 1 (Auth + CRUD Engagement) ### Added — Sprint 2 (Simulations + MITRE ATT&CK)
**Backend** (Flask + SQLAlchemy, 131 pytest passing)
- `Simulation` model with redteam-side (`name`, `mitre_technique_id`, `mitre_technique_name`, `description`, `commands`, `prerequisites`, `executed_at`, `execution_result`) and SOC-side (`log_source`, `logs`, `soc_comment`, `incident_number`) fields, plus `status` enum (`pending` / `in_progress` / `review_required` / `done`), FK to `Engagement` (cascade delete) and `User` (creator).
- Alembic migration `0002_add_simulations.py`.
- 7 new endpoints: `GET/POST /api/engagements/<eid>/simulations`, `GET/PATCH/DELETE /api/simulations/<sid>`, `POST /api/simulations/<sid>/transition`, `GET /api/mitre/techniques?q=`.
- `simulation_workflow` service: field-level RBAC (SOC blocked when status ∈ {pending, in_progress}; SOC rejected if payload contains a redteam field), state machine (only forward transitions, validated by role), and auto-transition `pending → in_progress` when admin/redteam saves any non-empty redteam field.
- `mitre` service: STIX 2.1 Enterprise bundle loaded at boot, indexed by T-id + name + tactic. Ranked search (`exact-id > prefix-id > substring-name`), max 20 results. Includes sub-techniques (`T1059.001`). Boot-safe: missing/corrupt bundle logs a warning and the endpoint returns 503 instead of crashing the app.
- `make update-mitre` is now a real target — fetches the upstream STIX bundle and restarts the container if running. Bundle is committed at `backend/data/mitre/enterprise-attack.json` (~46 MB) so `make build` stays self-contained.
- Upfront validation of `executed_at` (no partial mutation on parse failure).
**Frontend** (React + TanStack Query, 63 vitest passing)
- `SimulationList` component rendered inside `EngagementDetailPage` (replaces the Sprint 1 placeholder). Columns: name, MITRE id, status badge, executed_at. Row click → SPA navigation via `useNavigate` (no full reload).
- `SimulationFormPage` (`/engagements/:eid/simulations/new` and `/engagements/:eid/simulations/:sid/edit`): single role-aware page with two cards ("Red Team" / "SOC"). Redteam/admin can edit all fields; SOC sees the redteam card as read-only and the SOC card disabled (with an explanatory banner) until status reaches `review_required`. Footer surfaces context-appropriate transition buttons ("Marquer en revue" / "Clôturer") and a confirmation modal for delete.
- `MitreTechniquePicker`: debounced (200 ms) autocomplete input with keyboard navigation (↑↓ / Enter / Escape), listbox accessibility, and an inline 503 error path. Selection populates both `mitre_technique_id` and `mitre_technique_name`. A `hasHydratedFromProps` ref prevents the input from being wiped mid-stroke when the parent emits `onChange(null, null)`.
- `SimulationStatusBadge`: 4 variants mapped to DESIGN.md tokens (`bg-fog`, `bg-primary-soft`, `bg-bloom-coral`, `bg-storm-deep`). Sibling of the existing `StatusBadge` rather than a forked generic — the two badges share visual scaffolding but their enums diverge.
- `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.
**Acceptance tests** (Playwright, 68 specs)
- 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).
- 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.
### 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 — `EngagementDetailPage` no longer renders the "Simulations à venir au Sprint 2" placeholder; it embeds `<SimulationList>` instead.
---
## [Sprint 1] — Auth + CRUD Engagement (merged 2026-05-26)
### Added
**Backend** (Flask + SQLAlchemy + SQLite, 63 pytest passing) **Backend** (Flask + SQLAlchemy + SQLite, 63 pytest passing)
- `User` model with `admin / redteam / soc` enum, argon2 password hashing. - `User` model with `admin / redteam / soc` enum, argon2 password hashing.

View File

@@ -32,8 +32,16 @@ ifndef PASS
endif endif
docker exec $(CONTAINER) flask create-admin $(USER) $(PASS) docker exec $(CONTAINER) flask create-admin $(USER) $(PASS)
MITRE_URL ?= https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json
update-mitre: update-mitre:
@echo "MITRE update: Sprint 2+" @mkdir -p backend/data/mitre
@curl -fsSL "$(MITRE_URL)" -o backend/data/mitre/enterprise-attack.json
@echo "MITRE bundle updated"
@if docker ps --format '{{.Names}}' | grep -q "^$(CONTAINER)$$"; then \
echo "Restarting $(CONTAINER) to reload MITRE bundle..."; \
docker restart $(CONTAINER); \
fi
test-backend: test-backend:
docker exec $(CONTAINER) pytest -q backend/tests/ docker exec $(CONTAINER) pytest -q backend/tests/

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 1Auth + CRUD Engagement**. Simulation workflow and MITRE TTP autocomplete arrive in Sprint 2+. > 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.
--- ---
@@ -56,7 +56,8 @@ Single-container deployment. A multistage Dockerfile builds the Vite frontend, t
│ │ │ │
│ Flask (Python 3.12) │ │ Flask (Python 3.12) │
│ ├── /api/* ── blueprints (auth, users, │ │ ├── /api/* ── blueprints (auth, users, │
│ │ engagements) │ │ engagements, simulations,
│ │ mitre) │
│ └── / ── SPA fallback → React build │ │ └── / ── SPA fallback → React build │
│ │ │ │
│ SQLAlchemy ── SQLite at /data/mimic.sqlite │ │ SQLAlchemy ── SQLite at /data/mimic.sqlite │
@@ -65,9 +66,11 @@ Single-container deployment. A multistage Dockerfile builds the Vite frontend, t
``` ```
- **Auth**: JWT Bearer tokens (HS256, 60-min TTL). Stateless — no refresh tokens, no server-side session. - **Auth**: JWT Bearer tokens (HS256, 60-min TTL). Stateless — no refresh tokens, no server-side session.
- **Roles**: `admin` (super-user, manages users + engagements), `redteam` (CRUD engagements + simulations), `soc` (read engagements; will write the SOC half of simulations in Sprint 2). - **Roles**: `admin` (super-user — cumulates redteam rights on engagements/simulations), `redteam` (CRUD engagements + simulations, full field access), `soc` (read everything, write-only on the SOC half of simulations once the redteam marks them `review_required`).
- **Password hashing**: argon2 via `argon2-cffi`. - **Password hashing**: argon2 via `argon2-cffi`.
- **Migrations**: Alembic, applied automatically by the container entrypoint (`flask db upgrade && flask run`). - **Migrations**: Alembic, applied automatically by the container entrypoint (`flask db upgrade && flask run`).
- **MITRE ATT&CK**: STIX 2.1 Enterprise bundle committed at `backend/data/mitre/enterprise-attack.json` and indexed at app boot. `make update-mitre` re-fetches the latest bundle and (if the container is running) restarts it to reload the index. The endpoint `GET /api/mitre/techniques?q=` powers the autocomplete on simulations.
- **Simulation workflow**: Pending → In progress (auto-transition when redteam saves any non-empty field) → Review required (manual, redteam) → Done (manual, redteam or SOC). The state machine is enforced server-side; the UI surfaces the appropriate transition button per role + current state.
See [`SPEC.md`](SPEC.md) § "Décisions techniques" for the full architecture rationale and [`DESIGN.md`](DESIGN.md) for the UI design system. See [`SPEC.md`](SPEC.md) § "Décisions techniques" for the full architecture rationale and [`DESIGN.md`](DESIGN.md) for the UI design system.
@@ -102,7 +105,7 @@ mimic/
| `make update` | `git pull && make build && make restart` | | `make update` | `git pull && make build && make restart` |
| `make logs` | `docker logs -f mimic` | | `make logs` | `docker logs -f mimic` |
| `make create-admin USER=… PASS=…` | Run `flask create-admin` inside the container | | `make create-admin USER=… PASS=…` | Run `flask create-admin` inside the container |
| `make update-mitre` | No-op placeholder — Sprint 2+ will fetch the MITRE STIX bundle | | `make update-mitre` | Fetch the latest MITRE STIX 2.1 Enterprise bundle into `backend/data/mitre/`; auto-restart the container if running. Commit the resulting file change manually. |
| `make test-backend` | `pytest -q` inside the container | | `make test-backend` | `pytest -q` inside the container |
| `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) |
@@ -135,9 +138,9 @@ npm run dev # http://localhost:5173 with /api proxied to :5000
Tests: Tests:
```bash ```bash
cd backend && pytest -q # 63 tests cd backend && pytest -q # 131 tests
cd frontend && npm run test -- --run # 20 tests cd frontend && npm run test -- --run # 63 tests
cd e2e && npx playwright test # 36 tests (needs container up) cd e2e && npx playwright test # 68 tests (needs container up)
``` ```
--- ---

View File

@@ -6,7 +6,7 @@ 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, users_bp from backend.app.api import auth_bp, engagements_bp, simulations_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
@@ -36,6 +36,10 @@ def create_app(config_object: object | None = None) -> Flask:
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
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)
from backend.app.services import mitre as mitre_svc
mitre_svc.load_bundle()
register_error_handlers(app) register_error_handlers(app)
register_cli(app) register_cli(app)

View File

@@ -1,6 +1,7 @@
"""API blueprints.""" """API blueprints."""
from backend.app.api.auth import auth_bp from backend.app.api.auth import auth_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.users import users_bp from backend.app.api.users import users_bp
__all__ = ["auth_bp", "users_bp", "engagements_bp"] __all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp"]

View File

@@ -0,0 +1,138 @@
"""Simulation CRUD + workflow endpoints."""
from __future__ import annotations
from datetime import UTC, datetime
from flask import Blueprint, g, jsonify, request
from backend.app.auth import login_required, role_required
from backend.app.extensions import db
from backend.app.models import Engagement
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.serializers import serialize_simulation
from backend.app.services import simulation_workflow
simulations_bp = Blueprint("simulations", __name__)
# ---------------------------------------------------------------------------
# Nested under /api/engagements/<eid>/simulations
# ---------------------------------------------------------------------------
@simulations_bp.get("/api/engagements/<int:eid>/simulations")
@login_required
def list_simulations(eid: int):
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
sims = (
Simulation.query.filter_by(engagement_id=eid)
.order_by(Simulation.created_at.desc())
.all()
)
return jsonify([serialize_simulation(s) for s in sims]), 200
@simulations_bp.post("/api/engagements/<int:eid>/simulations")
@role_required("admin", "redteam")
def create_simulation(eid: int):
engagement = db.session.get(Engagement, eid)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
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.commit()
return jsonify(serialize_simulation(sim)), 201
# ---------------------------------------------------------------------------
# Flat /api/simulations/<sid>
# ---------------------------------------------------------------------------
@simulations_bp.get("/api/simulations/<int:sid>")
@login_required
def get_simulation(sid: int):
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
return jsonify(serialize_simulation(sim)), 200
@simulations_bp.patch("/api/simulations/<int:sid>")
@login_required
def update_simulation(sid: int):
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
user = g.current_user
data = request.get_json(silent=True) or {}
if not data:
return jsonify(serialize_simulation(sim)), 200
err = simulation_workflow.apply_patch(sim, data, user)
if err is not None:
return err
db.session.commit()
return jsonify(serialize_simulation(sim)), 200
@simulations_bp.delete("/api/simulations/<int:sid>")
@role_required("admin", "redteam")
def delete_simulation(sid: int):
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
db.session.delete(sim)
db.session.commit()
return "", 204
@simulations_bp.post("/api/simulations/<int:sid>/transition")
@login_required
def transition_simulation(sid: int):
sim = db.session.get(Simulation, sid)
if sim is None:
return jsonify({"error": "Simulation not found"}), 404
data = request.get_json(silent=True) or {}
to_status = data.get("to", "")
err = simulation_workflow.transition(sim, to_status, g.current_user)
if err is not None:
return err
return jsonify(serialize_simulation(sim)), 200
# ---------------------------------------------------------------------------
# MITRE autocomplete
# ---------------------------------------------------------------------------
@simulations_bp.get("/api/mitre/techniques")
@login_required
def mitre_techniques():
from backend.app.services import mitre as mitre_svc
if not mitre_svc.mitre_loaded:
return jsonify({"error": "mitre bundle not loaded"}), 503
q = request.args.get("q", "").strip()
results = mitre_svc.search(q)
return jsonify(results), 200

View File

@@ -1,5 +1,6 @@
"""SQLAlchemy models.""" """SQLAlchemy models."""
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.user import User, UserRole from backend.app.models.user import User, UserRole
__all__ = ["User", "UserRole", "Engagement", "EngagementStatus"] __all__ = ["User", "UserRole", "Engagement", "EngagementStatus", "Simulation", "SimulationStatus"]

View File

@@ -0,0 +1,62 @@
"""Simulation model."""
from __future__ import annotations
import enum
from datetime import UTC, datetime
from backend.app.extensions import db
class SimulationStatus(str, enum.Enum):
PENDING = "pending"
IN_PROGRESS = "in_progress"
REVIEW_REQUIRED = "review_required"
DONE = "done"
class Simulation(db.Model): # type: ignore[name-defined]
__tablename__ = "simulations"
id = db.Column(db.Integer, primary_key=True)
engagement_id = db.Column(
db.Integer,
db.ForeignKey("engagements.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name = db.Column(db.String(255), nullable=False)
mitre_technique_id = db.Column(db.String(32), nullable=True)
mitre_technique_name = db.Column(db.String(255), nullable=True)
description = db.Column(db.Text, nullable=True)
commands = db.Column(db.Text, nullable=True)
prerequisites = db.Column(db.Text, nullable=True)
executed_at = db.Column(db.DateTime, nullable=True)
execution_result = db.Column(db.Text, nullable=True)
log_source = db.Column(db.Text, nullable=True)
logs = db.Column(db.Text, nullable=True)
soc_comment = db.Column(db.Text, nullable=True)
incident_number = db.Column(db.String(128), nullable=True)
status = db.Column(
db.Enum(SimulationStatus, name="simulation_status"),
nullable=False,
default=SimulationStatus.PENDING,
)
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,
index=True,
)
engagement = db.relationship(
"Engagement",
backref=db.backref("simulations", cascade="all, delete-orphan", lazy="dynamic"),
)
created_by = db.relationship("User", backref="simulations", lazy="joined")
def __repr__(self) -> str:
return f"<Simulation {self.id} {self.name!r}>"

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any 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
def serialize_user(user: User) -> dict[str, Any]: def serialize_user(user: User) -> dict[str, Any]:
@@ -19,6 +20,31 @@ def serialize_user_brief(user: User) -> dict[str, Any]:
return {"id": user.id, "username": user.username} return {"id": user.id, "username": user.username}
def serialize_simulation(simulation: Simulation) -> dict[str, Any]:
return {
"id": simulation.id,
"engagement_id": simulation.engagement_id,
"name": simulation.name,
"mitre_technique_id": simulation.mitre_technique_id,
"mitre_technique_name": simulation.mitre_technique_name,
"description": simulation.description,
"commands": simulation.commands,
"prerequisites": simulation.prerequisites,
"executed_at": simulation.executed_at.isoformat() if simulation.executed_at else None,
"execution_result": simulation.execution_result,
"log_source": simulation.log_source,
"logs": simulation.logs,
"soc_comment": simulation.soc_comment,
"incident_number": simulation.incident_number,
"status": simulation.status.value,
"created_at": simulation.created_at.isoformat() if simulation.created_at else None,
"updated_at": simulation.updated_at.isoformat() if simulation.updated_at else None,
"created_by": serialize_user_brief(simulation.created_by) # type: ignore[arg-type]
if simulation.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

View File

@@ -0,0 +1,100 @@
"""MITRE ATT&CK bundle loader and search service."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# Absolute path to the committed bundle.
_BUNDLE_PATH = Path(__file__).parent.parent.parent / "data" / "mitre" / "enterprise-attack.json"
mitre_loaded: bool = False
_index: list[dict[str, Any]] = []
def _extract_tactics(obj: dict[str, Any]) -> list[str]:
phases = obj.get("kill_chain_phases") or []
return [
p["phase_name"]
for p in phases
if isinstance(p, dict) and "phase_name" in p
]
def _get_external_id(obj: dict[str, Any]) -> str | None:
for ref in obj.get("external_references") or []:
if isinstance(ref, dict) and ref.get("source_name") == "mitre-attack":
return ref.get("external_id")
return None
def load_bundle(path: Path | None = None) -> None:
"""Load the MITRE bundle into memory. Called once at app boot."""
global mitre_loaded, _index
bundle_path = path or _BUNDLE_PATH
try:
raw = bundle_path.read_text(encoding="utf-8")
data = json.loads(raw)
except FileNotFoundError:
logger.warning("MITRE bundle not found at %s — autocomplete disabled", bundle_path)
mitre_loaded = False
return
except (json.JSONDecodeError, OSError) as exc:
logger.warning("MITRE bundle parse error: %s — autocomplete disabled", exc)
mitre_loaded = False
return
entries: list[dict[str, Any]] = []
for obj in data.get("objects") or []:
if not isinstance(obj, dict):
continue
if obj.get("type") != "attack-pattern":
continue
if obj.get("revoked") or obj.get("x_mitre_deprecated"):
continue
ext_id = _get_external_id(obj)
if not ext_id:
continue
entries.append(
{
"id": ext_id,
"name": obj.get("name", ""),
"tactics": _extract_tactics(obj),
}
)
_index = entries
mitre_loaded = True
logger.info("MITRE bundle loaded: %d techniques", len(_index))
def search(query: str, limit: int = 20) -> list[dict[str, Any]]:
"""Return up to `limit` techniques matching `query`.
Ranking: exact id > prefix id > substring name (case-insensitive).
"""
q = query.strip().upper()
if not q:
return []
exact: list[dict[str, Any]] = []
prefix: list[dict[str, Any]] = []
name_match: list[dict[str, Any]] = []
for entry in _index:
tech_id = entry["id"].upper()
tech_name = entry["name"].upper()
if tech_id == q:
exact.append(entry)
elif tech_id.startswith(q):
prefix.append(entry)
elif q in tech_name:
name_match.append(entry)
combined = exact + prefix + name_match
return combined[:limit]

View File

@@ -0,0 +1,132 @@
"""Simulation business logic: PATCH rules and state machine transitions."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from flask import jsonify
from backend.app.extensions import db
from backend.app.models import User
from backend.app.models.simulation import Simulation, SimulationStatus
REDTEAM_FIELDS = frozenset(
{
"name",
"mitre_technique_id",
"mitre_technique_name",
"description",
"commands",
"prerequisites",
"executed_at",
"execution_result",
}
)
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]]] = {
"review_required": {
"from": {"pending", "in_progress"},
"roles": {"admin", "redteam"},
},
"done": {
"from": {"review_required"},
"roles": {"admin", "redteam", "soc"},
},
}
def _is_non_empty(value: Any) -> bool:
"""Return True if value counts as "filled" for auto-transition purposes."""
if value is None:
return False
if isinstance(value, str) and value == "":
return False
return not (isinstance(value, list) and len(value) == 0)
def apply_patch(
simulation: Simulation, payload: dict[str, Any], user: User
) -> tuple[Any, int] | None:
"""Apply a validated PATCH payload to a simulation.
Returns a (response, status_code) tuple on error, or None on success
(caller is responsible for committing).
"""
role = user.role.value
if role == "soc":
# SOC can only patch when status allows it.
if simulation.status not in (
SimulationStatus.REVIEW_REQUIRED,
SimulationStatus.DONE,
):
return jsonify({"error": "simulation not ready for SOC review"}), 403
# SOC must not send redteam fields.
redteam_keys_in_payload = REDTEAM_FIELDS & payload.keys()
if redteam_keys_in_payload:
return jsonify({"error": "soc cannot edit redteam fields"}), 403
for field in SOC_FIELDS:
if field in payload:
setattr(simulation, field, payload[field])
else:
# admin / redteam: apply all fields present.
redteam_keys_present = REDTEAM_FIELDS & payload.keys()
# Validate executed_at before any writes so a bad value causes no partial mutation.
executed_at_value: datetime | None = None
if "executed_at" in redteam_keys_present:
val = payload["executed_at"]
if val is not None:
if not isinstance(val, str):
return jsonify({"error": "invalid executed_at"}), 400
try:
executed_at_value = datetime.fromisoformat(val)
except ValueError:
return jsonify({"error": "invalid executed_at"}), 400
for field in redteam_keys_present:
if field == "executed_at":
simulation.executed_at = executed_at_value
else:
setattr(simulation, field, payload[field])
for field in SOC_FIELDS:
if field in payload:
setattr(simulation, field, payload[field])
# Auto-transition pending → in_progress: at least one redteam field with
# a non-empty value in the *incoming payload*.
if simulation.status == SimulationStatus.PENDING and any(
_is_non_empty(payload[k]) for k in redteam_keys_present
):
simulation.status = SimulationStatus.IN_PROGRESS
simulation.updated_at = datetime.now(UTC)
return None
def transition(
simulation: Simulation, to_status: str, user: User
) -> tuple[Any, int] | None:
"""Attempt a manual transition. Returns error tuple or None on success."""
rule = _ALLOWED_TRANSITIONS.get(to_status)
if rule is None:
return jsonify({"error": "invalid transition"}), 409
if simulation.status.value not in rule["from"]:
return jsonify({"error": "invalid transition"}), 409
if user.role.value not in rule["roles"]:
return jsonify({"error": "Forbidden"}), 403
simulation.status = SimulationStatus(to_status)
simulation.updated_at = datetime.now(UTC)
db.session.commit()
return None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
"""add simulations table
Revision ID: 0002
Revises: 0001
Create Date: 2026-05-26 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"simulations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("engagement_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("mitre_technique_id", sa.String(length=32), nullable=True),
sa.Column("mitre_technique_name", sa.String(length=255), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("commands", sa.Text(), nullable=True),
sa.Column("prerequisites", sa.Text(), nullable=True),
sa.Column("executed_at", sa.DateTime(), nullable=True),
sa.Column("execution_result", sa.Text(), nullable=True),
sa.Column("log_source", sa.Text(), nullable=True),
sa.Column("logs", sa.Text(), nullable=True),
sa.Column("soc_comment", sa.Text(), nullable=True),
sa.Column("incident_number", sa.String(length=128), nullable=True),
sa.Column(
"status",
sa.Enum("pending", "in_progress", "review_required", "done", name="simulation_status"),
nullable=False,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("created_by_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["engagement_id"], ["engagements.id"], ondelete="CASCADE",
name="fk_simulations_engagement_id_engagements",
),
sa.ForeignKeyConstraint(
["created_by_id"], ["users.id"], ondelete="RESTRICT",
name="fk_simulations_created_by_id_users",
),
)
op.create_index("ix_simulations_engagement_id", "simulations", ["engagement_id"])
op.create_index("ix_simulations_created_by_id", "simulations", ["created_by_id"])
def downgrade():
op.drop_index("ix_simulations_created_by_id", table_name="simulations")
op.drop_index("ix_simulations_engagement_id", table_name="simulations")
op.drop_table("simulations")
sa.Enum(name="simulation_status").drop(op.get_bind(), checkfirst=True)

247
backend/tests/test_mitre.py Normal file
View File

@@ -0,0 +1,247 @@
"""MITRE service and endpoint tests. Uses a tiny fixture bundle, not the 40 MB file."""
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
# ---------------------------------------------------------------------------
# Fixture STIX bundle (minimal, 4 techniques including one sub-technique)
# ---------------------------------------------------------------------------
_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": "Phishing",
"external_references": [
{"source_name": "mitre-attack", "external_id": "T1566"}
],
"kill_chain_phases": [{"phase_name": "initial-access", "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"},
],
},
{
# Revoked — must be excluded from index.
"type": "attack-pattern",
"name": "Old Technique",
"revoked": True,
"external_references": [
{"source_name": "mitre-attack", "external_id": "T9999"}
],
"kill_chain_phases": [],
},
{
# Not an attack-pattern — must be ignored.
"type": "relationship",
"name": "Ignored",
},
],
}
@pytest.fixture(autouse=True)
def _reset_mitre():
"""Reset the MITRE service state between tests."""
original_loaded = mitre_svc.mitre_loaded
original_index = list(mitre_svc._index)
yield
mitre_svc.mitre_loaded = original_loaded
mitre_svc._index = original_index
@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
# ---------------------------------------------------------------------------
# Unit tests for load_bundle
# ---------------------------------------------------------------------------
def test_load_bundle_success(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
assert mitre_svc.mitre_loaded is True
assert len(mitre_svc._index) == 4 # 5 objects minus 1 revoked = 4
def test_load_bundle_missing_file() -> None:
mitre_svc.load_bundle(pathlib.Path("/nonexistent/path.json"))
assert mitre_svc.mitre_loaded is False
def test_load_bundle_invalid_json(tmp_path: pathlib.Path) -> None:
bad = tmp_path / "bad.json"
bad.write_text("{ not json }", encoding="utf-8")
mitre_svc.load_bundle(bad)
assert mitre_svc.mitre_loaded is False
def test_load_bundle_excludes_revoked(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
ids = [e["id"] for e in mitre_svc._index]
assert "T9999" not in ids
def test_load_bundle_includes_subtechniques(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
ids = [e["id"] for e in mitre_svc._index]
assert "T1059.001" in ids
def test_load_bundle_extracts_tactics(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
t1078 = next(e for e in mitre_svc._index if e["id"] == "T1078")
assert "initial-access" in t1078["tactics"]
assert "persistence" in t1078["tactics"]
# ---------------------------------------------------------------------------
# Unit tests for search
# ---------------------------------------------------------------------------
def test_search_exact_id_first(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
results = mitre_svc.search("T1059")
assert results[0]["id"] == "T1059"
def test_search_prefix_id(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
results = mitre_svc.search("T105")
ids = [r["id"] for r in results]
assert "T1059" in ids
assert "T1059.001" in ids
def test_search_name_substring(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
results = mitre_svc.search("phish")
assert any(r["id"] == "T1566" for r in results)
def test_search_case_insensitive(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
results = mitre_svc.search("POWERSHELL")
assert any(r["id"] == "T1059.001" for r in results)
def test_search_limit(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
results = mitre_svc.search("T", limit=2)
assert len(results) <= 2
def test_search_empty_query(bundle_file: pathlib.Path) -> None:
mitre_svc.load_bundle(bundle_file)
assert mitre_svc.search("") == []
def test_search_ranking_order(bundle_file: pathlib.Path) -> None:
"""exact-id > prefix-id > name match."""
mitre_svc.load_bundle(bundle_file)
results = mitre_svc.search("T1059")
# T1059 must come before T1059.001 (prefix match)
ids = [r["id"] for r in results]
assert ids.index("T1059") < ids.index("T1059.001")
# ---------------------------------------------------------------------------
# Endpoint tests
# ---------------------------------------------------------------------------
def test_mitre_endpoint_503_when_not_loaded(
client: FlaskClient, redteam_token: str
) -> None:
mitre_svc.mitre_loaded = False
mitre_svc._index = []
resp = client.get("/api/mitre/techniques?q=T1059", headers=_h(redteam_token))
assert resp.status_code == 503
assert resp.get_json()["error"] == "mitre bundle not loaded"
def test_mitre_endpoint_returns_results(
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
) -> None:
mitre_svc.load_bundle(bundle_file)
resp = client.get("/api/mitre/techniques?q=T1059", headers=_h(redteam_token))
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, list)
assert any(r["id"] == "T1059" for r in data)
def test_mitre_endpoint_requires_auth(client: FlaskClient) -> None:
resp = client.get("/api/mitre/techniques?q=T1059")
assert resp.status_code == 401
def test_mitre_endpoint_all_roles_can_access(
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/techniques?q=T1059", headers=_h(token))
assert resp.status_code == 200
def test_mitre_endpoint_max_20_results(
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
) -> None:
mitre_svc.load_bundle(bundle_file)
resp = client.get("/api/mitre/techniques?q=T", headers=_h(redteam_token))
assert resp.status_code == 200
assert len(resp.get_json()) <= 20
def test_mitre_endpoint_includes_tactics(
client: FlaskClient, redteam_token: str, bundle_file: pathlib.Path
) -> None:
mitre_svc.load_bundle(bundle_file)
resp = client.get("/api/mitre/techniques?q=T1566", headers=_h(redteam_token))
assert resp.status_code == 200
data = resp.get_json()
assert len(data) >= 1
phishing = next((r for r in data if r["id"] == "T1566"), None)
assert phishing is not None
assert "initial-access" in phishing["tactics"]

View File

@@ -0,0 +1,236 @@
"""Simulation CRUD tests: create, list, get, delete, cascade."""
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 import Simulation
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 Alpha", "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, **kw) -> dict:
payload = {"name": "Sim 1", **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()
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
def test_create_simulation_as_redteam(
client: FlaskClient, redteam_user: User, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
body = _make_sim(client, redteam_token, eng["id"])
assert body["name"] == "Sim 1"
assert body["status"] == "pending"
assert body["engagement_id"] == eng["id"]
assert body["created_by"] == {"id": redteam_user.id, "username": "redteam1"}
def test_create_simulation_as_admin(
client: FlaskClient, admin_user: User, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
body = _make_sim(client, admin_token, eng["id"])
assert body["created_by"]["username"] == "admin1"
def test_create_simulation_soc_forbidden(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
resp = client.post(
f"/api/engagements/{eng['id']}/simulations",
headers=_h(soc_token),
json={"name": "x"},
)
assert resp.status_code == 403
def test_create_simulation_unauth(client: FlaskClient, redteam_token: str) -> None:
eng = _make_engagement(client, redteam_token)
resp = client.post(
f"/api/engagements/{eng['id']}/simulations", json={"name": "x"}
)
assert resp.status_code == 401
def test_create_simulation_missing_name(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
resp = client.post(
f"/api/engagements/{eng['id']}/simulations",
headers=_h(redteam_token),
json={"name": ""},
)
assert resp.status_code == 400
def test_create_simulation_engagement_not_found(
client: FlaskClient, redteam_token: str
) -> None:
resp = client.post(
"/api/engagements/9999/simulations",
headers=_h(redteam_token),
json={"name": "x"},
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
def test_list_simulations_empty(client: FlaskClient, redteam_token: str) -> None:
eng = _make_engagement(client, redteam_token)
resp = client.get(
f"/api/engagements/{eng['id']}/simulations", headers=_h(redteam_token)
)
assert resp.status_code == 200
assert resp.get_json() == []
def test_list_simulations_ordered_desc(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
_make_sim(client, redteam_token, eng["id"], name="First")
_make_sim(client, redteam_token, eng["id"], name="Second")
resp = client.get(
f"/api/engagements/{eng['id']}/simulations", headers=_h(redteam_token)
)
assert resp.status_code == 200
items = resp.get_json()
assert len(items) == 2
# Most recent first
assert items[0]["name"] == "Second"
assert items[1]["name"] == "First"
def test_list_simulations_soc_can_read(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
_make_sim(client, redteam_token, eng["id"])
resp = client.get(
f"/api/engagements/{eng['id']}/simulations", headers=_h(soc_token)
)
assert resp.status_code == 200
assert len(resp.get_json()) == 1
def test_list_simulations_engagement_not_found(
client: FlaskClient, redteam_token: str
) -> None:
resp = client.get(
"/api/engagements/9999/simulations", headers=_h(redteam_token)
)
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Get
# ---------------------------------------------------------------------------
def test_get_simulation_ok(client: FlaskClient, redteam_token: str) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(redteam_token))
assert resp.status_code == 200
assert resp.get_json()["id"] == sim["id"]
def test_get_simulation_404(client: FlaskClient, redteam_token: str) -> None:
resp = client.get("/api/simulations/9999", headers=_h(redteam_token))
assert resp.status_code == 404
def test_get_simulation_soc_can_read(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = client.get(f"/api/simulations/{sim['id']}", headers=_h(soc_token))
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
def test_delete_simulation_redteam(client: FlaskClient, redteam_token: str) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = client.delete(
f"/api/simulations/{sim['id']}", headers=_h(redteam_token)
)
assert resp.status_code == 204
def test_delete_simulation_admin(
client: FlaskClient, redteam_token: str, admin_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = client.delete(f"/api/simulations/{sim['id']}", headers=_h(admin_token))
assert resp.status_code == 204
def test_delete_simulation_soc_forbidden(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = client.delete(f"/api/simulations/{sim['id']}", headers=_h(soc_token))
assert resp.status_code == 403
def test_delete_simulation_404(client: FlaskClient, redteam_token: str) -> None:
resp = client.delete("/api/simulations/9999", headers=_h(redteam_token))
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Cascade delete
# ---------------------------------------------------------------------------
def test_cascade_delete_engagement_removes_simulations(
app, client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
sim_id = sim["id"]
resp = client.delete(
f"/api/engagements/{eng['id']}", headers=_h(redteam_token)
)
assert resp.status_code == 204
with app.app_context():
assert db.session.get(Simulation, sim_id) is None

View File

@@ -0,0 +1,295 @@
"""Simulation PATCH tests: auto-transition, RBAC field-level, SOC restrictions."""
from __future__ import annotations
from flask.testing import FlaskClient
from backend.tests.conftest import auth_headers as _h
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Beta", "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": "Test 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
)
# ---------------------------------------------------------------------------
# Auto-transition pending → in_progress (AC-8.2)
# ---------------------------------------------------------------------------
def test_patch_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"], {"description": "some desc"})
assert resp.status_code == 200
assert resp.get_json()["status"] == "in_progress"
def test_patch_name_triggers_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"], {"name": "Updated name"})
assert resp.status_code == 200
assert resp.get_json()["status"] == "in_progress"
def test_patch_commands_triggers_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"], {"commands": "cmd1\ncmd2"})
assert resp.status_code == 200
assert resp.get_json()["status"] == "in_progress"
def test_patch_null_value_does_not_trigger_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"], {"description": None})
assert resp.status_code == 200
assert resp.get_json()["status"] == "pending"
def test_patch_empty_string_does_not_trigger_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"], {"description": ""})
assert resp.status_code == 200
assert resp.get_json()["status"] == "pending"
def test_patch_admin_triggers_auto_transition(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _patch(client, admin_token, sim["id"], {"execution_result": "success"})
assert resp.status_code == 200
assert resp.get_json()["status"] == "in_progress"
def test_patch_soc_does_not_trigger_auto_transition(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
"""SOC patch on review_required must not trigger auto-transition."""
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
# Manually advance to review_required.
client.post(
f"/api/simulations/{sim['id']}/transition",
headers=_h(redteam_token),
json={"to": "review_required"},
)
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "looks good"})
assert resp.status_code == 200
assert resp.get_json()["status"] == "review_required"
# ---------------------------------------------------------------------------
# Field updates
# ---------------------------------------------------------------------------
def test_patch_updates_commands_as_text(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
commands = "whoami\nnet user\nipconfig"
resp = _patch(client, redteam_token, sim["id"], {"commands": commands})
assert resp.status_code == 200
assert resp.get_json()["commands"] == commands
def test_patch_updates_executed_at(
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"], {"executed_at": "2026-06-01T12:00:00"}
)
assert resp.status_code == 200
assert "2026-06-01" in resp.get_json()["executed_at"]
def test_patch_invalid_executed_at(
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"], {"executed_at": "not-a-date"}
)
assert resp.status_code == 400
assert resp.get_json()["error"] == "invalid executed_at"
def test_patch_clear_executed_at(
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"], {"executed_at": "2026-06-01T12:00:00"})
resp = _patch(client, redteam_token, sim["id"], {"executed_at": None})
assert resp.status_code == 200
assert resp.get_json()["executed_at"] is None
# ---------------------------------------------------------------------------
# SOC RBAC field-level (AC-9.1, AC-9.2)
# ---------------------------------------------------------------------------
def test_soc_cannot_patch_before_review_required(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
assert sim["status"] == "pending"
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "hello"})
assert resp.status_code == 403
assert "not ready" in resp.get_json()["error"]
def test_soc_cannot_patch_in_progress(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_patch(client, redteam_token, sim["id"], {"description": "in progress now"})
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "hello"})
assert resp.status_code == 403
def test_soc_can_patch_when_review_required(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
client.post(
f"/api/simulations/{sim['id']}/transition",
headers=_h(redteam_token),
json={"to": "review_required"},
)
resp = _patch(
client,
soc_token,
sim["id"],
{"soc_comment": "Detected", "log_source": "SIEM", "incident_number": "INC-001"},
)
assert resp.status_code == 200
body = resp.get_json()
assert body["soc_comment"] == "Detected"
assert body["log_source"] == "SIEM"
assert body["incident_number"] == "INC-001"
def test_soc_can_patch_when_done(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
client.post(
f"/api/simulations/{sim['id']}/transition",
headers=_h(redteam_token),
json={"to": "review_required"},
)
client.post(
f"/api/simulations/{sim['id']}/transition",
headers=_h(soc_token),
json={"to": "done"},
)
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "Final note"})
assert resp.status_code == 200
def test_soc_cannot_edit_redteam_fields(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
client.post(
f"/api/simulations/{sim['id']}/transition",
headers=_h(redteam_token),
json={"to": "review_required"},
)
resp = _patch(client, soc_token, sim["id"], {"description": "redteam field"})
assert resp.status_code == 403
assert resp.get_json()["error"] == "soc cannot edit redteam fields"
def test_patch_simulation_404(client: FlaskClient, redteam_token: str) -> None:
resp = _patch(client, redteam_token, 9999, {"name": "x"})
assert resp.status_code == 404
def test_invalid_executed_at_does_not_mutate_other_fields(
client: FlaskClient, redteam_token: str
) -> None:
"""invalid executed_at must return 400 without persisting other fields in the payload."""
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
original_description = sim["description"]
resp = _patch(
client,
redteam_token,
sim["id"],
{"description": "should-not-stick", "executed_at": "not-a-date"},
)
assert resp.status_code == 400
get_resp = client.get(
f"/api/simulations/{sim['id']}",
headers={"Authorization": f"Bearer {redteam_token}"},
)
assert get_resp.status_code == 200
assert get_resp.get_json()["description"] == original_description

View File

@@ -0,0 +1,192 @@
"""Simulation workflow / state machine tests."""
from __future__ import annotations
from flask.testing import FlaskClient
from backend.tests.conftest import auth_headers as _h
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Gamma", "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": "Workflow Sim"},
)
assert resp.status_code == 201
return resp.get_json()
def _transition(client: FlaskClient, token: str, sid: int, to: str):
return client.post(
f"/api/simulations/{sid}/transition",
headers=_h(token),
json={"to": to},
)
# ---------------------------------------------------------------------------
# Valid transitions
# ---------------------------------------------------------------------------
def test_transition_to_review_required_from_pending(
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 = _transition(client, redteam_token, sim["id"], "review_required")
assert resp.status_code == 200
assert resp.get_json()["status"] == "review_required"
def test_transition_to_review_required_from_in_progress(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
# Auto-advance to in_progress
client.patch(
f"/api/simulations/{sim['id']}",
headers=_h(redteam_token),
json={"description": "started"},
)
resp = _transition(client, redteam_token, sim["id"], "review_required")
assert resp.status_code == 200
assert resp.get_json()["status"] == "review_required"
def test_transition_to_done_by_redteam(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_transition(client, redteam_token, sim["id"], "review_required")
resp = _transition(client, redteam_token, sim["id"], "done")
assert resp.status_code == 200
assert resp.get_json()["status"] == "done"
def test_transition_to_done_by_soc(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_transition(client, redteam_token, sim["id"], "review_required")
resp = _transition(client, soc_token, sim["id"], "done")
assert resp.status_code == 200
assert resp.get_json()["status"] == "done"
def test_transition_to_done_by_admin(
client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
_transition(client, admin_token, sim["id"], "review_required")
resp = _transition(client, admin_token, sim["id"], "done")
assert resp.status_code == 200
assert resp.get_json()["status"] == "done"
# ---------------------------------------------------------------------------
# Invalid transitions (AC-11.3)
# ---------------------------------------------------------------------------
def test_transition_done_from_pending_rejected(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _transition(client, redteam_token, sim["id"], "done")
assert resp.status_code == 409
def test_transition_to_pending_rejected(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_transition(client, redteam_token, sim["id"], "review_required")
resp = _transition(client, redteam_token, sim["id"], "pending")
assert resp.status_code == 409
def test_transition_to_in_progress_rejected(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _transition(client, redteam_token, sim["id"], "in_progress")
assert resp.status_code == 409
def test_transition_unknown_status_rejected(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _transition(client, redteam_token, sim["id"], "nonexistent")
assert resp.status_code == 409
def test_transition_review_required_from_done_rejected(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_transition(client, redteam_token, sim["id"], "review_required")
_transition(client, redteam_token, sim["id"], "done")
resp = _transition(client, redteam_token, sim["id"], "review_required")
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# RBAC by role
# ---------------------------------------------------------------------------
def test_soc_cannot_transition_to_review_required(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _transition(client, soc_token, sim["id"], "review_required")
assert resp.status_code == 403
def test_soc_cannot_transition_to_done_from_pending(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _transition(client, soc_token, sim["id"], "done")
assert resp.status_code == 409
def test_transition_simulation_404(client: FlaskClient, redteam_token: str) -> None:
resp = _transition(client, redteam_token, 9999, "review_required")
assert resp.status_code == 404

View File

@@ -0,0 +1,174 @@
/**
* US-10 — MITRE ATT&CK autocomplete.
* Covers AC-10.1 → AC-10.5.
*
* AC-10.1 (make update-mitre CLI target) is not exercised from Playwright;
* the bundle is assumed present in the container image.
*/
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 = 'us10-redteam';
const PASS = 'us10-pass-strong';
interface Simulation {
id: number;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-10 sim',
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.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;
}
async function deleteSimulation(token: string, simId: number): Promise<void> {
const client = makeClient(token);
await client.delete(`/simulations/${simId}`);
}
test.describe('US-10 — MITRE autocomplete', () => {
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-10 Test 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-10.1 — bundle present in container (skipped: CLI-only, bundle assumed committed)', async () => {
// AC-10.1 is a Makefile target test. We verify the bundle is loaded
// indirectly by checking the API returns results (not 503).
const client = makeClient(redteamToken);
const r = await client.get('/mitre/techniques?q=T1059');
// If bundle not loaded, we'd get 503 — this confirms it loaded OK
expect(r.status).not.toBe(503);
});
test('AC-10.2 — GET /api/mitre/techniques?q= returns max 20 results with id/name/tactics', async () => {
const client = makeClient(redteamToken);
// Search by id prefix
const rId = await client.get('/mitre/techniques?q=T1059');
expect(rId.status).toBe(200);
expect(Array.isArray(rId.data)).toBe(true);
expect(rId.data.length).toBeGreaterThan(0);
expect(rId.data.length).toBeLessThanOrEqual(20);
const first = rId.data[0];
expect(first).toHaveProperty('id');
expect(first).toHaveProperty('name');
expect(first).toHaveProperty('tactics');
expect(Array.isArray(first.tactics)).toBe(true);
// Exact id match comes first
const exactMatch = rId.data.find((t: { id: string }) => t.id === 'T1059');
expect(exactMatch).toBeTruthy();
expect(rId.data[0].id).toBe('T1059');
// Search by name (case-insensitive)
const rName = await client.get(
'/mitre/techniques?q=command%20and%20scripting%20interpreter',
);
expect(rName.status).toBe(200);
expect(rName.data.length).toBeGreaterThan(0);
const nameMatch = rName.data.find((t: { name: string }) =>
t.name.toLowerCase().includes('command and scripting'),
);
expect(nameMatch).toBeTruthy();
});
test('AC-10.3 — 503 if bundle not loaded (verified by absence: bundle IS loaded)', async () => {
const client = makeClient(redteamToken);
// We can only test the happy path from e2e; 503 requires a container
// without the bundle. We verify the endpoint does NOT return 503,
// confirming the bundle is loaded.
const r = await client.get('/mitre/techniques?q=T1');
expect(r.status).toBe(200);
});
test('AC-10.4 — sub-techniques (T1059.001) included in search results', async () => {
const client = makeClient(redteamToken);
const r = await client.get('/mitre/techniques?q=T1059.001');
expect(r.status).toBe(200);
expect(r.data.length).toBeGreaterThan(0);
const subtech = r.data.find((t: { id: string }) => t.id === 'T1059.001');
expect(subtech).toBeTruthy();
expect(subtech.name).toBeTruthy();
});
test('AC-10.5 — MitreTechniquePicker: input, dropdown, keyboard nav, selection fills both fields', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-10.5 sim');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
const picker = page.getByRole('combobox', { name: /mitre technique/i });
await expect(picker).toBeVisible();
// Type a query — after debounce (200ms) the dropdown opens with results
await picker.fill('T1059');
// Wait for dropdown to appear (debounce + network)
const listbox = page.getByRole('listbox', { name: /mitre techniques/i });
await expect(listbox).toBeVisible({ timeout: 5_000 });
// Options visible in expected format: "T1059 — Command and Scripting Interpreter (...)"
const options = listbox.getByRole('option');
await expect(options.first()).toBeVisible();
const firstText = await options.first().textContent();
expect(firstText).toMatch(/T1059/);
expect(firstText).toMatch(/—/);
// Keyboard navigation: ArrowDown selects item, Enter confirms
await picker.press('ArrowDown');
await picker.press('Enter');
// After selection the dropdown closes and input shows the selected value
await expect(listbox).not.toBeVisible();
const inputValue = await picker.inputValue();
expect(inputValue).toMatch(/T1059/);
expect(inputValue).toMatch(/—/);
// Escape closes the dropdown
await picker.fill('T1');
await expect(listbox).toBeVisible({ timeout: 5_000 });
await picker.press('Escape');
await expect(listbox).not.toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,245 @@
/**
* US-11 — workflow transitions.
* Covers AC-11.1 → AC-11.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 = 'us11-redteam';
const SOC_USER = 'us11-soc';
const PASS = 'us11-pass-strong';
interface Simulation {
id: number;
status: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-11 sim',
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.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;
}
async function deleteSimulation(token: string, simId: number): Promise<void> {
const client = makeClient(token);
await client.delete(`/simulations/${simId}`);
}
test.describe('US-11 — workflow transitions', () => {
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-11 Test 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-11.1 — pending→review_required valid (redteam); invalid target → 409', async () => {
const rtClient = makeClient(redteamToken);
// pending → review_required: valid
const sim = await createSimulation(redteamToken, engagementId, 'AC-11.1 sim');
const rOk = await rtClient.post(`/simulations/${sim.id}/transition`, {
to: 'review_required',
});
expect(rOk.status).toBe(200);
expect(rOk.data.status).toBe('review_required');
// Invalid target → 409
const simBad = await createSimulation(redteamToken, engagementId, 'AC-11.1 bad sim');
const rBad = await rtClient.post(`/simulations/${simBad.id}/transition`, {
to: 'done',
});
expect(rBad.status).toBe(409);
expect(rBad.data.error).toMatch(/invalid transition/i);
await deleteSimulation(redteamToken, sim.id);
await deleteSimulation(redteamToken, simBad.id);
});
test('AC-11.1 — in_progress→review_required valid (redteam)', async () => {
const rtClient = makeClient(redteamToken);
const sim = await createSimulation(redteamToken, engagementId, 'AC-11.1 in_progress sim');
// Trigger in_progress via auto-transition
await rtClient.patch(`/simulations/${sim.id}`, { name: 'trigger' });
const r = await rtClient.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-11.2 — review_required→done valid for redteam and soc', async () => {
const rtClient = makeClient(redteamToken);
const socClient = makeClient(socToken);
// redteam can close
const simRT = await createSimulation(redteamToken, engagementId, 'AC-11.2 redteam close');
await rtClient.post(`/simulations/${simRT.id}/transition`, { to: 'review_required' });
const rRT = await rtClient.post(`/simulations/${simRT.id}/transition`, { to: 'done' });
expect(rRT.status).toBe(200);
expect(rRT.data.status).toBe('done');
// soc can close
const simSOC = await createSimulation(redteamToken, engagementId, 'AC-11.2 soc close');
await rtClient.post(`/simulations/${simSOC.id}/transition`, { to: 'review_required' });
const rSOC = await socClient.post(`/simulations/${simSOC.id}/transition`, { to: 'done' });
expect(rSOC.status).toBe(200);
expect(rSOC.data.status).toBe('done');
// done → review_required is invalid (409)
const rBack = await rtClient.post(`/simulations/${simRT.id}/transition`, {
to: 'review_required',
});
expect(rBack.status).toBe(409);
await deleteSimulation(redteamToken, simRT.id);
await deleteSimulation(redteamToken, simSOC.id);
});
test('AC-11.3 — no backward transitions; no →pending or →in_progress via endpoint', async () => {
const rtClient = makeClient(redteamToken);
const sim = await createSimulation(redteamToken, engagementId, 'AC-11.3 sim');
// done → pending: invalid
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'review_required' });
await rtClient.post(`/simulations/${sim.id}/transition`, { to: 'done' });
const rPending = await rtClient.post(`/simulations/${sim.id}/transition`, {
to: 'pending',
});
expect(rPending.status).toBe(409);
expect(rPending.data.error).toMatch(/invalid transition/i);
// →in_progress via endpoint is always invalid
const simNew = await createSimulation(redteamToken, engagementId, 'AC-11.3 in_progress');
const rIP = await rtClient.post(`/simulations/${simNew.id}/transition`, {
to: 'in_progress',
});
expect(rIP.status).toBe(409);
await deleteSimulation(redteamToken, sim.id);
await deleteSimulation(redteamToken, simNew.id);
});
test('AC-11.4 — workflow buttons visible per role+status in UI', async ({
page,
context,
}) => {
const rtClient = makeClient(redteamToken);
// pending → "Marquer en revue" visible for redteam; "Clôturer" hidden
const simPending = await createSimulation(
redteamToken,
engagementId,
'AC-11.4 pending UI',
);
await seedTokenInStorage(context, redteamToken);
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: /clôturer/i })).toHaveCount(0);
// in_progress → "Marquer en revue" visible
const simIP = await createSimulation(redteamToken, engagementId, 'AC-11.4 in_progress UI');
await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' });
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: /clôturer/i })).toHaveCount(0);
// review_required → "Clôturer" visible for redteam; "Marquer en revue" hidden
const simRR = await createSimulation(redteamToken, engagementId, 'AC-11.4 review UI');
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'review_required' });
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: /marquer en revue/i })).toHaveCount(0);
// review_required → "Clôturer" also visible for SOC
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${simRR.id}/edit`);
await expect(page.getByRole('button', { name: /clôturer/i })).toBeVisible();
// done → both buttons hidden
await rtClient.post(`/simulations/${simRR.id}/transition`, { to: 'done' });
await seedTokenInStorage(context, redteamToken);
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: /clôturer/i })).toHaveCount(0);
await deleteSimulation(redteamToken, simPending.id);
await deleteSimulation(redteamToken, simIP.id);
await deleteSimulation(redteamToken, simRR.id);
});
test('AC-11.5 — after transition, badge updates in UI (TanStack Query invalidation)', async ({
page,
context,
}) => {
const rtClient = makeClient(redteamToken);
const sim = await createSimulation(redteamToken, engagementId, 'AC-11.5 badge sim');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Initially pending
const badge = page.getByTestId('simulation-status-badge');
await expect(badge).toHaveAttribute('data-status', 'pending');
// Click "Marquer en revue"
await page.getByRole('button', { name: /marquer en revue/i }).click();
// Badge updates to review_required without page reload
await expect(badge).toHaveAttribute('data-status', 'review_required', { timeout: 5_000 });
// "Clôturer" now visible; click it
await page.getByRole('button', { name: /clôturer/i }).click();
await expect(badge).toHaveAttribute('data-status', 'done', { timeout: 5_000 });
// Verify list is also updated: navigate to engagement detail and check badge there
await page.goto(`/engagements/${engagementId}`);
const listBadge = page
.getByRole('row', { name: /AC-11.5 badge sim/i })
.getByTestId('simulation-status-badge');
await expect(listBadge).toHaveAttribute('data-status', 'done');
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,153 @@
/**
* US-12 — simulation delete (RBAC + cascade + confirm modal).
* Covers AC-12.1 → AC-12.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 = 'us12-redteam';
const SOC_USER = 'us12-soc';
const PASS = 'us12-pass-strong';
interface Simulation {
id: number;
engagement_id: number;
name: string;
status: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-12 sim',
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.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-12 — simulation delete', () => {
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-12 Test 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-12.1 — DELETE /api/simulations/<sid> (redteam) → 204, then 404', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-12.1 to delete');
const client = makeClient(redteamToken);
const rDel = await client.delete(`/simulations/${sim.id}`);
expect(rDel.status).toBe(204);
const rGet = await client.get(`/simulations/${sim.id}`);
expect(rGet.status).toBe(404);
});
test('AC-12.2 — soc → 403 on DELETE', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-12.2 soc blocked');
const socClient = makeClient(socToken);
const r = await socClient.delete(`/simulations/${sim.id}`);
expect(r.status).toBe(403);
// Clean up
const rtClient = makeClient(redteamToken);
await rtClient.delete(`/simulations/${sim.id}`);
});
test('AC-12.3 — cascade: deleting engagement deletes its simulations', async () => {
const adminTok = await adminToken();
const adminClient = makeClient(adminTok);
// Create a fresh engagement with simulations
const eng = await createEngagement(redteamToken, {
name: 'US-12 cascade test',
start_date: '2026-01-01',
});
const s1 = await createSimulation(redteamToken, eng.id, 'cascade sim 1');
const s2 = await createSimulation(redteamToken, eng.id, 'cascade sim 2');
// Delete the engagement
await deleteEngagement(redteamToken, eng.id);
// Simulations must be gone
const rS1 = await adminClient.get(`/simulations/${s1.id}`);
expect(rS1.status).toBe(404);
const rS2 = await adminClient.get(`/simulations/${s2.id}`);
expect(rS2.status).toBe(404);
});
test('AC-12.4 — delete button visible for redteam, confirmation modal, deletes and redirects', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-12.4 UI delete');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Delete button is visible for redteam
const deleteBtn = page.getByRole('button', { name: /supprimer/i });
await expect(deleteBtn).toBeVisible();
// SOC should NOT see delete button
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
await expect(page.getByRole('button', { name: /supprimer/i })).toHaveCount(0);
// Back to redteam — click delete, confirm modal appears
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
await page.getByRole('button', { name: /supprimer/i }).click();
// Confirmation dialog must appear
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText(/supprimer la simulation/i)).toBeVisible();
// Confirm deletion
await dialog.getByRole('button', { name: /supprimer/i }).click();
// Should navigate back to engagement detail
await page.waitForURL(new RegExp(`/engagements/${engagementId}$`));
// Simulation no longer in list
await expect(page.getByRole('row', { name: /AC-12.4 UI delete/i })).toHaveCount(0);
});
});

View File

@@ -251,7 +251,7 @@ test.describe('US-4 — engagement CRUD', () => {
await expect(page.getByRole('heading', { name: /UI form test \(edited\)/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /UI form test \(edited\)/i })).toBeVisible();
}); });
test('AC-4.9 — /engagements/<id> detail page shows Sprint 2 placeholder', async ({ test('AC-4.9 — /engagements/<id> detail page shows Simulations section (sprint 2 replaced placeholder)', async ({
page, page,
context, context,
}) => { }) => {
@@ -263,8 +263,11 @@ test.describe('US-4 — engagement CRUD', () => {
await seedTokenInStorage(context, redteamToken); await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${seeded.id}`); await page.goto(`/engagements/${seeded.id}`);
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.
await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible();
// admin/redteam see the "Nouvelle simulation" button
await expect( await expect(
page.getByText(/simulations à venir au sprint 2/i), page.getByRole('link', { name: /nouvelle simulation/i }),
).toBeVisible(); ).toBeVisible();
}); });
}); });

View File

@@ -0,0 +1,192 @@
/**
* US-7 — redteam creates a simulation inside an engagement.
* Covers AC-7.1 → AC-7.6.
*/
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 = 'us7-redteam';
const SOC_USER = 'us7-soc';
const PASS = 'us7-pass-strong';
interface Simulation {
id: number;
engagement_id: number;
name: string;
status: string;
created_at: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
payload: { name: string },
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.post(`/engagements/${engagementId}/simulations`, payload);
if (r.status !== 201) {
throw new Error(`create simulation failed: ${r.status} ${JSON.stringify(r.data)}`);
}
return r.data as Simulation;
}
async function deleteSimulation(token: string, simId: number): Promise<void> {
const client = makeClient(token);
await client.delete(`/simulations/${simId}`);
}
test.describe('US-7 — simulation create', () => {
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-7 Test 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-7.1 — POST creates simulation with status pending, name required', async () => {
const sim = await createSimulation(redteamToken, engagementId, { name: 'Test sim 7.1' });
expect(sim.id).toBeTruthy();
expect(sim.engagement_id).toBe(engagementId);
expect(sim.name).toBe('Test sim 7.1');
expect(sim.status).toBe('pending');
expect(sim.created_at).toBeTruthy();
// name required: blank name → 400
const client = makeClient(redteamToken);
const r = await client.post(`/engagements/${engagementId}/simulations`, { name: '' });
expect(r.status).toBe(400);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-7.2 — soc role → 403 on POST', async () => {
const client = makeClient(socToken);
const r = await client.post(`/engagements/${engagementId}/simulations`, {
name: 'soc-blocked',
});
expect(r.status).toBe(403);
});
test('AC-7.3 — unknown engagement → 404; existing engagement with no sims → empty list', async () => {
const client = makeClient(redteamToken);
const r404 = await client.post('/engagements/999999/simulations', { name: 'ghost' });
expect(r404.status).toBe(404);
// Create a fresh engagement with no sims and verify list is empty
const freshEng = await createEngagement(redteamToken, {
name: 'US-7 empty engagement',
start_date: '2026-01-01',
});
const listR = await client.get(`/engagements/${freshEng.id}/simulations`);
expect(listR.status).toBe(200);
expect(listR.data).toEqual([]);
await deleteEngagement(redteamToken, freshEng.id);
});
test('AC-7.4 — GET list returns sims ordered by created_at desc', async () => {
const client = makeClient(redteamToken);
const s1 = await createSimulation(redteamToken, engagementId, { name: 'First sim' });
// Small delay so created_at timestamps differ
await new Promise((r) => setTimeout(r, 50));
const s2 = await createSimulation(redteamToken, engagementId, { name: 'Second sim' });
const listR = await client.get(`/engagements/${engagementId}/simulations`);
expect(listR.status).toBe(200);
const list: Simulation[] = listR.data;
expect(list.length).toBeGreaterThanOrEqual(2);
// Most recent first
const ids = list.map((s) => s.id);
expect(ids.indexOf(s2.id)).toBeLessThan(ids.indexOf(s1.id));
await deleteSimulation(redteamToken, s1.id);
await deleteSimulation(redteamToken, s2.id);
});
test('AC-7.5 — /engagements/:eid shows Simulations section with list + "Nouvelle simulation" button for redteam', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, { name: 'Visible sim' });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
// Simulations section visible
await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible();
// Required columns
for (const col of ['Name', 'MITRE', 'Status', 'Executed at']) {
await expect(page.getByRole('columnheader', { name: new RegExp(col, 'i') })).toBeVisible();
}
// The created simulation row is visible
await expect(page.getByRole('row', { name: /Visible sim/i })).toBeVisible();
// "Nouvelle simulation" button visible for redteam
await expect(
page.getByRole('link', { name: /nouvelle simulation/i }),
).toBeVisible();
// SOC should NOT see "Nouvelle simulation" button
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}`);
await expect(page.getByRole('link', { name: /nouvelle simulation/i })).toHaveCount(0);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-7.6 — clicking a simulation row navigates to /engagements/:eid/simulations/:sid/edit', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, { name: 'Click me sim' });
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}`);
// Click the sim name link
await page.getByRole('link', { name: 'Click me sim' }).click();
await page.waitForURL(
new RegExp(`/engagements/${engagementId}/simulations/${sim.id}/edit`),
);
await expect(page.url()).toContain(
`/engagements/${engagementId}/simulations/${sim.id}/edit`,
);
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,216 @@
/**
* US-8 — redteam fills technical details of a simulation.
* Covers AC-8.1 → AC-8.6 (AC-8.6 defers to US-10 for the autocomplete UI detail).
*/
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 = 'us8-redteam';
const SOC_USER = 'us8-soc';
const PASS = 'us8-pass-strong';
interface Simulation {
id: number;
engagement_id: number;
name: string;
status: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-8 sim',
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.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;
}
async function deleteSimulation(token: string, simId: number): Promise<void> {
const client = makeClient(token);
await client.delete(`/simulations/${simId}`);
}
test.describe('US-8 — redteam fill simulation details', () => {
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-8 Test 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-8.1 — PATCH accepts all redteam fields (partial OK)', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.1 sim');
const client = makeClient(redteamToken);
const patch = {
name: 'Updated name',
mitre_technique_id: 'T1059',
mitre_technique_name: 'Command and Scripting Interpreter',
description: 'Some description',
commands: 'cmd /c whoami\ncmd /c ipconfig',
prerequisites: 'Admin shell',
executed_at: '2026-05-01T12:00:00',
execution_result: 'Success',
};
const r = await client.patch(`/simulations/${sim.id}`, patch);
expect(r.status).toBe(200);
expect(r.data.name).toBe('Updated name');
expect(r.data.mitre_technique_id).toBe('T1059');
expect(r.data.mitre_technique_name).toBe('Command and Scripting Interpreter');
expect(r.data.description).toBe('Some description');
expect(r.data.commands).toBe('cmd /c whoami\ncmd /c ipconfig');
expect(r.data.prerequisites).toBe('Admin shell');
expect(r.data.execution_result).toBe('Success');
// Partial PATCH (only name) should also work
const rPartial = await client.patch(`/simulations/${sim.id}`, { name: 'Partial update' });
expect(rPartial.status).toBe(200);
expect(rPartial.data.name).toBe('Partial update');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.2 — auto-transition pending→in_progress on PATCH with non-empty redteam field', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.2 sim');
expect(sim.status).toBe('pending');
const client = makeClient(redteamToken);
// PATCH with a non-empty redteam field triggers auto-transition
const r = await client.patch(`/simulations/${sim.id}`, { name: 'trigger transition' });
expect(r.status).toBe(200);
expect(r.data.status).toBe('in_progress');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.2 — auto-transition does NOT trigger for soc PATCH', async () => {
// Create sim then transition it to review_required so SOC can patch it
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.2 soc no-trigger');
const rtClient = makeClient(redteamToken);
// Move to in_progress first
await rtClient.patch(`/simulations/${sim.id}`, { name: 'AC-8.2 soc no-trigger' });
// Move to review_required
const tr = await rtClient.post(`/simulations/${sim.id}/transition`, {
to: 'review_required',
});
expect(tr.status).toBe(200);
// Now SOC patches soc-only fields — status must NOT change
const socClient = makeClient(socToken);
const rSoc = await socClient.patch(`/simulations/${sim.id}`, { soc_comment: 'noted' });
expect(rSoc.status).toBe(200);
expect(rSoc.data.status).toBe('review_required');
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.3 — commands stored and returned as raw multiline string', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.3 sim');
const client = makeClient(redteamToken);
const cmds = 'cmd1\ncmd2\ncmd3';
const r = await client.patch(`/simulations/${sim.id}`, { commands: cmds });
expect(r.status).toBe(200);
expect(r.data.commands).toBe(cmds);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.4 — invalid executed_at → 400; null clears field', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.4 sim');
const client = makeClient(redteamToken);
const rBad = await client.patch(`/simulations/${sim.id}`, {
executed_at: 'not-a-date',
});
expect(rBad.status).toBe(400);
expect(rBad.data.error).toMatch(/invalid executed_at/i);
// Valid ISO 8601
const rOk = await client.patch(`/simulations/${sim.id}`, {
executed_at: '2026-05-01T09:00:00',
});
expect(rOk.status).toBe(200);
// Null clears the field
const rNull = await client.patch(`/simulations/${sim.id}`, { executed_at: null });
expect(rNull.status).toBe(200);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.5 — edit page shows Red Team and SOC sections; redteam sees both editable, name validation', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.5 sim');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// Both sections visible
await expect(page.getByRole('heading', { name: /red team/i })).toBeVisible();
await expect(page.getByRole('heading', { name: /soc/i })).toBeVisible();
// Name field is present and enabled for redteam
const nameField = page.locator('#sim-name');
await expect(nameField).toBeVisible();
await expect(nameField).toBeEnabled();
// Clear the name, try to save → client validation error
await nameField.fill('');
await page.getByRole('button', { name: /save red team/i }).click();
await expect(page.getByText(/name is required/i)).toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-8.6 — MITRE technique picker is present on the edit form', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-8.6 sim');
await seedTokenInStorage(context, redteamToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// MitreTechniquePicker renders an input with combobox role
await expect(page.getByRole('combobox', { name: /mitre technique/i })).toBeVisible();
await deleteSimulation(redteamToken, sim.id);
});
});

View File

@@ -0,0 +1,200 @@
/**
* US-9 — SOC analyst fills their part; redteam fields blocked.
* Covers AC-9.1 → AC-9.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 = 'us9-redteam';
const SOC_USER = 'us9-soc';
const PASS = 'us9-pass-strong';
interface Simulation {
id: number;
status: string;
[key: string]: unknown;
}
async function createSimulation(
token: string,
engagementId: number,
name = 'US-9 sim',
): Promise<Simulation> {
const client = makeClient(token);
const r = await client.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;
}
async function deleteSimulation(token: string, simId: number): Promise<void> {
const client = makeClient(token);
await client.delete(`/simulations/${simId}`);
}
async function advanceToReviewRequired(
redteamToken: string,
simId: number,
): Promise<void> {
const client = makeClient(redteamToken);
// Trigger auto-transition to in_progress
await client.patch(`/simulations/${simId}`, { name: 'ready' });
// Transition to review_required
const r = await client.post(`/simulations/${simId}/transition`, {
to: 'review_required',
});
if (r.status !== 200) {
throw new Error(`transition to review_required failed: ${r.status}`);
}
}
test.describe('US-9 — SOC restricted edit', () => {
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-9 Test 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-9.1 — soc PATCH with redteam field → 403 soc cannot edit redteam fields', async () => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-9.1 sim');
await advanceToReviewRequired(redteamToken, sim.id);
const socClient = makeClient(socToken);
// Redteam field in payload → 403
const r = await socClient.patch(`/simulations/${sim.id}`, {
name: 'SOC tries to change name',
});
expect(r.status).toBe(403);
expect(r.data.error).toMatch(/soc cannot edit redteam fields/i);
// SOC-only fields → 200
const rOk = await socClient.patch(`/simulations/${sim.id}`, {
soc_comment: 'Detected',
log_source: 'SIEM',
logs: 'log entry',
incident_number: 'INC-001',
});
expect(rOk.status).toBe(200);
await deleteSimulation(redteamToken, sim.id);
});
test('AC-9.2 — soc PATCH blocked when status is pending or in_progress', async () => {
const socClient = makeClient(socToken);
// pending → 403
const simPending = await createSimulation(redteamToken, engagementId, 'AC-9.2 pending');
const rPending = await socClient.patch(`/simulations/${simPending.id}`, {
soc_comment: 'too early',
});
expect(rPending.status).toBe(403);
expect(rPending.data.error).toMatch(/simulation not ready for SOC review/i);
// in_progress → 403
const simInProgress = await createSimulation(
redteamToken,
engagementId,
'AC-9.2 in_progress',
);
const rtClient = makeClient(redteamToken);
await rtClient.patch(`/simulations/${simInProgress.id}`, { name: 'trigger' });
const rInProgress = await socClient.patch(`/simulations/${simInProgress.id}`, {
soc_comment: 'still too early',
});
expect(rInProgress.status).toBe(403);
expect(rInProgress.data.error).toMatch(/simulation not ready for SOC review/i);
await deleteSimulation(redteamToken, simPending.id);
await deleteSimulation(redteamToken, simInProgress.id);
});
test('AC-9.3 — edit page for soc: RT section read-only, SOC section editable when review_required', async ({
page,
context,
}) => {
const sim = await createSimulation(redteamToken, engagementId, 'AC-9.3 sim');
await advanceToReviewRequired(redteamToken, sim.id);
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
// RT fields are disabled
await expect(page.locator('#sim-name')).toBeDisabled();
await expect(page.locator('#sim-description')).toBeDisabled();
await expect(page.locator('#sim-commands')).toBeDisabled();
// SOC fields are enabled
await expect(page.locator('#sim-log-source')).toBeEnabled();
await expect(page.locator('#sim-logs')).toBeEnabled();
await expect(page.locator('#sim-soc-comment')).toBeEnabled();
await expect(page.locator('#sim-incident')).toBeEnabled();
await deleteSimulation(redteamToken, sim.id);
});
test('AC-9.4 — soc visits pending/in_progress simulation: banner visible, SOC fields disabled', async ({
page,
context,
}) => {
// Test with pending status
const simPending = await createSimulation(redteamToken, engagementId, 'AC-9.4 pending');
await seedTokenInStorage(context, socToken);
await page.goto(`/engagements/${engagementId}/simulations/${simPending.id}/edit`);
// Banner must be visible
await expect(page.getByTestId('soc-blocked-banner')).toBeVisible();
await expect(
page.getByText(/simulation pas encore en revue/i),
).toBeVisible();
// SOC fields are disabled
await expect(page.locator('#sim-log-source')).toBeDisabled();
await expect(page.locator('#sim-soc-comment')).toBeDisabled();
// Test with in_progress status
const simIP = await createSimulation(redteamToken, engagementId, 'AC-9.4 in_progress');
const rtClient = makeClient(redteamToken);
await rtClient.patch(`/simulations/${simIP.id}`, { name: 'trigger' });
await page.goto(`/engagements/${engagementId}/simulations/${simIP.id}/edit`);
await expect(page.getByTestId('soc-blocked-banner')).toBeVisible();
await expect(page.locator('#sim-log-source')).toBeDisabled();
await deleteSimulation(redteamToken, simPending.id);
await deleteSimulation(redteamToken, simIP.id);
});
});

View File

@@ -7,6 +7,7 @@ import { EngagementsListPage } from '@/pages/EngagementsListPage';
import { EngagementFormPage } from '@/pages/EngagementFormPage'; 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';
/** /**
* Router. Auth + role gates handled by <ProtectedRoute />. * Router. Auth + role gates handled by <ProtectedRoute />.
@@ -29,8 +30,15 @@ export function App(): JSX.Element {
<Route element={<ProtectedRoute roles={['admin', 'redteam']} />}> <Route element={<ProtectedRoute roles={['admin', 'redteam']} />}>
<Route path="/engagements/new" element={<EngagementFormPage />} /> <Route path="/engagements/new" element={<EngagementFormPage />} />
<Route path="/engagements/:id/edit" element={<EngagementFormPage />} /> <Route path="/engagements/:id/edit" element={<EngagementFormPage />} />
<Route path="/engagements/:eid/simulations/new" element={<SimulationFormPage />} />
</Route> </Route>
{/* simulation edit — all authenticated roles, RBAC handled inside the page */}
<Route
path="/engagements/:eid/simulations/:sid/edit"
element={<SimulationFormPage />}
/>
{/* admin-only routes */} {/* admin-only routes */}
<Route element={<ProtectedRoute roles={['admin']} />}> <Route element={<ProtectedRoute roles={['admin']} />}>
<Route path="/admin/users" element={<UsersAdminPage />} /> <Route path="/admin/users" element={<UsersAdminPage />} />

View File

@@ -0,0 +1,9 @@
import { apiClient } from './client';
import type { MitreTechnique } from './types';
export async function searchMitreTechniques(query: string): Promise<MitreTechnique[]> {
const { data } = await apiClient.get<MitreTechnique[]>('/mitre/techniques', {
params: { q: query },
});
return data;
}

View File

@@ -0,0 +1,37 @@
import { apiClient } from './client';
import type { Simulation, SimulationCreateInput, SimulationPatchInput, SimulationStatus } from './types';
export async function listSimulations(engagementId: number): Promise<Simulation[]> {
const { data } = await apiClient.get<Simulation[]>(`/engagements/${engagementId}/simulations`);
return data;
}
export async function createSimulation(
engagementId: number,
input: SimulationCreateInput,
): Promise<Simulation> {
const { data } = await apiClient.post<Simulation>(`/engagements/${engagementId}/simulations`, input);
return data;
}
export async function getSimulation(id: number): Promise<Simulation> {
const { data } = await apiClient.get<Simulation>(`/simulations/${id}`);
return data;
}
export async function updateSimulation(id: number, patch: SimulationPatchInput): Promise<Simulation> {
const { data } = await apiClient.patch<Simulation>(`/simulations/${id}`, patch);
return data;
}
export async function deleteSimulation(id: number): Promise<void> {
await apiClient.delete(`/simulations/${id}`);
}
export async function transitionSimulation(
id: number,
to: Extract<SimulationStatus, 'review_required' | 'done'>,
): Promise<Simulation> {
const { data } = await apiClient.post<Simulation>(`/simulations/${id}/transition`, { to });
return data;
}

View File

@@ -52,3 +52,51 @@ export interface UserPatchInput {
export interface ApiError { export interface ApiError {
error: string; error: string;
} }
export type SimulationStatus = 'pending' | 'in_progress' | 'review_required' | 'done';
export interface MitreTechnique {
id: string;
name: string;
tactics: string[];
}
export interface Simulation {
id: number;
engagement_id: number;
name: string;
mitre_technique_id: string | null;
mitre_technique_name: string | null;
description: string | null;
commands: string | null;
prerequisites: string | null;
executed_at: string | null;
execution_result: string | null;
log_source: string | null;
logs: string | null;
soc_comment: string | null;
incident_number: string | null;
status: SimulationStatus;
created_at: string;
updated_at: string | null;
created_by: { id: number; username: string };
}
export interface SimulationCreateInput {
name: string;
}
export interface SimulationPatchInput {
name?: string;
mitre_technique_id?: string | null;
mitre_technique_name?: string | null;
description?: string | null;
commands?: string | null;
prerequisites?: string | null;
executed_at?: string | null;
execution_result?: string | null;
log_source?: string | null;
logs?: string | null;
soc_comment?: string | null;
incident_number?: string | null;
}

View File

@@ -0,0 +1,48 @@
interface ConfirmDialogProps {
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => void;
onCancel: () => void;
destructive?: boolean;
}
export function ConfirmDialog({
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
onConfirm,
onCancel,
destructive = false,
}: ConfirmDialogProps): JSX.Element {
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="absolute inset-0 bg-ink/40" onClick={onCancel} aria-hidden="true" />
<div className="relative card-product shadow-floating max-w-sm w-full mx-md flex flex-col gap-md">
<h2 id="confirm-dialog-title" className="text-[20px] font-medium text-ink">
{title}
</h2>
<p className="text-[16px] text-charcoal">{description}</p>
<div className="flex items-center gap-md pt-xs">
<button
type="button"
className={destructive ? 'btn-ink' : 'btn-primary'}
onClick={onConfirm}
>
{confirmLabel}
</button>
<button type="button" className="btn-outline-ink" onClick={onCancel}>
{cancelLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
import {
useEffect,
useRef,
useState,
type KeyboardEvent,
} from 'react';
import { extractApiError } from '@/api/client';
import type { MitreTechnique } from '@/api/types';
import { useMitreSearch } from '@/hooks/useMitre';
interface MitreTechniquePickerProps {
techniqueId: string | null;
techniqueName: string | null;
onChange: (id: string | null, name: string | null) => void;
disabled?: boolean;
}
function formatOption(t: MitreTechnique): string {
const tacticList = t.tactics.length > 0 ? ` (${t.tactics[0]})` : '';
return `${t.id}${t.name}${tacticList}`;
}
const DEBOUNCE_MS = 200;
export function MitreTechniquePicker({
techniqueId,
techniqueName,
onChange,
disabled = false,
}: MitreTechniquePickerProps): JSX.Element {
const [inputValue, setInputValue] = useState(
techniqueId && techniqueName ? `${techniqueId}${techniqueName}` : '',
);
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// True once we've synced the first real techniqueId from props (parent/API load).
// After that we stop reacting to null, so keystrokes that emit onChange(null,null)
// don't propagate back and wipe the input mid-stroke.
const hasHydratedFromProps = useRef(false);
useEffect(() => {
if (techniqueId && techniqueName) {
setInputValue(`${techniqueId}${techniqueName}`);
hasHydratedFromProps.current = true;
} else if (!techniqueId && !hasHydratedFromProps.current) {
setInputValue('');
}
}, [techniqueId, techniqueName]);
const { data: results, isFetching, isError, error } = useMitreSearch(query, open);
const items = results ?? [];
const handleInputChange = (value: string) => {
setInputValue(value);
// Clear the selection when user starts typing
onChange(null, null);
setOpen(true);
setActiveIndex(-1);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setQuery(value);
}, DEBOUNCE_MS);
};
const selectItem = (item: MitreTechnique) => {
setInputValue(formatOption(item));
onChange(item.id, item.name);
setOpen(false);
setActiveIndex(-1);
setQuery('');
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (!open || items.length === 0) {
if (e.key === 'Escape') setOpen(false);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && items[activeIndex]) {
selectItem(items[activeIndex]);
}
} else if (e.key === 'Escape') {
setOpen(false);
setActiveIndex(-1);
}
};
// Scroll active item into view
useEffect(() => {
if (activeIndex >= 0 && listRef.current) {
const el = listRef.current.children[activeIndex] as HTMLElement | undefined;
el?.scrollIntoView?.({ block: 'nearest' });
}
}, [activeIndex]);
// Close dropdown on click outside
useEffect(() => {
const onPointerDown = (e: PointerEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, []);
const listboxId = 'mitre-picker-listbox';
return (
<div ref={containerRef} className="relative">
<input
type="text"
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined}
aria-label="MITRE technique"
className="text-input"
value={inputValue}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => {
if (!techniqueId) setOpen(true);
}}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder="Search by ID or name (e.g. T1059)"
autoComplete="off"
/>
{open && (
<div className="absolute z-20 w-full mt-xxs bg-canvas border border-steel rounded-md shadow-floating overflow-hidden">
{isFetching && (
<div className="px-md py-sm text-[14px] text-graphite">Searching</div>
)}
{isError && !isFetching && (
<div className="px-md py-sm text-[14px] text-bloom-deep" role="alert">
{extractApiError(error, 'MITRE search unavailable')}
</div>
)}
{!isFetching && !isError && items.length === 0 && query.trim().length > 0 && (
<div className="px-md py-sm text-[14px] text-graphite">No results</div>
)}
{!isFetching && items.length > 0 && (
<ul
id={listboxId}
ref={listRef}
role="listbox"
aria-label="MITRE techniques"
className="max-h-64 overflow-y-auto"
>
{items.map((item, i) => (
<li
key={item.id}
id={`mitre-option-${i}`}
role="option"
aria-selected={i === activeIndex}
className={`px-md py-sm text-[14px] cursor-pointer select-none ${
i === activeIndex ? 'bg-primary-soft text-ink' : 'text-ink hover:bg-cloud'
}`}
onPointerDown={(e) => {
// Prevent input blur before we handle the click
e.preventDefault();
selectItem(item);
}}
>
<span className="font-medium">{item.id}</span>
<span className="text-charcoal"> {item.name}</span>
{item.tactics.length > 0 && (
<span className="text-graphite"> ({item.tactics[0]})</span>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { Link, useNavigate } from 'react-router-dom';
import { extractApiError } from '@/api/client';
import { useAuth } from '@/hooks/useAuth';
import { useEngagementSimulations } from '@/hooks/useSimulations';
import { LoadingState } from './LoadingState';
import { ErrorState } from './ErrorState';
import { EmptyState } from './EmptyState';
import { SimulationStatusBadge } from './SimulationStatusBadge';
interface SimulationListProps {
engagementId: number;
}
function formatDate(value: string | null): string {
if (!value) return '—';
return value.replace('T', ' ').slice(0, 16);
}
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
const { canEditEngagements } = useAuth();
const navigate = useNavigate();
if (isLoading) return <LoadingState label="Loading simulations…" />;
if (isError) {
return (
<ErrorState
message={extractApiError(error, 'Could not load simulations')}
onRetry={() => refetch()}
/>
);
}
if (!data || data.length === 0) {
return (
<EmptyState
title="No simulations yet"
description="Create the first simulation to start tracking red team tests."
action={
canEditEngagements ? (
<Link
to={`/engagements/${engagementId}/simulations/new`}
className="btn-primary"
data-testid="new-simulation-btn"
>
Nouvelle simulation
</Link>
) : undefined
}
/>
);
}
return (
<div className="flex flex-col gap-md">
<div className="flex items-center justify-between">
<h2 className="text-[24px] font-medium text-ink">Simulations</h2>
{canEditEngagements ? (
<Link
to={`/engagements/${engagementId}/simulations/new`}
className="btn-primary"
data-testid="new-simulation-btn"
>
Nouvelle simulation
</Link>
) : null}
</div>
<div className="card-product overflow-hidden p-0">
<table className="w-full text-left">
<thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
<th className="px-xl py-md">Name</th>
<th className="px-xl py-md">MITRE</th>
<th className="px-xl py-md">Status</th>
<th className="px-xl py-md">Executed at</th>
</tr>
</thead>
<tbody>
{data.map((sim) => (
<tr
key={sim.id}
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
onClick={() =>
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`)
}
>
<td className="px-xl py-md">
<Link
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
className="text-ink font-medium hover:underline"
>
{sim.name}
</Link>
</td>
<td className="px-xl py-md text-charcoal text-[14px]">
{sim.mitre_technique_id ?? '—'}
</td>
<td className="px-xl py-md">
<SimulationStatusBadge status={sim.status} />
</td>
<td className="px-xl py-md text-charcoal text-[14px]">
{formatDate(sim.executed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { SimulationStatus } from '@/api/types';
const LABELS: Record<SimulationStatus, string> = {
pending: 'Pending',
in_progress: 'In progress',
review_required: 'Review required',
done: 'Done',
};
// pending=fog, in_progress=primary-soft, review_required=bloom-coral, done=storm-deep
const STYLES: Record<SimulationStatus, string> = {
pending: 'bg-fog text-charcoal border border-hairline',
in_progress: 'bg-primary-soft text-primary-deep',
review_required: 'bg-bloom-coral text-canvas',
done: 'bg-storm-deep text-canvas',
};
export function SimulationStatusBadge({ status }: { status: SimulationStatus }): JSX.Element {
return (
<span
className={`inline-flex items-center rounded-lg px-3 py-[6px] text-[14px] leading-[1.3] font-medium ${STYLES[status]}`}
data-testid="simulation-status-badge"
data-status={status}
>
{LABELS[status]}
</span>
);
}

View File

@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { searchMitreTechniques } from '@/api/mitre';
export function useMitreSearch(query: string, enabled: boolean) {
return useQuery({
queryKey: ['mitre', 'techniques', query],
queryFn: () => searchMitreTechniques(query),
enabled: enabled && query.trim().length > 0,
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -0,0 +1,76 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
createSimulation,
deleteSimulation,
getSimulation,
listSimulations,
transitionSimulation,
updateSimulation,
} from '@/api/simulations';
import type { SimulationCreateInput, SimulationPatchInput, SimulationStatus } from '@/api/types';
function simulationsKey(engagementId: number) {
return ['engagements', engagementId, 'simulations'] as const;
}
function simulationKey(id: number) {
return ['simulations', id] as const;
}
export function useEngagementSimulations(engagementId: number | undefined) {
return useQuery({
queryKey: engagementId ? simulationsKey(engagementId) : ['simulations', 'none'],
queryFn: () => listSimulations(engagementId as number),
enabled: typeof engagementId === 'number' && !Number.isNaN(engagementId),
});
}
export function useSimulation(id: number | undefined) {
return useQuery({
queryKey: id ? simulationKey(id) : ['simulations', 'none'],
queryFn: () => getSimulation(id as number),
enabled: typeof id === 'number' && !Number.isNaN(id),
});
}
export function useCreateSimulation(engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: SimulationCreateInput) => createSimulation(engagementId, input),
onSuccess: () => qc.invalidateQueries({ queryKey: simulationsKey(engagementId) }),
});
}
export function useUpdateSimulation(id: number, engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (patch: SimulationPatchInput) => updateSimulation(id, patch),
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
},
});
}
export function useDeleteSimulation(engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteSimulation(id),
onSuccess: (_data, id) => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
},
});
}
export function useTransitionSimulation(id: number, engagementId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (to: Extract<SimulationStatus, 'review_required' | 'done'>) =>
transitionSimulation(id, to),
onSuccess: () => {
qc.invalidateQueries({ queryKey: simulationKey(id) });
qc.invalidateQueries({ queryKey: simulationsKey(engagementId) });
},
});
}

View File

@@ -5,6 +5,7 @@ import { useEngagement } from '@/hooks/useEngagements';
import { LoadingState } from '@/components/LoadingState'; import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState'; import { ErrorState } from '@/components/ErrorState';
import { StatusBadge } from '@/components/StatusBadge'; import { StatusBadge } from '@/components/StatusBadge';
import { SimulationList } from '@/components/SimulationList';
export function EngagementDetailPage(): JSX.Element { export function EngagementDetailPage(): JSX.Element {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -71,13 +72,8 @@ export function EngagementDetailPage(): JSX.Element {
</div> </div>
</section> </section>
{/* Sprint 2 placeholder per AC-4.9 */} <section>
<section className="bg-ink text-ink-on rounded-xl p-xxl"> <SimulationList engagementId={eng.id} />
<h2 className="text-[32px] font-medium leading-none">Simulations</h2>
<p className="text-[16px] mt-sm text-steel">
Simulations à venir au Sprint 2 tracking of red team tests and SOC detection coverage
will live here.
</p>
</section> </section>
</div> </div>
); );

View File

@@ -0,0 +1,507 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { extractApiError } from '@/api/client';
import type { SimulationPatchInput } from '@/api/types';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/useToast';
import {
useCreateSimulation,
useDeleteSimulation,
useSimulation,
useTransitionSimulation,
useUpdateSimulation,
} from '@/hooks/useSimulations';
import { FormField, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
interface RedteamFormState {
name: string;
mitre_technique_id: string | null;
mitre_technique_name: string | null;
description: string;
commands: string;
prerequisites: string;
executed_at: string;
execution_result: string;
}
interface SocFormState {
log_source: string;
logs: string;
soc_comment: string;
incident_number: string;
}
const EMPTY_RT: RedteamFormState = {
name: '',
mitre_technique_id: null,
mitre_technique_name: null,
description: '',
commands: '',
prerequisites: '',
executed_at: '',
execution_result: '',
};
const EMPTY_SOC: SocFormState = {
log_source: '',
logs: '',
soc_comment: '',
incident_number: '',
};
export function SimulationFormPage(): JSX.Element {
const { eid, sid } = useParams<{ eid: string; sid: string }>();
const engagementId = eid ? Number(eid) : undefined;
const simulationId = sid ? Number(sid) : undefined;
const isNew = !simulationId;
const navigate = useNavigate();
const { push } = useToast();
const { isAdmin, isRedteam, isSoc, canEditEngagements } = useAuth();
const detail = useSimulation(isNew ? undefined : simulationId);
const createMutation = useCreateSimulation(engagementId ?? 0);
const updateMutation = useUpdateSimulation(simulationId ?? 0, engagementId ?? 0);
const deleteMutation = useDeleteSimulation(engagementId ?? 0);
const transitionMutation = useTransitionSimulation(simulationId ?? 0, engagementId ?? 0);
const [rt, setRt] = useState<RedteamFormState>(EMPTY_RT);
const [soc, setSoc] = useState<SocFormState>(EMPTY_SOC);
const [nameError, setNameError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
if (!isNew && detail.data) {
const s = detail.data;
setRt({
name: s.name,
mitre_technique_id: s.mitre_technique_id,
mitre_technique_name: s.mitre_technique_name,
description: s.description ?? '',
commands: s.commands ?? '',
prerequisites: s.prerequisites ?? '',
executed_at: s.executed_at ? s.executed_at.slice(0, 16) : '',
execution_result: s.execution_result ?? '',
});
setSoc({
log_source: s.log_source ?? '',
logs: s.logs ?? '',
soc_comment: s.soc_comment ?? '',
incident_number: s.incident_number ?? '',
});
}
}, [isNew, detail.data]);
if (!isNew && detail.isLoading) return <LoadingState label="Loading simulation…" />;
if (!isNew && detail.isError) {
return (
<ErrorState
message={extractApiError(detail.error, 'Could not load simulation')}
onRetry={() => detail.refetch()}
/>
);
}
const simulation = detail.data;
const status = simulation?.status;
// Role-based field locking
const canEditRT = isAdmin || isRedteam;
// SOC can only edit when status is review_required or done
const socCanEdit = isSoc && (status === 'review_required' || status === 'done');
const socBlocked = isSoc && (status === 'pending' || status === 'in_progress');
const canSaveSoc = socCanEdit || canEditEngagements;
const rtDisabled = !canEditRT;
const socDisabled = !canEditEngagements && !socCanEdit;
// Transition buttons visibility
const showMarkReview =
canEditEngagements && (status === 'pending' || status === 'in_progress');
const showClose =
(canEditEngagements || isSoc) && status === 'review_required';
const onSubmitNew = async (e: FormEvent) => {
e.preventDefault();
setNameError(null);
setSubmitError(null);
if (!rt.name.trim()) {
setNameError('Name is required');
return;
}
try {
const created = await createMutation.mutateAsync({ name: rt.name.trim() });
push('Simulation créée', 'success');
navigate(`/engagements/${engagementId}/simulations/${created.id}/edit`);
} catch (err) {
setSubmitError(extractApiError(err, 'Could not create simulation'));
}
};
const onSaveRT = async (e: FormEvent) => {
e.preventDefault();
setNameError(null);
setSubmitError(null);
if (!rt.name.trim()) {
setNameError('Name is required');
return;
}
const patch: SimulationPatchInput = {
name: rt.name.trim(),
mitre_technique_id: rt.mitre_technique_id ?? null,
mitre_technique_name: rt.mitre_technique_name ?? null,
description: rt.description.trim() || null,
commands: rt.commands.trim() || null,
prerequisites: rt.prerequisites.trim() || null,
executed_at: rt.executed_at || null,
execution_result: rt.execution_result.trim() || null,
};
try {
await updateMutation.mutateAsync(patch);
push('Simulation mise à jour', 'success');
} catch (err) {
setSubmitError(extractApiError(err, 'Could not update simulation'));
}
};
const onSaveSOC = async (e: FormEvent) => {
e.preventDefault();
setSubmitError(null);
const patch: SimulationPatchInput = {
log_source: soc.log_source.trim() || null,
logs: soc.logs.trim() || null,
soc_comment: soc.soc_comment.trim() || null,
incident_number: soc.incident_number.trim() || null,
};
try {
await updateMutation.mutateAsync(patch);
push('Rapport SOC mis à jour', 'success');
} catch (err) {
setSubmitError(extractApiError(err, 'Could not update SOC fields'));
}
};
const onMarkReview = async () => {
try {
await transitionMutation.mutateAsync('review_required');
push('Simulation marquée en revue', 'success');
} catch (err) {
push(extractApiError(err, 'Transition impossible'), 'error');
}
};
const onClose = async () => {
try {
await transitionMutation.mutateAsync('done');
push('Simulation clôturée', 'success');
} catch (err) {
push(extractApiError(err, 'Transition impossible'), 'error');
}
};
const onDelete = async () => {
setShowDeleteConfirm(false);
try {
await deleteMutation.mutateAsync(simulationId as number);
push('Simulation supprimée', 'success');
navigate(`/engagements/${engagementId}`);
} catch (err) {
push(extractApiError(err, 'Suppression impossible'), 'error');
}
};
// New simulation form (minimal)
if (isNew) {
const submitting = createMutation.isPending;
return (
<div className="flex flex-col gap-xl max-w-2xl">
<header>
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement
</Link>
<h1 className="text-[44px] font-medium leading-none mt-sm">Nouvelle simulation</h1>
</header>
<form onSubmit={onSubmitNew} noValidate className="card-product flex flex-col gap-md">
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
<TextInput
id="sim-name"
name="name"
value={rt.name}
onChange={(e) => setRt({ ...rt, name: e.target.value })}
required
/>
</FormField>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</div>
) : null}
<div className="flex items-center gap-md pt-sm">
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create simulation'}
</button>
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">
Cancel
</Link>
</div>
</form>
</div>
);
}
// Edit form
const submitting =
updateMutation.isPending || transitionMutation.isPending || deleteMutation.isPending;
return (
<div className="flex flex-col gap-xl max-w-3xl">
<header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm">
<Link to={`/engagements/${engagementId}`} className="btn-text-link text-[14px]">
Back to engagement
</Link>
<h1 className="text-[44px] font-medium leading-none">{rt.name || simulation?.name}</h1>
{status ? (
<div className="flex items-center gap-md">
<SimulationStatusBadge status={status} />
{simulation?.created_by && (
<span className="text-[14px] text-graphite">
Created by <span className="text-ink">{simulation.created_by.username}</span>
</span>
)}
</div>
) : null}
</div>
</header>
{/* SOC banner — shown when soc user visits pending/in_progress */}
{socBlocked && (
<div
role="alert"
data-testid="soc-blocked-banner"
className="rounded-xl px-xl py-md bg-fog border border-hairline text-[14px] text-charcoal"
>
Simulation pas encore en revue la redteam doit la marquer comme &quot;Review required&quot; avant
que vous puissiez intervenir.
</div>
)}
{/* Red Team card */}
<form
id="rt-form"
onSubmit={canEditRT ? onSaveRT : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">Red Team</h2>
<FormField label="Name" htmlFor="sim-name" required error={nameError}>
<TextInput
id="sim-name"
name="name"
value={rt.name}
onChange={(e) => setRt({ ...rt, name: e.target.value })}
disabled={rtDisabled}
required
/>
</FormField>
<FormField label="MITRE Technique" htmlFor="sim-mitre">
<MitreTechniquePicker
techniqueId={rt.mitre_technique_id}
techniqueName={rt.mitre_technique_name}
onChange={(id, name) =>
setRt({ ...rt, mitre_technique_id: id, mitre_technique_name: name })
}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Description" htmlFor="sim-description">
<TextArea
id="sim-description"
name="description"
value={rt.description}
onChange={(e) => setRt({ ...rt, description: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField
label="Commands"
htmlFor="sim-commands"
hint="One command per line"
>
<TextArea
id="sim-commands"
name="commands"
value={rt.commands}
onChange={(e) => setRt({ ...rt, commands: e.target.value })}
disabled={rtDisabled}
className="min-h-[160px] font-mono text-[14px]"
/>
</FormField>
<FormField label="Prerequisites" htmlFor="sim-prerequisites">
<TextArea
id="sim-prerequisites"
name="prerequisites"
value={rt.prerequisites}
onChange={(e) => setRt({ ...rt, prerequisites: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<div className="grid grid-cols-1 md:grid-cols-2 gap-md">
<FormField label="Executed at" htmlFor="sim-executed-at">
<TextInput
id="sim-executed-at"
type="datetime-local"
name="executed_at"
value={rt.executed_at}
onChange={(e) => setRt({ ...rt, executed_at: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
<FormField label="Execution result" htmlFor="sim-exec-result">
<TextInput
id="sim-exec-result"
name="execution_result"
value={rt.execution_result}
onChange={(e) => setRt({ ...rt, execution_result: e.target.value })}
disabled={rtDisabled}
/>
</FormField>
</div>
{canEditRT && (
<div className="flex items-center gap-md pt-sm border-t border-hairline">
<button type="submit" form="rt-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save Red Team'}
</button>
</div>
)}
</form>
{/* SOC card */}
<form
id="soc-form"
onSubmit={canSaveSoc ? onSaveSOC : (e) => e.preventDefault()}
noValidate
className="card-product flex flex-col gap-md"
>
<h2 className="text-[20px] font-medium text-ink">SOC</h2>
<FormField label="Log source" htmlFor="sim-log-source">
<TextInput
id="sim-log-source"
name="log_source"
value={soc.log_source}
onChange={(e) => setSoc({ ...soc, log_source: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Logs" htmlFor="sim-logs">
<TextArea
id="sim-logs"
name="logs"
value={soc.logs}
onChange={(e) => setSoc({ ...soc, logs: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="SOC comment" htmlFor="sim-soc-comment">
<TextArea
id="sim-soc-comment"
name="soc_comment"
value={soc.soc_comment}
onChange={(e) => setSoc({ ...soc, soc_comment: e.target.value })}
disabled={socDisabled}
/>
</FormField>
<FormField label="Incident number" htmlFor="sim-incident">
<TextInput
id="sim-incident"
name="incident_number"
value={soc.incident_number}
onChange={(e) => setSoc({ ...soc, incident_number: e.target.value })}
disabled={socDisabled}
/>
</FormField>
{canSaveSoc && (
<div className="flex items-center gap-md pt-sm border-t border-hairline">
<button type="submit" form="soc-form" className="btn-primary" disabled={submitting}>
{updateMutation.isPending ? 'Saving…' : 'Save SOC'}
</button>
</div>
)}
</form>
{submitError ? (
<div role="alert" className="text-[14px] text-bloom-deep">
{submitError}
</div>
) : null}
{/* Workflow + delete footer */}
<div className="flex items-center gap-md flex-wrap">
{showMarkReview && (
<button
type="button"
className="btn-outline"
onClick={onMarkReview}
disabled={transitionMutation.isPending}
>
Marquer en revue
</button>
)}
{showClose && (
<button
type="button"
className="btn-outline"
onClick={onClose}
disabled={transitionMutation.isPending}
>
Clôturer
</button>
)}
{canEditEngagements && simulationId && (
<button
type="button"
className="btn-text-link text-bloom-deep"
onClick={() => setShowDeleteConfirm(true)}
disabled={submitting}
>
Supprimer
</button>
)}
</div>
{showDeleteConfirm && (
<ConfirmDialog
title="Supprimer la simulation"
description="Cette action est irréversible. La simulation sera définitivement supprimée."
confirmLabel="Supprimer"
cancelLabel="Annuler"
destructive
onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
import { renderWithProviders } from './utils';
import type { MitreTechnique } from '@/api/types';
const TECHNIQUES: MitreTechnique[] = [
{ id: 'T1059', name: 'Command and Scripting Interpreter', tactics: ['execution'] },
{ id: 'T1059.001', name: 'PowerShell', tactics: ['execution'] },
{ id: 'T1021', name: 'Remote Services', tactics: ['lateral-movement'] },
];
describe('MitreTechniquePicker', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
mock.restore();
vi.useRealTimers();
});
it('renders input with placeholder', () => {
vi.useRealTimers();
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/Search by ID or name/i)).toBeInTheDocument();
});
it('shows preselected value when techniqueId and name provided', () => {
vi.useRealTimers();
renderWithProviders(
<MitreTechniquePicker
techniqueId="T1059"
techniqueName="Command and Scripting Interpreter"
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox') as HTMLInputElement;
expect(input.value).toContain('T1059');
expect(input.value).toContain('Command and Scripting Interpreter');
});
it('is disabled when disabled prop is true', () => {
vi.useRealTimers();
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
disabled
/>,
);
expect(screen.getByRole('combobox')).toBeDisabled();
});
it('debounces search: no request fires before 200ms', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T');
// Before debounce fires
expect(mock.history.get.length).toBe(0);
// Advance past debounce
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => expect(mock.history.get.length).toBeGreaterThan(0));
});
it('displays results in dropdown after debounce', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
const options = screen.getAllByRole('option');
expect(options.length).toBeGreaterThan(0);
expect(options[0].textContent).toContain('T1059');
});
it('selecting a result calls onChange with id and name', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const onChange = vi.fn();
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={onChange}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => screen.getByRole('listbox'));
const options = screen.getAllByRole('option');
await user.click(options[0]);
expect(onChange).toHaveBeenCalledWith('T1059', 'Command and Scripting Interpreter');
});
it('populates input display string after selection', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox') as HTMLInputElement;
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => screen.getByRole('listbox'));
const options = screen.getAllByRole('option');
await user.click(options[0]);
expect(input.value).toContain('T1059');
expect(input.value).toContain('Command and Scripting Interpreter');
});
it('keyboard ArrowDown + Enter selects item', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const onChange = vi.fn();
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={onChange}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T105');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => screen.getByRole('listbox'));
await user.keyboard('{ArrowDown}');
await user.keyboard('{Enter}');
expect(onChange).toHaveBeenCalled();
});
it('Escape closes the dropdown', async () => {
mock.onGet('/mitre/techniques').reply(200, TECHNIQUES);
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => screen.getByRole('listbox'));
await user.keyboard('{Escape}');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('typing while techniqueId is null does not reset inputValue between keystrokes', async () => {
mock.onGet('/mitre/techniques').reply(200, []);
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox') as HTMLInputElement;
await user.click(input);
await user.type(input, 'T10');
// Input must retain the full typed value — no mid-stroke reset
expect(input.value).toBe('T10');
});
it('shows inline error when API returns 503', async () => {
mock.onGet('/mitre/techniques').reply(503, { error: 'mitre bundle not loaded' });
const user = userEvent.setup({ advanceTimers: (ms) => vi.advanceTimersByTime(ms) });
renderWithProviders(
<MitreTechniquePicker
techniqueId={null}
techniqueName={null}
onChange={vi.fn()}
/>,
);
const input = screen.getByRole('combobox');
await user.click(input);
await user.type(input, 'T1059');
act(() => { vi.advanceTimersByTime(300); });
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,275 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { SimulationFormPage } from '@/pages/SimulationFormPage';
import { renderWithProviders } from './utils';
import type { Simulation } from '@/api/types';
const BASE_SIM: Simulation = {
id: 7,
engagement_id: 42,
name: 'Recon test',
mitre_technique_id: null,
mitre_technique_name: null,
description: 'Some description',
commands: 'whoami\nipconfig',
prerequisites: null,
executed_at: null,
execution_result: null,
log_source: null,
logs: null,
soc_comment: null,
incident_number: null,
status: 'pending',
created_at: '2026-05-26T08:00:00',
updated_at: null,
created_by: { id: 1, username: 'alice' },
};
let mockRole: 'admin' | 'redteam' | 'soc' = 'redteam';
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: mockRole, created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: mockRole === 'admin',
isRedteam: mockRole === 'redteam',
isSoc: mockRole === 'soc',
canEditEngagements: mockRole === 'admin' || mockRole === 'redteam',
}),
}));
// Wrap the page in a Route so useParams gets eid and sid
function EditPage() {
return (
<Routes>
<Route path="/engagements/:eid/simulations/:sid/edit" element={<SimulationFormPage />} />
</Routes>
);
}
function NewPage() {
return (
<Routes>
<Route path="/engagements/:eid/simulations/new" element={<SimulationFormPage />} />
</Routes>
);
}
describe('SimulationFormPage — redteam mode (edit existing)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('renders loading state initially', () => {
mock.onGet('/simulations/7').reply(() => new Promise(() => {}));
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
});
it('all Red Team fields are enabled for redteam', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).not.toBeDisabled();
});
expect(screen.getByLabelText(/Description/i)).not.toBeDisabled();
expect(screen.getByLabelText(/Commands/i)).not.toBeDisabled();
expect(screen.getByLabelText(/Executed at/i)).not.toBeDisabled();
});
it('shows "Marquer en revue" button when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
});
});
it('does not show "Clôturer" when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => screen.getByRole('button', { name: /Marquer en revue/i }));
expect(screen.queryByRole('button', { name: /Clôturer/i })).toBeNull();
});
it('shows "Marquer en revue" for in_progress status', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'in_progress' });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Marquer en revue/i })).toBeInTheDocument();
});
});
it('shows "Clôturer" button when status is review_required', async () => {
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
});
});
it('shows "Supprimer" button for redteam', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Supprimer/i })).toBeInTheDocument();
});
});
});
describe('SimulationFormPage — SOC role + pending (blocked)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'soc';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, BASE_SIM);
});
afterEach(() => {
mock.restore();
});
it('shows the SOC blocked banner', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByTestId('soc-blocked-banner')).toBeInTheDocument();
});
});
it('SOC inputs are disabled when status is pending', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).toBeDisabled();
});
expect(screen.getByLabelText(/Incident number/i)).toBeDisabled();
});
it('Red Team inputs are disabled for SOC', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).toBeDisabled();
});
expect(screen.getByLabelText(/Description/i)).toBeDisabled();
});
});
describe('SimulationFormPage — SOC role + review_required (can edit SOC fields)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'soc';
mock = new MockAdapter(apiClient);
mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' });
});
afterEach(() => {
mock.restore();
});
it('SOC inputs are enabled when status is review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled();
});
expect(screen.getByLabelText(/Incident number/i)).not.toBeDisabled();
});
it('Red Team inputs remain disabled for SOC even when review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/^Name/i)).toBeDisabled();
});
});
it('does not show the blocked banner when status is review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByLabelText(/Log source/i)).not.toBeDisabled();
});
expect(screen.queryByTestId('soc-blocked-banner')).toBeNull();
});
it('shows "Clôturer" for SOC when review_required', async () => {
renderWithProviders(<EditPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] },
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /Clôturer/i })).toBeInTheDocument();
});
});
});
describe('SimulationFormPage — new simulation', () => {
let mock: MockAdapter;
beforeEach(() => {
mockRole = 'redteam';
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('renders the new simulation form with name field', () => {
renderWithProviders(<NewPage />, {
routerProps: { initialEntries: ['/engagements/42/simulations/new'] },
});
expect(screen.getByLabelText(/^Name/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create simulation/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { SimulationList } from '@/components/SimulationList';
import { renderWithProviders } from './utils';
import type { Simulation } from '@/api/types';
const SIMULATIONS: Simulation[] = [
{
id: 1,
engagement_id: 42,
name: 'Lateral movement test',
mitre_technique_id: 'T1021',
mitre_technique_name: 'Remote Services',
description: null,
commands: null,
prerequisites: null,
executed_at: '2026-06-01T10:00:00',
execution_result: null,
log_source: null,
logs: null,
soc_comment: null,
incident_number: null,
status: 'in_progress',
created_at: '2026-05-26T08:00:00',
updated_at: null,
created_by: { id: 1, username: 'alice' },
},
];
let mockCanEdit = true;
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: mockCanEdit ? 'admin' : 'soc', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: mockCanEdit,
isRedteam: false,
isSoc: !mockCanEdit,
canEditEngagements: mockCanEdit,
}),
}));
describe('SimulationList — admin/redteam', () => {
let mock: MockAdapter;
beforeEach(() => {
mockCanEdit = true;
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('shows loading state initially', () => {
mock.onGet('/engagements/42/simulations').reply(() => new Promise(() => {}));
renderWithProviders(<SimulationList engagementId={42} />);
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
});
it('shows error state when request fails', async () => {
mock.onGet('/engagements/42/simulations').reply(500, { error: 'Server error' });
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('error-state')).toBeInTheDocument();
});
});
it('shows empty state when no simulations', async () => {
mock.onGet('/engagements/42/simulations').reply(200, []);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
});
it('shows "Nouvelle simulation" button for admin/redteam in empty state', async () => {
mock.onGet('/engagements/42/simulations').reply(200, []);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
});
});
it('renders the simulation list with correct data', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
expect(screen.getByText('T1021')).toBeInTheDocument();
expect(screen.getByTestId('simulation-status-badge')).toHaveAttribute('data-status', 'in_progress');
});
it('shows "Nouvelle simulation" button in header when simulations exist', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
});
it('clicking a row uses SPA navigation and does not trigger window.location change', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
const originalHref = window.location.href;
renderWithProviders(<SimulationList engagementId={42} />, {
routerProps: { initialEntries: ['/engagements/42'] },
});
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
const row = screen.getByText('Lateral movement test').closest('tr') as HTMLElement;
fireEvent.click(row);
// window.location.href must be unchanged (no full-page reload)
expect(window.location.href).toBe(originalHref);
});
});
describe('SimulationList — SOC role (no edit button)', () => {
let mock: MockAdapter;
beforeEach(() => {
mockCanEdit = false;
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('does not show "Nouvelle simulation" button for SOC in empty state', async () => {
mock.onGet('/engagements/42/simulations').reply(200, []);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
expect(screen.queryByTestId('new-simulation-btn')).toBeNull();
});
it('does not show "Nouvelle simulation" button for SOC when simulations exist', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
expect(screen.queryByTestId('new-simulation-btn')).toBeNull();
});
});

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import type { SimulationStatus } from '@/api/types';
const CASES: { status: SimulationStatus; label: string }[] = [
{ status: 'pending', label: 'Pending' },
{ status: 'in_progress', label: 'In progress' },
{ status: 'review_required', label: 'Review required' },
{ status: 'done', label: 'Done' },
];
describe('SimulationStatusBadge', () => {
it.each(CASES)('renders $status with correct label and data attr', ({ status, label }) => {
render(<SimulationStatusBadge status={status} />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge).toHaveAttribute('data-status', status);
expect(badge.textContent).toBe(label);
});
it('applies fog surface for pending', () => {
render(<SimulationStatusBadge status="pending" />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-fog');
});
it('applies primary-soft surface for in_progress', () => {
render(<SimulationStatusBadge status="in_progress" />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-primary-soft');
});
it('applies bloom-coral surface for review_required', () => {
render(<SimulationStatusBadge status="review_required" />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-bloom-coral');
});
it('applies storm-deep surface for done', () => {
render(<SimulationStatusBadge status="done" />);
const badge = screen.getByTestId('simulation-status-badge');
expect(badge.className).toContain('bg-storm-deep');
});
});

View File

@@ -4,4 +4,24 @@ Recurring mistakes and the rule we adopted so the same issue doesn't bite twice.
--- ---
_(empty — to be filled by the team-lead at the end of each sprint, with input from builders and reviewers)_ ## Sprint 2 (closed 2026-05-26)
### Testing — Vitest module hoisting (frontend-builder)
**Context** : `vi.mock(path, factory)` is hoisted to module scope before any other statement runs. A `mockAuth(role)` helper that captures `role` in a closure crashes at runtime with `"role is not defined"` because the factory executes before the closure is set up.
**Lesson** : when a Vitest mock needs runtime-mutable state, declare the mutable in module scope (`let mockRole = 'redteam'`) and mutate it inside `beforeEach`. Closures over test-local variables don't survive the hoist.
### Testing — `useParams()` in MemoryRouter (frontend-builder)
**Context** : a page component that reads `useParams()` returns an empty object when rendered directly inside `<MemoryRouter>` — no params are extracted unless the component is mounted under a matching `<Route path="...">`.
**Lesson** : page tests that depend on params must wrap the component in `<MemoryRouter><Routes><Route path="/foo/:id" element={<Page />} /></Routes></MemoryRouter>` and set `initialEntries={['/foo/42']}`.
### Testing — jsdom missing browser APIs (frontend-builder)
**Context** : jsdom doesn't implement `Element.scrollIntoView`. Calling it in a component (e.g., scrolling the active autocomplete option into view) throws inside Vitest unless guarded.
**Lesson** : in components meant to run in both browser and jsdom, guard browser-only DOM APIs with optional chaining (`el?.scrollIntoView?.({ block: 'nearest' })`) or feature-detect before calling.
### Process — Reuse idle team agents via SendMessage, not Agent (team-lead)
**Context** : during post-review fixes, I re-spawned `backend-builder` and `frontend-builder` via `Agent({name: "..."})` even though the original instances were still alive (just idle). The system auto-suffixed `-2` and BOTH instances received the same brief, producing duplicate parallel commits on the branch. Frontend got two fix commits (`c9032a9` + `cf0e8a8`) where one would have sufficed; the second commit happened to layer cleanly on top, but only by luck.
**Lesson** : to redispatch work to an existing team agent, use `SendMessage({to: "backend-builder", ...})`. `Agent({name: ...})` creates a new instance when the name is taken. The team config at `~/.claude/teams/<team>/config.json` is the source of truth for who's already present.
### Workflow — Update e2e assertions when later sprints supersede placeholders (team-lead)
**Context** : Sprint 1 AC-4.9 asserted the literal text "Simulations à venir au Sprint 2" on `EngagementDetailPage`. Sprint 2 correctly replaced that placeholder with `<SimulationList>`, breaking the assertion. The test-verifier initially classified this as "pre-existing failure".
**Lesson** : whenever a later sprint replaces a placeholder asserted by an earlier sprint's e2e test, the earlier test must be refreshed in the same sprint (not deferred). A failing test that's "expected" is still a failing test — and it muddies the signal of the PR.

View File

@@ -1,315 +1,252 @@
# Sprint 1Auth + CRUD Engagement # Sprint 2Simulations + MITRE ATT&CK
**Branche** : `sprint/1-auth-engagements` **Branche** : `sprint/2-simulations`
**Statut** : 🟢 PLAN APPROUVÉ (spec-reviewer 2026-05-26) — prêt pour dispatch backend-builder **Statut** : 🟢 SPRINT COMPLET — 32 acceptance tests sprint 2 verts, code-review traité (2 MAJOR + 2 MINOR + 2 NITs fixés), PR prête
**Base** : `main` **Base** : `main` (sprint 1 mergé en `7fc79cc`)
**Objectif** : poser l'infrastructure (Flask + SQLite + React + Docker + Makefile + tests) ET livrer une première feature de bout en bout testable sur l'UI — login + admin gère les comptes + tout utilisateur authentifié peut créer/lister/éditer/supprimer des engagements. **Objectif** : livrer les simulations (CRUD + workflow Pending→In progress→Review required→Done) à l'intérieur d'un engagement, avec autocomplete MITRE ATT&CK alimenté par un bundle STIX local. C'est le cœur métier — l'app remplace enfin le fichier Excel partagé redteam/SOC.
--- ---
## 1. User stories ## 1. User stories
### US-1 — En tant qu'admin, je bootstrap le premier compte admin ### US-7 — En tant que redteam, je crée une simulation dans un engagement
**Pourquoi** : sinon impossible d'utiliser l'application au premier démarrage. **Pourquoi** : c'est la feature centrale du sprint 2.
**Critères d'acceptation** **Critères d'acceptation**
- [ ] AC-1.1 : la commande `make create-admin USER=alice PASS=p4ssw0rd` crée un user `alice` avec le rôle `admin` et le password hashé (argon2). - [ ] AC-7.1 : `POST /api/engagements/<eid>/simulations {name}` (admin|redteam) → 201 + simulation `{id, engagement_id, name, status: "pending", ...}`. `name` requis, non vide.
- [ ] AC-1.2 : la commande échoue proprement (exit ≠ 0, message clair) si le username existe déjà. - [ ] AC-7.2 : autres rôles (soc) → 403.
- [ ] AC-1.3 : la commande échoue si le password fait moins de 8 caractères. - [ ] AC-7.3 : engagement inexistant → 404. Engagement existant mais aucune simulation → liste vide.
- [ ] AC-1.4 : la commande s'exécute via `docker exec mimic flask create-admin …` (le Makefile encapsule cet appel). - [ ] AC-7.4 : `GET /api/engagements/<eid>/simulations` (auth) → liste des simulations de l'engagement, ordonnée `created_at desc`.
- [ ] AC-7.5 : page `/engagements/:eid` (EngagementDetailPage) remplace le placeholder Sprint 2 par une section "Simulations" : liste (colonnes: name, MITRE id, status badge, executed_at) + bouton "Nouvelle simulation" pour admin/redteam.
- [ ] AC-7.6 : depuis cette liste, click sur une ligne → ouvre `/engagements/:eid/simulations/:sid/edit` (page d'édition role-aware, unique URL pour view+edit).
### US-2 — En tant qu'utilisateur, je me connecte et me déconnecte ### US-8 — En tant que redteam, je renseigne les détails techniques d'une simulation
**Pourquoi** : porte d'entrée de l'application. **Pourquoi** : c'est la trace de ce que la redteam a exécuté.
**Critères d'acceptation** **Critères d'acceptation**
- [ ] AC-2.1 : `POST /api/auth/login {username, password}` retourne `{access_token, user: {id, username, role}}` (200) si credentials valides. - [ ] AC-8.1 : `PATCH /api/simulations/<sid>` (admin|redteam) accepte les champs redteam : `name`, `mitre_technique_id`, `mitre_technique_name`, `description`, `commands` (texte multiligne, une commande par ligne), `prerequisites`, `executed_at` (ISO datetime), `execution_result`. Champs partiels OK.
- [ ] AC-2.2 : 401 si credentials invalides, avec un message générique ("Invalid credentials") — pas de fuite username vs password. - [ ] AC-8.2 : règle d'auto-transition pending → in_progress. Trigger PRÉCIS : `PATCH /api/simulations/<sid>` par admin|redteam où **le payload JSON contient au moins une clé parmi les champs redteam** (`name`, `mitre_technique_id`, `mitre_technique_name`, `description`, `commands`, `prerequisites`, `executed_at`, `execution_result`) **dont la valeur n'est ni `null` ni une string vide ni une liste vide**, ET status courant == `pending`. La comparaison se fait sur le payload entrant — pas sur l'état final de la simulation. Un PATCH qui ne ré-envoie qu'un champ inchangé (ex: même `name`) déclenche quand même la transition, car c'est une action explicite "la redteam saisit". L'auto-transition ne se déclenche jamais sur un PATCH `soc`.
- [ ] AC-2.3 : `POST /api/auth/logout` invalide le token côté client (UI supprime le token). Côté serveur : optionnel V1, on accepte un logout client-side. - [ ] AC-8.3 : `commands` est stocké en colonne `text` (chaîne multiligne, une commande par ligne). Sérialisation API = texte brut tel que stocké. Le frontend affiche dans un `<textarea>`.
- [ ] AC-2.4 : page `/login` affiche le formulaire ; soumission OK → redirection `/engagements`. Soumission KO → message d'erreur visible. - [ ] AC-8.4 : `executed_at` valide ISO 8601 ou null. Si invalide → 400 `{error: "invalid executed_at"}`.
- [ ] AC-2.5 : navigation vers `/engagements` sans token → redirection `/login`. - [ ] AC-8.5 : page `/engagements/:eid/simulations/:sid` affiche un formulaire avec deux sections visibles ("Red Team" et "SOC"). Pour admin/redteam, les deux sections sont éditables. Validation client : `name` non vide.
- [ ] AC-2.6 : si une requête API retourne 401 (token expiré ou invalide), l'intercepteur axios purge le token et redirige vers `/login` avec un toast "Session expirée". - [ ] AC-8.6 : autocomplete MITRE dans le champ "Technique" — voir US-10.
### US-3 — En tant qu'admin, je gère les comptes utilisateurs ### US-9 — En tant qu'analyste SOC, je remplis ma partie de la simulation
**Pourquoi** : créer redteam/soc accounts depuis l'UI. **Pourquoi** : le SOC documente la détection sans toucher au scope redteam.
**Critères d'acceptation** **Critères d'acceptation**
- [ ] AC-3.1 : `GET /api/users` (admin only) → liste `[{id, username, role, created_at}]`. - [ ] AC-9.1 : `PATCH /api/simulations/<sid>` envoyé par un user `soc` n'accepte QUE les champs SOC : `log_source`, `logs`, `soc_comment`, `incident_number`. Si la requête contient un champ redteam → 403 `{error: "soc cannot edit redteam fields"}`.
- [ ] AC-3.2 : `POST /api/users {username, password, role}` (admin only) → 201 + objet user (sans password_hash). 400 si username existe ou password < 8 chars. - [ ] AC-9.2 : un user `soc` ne peut PATCH une simulation que si son status est `review_required` ou `done`. Avant ça → 403 `{error: "simulation not ready for SOC review"}`.
- [ ] AC-3.3 : `PATCH /api/users/<id> {role?, password?}` (admin only) → 200, modifie role et/ou password. - [ ] AC-9.3 : page `/engagements/:eid/simulations/:sid` pour un user `soc` : la section "Red Team" est rendue en read-only (champs grisés) ; la section "SOC" est éditable.
- [ ] AC-3.4 : `DELETE /api/users/<id>` (admin only) → 204. Refuse de supprimer le dernier admin (409). - [ ] AC-9.4 : si la simulation est en `pending` ou `in_progress` et qu'un soc visite la page, un bandeau "Simulation pas encore en revue — la redteam doit la marquer comme 'Review required' avant que vous puissiez intervenir" s'affiche, les champs SOC sont désactivés.
- [ ] AC-3.5 : tout autre rôle (redteam/soc) appelant ces endpoints reçoit 403.
- [ ] AC-3.6 : page `/admin/users` (admin only) liste les users avec actions "Créer", "Modifier rôle", "Reset password", "Supprimer".
- [ ] AC-3.7 : un user redteam/soc qui visite `/admin/users` est redirigé vers `/engagements` avec un toast "Accès refusé".
### US-4 — En tant qu'utilisateur authentifié, je gère les engagements ### US-10 — En tant que redteam, j'autocomplète une technique MITRE ATT&CK
**Pourquoi** : la feature métier centrale du Sprint 1. **Pourquoi** : éviter de taper l'id à la main, garantir la cohérence.
**Critères d'acceptation** **Critères d'acceptation**
- [ ] AC-4.1 : `GET /api/engagements` (auth) → `[{id, name, description, start_date, end_date, status, created_at, created_by}]`. - [ ] AC-10.1 : `make update-mitre` télécharge le bundle STIX 2.1 Enterprise depuis `https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json` et l'écrit dans `backend/data/mitre/enterprise-attack.json`. Le bundle est COMMITTÉ dans le repo (`make build` reste autosuffisant). `make update-mitre` reste l'unique méthode de rafraîchissement et le diff résultant est committé manuellement.
- [ ] AC-4.2 : `POST /api/engagements {name, description?, start_date, end_date?, status?}` (auth) → 201. Valide : `name` non vide, `start_date` parseable, `end_date >= start_date` si fournie, `status ∈ {planned, active, closed}` (défaut `planned`). - [ ] AC-10.2 : `GET /api/mitre/techniques?q=<query>` (auth, tous rôles) → liste max 20 résultats `[{id, name, tactics: ["initial-access", ...]}]`. Recherche full-text sur `id` (ex: "T1059") OU `name` (ex: "Command and Scripting Interpreter"), case-insensitive, ordonnée : match exact id > match préfixe id > match nom.
- [ ] AC-4.3 : `GET /api/engagements/<id>` (auth) → 200 + objet, 404 si inconnu. - [ ] AC-10.3 : si le bundle local est absent → endpoint répond 503 `{error: "mitre bundle not loaded"}`. Le team-lead documente `make update-mitre` dans le README.
- [ ] AC-4.4 : `PATCH /api/engagements/<id>` (auth, redteam ou admin) → 200, modifie les champs fournis. - [ ] AC-10.4 : sous-techniques (id format `T1059.001`) incluses dans l'index.
- [ ] AC-4.5 : `DELETE /api/engagements/<id>` (admin ou redteam) → 204. - [ ] AC-10.5 : composant frontend `MitreTechniquePicker` : input + dropdown des matches (debounce 200ms, navigation clavier ↑↓ + Enter, Escape ferme le dropdown), affichage `T1059 — Command and Scripting Interpreter (initial-access)`. La sélection d'une suggestion remplit `mitre_technique_id` ET `mitre_technique_name` du form. Pas de fallback free-text : si l'utilisateur tape sans sélectionner, le champ technique reste vide en sortie de form (id et name `null`).
- [ ] AC-4.6 : un user `soc` peut lire (GET) mais pas créer/modifier/supprimer (403).
- [ ] AC-4.7 : page `/engagements` liste les engagements avec colonnes (name, status badge, dates, created_by). Boutons "Nouveau", "Voir", "Éditer", "Supprimer" selon rôle.
- [ ] AC-4.8 : page `/engagements/new` et `/engagements/<id>/edit` (formulaire avec validation côté client + erreurs API affichées).
- [ ] AC-4.9 : page `/engagements/<id>` (détail), placeholder "Simulations à venir au Sprint 2".
### US-5 — En tant qu'utilisateur, l'UI respecte DESIGN.md ### US-11 — En tant qu'utilisateur, je transitionne le workflow d'une simulation
**Pourquoi** : non négociable selon SPEC.md. **Pourquoi** : la coordination redteam ↔ soc passe par le statut.
**Critères d'acceptation** **Critères d'acceptation**
- [ ] AC-5.1 : la palette, typographie, espacements, composants (boutons, inputs, badges) suivent strictement `DESIGN.md`. - [ ] AC-11.1 : `POST /api/simulations/<sid>/transition {to: "review_required"}` → 200, requiert status courant ∈ {`pending`, `in_progress`} et role ∈ {admin, redteam}. Refuse les autres transitions → 409 `{error: "invalid transition"}`.
- [ ] AC-5.2 : layout responsive desktop-first (≥ 1024px), pas de breakage visible jusqu'à 1280×720 minimum. - [ ] AC-11.2 : `POST /api/simulations/<sid>/transition {to: "done"}` → 200, requiert status courant == `review_required` et role ∈ {admin, redteam, soc}. Autres transitions → 409.
- [ ] AC-5.3 : états loading / error / empty implémentés pour la liste d'engagements et la liste d'users. - [ ] AC-11.3 : aucune transition arrière (ex: done → pending) n'est permise. Pas de transition `→ pending` ni `→ in_progress` via cet endpoint (le passage à `in_progress` est strictement automatique cf AC-8.2).
- [ ] AC-11.4 : sur la page d'édition simulation, deux boutons contextuels :
### US-6 — Le livrable se déploie via Docker + Makefile - Pour admin/redteam, status ∈ {pending, in_progress} : bouton "Marquer en revue".
**Pourquoi** : exigence SPEC.md. - Pour admin/redteam/soc, status == review_required : bouton "Clôturer".
- Sinon : boutons cachés.
- [ ] AC-11.5 : après transition réussie, la query simulation et la liste sont invalidées (TanStack Query), le badge se met à jour.
### US-12 — En tant qu'admin ou redteam, je supprime une simulation
**Critères d'acceptation** **Critères d'acceptation**
- [ ] AC-6.1 : `make build` produit l'image docker `mimic:latest` (Dockerfile multistage : Node build → Python runtime). - [ ] AC-12.1 : `DELETE /api/simulations/<sid>` (admin|redteam) → 204.
- [ ] AC-6.2 : `make start` lance le container, l'app est accessible sur `http://localhost:5000` (front + API). - [ ] AC-12.2 : `soc` → 403.
- [ ] AC-6.3 : `make stop`, `make restart`, `make logs` fonctionnent. - [ ] AC-12.3 : suppression d'engagement (cascade) supprime toutes ses simulations.
- [ ] AC-6.4 : SQLite persisté via volume nommé `mimic-data` (la DB survit à `make restart`). - [ ] AC-12.4 : bouton "Supprimer" sur la page d'édition (admin/redteam uniquement), avec confirmation modal.
- [ ] AC-6.5 : `make test-backend`, `make test-frontend`, `make test-e2e` exécutent les suites respectives.
--- ---
## 2. Brief technique — Backend Builder ## 2. Brief technique — Backend Builder
**Scope strict** : `backend/`, `Dockerfile`, `Makefile` (en collab avec ce sprint uniquement). **Scope strict** : `backend/`, `docker/`, `Makefile` (target `update-mitre`).
### Livrables ### Livrables
1. **Structure** :
``` **Modèle `Simulation`** (`backend/app/models/simulation.py`)
backend/ | Champ | Type | Notes |
__init__.py # ⚠️ requis pour que `backend.app:create_app` soit importable depuis /app dans le Dockerfile |---|---|---|
app/ | id | int PK | |
__init__.py # create_app() factory | engagement_id | int FK Engagement, CASCADE | requis |
config.py # DevConfig / ProdConfig (SECRET_KEY, JWT_SECRET, SQLALCHEMY_DATABASE_URI) | name | str, NOT NULL | redteam-side |
extensions.py # db = SQLAlchemy(), migrate = Migrate() | mitre_technique_id | str, nullable | ex "T1059" / "T1059.001" |
cli.py # @app.cli.command("create-admin") | mitre_technique_name | str, nullable | snapshot pour résilience aux maj MITRE |
models/ | description | text, nullable | redteam-side |
__init__.py | commands | text, nullable | chaîne multiligne, une commande par ligne — pas de JSON |
user.py # User(id, username, password_hash, role, created_at) | prerequisites | text, nullable | redteam-side |
engagement.py # Engagement(id, name, description, start_date, end_date, status, created_at, created_by) | executed_at | datetime, nullable | redteam-side |
auth/ | execution_result | text, nullable | redteam-side |
__init__.py | log_source | text, nullable | soc-side |
jwt.py # encode_token / decode_token | logs | text, nullable | soc-side |
hashing.py # argon2 hash & verify | soc_comment | text, nullable | soc-side |
decorators.py # @login_required, @role_required("admin") | incident_number | str, nullable | soc-side |
api/ | status | enum(pending/in_progress/review_required/done), défaut `pending` | |
__init__.py | created_at | datetime | |
auth.py # /login, /logout, /me | updated_at | datetime, nullable | mis à jour à chaque PATCH |
users.py # CRUD users (admin) | created_by_id | int FK User | |
engagements.py # CRUD engagements
serializers.py # to_dict() helpers — engagement renvoie created_by={id, username}, jamais l'objet User brut **Migration Alembic** `0002_add_simulations.py` — table `simulations` + FK indexes (`engagement_id`, `created_by_id`).
errors.py # uniform JSON error handler
migrations/ # alembic init + 0001 initial schema **Endpoints** (nouveau blueprint `backend/app/api/simulations.py`)
tests/ - `GET /api/engagements/<eid>/simulations` — list, auth, all roles
conftest.py # app fixture, db fixture, auth helpers - `POST /api/engagements/<eid>/simulations` — create, admin|redteam
test_auth.py # login OK + 401 invalid + 401 token expiré - `GET /api/simulations/<sid>` — get, auth
test_users.py # CRUD admin only, 403 redteam/soc, last-admin protection (AC-3.4) - `PATCH /api/simulations/<sid>` — update avec RBAC field-level (voir AC-8/9)
test_engagements.py # CRUD redteam/admin, 403 soc en write, serializer created_by - `DELETE /api/simulations/<sid>` — admin|redteam
test_cli_create_admin.py # success + duplicate username (AC-1.2) + password < 8 chars (AC-1.3) - `POST /api/simulations/<sid>/transition` — state machine
pyproject.toml - `GET /api/mitre/techniques?q=` — autocomplete (200 OK + array, 503 si bundle absent)
requirements.txt # flask, flask-sqlalchemy, flask-migrate, pyjwt, argon2-cffi, ruff, mypy, pytest
**Serializer** : retourne `created_by={id, username}` (pattern existant). `commands` → string brut (tel que stocké en DB, peut être `null` ou chaîne multiligne).
**Service workflow** (`backend/app/services/simulation_workflow.py`)
- `apply_patch(simulation, payload, user)` :
- sépare champs redteam vs soc
- vérifie RBAC field-level
- détecte auto-transition pending → in_progress (AC-8.2)
- applique le patch + commit
- `transition(simulation, to_status, user)` :
- vérifie state machine (transitions autorisées)
- vérifie RBAC role
- met à jour status + updated_at
**Service MITRE** (`backend/app/services/mitre.py`)
- Au boot de l'app : tente de charger `backend/data/mitre/enterprise-attack.json` en mémoire ; si absent ou parse error → flag `mitre_loaded = False` (logue warning, app démarre quand même).
- Indexe les objets STIX `type == "attack-pattern"` : extract `external_id` (T-id), `name`, `kill_chain_phases[].phase_name`.
- Fonction `search(query, limit=20)` : ranking par exact-id > prefix-id > substring-name.
**`Makefile`** : remplacer le no-op de `update-mitre` par :
```makefile
MITRE_URL ?= https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json
update-mitre:
@mkdir -p backend/data/mitre
@curl -fsSL "$(MITRE_URL)" -o backend/data/mitre/enterprise-attack.json
@echo "MITRE bundle updated"
@if docker ps --format '{{.Names}}' | grep -q "^$(CONTAINER)$$"; then \
echo "Restarting $(CONTAINER) to reload MITRE bundle..."; \
docker restart $(CONTAINER); \
fi
``` ```
2. **Endpoints** : voir critères AC-2 / AC-3 / AC-4. **Dockerfile** : copier `backend/data/mitre/` dans l'image (présent dans le repo, donc fonctionne au premier build).
3. **Modèles** : voir SPEC.md § Modèle de données.
4. **Config** : **Bundle MITRE** : committé dans le repo à `backend/data/mitre/enterprise-attack.json`. Le backend-builder l'inclut dans son premier commit via `make update-mitre`.
- `SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.environ.get('MIMIC_DB_PATH', '/data/mimic.sqlite')}"` — l'env var `MIMIC_DB_PATH` du Dockerfile surcharge le chemin si présent (utile pour tests ou changement de mount).
- `JWT_SECRET` lu depuis env var `MIMIC_JWT_SECRET`, requis (raise si absent en Prod). **Tests pytest** (`backend/tests/`)
- `JWT_EXP_MINUTES = 60`. - `test_simulations_crud.py` : create + list + get + delete + cascade, RBAC create/delete.
5. **CLI** : `flask create-admin <user> <pass>` (avec validations AC-1.2/1.3). - `test_simulations_patch.py` : auto-transition pending→in_progress, RBAC field-level soc, blocage soc avant review_required (AC-9.2).
6. **Serializer engagement** : la réponse JSON expose `created_by` sous forme `{"id": <int>, "username": <str>}` — pas l'objet User complet, pas seulement l'id. - `test_simulations_workflow.py` : transitions valides/invalides, RBAC par transition.
7. **Tests** : couverture success / failure / edge sur chaque endpoint et CLI. Les AC-1.2 et AC-1.3 doivent avoir leur propre test dans `test_cli_create_admin.py`. - `test_mitre.py` : load bundle (fixture mini), search ranking, endpoint 503 si pas chargé, sous-techniques incluses.
8. **Lint / typing** : ruff clean, mypy clean sur `app/`.
Tous les tests existants doivent rester verts. Lint ruff + mypy clean.
### Règles ### Règles
- Pas de touche au frontend. - Pas de touche au frontend.
- Pas d'invention de dépendances hors de la liste ci-dessus sans escalade au team-lead. - Pas d'invention de dépendances (pas besoin d'en ajouter).
- Renvoyer le summary attendu (cf. `.claude/agents/backend-builder.md`). - Renvoyer le summary attendu (cf. `.claude/agents/backend-builder.md`).
--- ---
## 3. Brief technique — Frontend Builder ## 3. Brief technique — Frontend Builder
**Scope strict** : `frontend/` UNIQUEMENT. Le dossier `e2e/` est **interdit** au frontend-builder — il est sous la responsabilité exclusive du test-verifier (scaffolding Playwright + tests). **Scope strict** : `frontend/` UNIQUEMENT. Interdiction de toucher `e2e/`.
### Livrables ### Livrables
1. **Structure** :
```
frontend/
package.json # react, react-dom, react-router-dom, @tanstack/react-query, axios, tailwindcss, vite, typescript, vitest, @testing-library/react
vite.config.ts # proxy /api -> http://localhost:5000 en dev
tailwind.config.ts # tokens issus de DESIGN.md, font-family principale = "Inter"
tsconfig.json
index.html
src/
main.tsx
App.tsx # router, QueryClientProvider
api/
client.ts # axios + interceptor (Bearer + 401 purge → /login)
auth.ts # login, me
users.ts # CRUD users
engagements.ts # CRUD engagements
types.ts
hooks/
useAuth.ts # token in memory + localStorage, role helpers (isAdmin, isRedteam, isSoc)
useEngagements.ts # TanStack Query hooks
useUsers.ts
useToast.ts # provider + hook pour notifications éphémères
pages/
LoginPage.tsx
EngagementsListPage.tsx
EngagementFormPage.tsx # new + edit
EngagementDetailPage.tsx
UsersAdminPage.tsx
components/
Layout.tsx # nav, topbar, role-aware menu
ProtectedRoute.tsx # redirect to /login if no token, role gate
StatusBadge.tsx
FormField.tsx
EmptyState.tsx
ErrorState.tsx
LoadingState.tsx
Toast.tsx # composant + ToastProvider (utilisé par AC-2.6 + AC-3.7)
styles/
index.css # tailwind base + DESIGN.md tokens
fonts.css # @font-face Inter (bundlée localement dans public/fonts/, AUCUN CDN)
public/
fonts/ # fichiers Inter .woff2 (déposés via npm install + copie post-install, ou commit direct)
tests/
components/*.test.tsx # Vitest
```
2. **Police** : `Inter` (substitut Forma DJR Micro choisi par défaut parmi les 3 options DESIGN.md §86-89). Bundlée localement en `.woff2`, jamais via Google Fonts/CDN. Configurée dans `tailwind.config.ts` comme `font-sans` et chargée via `@font-face` dans `styles/fonts.css`. **Types** (`frontend/src/api/types.ts`) : ajouter `Simulation`, `SimulationStatus`, `MitreTechnique`, et les payloads PATCH/POST.
3. **Routing** : **Client API** (`frontend/src/api/simulations.ts`, `frontend/src/api/mitre.ts`)
- `/login` - `listSimulations(engagementId)`, `createSimulation(engagementId, {name})`, `getSimulation(id)`, `updateSimulation(id, patch)`, `deleteSimulation(id)`, `transitionSimulation(id, to)`.
- `/engagements` (auth, all roles) - `searchMitreTechniques(query)`.
- `/engagements/new` (auth, redteam|admin)
- `/engagements/:id` (auth, all roles)
- `/engagements/:id/edit` (auth, redteam|admin)
- `/admin/users` (auth, admin only)
- `/` → redirige vers `/engagements` ou `/login`
4. **Auth** : token JWT en mémoire + `localStorage` ; intercepteur axios ajoute `Authorization: Bearer <token>` ; 401 → purge token + redirect `/login` + toast "Session expirée" (AC-2.6). **Hooks TanStack Query** (`frontend/src/hooks/useSimulations.ts`)
- `useEngagementSimulations(engagementId)`, `useSimulation(id)`, mutations `useCreateSimulation`, `useUpdateSimulation`, `useDeleteSimulation`, `useTransitionSimulation`.
- Invalidation : transition + update + delete invalident `["simulations", id]` et `["engagements", eid, "simulations"]`.
5. **Tests Vitest** : 1 test par composant non trivial (états loading/error/empty, comportement des rôles dans `ProtectedRoute`, Toast déclenché par 401). **Hook `useMitre`** : `useMitreSearch(query, enabled)` (debounce géré côté composant, hook sans staleTime court — cache 5min).
**Pages**
- `EngagementDetailPage.tsx` : remplacer le placeholder (lignes 74-81) par `<SimulationList engagementId={eng.id} />`. Conserver le reste.
- `SimulationFormPage.tsx` (`/engagements/:eid/simulations/new` et `/engagements/:eid/simulations/:sid/edit`) :
- Layout en deux cards : "Red Team" et "SOC".
- Champs redteam : name, MitreTechniquePicker, description, commands (textarea, une commande par ligne, envoyé tel quel — pas de split), prerequisites, executed_at (datetime-local input), execution_result.
- Champs SOC : log_source, logs, soc_comment, incident_number.
- Boutons en footer : "Save", "Marquer en revue" (si AC-11.4), "Clôturer" (si AC-11.4), "Supprimer" (modal de confirmation, admin/redteam).
- Mode création (`new`) : seul `name` requis ; après création, redirige sur `/engagements/:eid/simulations/:sid/edit`.
**Composants** (`frontend/src/components/`)
- `SimulationList.tsx` : table tri par created_at desc, colonnes (Name, MITRE, Status badge, Executed at), bouton "Nouvelle" si admin/redteam, ligne cliquable → navigate edit page.
- `SimulationStatusBadge.tsx` : variant du StatusBadge existant si possible (factoriser), 4 couleurs (pending=fog, in_progress=primary-soft, review_required=bloom-coral, done=storm-deep). Si le StatusBadge existant n'est pas factorisable proprement, créer un nouveau composant — pas d'over-engineering.
- `MitreTechniquePicker.tsx` : input + dropdown, debounce 200ms (`useDebouncedValue` ou util inline), navigation clavier (↑/↓/Enter/Escape), affichage `T1059 — Command and Scripting Interpreter (initial-access)`. Loading state inline.
- `ConfirmDialog.tsx` : modal générique de confirmation (utilisée pour delete).
**Routing** (`App.tsx`)
- Ajouter `/engagements/:eid/simulations/new` (auth, admin|redteam)
- Ajouter `/engagements/:eid/simulations/:sid/edit` (auth, all roles, RBAC champs interne)
**Tests Vitest** (`frontend/tests/`)
- `SimulationList.test.tsx` : loading/error/empty + bouton "Nouvelle" gated par role.
- `MitreTechniquePicker.test.tsx` : autocomplete debounce, sélection met à jour, navigation clavier.
- `SimulationFormPage.test.tsx` : rôle redteam → tous champs éditables ; rôle soc → champs RT disabled, soc-side enabled si status review_required, bandeau si pending.
- `SimulationStatusBadge.test.tsx` : 4 variants.
### Règles ### Règles
- Lit le summary du backend-builder EN PREMIER. - Lit le summary du backend EN PREMIER (contrat API).
- Pas d'invention d'endpoints. Mismatch → escalade au team-lead. - Pas d'invention d'endpoints. Mismatch → escalade au team-lead.
- Respect strict de `DESIGN.md` pour palette/typo/composants. - Réutiliser `LoadingState`, `ErrorState`, `EmptyState`, `Toast`, `FormField`, `StatusBadge` existants. NE PAS dupliquer.
- Pas de CDN remote au runtime — bundle local (police Inter incluse). - Respect DESIGN.md (utiliser tokens Tailwind existants — pas de couleurs hardcodées).
- **Interdiction absolue de toucher `e2e/`** (responsabilité test-verifier). - Pas de CDN remote.
--- ---
## 4. Brief — Docker / Makefile (réalisé par le backend-builder) ## 4. Definition of Done — Sprint 2
### `docker/Dockerfile` (multistage) - [ ] Tous les critères AC-7 → AC-12 passent.
```dockerfile - [ ] `pytest` (existing 63 + nouveaux ~25) tous verts. `ruff`, `mypy` clean.
# Stage 1: build front - [ ] `npm run typecheck`, `lint`, `test` clean frontend.
FROM node:20-alpine AS frontend-build - [ ] Playwright suite (existing 36 + nouveaux ~15) verte.
WORKDIR /app/frontend - [ ] `make build` + `make start` + `make update-mitre` + workflow simulation complet manuel OK.
COPY frontend/package*.json ./ - [ ] Code-reviewer (Opus) sans BLOCKER ouvert.
RUN npm ci - [ ] `SPEC.md` (section Simulation enrichie si besoin), `README.md` (mention `make update-mitre` + workflow), `CHANGELOG.md` à jour.
COPY frontend/ ./ - [ ] PR ouverte sur `sprint/2-simulations`, récap synthétique team-lead, validation utilisateur.
RUN npm run build
# Stage 2: python runtime
FROM python:3.12-slim
WORKDIR /app
COPY backend/requirements.txt ./backend/
RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend/ ./backend/
COPY --from=frontend-build /app/frontend/dist ./backend/app/static
ENV FLASK_APP=backend.app:create_app
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# Variables surchargeables au `docker run` :
ENV MIMIC_PORT=5000
ENV MIMIC_DB_PATH=/data/mimic.sqlite
VOLUME ["/data"]
EXPOSE 5000
# Entrypoint : applique les migrations Alembic puis lance Flask
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
```
**`docker/entrypoint.sh`** :
```bash
#!/bin/sh
set -e
flask db upgrade
exec flask run --host=0.0.0.0 --port="${MIMIC_PORT:-5000}"
```
Flask sert `backend/app/static` en racine `/` ET expose les blueprints sous `/api/*`. La DB SQLite vit dans `/data/mimic.sqlite` (volume nommé `mimic-data`) — survit à `make restart` (AC-6.4).
### `Makefile`
Targets requis et leur sémantique :
| Target | Action |
|---|---|
| `build` | `docker build -f docker/Dockerfile -t mimic:latest .` |
| `start` | `docker run -d --name mimic -p $(PORT):5000 -v mimic-data:/data --env-file .env mimic:latest` (`PORT ?= 5000`) |
| `stop` | `docker stop mimic && docker rm mimic` |
| `restart` | `$(MAKE) stop && $(MAKE) start` |
| `update` | `git pull && $(MAKE) build && $(MAKE) restart` |
| `logs` | `docker logs -f mimic` |
| `create-admin` | `docker exec mimic flask create-admin $(USER) $(PASS)` (requiert `USER=` et `PASS=`) |
| `update-mitre` | placeholder no-op Sprint 1 (`@echo "MITRE update: Sprint 2+"`) |
| `test-backend` | `docker exec mimic pytest -q backend/tests/` (ou run local en venv) |
| `test-frontend` | `cd frontend && npm run test -- --run` |
| `test-e2e` | `cd e2e && npx playwright test` (container doit être up) |
| `clean` | `docker rm -f mimic 2>/dev/null; docker volume rm mimic-data 2>/dev/null; rm -rf backend/__pycache__ frontend/node_modules frontend/dist` |
--- ---
## 5. Definition of Done — Sprint 1 ## 5. Décisions arrêtées (utilisateur 2026-05-26)
- [ ] Tous les critères d'acceptation des US 1→6 passent. 1. **Source MITRE** : `https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json` (default team-lead).
- [ ] `pytest`, `ruff`, `mypy` clean côté backend. 2. **MITRE bundle dans le repo** : COMMITTÉ (`backend/data/mitre/enterprise-attack.json` versionné, `make build` autosuffisant).
- [ ] `npm run typecheck`, `lint`, `test` clean côté frontend. 3. **Commands storage** : colonne `text` multiligne, une commande par ligne, transport tel quel.
- [ ] Playwright suite verte côté `e2e/`. 4. **Workflow auto-transition** pending→in_progress : déclenchée par toute PATCH admin/redteam touchant ≥1 champ redteam à valeur non vide (default team-lead).
- [ ] Image docker se build (`make build`), démarre (`make start`), répond sur `:5000`. 5. **Page simulation** : UNE page d'édition role-aware (`/engagements/:eid/simulations/:sid/edit`), pas de page détail séparée.
- [ ] Code review (Opus) sans BLOCKER ouvert. 6. **Suppression cascade** : delete engagement → delete simulations (default team-lead).
- [ ] `SPEC.md`, `README.md`, `CHANGELOG.md` à jour. 7. **SOC restriction status** : soc ne peut PATCH que si status ∈ {review_required, done}.
- [ ] PR ouverte sur la branche sprint, validée par l'utilisateur après récap synthétique. 8. **Sous-techniques MITRE** : incluses dans l'autocomplete (T1059.001 visible) (default team-lead).
--- ---
## 6. Risques & questions à clarifier avec l'utilisateur AVANT de coder ## 6. Plan d'exécution (séquence)
1. **DESIGN.md** : faut-il que je relise DESIGN.md (27 Ko, déjà présent dans le repo) avant que le frontend-builder l'utilise ? OUI 1. ✅ User a validé les 8 décisions §5 (2026-05-26).
2. **Persistance SQLite** : DB stockée dans `/data/mimic.sqlite` montée comme volume nommé `mimic-data`. OK ? OK 2.**Spec-reviewer** : APPROVED WITH NOTES (4 items mineurs corrigés avant dispatch).
3. **Port** : `5000` (Flask défaut). Conflit possible avec macOS AirPlay si jamais — on s'en fiche en Linux. Surcharge du port possible via dockerfile et container. 3.**Backend-builder** : commit `006c4c2` (67 nouveaux tests, 130 passing).
4. **CSRF** : on est en API JWT pure, pas de cookie session, donc pas de CSRF protection nécessaire côté serveur. OK ? OK 4.**Frontend-builder** : commit `765bb5a` (41 nouveaux tests, 61 passing).
5. **Refresh token** : exclu V1. JWT court (60 min) ; user devra se reconnecter. OK ? OK 5.**Code-reviewer** : 2 MAJOR + 4 MINOR + 3 NITs → 2 commits de fix (`83bf60f` backend, `c9032a9`+`cf0e8a8` frontend).
6. **Logout** : V1 = client-side uniquement (purge du token). Pas de blacklist. OK ? OK 6.**Test-verifier** : 32/32 sprint 2 verts, commits `da905cc` + `54e90f7` (AC-4.9 refresh).
7. **CI** : pas mentionné dans la spec. Skip pour Sprint 1 ? (à confirmer) On verra plus tard. 7. 🟡 **Team-lead** : récap + PR en cours.
8. **README.md** : actuellement absent. Le team-lead le crée en fin de Sprint 1 avec instructions `make build / start / create-admin`. OK.
--- Branche unique : `sprint/2-simulations`.
## 7. Plan d'exécution (séquence)
**Branche unique pour ce sprint** : `sprint/1-auth-engagements` (pas de sous-branches builders, le sprint est séquentiel).
1. ✅ **Spec-reviewer** (`.claude/agents/spec-reviewer.md`, override projet du built-in) valide ce plan vs SPEC.md.
2. 🔵 **Backend-builder** implémente `backend/` + `docker/Dockerfile` + `docker/entrypoint.sh` + `Makefile`, livre son summary (incluant le contrat API).
3. 🔵 **Frontend-builder** lit le summary backend puis implémente le front (`frontend/` UNIQUEMENT, jamais `e2e/`).
4. 🔵 **Code-reviewer** relit le diff du sprint (LSP-first, focus bugs/qualité/scope).
5. 🔵 **Test-verifier** **scaffolds** `e2e/` (Playwright config, package.json, fixtures auth) **puis écrit** les acceptance tests Playwright et exécute la suite contre le container démarré via `make start`.
6. 🟢 **Team-lead** (moi) prépare la PR + récap synthétique → user valide.
**Responsabilité `e2e/`** : exclusivement le test-verifier. Backend-builder et frontend-builder n'y touchent jamais.