Compare commits
9 Commits
7fc79cc5a6
...
868097d78a
| Author | SHA1 | Date | |
|---|---|---|---|
| 868097d78a | |||
|
|
9ace9ac0d8 | ||
|
|
54e90f78bb | ||
|
|
da905cc0a0 | ||
|
|
cf0e8a8a6b | ||
|
|
c9032a9057 | ||
|
|
83bf60fb30 | ||
|
|
765bb5a1a4 | ||
|
|
006c4c2c5f |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -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.
|
||||||
|
|||||||
10
Makefile
10
Makefile
@@ -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/
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs.
|
**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 1 — Auth + CRUD Engagement**. Simulation workflow and MITRE TTP autocomplete arrive in Sprint 2+.
|
> Status: **Sprint 2 — Simulations + 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
138
backend/app/api/simulations.py
Normal file
138
backend/app/api/simulations.py
Normal 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
|
||||||
@@ -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"]
|
||||||
|
|||||||
62
backend/app/models/simulation.py
Normal file
62
backend/app/models/simulation.py
Normal 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}>"
|
||||||
@@ -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,
|
||||||
|
|||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
100
backend/app/services/mitre.py
Normal file
100
backend/app/services/mitre.py
Normal 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]
|
||||||
132
backend/app/services/simulation_workflow.py
Normal file
132
backend/app/services/simulation_workflow.py
Normal 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
|
||||||
795203
backend/data/mitre/enterprise-attack.json
Normal file
795203
backend/data/mitre/enterprise-attack.json
Normal file
File diff suppressed because it is too large
Load Diff
59
backend/migrations/versions/0002_add_simulations.py
Normal file
59
backend/migrations/versions/0002_add_simulations.py
Normal 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
247
backend/tests/test_mitre.py
Normal 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"]
|
||||||
236
backend/tests/test_simulations_crud.py
Normal file
236
backend/tests/test_simulations_crud.py
Normal 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
|
||||||
295
backend/tests/test_simulations_patch.py
Normal file
295
backend/tests/test_simulations_patch.py
Normal 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
|
||||||
192
backend/tests/test_simulations_workflow.py
Normal file
192
backend/tests/test_simulations_workflow.py
Normal 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
|
||||||
174
e2e/tests/us10-mitre-autocomplete.spec.ts
Normal file
174
e2e/tests/us10-mitre-autocomplete.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
245
e2e/tests/us11-workflow-transitions.spec.ts
Normal file
245
e2e/tests/us11-workflow-transitions.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
153
e2e/tests/us12-simulation-delete.spec.ts
Normal file
153
e2e/tests/us12-simulation-delete.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
192
e2e/tests/us7-simulation-create.spec.ts
Normal file
192
e2e/tests/us7-simulation-create.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
216
e2e/tests/us8-simulation-redteam-fill.spec.ts
Normal file
216
e2e/tests/us8-simulation-redteam-fill.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
200
e2e/tests/us9-soc-restricted-edit.spec.ts
Normal file
200
e2e/tests/us9-soc-restricted-edit.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
9
frontend/src/api/mitre.ts
Normal file
9
frontend/src/api/mitre.ts
Normal 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;
|
||||||
|
}
|
||||||
37
frontend/src/api/simulations.ts
Normal file
37
frontend/src/api/simulations.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
48
frontend/src/components/ConfirmDialog.tsx
Normal file
48
frontend/src/components/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
frontend/src/components/MitreTechniquePicker.tsx
Normal file
195
frontend/src/components/MitreTechniquePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
frontend/src/components/SimulationList.tsx
Normal file
113
frontend/src/components/SimulationList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/components/SimulationStatusBadge.tsx
Normal file
28
frontend/src/components/SimulationStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/hooks/useMitre.ts
Normal file
11
frontend/src/hooks/useMitre.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
76
frontend/src/hooks/useSimulations.ts
Normal file
76
frontend/src/hooks/useSimulations.ts
Normal 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) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
507
frontend/src/pages/SimulationFormPage.tsx
Normal file
507
frontend/src/pages/SimulationFormPage.tsx
Normal 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 "Review required" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
frontend/tests/MitreTechniquePicker.test.tsx
Normal file
263
frontend/tests/MitreTechniquePicker.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
275
frontend/tests/SimulationFormPage.test.tsx
Normal file
275
frontend/tests/SimulationFormPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
157
frontend/tests/SimulationList.test.tsx
Normal file
157
frontend/tests/SimulationList.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
frontend/tests/SimulationStatusBadge.test.tsx
Normal file
44
frontend/tests/SimulationStatusBadge.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
445
tasks/todo.md
445
tasks/todo.md
@@ -1,315 +1,252 @@
|
|||||||
# Sprint 1 — Auth + CRUD Engagement
|
# Sprint 2 — Simulations + 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** :
|
|
||||||
```
|
|
||||||
backend/
|
|
||||||
__init__.py # ⚠️ requis pour que `backend.app:create_app` soit importable depuis /app dans le Dockerfile
|
|
||||||
app/
|
|
||||||
__init__.py # create_app() factory
|
|
||||||
config.py # DevConfig / ProdConfig (SECRET_KEY, JWT_SECRET, SQLALCHEMY_DATABASE_URI)
|
|
||||||
extensions.py # db = SQLAlchemy(), migrate = Migrate()
|
|
||||||
cli.py # @app.cli.command("create-admin")
|
|
||||||
models/
|
|
||||||
__init__.py
|
|
||||||
user.py # User(id, username, password_hash, role, created_at)
|
|
||||||
engagement.py # Engagement(id, name, description, start_date, end_date, status, created_at, created_by)
|
|
||||||
auth/
|
|
||||||
__init__.py
|
|
||||||
jwt.py # encode_token / decode_token
|
|
||||||
hashing.py # argon2 hash & verify
|
|
||||||
decorators.py # @login_required, @role_required("admin")
|
|
||||||
api/
|
|
||||||
__init__.py
|
|
||||||
auth.py # /login, /logout, /me
|
|
||||||
users.py # CRUD users (admin)
|
|
||||||
engagements.py # CRUD engagements
|
|
||||||
serializers.py # to_dict() helpers — engagement renvoie created_by={id, username}, jamais l'objet User brut
|
|
||||||
errors.py # uniform JSON error handler
|
|
||||||
migrations/ # alembic init + 0001 initial schema
|
|
||||||
tests/
|
|
||||||
conftest.py # app fixture, db fixture, auth helpers
|
|
||||||
test_auth.py # login OK + 401 invalid + 401 token expiré
|
|
||||||
test_users.py # CRUD admin only, 403 redteam/soc, last-admin protection (AC-3.4)
|
|
||||||
test_engagements.py # CRUD redteam/admin, 403 soc en write, serializer created_by
|
|
||||||
test_cli_create_admin.py # success + duplicate username (AC-1.2) + password < 8 chars (AC-1.3)
|
|
||||||
pyproject.toml
|
|
||||||
requirements.txt # flask, flask-sqlalchemy, flask-migrate, pyjwt, argon2-cffi, ruff, mypy, pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Endpoints** : voir critères AC-2 / AC-3 / AC-4.
|
**Modèle `Simulation`** (`backend/app/models/simulation.py`)
|
||||||
3. **Modèles** : voir SPEC.md § Modèle de données.
|
| Champ | Type | Notes |
|
||||||
4. **Config** :
|
|---|---|---|
|
||||||
- `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).
|
| id | int PK | |
|
||||||
- `JWT_SECRET` lu depuis env var `MIMIC_JWT_SECRET`, requis (raise si absent en Prod).
|
| engagement_id | int FK Engagement, CASCADE | requis |
|
||||||
- `JWT_EXP_MINUTES = 60`.
|
| name | str, NOT NULL | redteam-side |
|
||||||
5. **CLI** : `flask create-admin <user> <pass>` (avec validations AC-1.2/1.3).
|
| mitre_technique_id | str, nullable | ex "T1059" / "T1059.001" |
|
||||||
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.
|
| mitre_technique_name | str, nullable | snapshot pour résilience aux maj MITRE |
|
||||||
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`.
|
| description | text, nullable | redteam-side |
|
||||||
8. **Lint / typing** : ruff clean, mypy clean sur `app/`.
|
| commands | text, nullable | chaîne multiligne, une commande par ligne — pas de JSON |
|
||||||
|
| prerequisites | text, nullable | redteam-side |
|
||||||
|
| executed_at | datetime, nullable | redteam-side |
|
||||||
|
| execution_result | text, nullable | redteam-side |
|
||||||
|
| log_source | text, nullable | soc-side |
|
||||||
|
| logs | text, nullable | soc-side |
|
||||||
|
| soc_comment | text, nullable | soc-side |
|
||||||
|
| incident_number | str, nullable | soc-side |
|
||||||
|
| status | enum(pending/in_progress/review_required/done), défaut `pending` | |
|
||||||
|
| created_at | datetime | |
|
||||||
|
| updated_at | datetime, nullable | mis à jour à chaque PATCH |
|
||||||
|
| created_by_id | int FK User | |
|
||||||
|
|
||||||
|
**Migration Alembic** `0002_add_simulations.py` — table `simulations` + FK indexes (`engagement_id`, `created_by_id`).
|
||||||
|
|
||||||
|
**Endpoints** (nouveau blueprint `backend/app/api/simulations.py`)
|
||||||
|
- `GET /api/engagements/<eid>/simulations` — list, auth, all roles
|
||||||
|
- `POST /api/engagements/<eid>/simulations` — create, admin|redteam
|
||||||
|
- `GET /api/simulations/<sid>` — get, auth
|
||||||
|
- `PATCH /api/simulations/<sid>` — update avec RBAC field-level (voir AC-8/9)
|
||||||
|
- `DELETE /api/simulations/<sid>` — admin|redteam
|
||||||
|
- `POST /api/simulations/<sid>/transition` — state machine
|
||||||
|
- `GET /api/mitre/techniques?q=` — autocomplete (200 OK + array, 503 si bundle absent)
|
||||||
|
|
||||||
|
**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
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dockerfile** : copier `backend/data/mitre/` dans l'image (présent dans le repo, donc fonctionne au premier build).
|
||||||
|
|
||||||
|
**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`.
|
||||||
|
|
||||||
|
**Tests pytest** (`backend/tests/`)
|
||||||
|
- `test_simulations_crud.py` : create + list + get + delete + cascade, RBAC create/delete.
|
||||||
|
- `test_simulations_patch.py` : auto-transition pending→in_progress, RBAC field-level soc, blocage soc avant review_required (AC-9.2).
|
||||||
|
- `test_simulations_workflow.py` : transitions valides/invalides, RBAC par transition.
|
||||||
|
- `test_mitre.py` : load bundle (fixture mini), search ranking, endpoint 503 si pas chargé, sous-techniques incluses.
|
||||||
|
|
||||||
|
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.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user