Merge pull request 'feat: sprint 5 — simulation templates + instantiation + nav + dropdown' (#8) from sprint/5-templates into main

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-06-07 16:08:38 +00:00
33 changed files with 3355 additions and 302 deletions

View File

@@ -6,6 +6,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased] ## [Unreleased]
### Added — Sprint 5 (Simulation templates)
**Backend** (226 pytest passing — 193 sprint-1-to-4 + 28 sprint 5 + 5 post-code-review)
- `SimulationTemplate` model (table `simulation_templates`) — UNIQUE constraint on `name`, JSON `techniques` + `tactic_ids` (default `[]`, NOT NULL via `server_default`), Text fields `description` / `commands` / `prerequisites`, FK `created_by_id` to `users`, `created_at` / `updated_at`.
- Alembic migration `0005_simulation_templates.py` — CREATE TABLE (SQLite native, no batch); downgrade via DROP TABLE.
- 5 new endpoints under `/api/templates`, all gated `@role_required("admin", "redteam")` (SOC → 403):
- `GET /api/templates` — list, sorted name ASC, serialized with enriched `techniques: [{id, name, tactics}]` and `tactics: [{id, name}]`.
- `POST /api/templates` — create. `name` required (400 if empty), unique (409 via `IntegrityError` catch, no pre-check race). `technique_ids` / `tactic_ids` validated upfront — type check `isinstance(list)` (400 with friendly message) THEN resolved against the bundle / `_TACTIC_IDS` (400 with id on unknown).
- `GET /api/templates/<tid>` — single, 404 on miss.
- `PATCH /api/templates/<tid>` — partial update. Same validations. 409 on `name` conflict; no-op rename (`name == current`) returns 200.
- `DELETE /api/templates/<tid>` — 204. **No cascade** to instantiated simulations (decoupling guarantee).
- `POST /api/engagements/<eid>/simulations` extended with optional `template_id`. When provided:
- Template loaded (404 on miss).
- Fields copied directly onto the new `Simulation` ORM object (`techniques`, `tactic_ids`, `description`, `commands`, `prerequisites`, and `name` if missing from body).
- **Explicit non-call to `apply_patch()` / `_resolve_*` helpers** — avoids re-hitting the MITRE bundle AND avoids triggering the auto-transition `pending → in_progress`. Status stays `pending`, engagement stays `planned` (no `_maybe_activate_engagement` call). Decorrelation: no `template_id` FK on `Simulation`, deep copy of JSON arrays.
- New helpers in `mitre.py` reused / re-exposed; new `serialize_template()` in `serializers.py` mirrors `serialize_simulation` (minus SOC fields, status, executed_at) and uses the shared `_enrich_techniques` + `_enrich_tactics` (no duplication).
- All migration tests (0003, 0004, 0005) now use `Path(__file__).resolve().parent.parent / "migrations" / "versions" / "..."` — sprint 4's hardcoded-path MAJOR is closed for the third sprint running.
**Frontend** (121 vitest passing — 92 sprint-1-to-4 + 26 sprint 5 + 3 post-code-review)
- New page `TemplatesListPage` (`/admin/templates`, admin+redteam only) — table (Name / MITRE count / Created by / Updated / Actions), `+ New` CTA with Plus icon.
- New page `TemplateFormPage` (`/admin/templates/new` and `/admin/templates/:id/edit`) — single-column FormField stack (sidesteps the multi-column grid trap that broke AC-17.3 on UsersAdminPage). Includes `MitreTechniquePicker` + `MitreMatrixModal` inline (NOT `MitreTechniquesField` — that one auto-saves; template form needs batched save). Delete via `ConfirmDialog`.
- New component `TemplatePickerModal` — modal listing all templates (Name / MITRE count / Created by). Empty state when `useTemplates()` returns `[]`: "No templates available — Create one from the Templates page."
- New nav link "Templates" in `Layout.tsx` topbar — visible to admin + redteam only, masked for SOC. Mirrors the pattern used by the "Users" link.
- `SimulationList` "New" button refactored into a **split-button dropdown**: `[+ New] [▼]`. Primary half → `/.../simulations/new` (blank). Dropdown → "Blank" + "From template…". Open dropdown closes on click-outside or Escape (sprint 3 picker pattern). Empty-state `SimulationList` now also exposes the same dropdown (so users can instantiate from a template on a fresh engagement without creating a blank first).
- `dark:shadow-floating-dark` consistently applied to the new dropdown and `TemplatePickerModal` — matches the sprint 4 shadow token model. `dark:hover:bg-fog` on dropdown items for contrast.
- New types: `SimulationTemplate`, `SimulationTemplateCreateInput`, `SimulationTemplatePatchInput`. `SimulationCreateInput` extended with `template_id?: number`.
- New TanStack Query hooks (`useTemplates`, `useTemplate`, `useCreateTemplate`, `useUpdateTemplate`, `useDeleteTemplate`) with cache invalidation on mutations.
- API client `frontend/src/api/templates.ts` — 5 calls to `/api/templates*`. (Sprint-5 in-flight bug : initial commit `90fc5ba` used `/simulation-templates` paths everywhere; caught immediately, fixed in `2b70011`.)
**Acceptance tests** (Playwright, **201 passed**)
- 3 new spec files (one per US): `us26-templates-crud.spec.ts` (22 tests), `us27-instantiate-from-template.spec.ts` (14 tests), `us28-templates-nav.spec.ts` (8 tests).
- Coverage gaps from code-reviewer filled: bidirectional template↔instance decorrelation, dropdown click-outside + Escape, SOC + template_id 403.
- Sprint 2/3 spec adapts: `us4-engagements.spec.ts` and `us7-simulation-create.spec.ts` now use `getByTestId('new-simulation-btn')` instead of `getByRole('link', /new simulation/)` — the link became a split-button dropdown.
- 1 pre-existing flaky in `us3-users-admin AC-3.4` (DB contamination across runs) — predates sprint 5, unrelated.
### Changed
- 2026-05-28 — SPEC.md § Templates de simulations added (between § Fonctionnement and § Authentification & rôles). Spells out the decoupling rule and the SOC-zero-access RBAC.
- 2026-05-28 — `POST /api/engagements/<eid>/simulations` API contract: `name` is now optional when `template_id` is provided (falls back to `template.name`).
---
## [Sprint 4] — UI polish + workflow tightening + dark mode + process hygiene (merged 2026-05-28)
### Added — Sprint 4 (UI polish + workflow tightening + dark mode + process hygiene) ### Added — Sprint 4 (UI polish + workflow tightening + dark mode + process hygiene)
**Backend** (193 pytest passing — 192 sprint-1-to-3 + 1 sprint-4) **Backend** (193 pytest passing — 192 sprint-1-to-3 + 1 sprint-4)

View File

@@ -2,7 +2,7 @@
**Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs. **Mimic** is a Breach and Attack Simulation (BAS) web UI built on the MITRE ATT&CK matrix. It replaces the flat Excel spreadsheets that red-teams and SOC analysts pass around at the end of an engagement, providing a shared workspace for Purple Team handoffs.
> Status: **Sprint 4UI polish + workflow tightening + dark mode + process hygiene**. The Purple Team workflow is now tighter (Done is terminal, Reopen returns to Review required, engagements auto-flip Planned → Active on first in-progress simulation), simulations can be tagged with both techniques AND tactics (TA-ids), the MITRE matrix modal fits the viewport without horizontal scroll, the app supports light / dark / system theming, and PR creation is one Make target away. > Status: **Sprint 5Simulation templates**. Admin/redteam can now create reusable simulation templates (name + description + commands + prerequisites + MITRE techniques + tactics) and instantiate them inside an engagement in one click. Template and instance are fully decoupled — editing one never affects the other. SOC has no access to templates.
--- ---
@@ -139,9 +139,9 @@ npm run dev # http://localhost:5173 with /api proxied to :5000
Tests: Tests:
```bash ```bash
cd backend && pytest -q # 193 tests cd backend && pytest -q # 226 tests
cd frontend && npm run test -- --run # 92 tests cd frontend && npm run test -- --run # 121 tests
cd e2e && npx playwright test # 158 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6) cd e2e && npx playwright test # 201 tests (needs container up — use MIMIC_BASE_URL=http://127.0.0.1:5000 if localhost resolves to IPv6)
``` ```
--- ---

View File

@@ -32,6 +32,13 @@ Le workflow se mettra à jour de la manière suivante :
Un engagement correspond à une mission redteam. Il est possible d'ajouter plusieurs test dans un engagement. **Le statut de l'engagement progresse automatiquement** : créer un engagement le met à "planned" ; dès qu'une simulation de cet engagement passe en "in progress" (auto-transition par la redteam ou manuelle), l'engagement passe à "active" — pas de retour arrière automatique. La transition vers "closed" reste manuelle. Un engagement correspond à une mission redteam. Il est possible d'ajouter plusieurs test dans un engagement. **Le statut de l'engagement progresse automatiquement** : créer un engagement le met à "planned" ; dès qu'une simulation de cet engagement passe en "in progress" (auto-transition par la redteam ou manuelle), l'engagement passe à "active" — pas de retour arrière automatique. La transition vers "closed" reste manuelle.
## Templates de simulations
Un **template de simulation** est une simulation pré-remplie côté redteam (name + description + commandes + pré-requis + techniques MITRE + tactiques MITRE) qui sert de point de départ pour instancier rapidement des simulations dans un engagement. Le template ne contient PAS de partie SOC, ni de date d'exécution, ni de résultat d'exécution — ces champs restent par-instance.
L'instanciation d'un template dans un engagement crée une **nouvelle simulation indépendante** : le template et l'instance sont décorrélés, l'édition de l'un n'affecte pas l'autre. Aucune référence (FK `template_id`) n'est conservée sur la simulation instanciée.
**RBAC templates = ressource Red Team uniquement** : admin et redteam les gèrent (CRUD). SOC n'a aucun accès (pas de nav link, tous endpoints templates retournent 403). Les nouveaux noms de templates sont uniques pour la clarté UX du dropdown d'instanciation.
Prévoir un module d'authentification : dans un premier temps local à la bdd. Prévoir un module d'authentification : dans un premier temps local à la bdd.
Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests. Dans un premier temps, il s'agit juste de notifier manuellement de l'exécution et les résultats des tests.

View File

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

View File

@@ -2,6 +2,7 @@
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.simulations import simulations_bp
from backend.app.api.templates import templates_bp
from backend.app.api.users import users_bp from backend.app.api.users import users_bp
__all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp"] __all__ = ["auth_bp", "users_bp", "engagements_bp", "simulations_bp", "templates_bp"]

View File

@@ -43,9 +43,16 @@ def create_simulation(eid: int):
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip() name = (data.get("name") or "").strip()
if not name: template_id = data.get("template_id")
return jsonify({"error": "name is required"}), 400
if template_id is not None:
from backend.app.models.simulation_template import SimulationTemplate
tmpl = db.session.get(SimulationTemplate, template_id)
if tmpl is None:
return jsonify({"error": "Template not found"}), 404
if not name:
name = tmpl.name
sim = Simulation( sim = Simulation(
engagement_id=eid, engagement_id=eid,
name=name, name=name,
@@ -53,6 +60,22 @@ def create_simulation(eid: int):
created_at=datetime.now(UTC), created_at=datetime.now(UTC),
created_by_id=g.current_user.id, created_by_id=g.current_user.id,
) )
sim.description = tmpl.description
sim.commands = tmpl.commands
sim.prerequisites = tmpl.prerequisites
sim.techniques = list(tmpl.techniques or [])
sim.tactic_ids = list(tmpl.tactic_ids or [])
else:
if not name:
return jsonify({"error": "name is required"}), 400
sim = Simulation(
engagement_id=eid,
name=name,
status=SimulationStatus.PENDING,
created_at=datetime.now(UTC),
created_by_id=g.current_user.id,
)
db.session.add(sim) db.session.add(sim)
db.session.commit() db.session.commit()
return jsonify(serialize_simulation(sim)), 201 return jsonify(serialize_simulation(sim)), 201

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ from typing import Any
from backend.app.models import Engagement, User from backend.app.models import Engagement, User
from backend.app.models.simulation import Simulation from backend.app.models.simulation import Simulation
from backend.app.models.simulation_template import SimulationTemplate
def serialize_user(user: User) -> dict[str, Any]: def serialize_user(user: User) -> dict[str, Any]:
@@ -69,6 +70,23 @@ def serialize_simulation(simulation: Simulation) -> dict[str, Any]:
} }
def serialize_template(t: SimulationTemplate) -> dict[str, Any]:
return {
"id": t.id,
"name": t.name,
"description": t.description,
"commands": t.commands,
"prerequisites": t.prerequisites,
"techniques": _enrich_techniques(t.techniques or []),
"tactics": _enrich_tactics(t.tactic_ids or []),
"created_at": t.created_at.isoformat() if t.created_at else None,
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
"created_by": serialize_user_brief(t.created_by) # type: ignore[arg-type]
if t.created_by
else None,
}
def serialize_engagement(engagement: Engagement) -> dict[str, Any]: def serialize_engagement(engagement: Engagement) -> dict[str, Any]:
return { return {
"id": engagement.id, "id": engagement.id,

View File

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

View File

@@ -0,0 +1,289 @@
"""SimulationTemplate CRUD: list, create, get, patch, delete + RBAC + dedup."""
from __future__ import annotations
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models import User
from backend.app.models.simulation_template import SimulationTemplate
from backend.tests.conftest import auth_headers as _h # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_template(client: FlaskClient, token: str, **kw) -> dict:
payload = {"name": "Template Alpha", **kw}
resp = client.post("/api/templates", headers=_h(token), json=payload)
assert resp.status_code == 201, resp.get_json()
return resp.get_json()
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
def test_list_templates_empty(client: FlaskClient, admin_token: str) -> None:
resp = client.get("/api/templates", headers=_h(admin_token))
assert resp.status_code == 200
assert resp.get_json() == []
def test_list_templates_soc_forbidden(client: FlaskClient, soc_token: str) -> None:
resp = client.get("/api/templates", headers=_h(soc_token))
assert resp.status_code == 403
def test_list_templates_unauthenticated(client: FlaskClient) -> None:
resp = client.get("/api/templates")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------
def test_create_template_as_admin(
client: FlaskClient, admin_user: User, admin_token: str
) -> None:
body = _make_template(
client,
admin_token,
description="desc",
commands="cmd",
prerequisites="prereq",
)
assert body["name"] == "Template Alpha"
assert body["description"] == "desc"
assert body["commands"] == "cmd"
assert body["prerequisites"] == "prereq"
assert body["techniques"] == []
assert body["tactics"] == []
assert body["created_by"] == {"id": admin_user.id, "username": "admin1"}
assert body["id"] is not None
def test_create_template_as_redteam(
client: FlaskClient, redteam_user: User, redteam_token: str
) -> None:
body = _make_template(client, redteam_token)
assert body["created_by"]["username"] == "redteam1"
def test_create_template_soc_forbidden(client: FlaskClient, soc_token: str) -> None:
resp = client.post(
"/api/templates", headers=_h(soc_token), json={"name": "T"}
)
assert resp.status_code == 403
def test_create_template_missing_name(client: FlaskClient, admin_token: str) -> None:
resp = client.post("/api/templates", headers=_h(admin_token), json={})
assert resp.status_code == 400
assert "name" in resp.get_json()["error"]
def test_create_template_duplicate_name_409(
client: FlaskClient, admin_token: str
) -> None:
_make_template(client, admin_token)
resp = client.post(
"/api/templates", headers=_h(admin_token), json={"name": "Template Alpha"}
)
assert resp.status_code == 409
assert "already exists" in resp.get_json()["error"]
def test_create_template_unknown_technique_id_400(
client: FlaskClient, admin_token: str
) -> None:
resp = client.post(
"/api/templates",
headers=_h(admin_token),
json={"name": "T", "technique_ids": ["T9999.999"]},
)
assert resp.status_code == 400
assert "unknown technique id" in resp.get_json()["error"]
def test_create_template_unknown_tactic_id_400(
client: FlaskClient, admin_token: str
) -> None:
resp = client.post(
"/api/templates",
headers=_h(admin_token),
json={"name": "T", "tactic_ids": ["TA9999"]},
)
assert resp.status_code == 400
assert "unknown tactic id" in resp.get_json()["error"]
# ---------------------------------------------------------------------------
# Get single
# ---------------------------------------------------------------------------
def test_get_template(client: FlaskClient, admin_token: str) -> None:
created = _make_template(client, admin_token)
resp = client.get(f"/api/templates/{created['id']}", headers=_h(admin_token))
assert resp.status_code == 200
assert resp.get_json()["id"] == created["id"]
def test_get_template_not_found(client: FlaskClient, admin_token: str) -> None:
resp = client.get("/api/templates/9999", headers=_h(admin_token))
assert resp.status_code == 404
def test_get_template_soc_forbidden(
client: FlaskClient, admin_token: str, soc_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.get(f"/api/templates/{created['id']}", headers=_h(soc_token))
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Patch
# ---------------------------------------------------------------------------
def test_patch_template_name(client: FlaskClient, admin_token: str) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"name": "Renamed"},
)
assert resp.status_code == 200
assert resp.get_json()["name"] == "Renamed"
assert resp.get_json()["updated_at"] is not None
def test_patch_template_empty_name_rejected(
client: FlaskClient, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"name": ""},
)
assert resp.status_code == 400
def test_patch_template_unknown_field_rejected(
client: FlaskClient, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"bogus_field": "x"},
)
assert resp.status_code == 400
assert "unknown fields" in resp.get_json()["error"]
def test_patch_template_duplicate_name_409(
client: FlaskClient, admin_token: str
) -> None:
_make_template(client, admin_token, name="T1")
t2 = _make_template(client, admin_token, name="T2")
resp = client.patch(
f"/api/templates/{t2['id']}",
headers=_h(admin_token),
json={"name": "T1"},
)
assert resp.status_code == 409
def test_patch_template_soc_forbidden(
client: FlaskClient, admin_token: str, soc_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(soc_token),
json={"name": "X"},
)
assert resp.status_code == 403
def test_patch_template_not_found(client: FlaskClient, admin_token: str) -> None:
resp = client.patch(
"/api/templates/9999", headers=_h(admin_token), json={"name": "X"}
)
assert resp.status_code == 404
def test_patch_template_unknown_technique_id_400(
client: FlaskClient, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"technique_ids": ["T9999.999"]},
)
assert resp.status_code == 400
assert "unknown technique id" in resp.get_json()["error"]
def test_patch_template_unknown_tactic_id_400(
client: FlaskClient, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.patch(
f"/api/templates/{created['id']}",
headers=_h(admin_token),
json={"tactic_ids": ["TA9999"]},
)
assert resp.status_code == 400
assert "unknown tactic id" in resp.get_json()["error"]
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
def test_delete_template(
client: FlaskClient, app, admin_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.delete(f"/api/templates/{created['id']}", headers=_h(admin_token))
assert resp.status_code == 204
with app.app_context():
assert db.session.get(SimulationTemplate, created["id"]) is None
def test_delete_template_not_found(client: FlaskClient, admin_token: str) -> None:
resp = client.delete("/api/templates/9999", headers=_h(admin_token))
assert resp.status_code == 404
def test_delete_template_soc_forbidden(
client: FlaskClient, admin_token: str, soc_token: str
) -> None:
created = _make_template(client, admin_token)
resp = client.delete(f"/api/templates/{created['id']}", headers=_h(soc_token))
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# List returns ordered by name
# ---------------------------------------------------------------------------
def test_list_templates_ordered_by_name(
client: FlaskClient, admin_token: str
) -> None:
for name in ("Zebra", "Alpha", "Midpoint"):
_make_template(client, admin_token, name=name)
body = client.get("/api/templates", headers=_h(admin_token)).get_json()
names = [t["name"] for t in body]
assert names == sorted(names)

View File

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

View File

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

View File

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

View File

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

View File

@@ -265,9 +265,7 @@ test.describe('US-4 — engagement CRUD', () => {
await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /AC-4.9 detail target/i })).toBeVisible();
// Sprint 2 replaced the placeholder with the real SimulationList — covered by AC-7.5. // Sprint 2 replaced the placeholder with the real SimulationList — covered by AC-7.5.
await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /simulations/i })).toBeVisible();
// admin/redteam see the "New simulation" button // Sprint 5: "New" is now a split-button dropdown (data-testid="new-simulation-btn")
await expect( await expect(page.getByTestId('new-simulation-btn')).toBeVisible();
page.getByRole('link', { name: /new simulation/i }),
).toBeVisible();
}); });
}); });

View File

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

View File

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

View File

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

View File

@@ -104,8 +104,40 @@ export interface Simulation {
created_by: { id: number; username: string }; created_by: { id: number; username: string };
} }
export interface SimulationTemplate {
id: number;
name: string;
description: string | null;
commands: string | null;
prerequisites: string | null;
techniques: MitreTechnique[];
tactics: MitreTacticRef[];
created_at: string;
updated_at: string | null;
created_by: { id: number; username: string };
}
export interface SimulationTemplateCreateInput {
name: string;
description?: string | null;
commands?: string | null;
prerequisites?: string | null;
technique_ids?: string[];
tactic_ids?: string[];
}
export interface SimulationTemplatePatchInput {
name?: string;
description?: string | null;
commands?: string | null;
prerequisites?: string | null;
technique_ids?: string[];
tactic_ids?: string[];
}
export interface SimulationCreateInput { export interface SimulationCreateInput {
name: string; name: string;
template_id?: number;
} }
export interface SimulationPatchInput { export interface SimulationPatchInput {

View File

@@ -17,7 +17,7 @@ function themeLabel(theme: Theme): string {
} }
export function Layout(): JSX.Element { export function Layout(): JSX.Element {
const { user, isAdmin, logout } = useAuth(); const { user, isAdmin, isRedteam, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { theme, cycleTheme } = useTheme(); const { theme, cycleTheme } = useTheme();
@@ -78,6 +78,18 @@ export function Layout(): JSX.Element {
> >
Engagements Engagements
</NavLink> </NavLink>
{isAdmin || isRedteam ? (
<NavLink
to="/admin/templates"
className={({ isActive }) =>
`text-[16px] py-2 px-md ${
isActive ? 'text-ink border-b-2 border-primary -mb-[1px]' : 'text-charcoal'
}`
}
>
Templates
</NavLink>
) : null}
{isAdmin ? ( {isAdmin ? (
<NavLink <NavLink
to="/admin/users" to="/admin/users"

View File

@@ -1,11 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { ChevronDown, Plus } from 'lucide-react';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
import type { SimulationTemplate } from '@/api/types';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useEngagementSimulations } from '@/hooks/useSimulations'; import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations';
import { useToast } from '@/hooks/useToast';
import { LoadingState } from './LoadingState'; import { LoadingState } from './LoadingState';
import { ErrorState } from './ErrorState'; import { ErrorState } from './ErrorState';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { SimulationStatusBadge } from './SimulationStatusBadge'; import { SimulationStatusBadge } from './SimulationStatusBadge';
import { TemplatePickerModal } from './TemplatePickerModal';
interface SimulationListProps { interface SimulationListProps {
engagementId: number; engagementId: number;
@@ -16,6 +21,117 @@ function formatDate(value: string | null): string {
return value.replace('T', ' ').slice(0, 16); return value.replace('T', ' ').slice(0, 16);
} }
function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.Element {
const navigate = useNavigate();
const { push } = useToast();
const [open, setOpen] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const createMutation = useCreateSimulation(engagementId);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
const handleBlank = () => {
setOpen(false);
navigate(`/engagements/${engagementId}/simulations/new`);
};
const handleFromTemplate = () => {
setOpen(false);
setShowPicker(true);
};
const handleSelectTemplate = async (template: SimulationTemplate) => {
try {
const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id });
setShowPicker(false);
push(`Created "${sim.name}" from template`, 'success');
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
} catch (err) {
push(extractApiError(err, 'Could not create simulation from template'), 'error');
}
};
return (
<div className="relative" ref={ref}>
<div className="inline-flex">
<button
type="button"
className="btn-primary rounded-r-none border-r border-primary-deep"
onClick={handleBlank}
data-testid="new-simulation-btn"
>
<Plus size={14} aria-hidden /> New
</button>
<button
type="button"
aria-label="More options"
aria-expanded={open}
className="btn-primary rounded-l-none px-sm"
onClick={() => setOpen((v) => !v)}
data-testid="new-simulation-dropdown-toggle"
>
<ChevronDown size={14} aria-hidden />
</button>
</div>
{open ? (
<div
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-md shadow-floating dark:shadow-floating-dark z-20 min-w-[180px]"
role="menu"
>
<button
type="button"
role="menuitem"
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog"
onClick={handleBlank}
>
Blank
</button>
<button
type="button"
role="menuitem"
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog"
onClick={handleFromTemplate}
data-testid="from-template-btn"
>
From template
</button>
</div>
) : null}
{showPicker ? (
<TemplatePickerModal
engagementId={engagementId}
onClose={() => setShowPicker(false)}
onInstantiated={(simId) => {
setShowPicker(false);
navigate(`/engagements/${engagementId}/simulations/${simId}/edit`);
}}
onSelectTemplate={handleSelectTemplate}
isPending={createMutation.isPending}
/>
) : null}
</div>
);
}
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element { export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId); const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
const { canEditEngagements } = useAuth(); const { canEditEngagements } = useAuth();
@@ -39,13 +155,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
description="Create the first simulation to start tracking red team tests." description="Create the first simulation to start tracking red team tests."
action={ action={
canEditEngagements ? ( canEditEngagements ? (
<Link <NewSimulationDropdown engagementId={engagementId} />
to={`/engagements/${engagementId}/simulations/new`}
className="btn-primary"
data-testid="new-simulation-btn"
>
New simulation
</Link>
) : undefined ) : undefined
} }
/> />
@@ -57,13 +167,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-[24px] font-medium text-ink">Simulations</h2> <h2 className="text-[24px] font-medium text-ink">Simulations</h2>
{canEditEngagements ? ( {canEditEngagements ? (
<Link <NewSimulationDropdown engagementId={engagementId} />
to={`/engagements/${engagementId}/simulations/new`}
className="btn-primary"
data-testid="new-simulation-btn"
>
New simulation
</Link>
) : null} ) : null}
</div> </div>

View File

@@ -0,0 +1,104 @@
import { extractApiError } from '@/api/client';
import type { SimulationTemplate } from '@/api/types';
import { useTemplates } from '@/hooks/useTemplates';
import { LoadingState } from './LoadingState';
import { ErrorState } from './ErrorState';
import { EmptyState } from './EmptyState';
interface TemplatePickerModalProps {
engagementId: number;
onClose: () => void;
onInstantiated: (simId: number) => void;
onSelectTemplate: (template: SimulationTemplate) => void;
isPending?: boolean;
}
function mitreCount(t: SimulationTemplate): string {
const count = t.techniques.length + t.tactics.length;
return count === 0 ? '—' : String(count);
}
export function TemplatePickerModal({
onClose,
onSelectTemplate,
isPending = false,
}: TemplatePickerModalProps): JSX.Element {
const { data, isLoading, isError, error, refetch } = useTemplates();
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="tpl-picker-title"
className="fixed inset-0 z-50 flex items-center justify-center"
>
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
<div className="relative card-product shadow-floating dark:shadow-floating-dark max-w-xl w-full mx-md flex flex-col gap-md max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-between">
<h2 id="tpl-picker-title" className="text-[20px] font-medium text-ink">
From template
</h2>
<button
type="button"
aria-label="Close"
onClick={onClose}
className="text-graphite hover:text-ink transition-colors text-[20px] leading-none"
>
×
</button>
</div>
<div className="overflow-y-auto flex-1 -mx-xl px-xl">
{isLoading ? <LoadingState label="Loading templates…" /> : null}
{isError ? (
<ErrorState
message={extractApiError(error, 'Could not load templates')}
onRetry={() => refetch()}
/>
) : null}
{!isLoading && !isError && data && data.length === 0 ? (
<EmptyState
title="No templates available"
description="Create one from the Templates page."
/>
) : null}
{!isLoading && !isError && data && data.length > 0 ? (
<table className="w-full text-left" data-testid="template-picker-table">
<thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
<th className="px-md py-sm">Name</th>
<th className="px-md py-sm">MITRE</th>
<th className="px-md py-sm">Created by</th>
</tr>
</thead>
<tbody>
{data.map((t) => (
<tr
key={t.id}
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
onClick={() => !isPending && onSelectTemplate(t)}
data-testid={`template-row-${t.id}`}
>
<td className="px-md py-sm text-ink font-medium">{t.name}</td>
<td className="px-md py-sm text-charcoal text-[14px]">{mitreCount(t)}</td>
<td className="px-md py-sm text-charcoal text-[14px]">{t.created_by.username}</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
<div className="border-t border-hairline pt-sm">
<button type="button" className="btn-outline-ink" onClick={onClose}>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
createTemplate,
deleteTemplate,
getTemplate,
listTemplates,
updateTemplate,
} from '@/api/templates';
import type { SimulationTemplateCreateInput, SimulationTemplatePatchInput } from '@/api/types';
function templatesKey() {
return ['templates'] as const;
}
function templateKey(id: number) {
return ['templates', id] as const;
}
export function useTemplates() {
return useQuery({
queryKey: templatesKey(),
queryFn: listTemplates,
});
}
export function useTemplate(id: number | undefined) {
return useQuery({
queryKey: id ? templateKey(id) : ['templates', 'none'],
queryFn: () => getTemplate(id as number),
enabled: typeof id === 'number' && !Number.isNaN(id),
});
}
export function useCreateTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: SimulationTemplateCreateInput) => createTemplate(input),
onSuccess: () => qc.invalidateQueries({ queryKey: templatesKey() }),
});
}
export function useUpdateTemplate(id: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (patch: SimulationTemplatePatchInput) => updateTemplate(id, patch),
onSuccess: () => {
qc.invalidateQueries({ queryKey: templateKey(id) });
qc.invalidateQueries({ queryKey: templatesKey() });
},
});
}
export function useDeleteTemplate() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteTemplate(id),
onSuccess: (_data, id) => {
qc.invalidateQueries({ queryKey: templateKey(id) });
qc.invalidateQueries({ queryKey: templatesKey() });
},
});
}

View File

@@ -0,0 +1,268 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Save, Grid2x2 } from 'lucide-react';
import { extractApiError } from '@/api/client';
import type { MitreTechnique, MitreTacticRef } from '@/api/types';
import { useToast } from '@/hooks/useToast';
import { useCreateTemplate, useDeleteTemplate, useTemplate, useUpdateTemplate } from '@/hooks/useTemplates';
import { FormField, TextArea, TextInput } from '@/components/FormField';
import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { MitreTechniqueTag, MitreTacticTag } from '@/components/MitreTechniqueTag';
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
import { MitreMatrixModal } from '@/components/MitreMatrixModal';
import type { MatrixSelection } from '@/components/MitreMatrixModal';
interface FormState {
name: string;
description: string;
commands: string;
prerequisites: string;
}
const EMPTY: FormState = { name: '', description: '', commands: '', prerequisites: '' };
export function TemplateFormPage(): JSX.Element {
const { id } = useParams<{ id: string }>();
const templateId = id ? Number(id) : undefined;
const isNew = !templateId;
const navigate = useNavigate();
const { push } = useToast();
const existing = useTemplate(templateId);
const createMutation = useCreateTemplate();
const updateMutation = useUpdateTemplate(templateId ?? 0);
const deleteMutation = useDeleteTemplate();
const [form, setForm] = useState<FormState>(EMPTY);
const [techniques, setTechniques] = useState<MitreTechnique[]>([]);
const [tactics, setTactics] = useState<MitreTacticRef[]>([]);
const [formError, setFormError] = useState<string | null>(null);
const [showMatrix, setShowMatrix] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
if (existing.data) {
const t = existing.data;
setForm({
name: t.name,
description: t.description ?? '',
commands: t.commands ?? '',
prerequisites: t.prerequisites ?? '',
});
setTechniques(t.techniques);
setTactics(t.tactics);
}
}, [existing.data]);
const isPending = createMutation.isPending || updateMutation.isPending;
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setFormError(null);
if (!form.name.trim()) {
setFormError('Name is required');
return;
}
const payload = {
name: form.name.trim(),
description: form.description.trim() || null,
commands: form.commands.trim() || null,
prerequisites: form.prerequisites.trim() || null,
technique_ids: techniques.map((t) => t.id),
tactic_ids: tactics.map((t) => t.id),
};
try {
if (isNew) {
const created = await createMutation.mutateAsync(payload);
push('Template created', 'success');
navigate(`/admin/templates/${created.id}/edit`, { replace: true });
} else {
await updateMutation.mutateAsync(payload);
push('Template saved', 'success');
}
} catch (err) {
setFormError(extractApiError(err, 'Could not save template'));
}
};
const onDelete = async () => {
if (!templateId) return;
try {
await deleteMutation.mutateAsync(templateId);
push('Template deleted', 'success');
navigate('/admin/templates', { replace: true });
} catch (err) {
push(extractApiError(err, 'Could not delete template'), 'error');
}
setShowDeleteConfirm(false);
};
const handleMatrixApply = ({ techniques: newTech, tactics: newTac }: MatrixSelection) => {
setShowMatrix(false);
setTechniques(newTech);
setTactics(newTac);
};
const handlePickerSelect = (technique: MitreTechnique) => {
if (techniques.some((t) => t.id === technique.id)) return;
setTechniques((prev) => [...prev, technique]);
setShowPicker(false);
};
if (!isNew && existing.isLoading) return <LoadingState label="Loading template…" />;
if (!isNew && existing.isError) {
return (
<ErrorState
message={extractApiError(existing.error, 'Could not load template')}
onRetry={() => existing.refetch()}
/>
);
}
return (
<div className="flex flex-col gap-xl">
<header className="flex items-start justify-between gap-md">
<div className="flex flex-col gap-sm">
<Link to="/admin/templates" className="btn-text-link text-[14px]">
Back to templates
</Link>
<h1 className="text-[44px] font-medium leading-none">
{isNew ? 'New template' : (existing.data?.name ?? 'Edit template')}
</h1>
</div>
{!isNew ? (
<button
type="button"
className="btn-text-link text-bloom-deep"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending}
>
Delete
</button>
) : null}
</header>
<form onSubmit={onSubmit} className="card-product flex flex-col gap-lg max-w-2xl">
<FormField label="Name" htmlFor="tpl-name" required error={formError}>
<TextInput
id="tpl-name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
disabled={isPending}
/>
</FormField>
<FormField label="Description" htmlFor="tpl-desc">
<TextArea
id="tpl-desc"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
disabled={isPending}
placeholder="What does this simulation cover?"
/>
</FormField>
<FormField label="Commands" htmlFor="tpl-commands" hint="One command per line">
<TextArea
id="tpl-commands"
value={form.commands}
onChange={(e) => setForm({ ...form, commands: e.target.value })}
disabled={isPending}
placeholder="e.g. mimikatz.exe&#10;sekurlsa::logonpasswords"
/>
</FormField>
<FormField label="Prerequisites" htmlFor="tpl-prereqs">
<TextArea
id="tpl-prereqs"
value={form.prerequisites}
onChange={(e) => setForm({ ...form, prerequisites: e.target.value })}
disabled={isPending}
placeholder="e.g. Local admin access required"
/>
</FormField>
<div className="flex flex-col gap-sm">
<span className="text-[14px] font-medium text-ink">MITRE Techniques &amp; Tactics</span>
{techniques.length === 0 && tactics.length === 0 ? (
<p className="text-[13px] text-graphite">No techniques selected</p>
) : (
<div className="flex flex-wrap gap-xs" data-testid="techniques-tag-list">
{tactics.map((t) => (
<MitreTacticTag
key={t.id}
tactic={t}
onRemove={() => setTactics((prev) => prev.filter((x) => x.id !== t.id))}
/>
))}
{techniques.map((t) => (
<MitreTechniqueTag
key={t.id}
technique={t}
onRemove={() => setTechniques((prev) => prev.filter((x) => x.id !== t.id))}
/>
))}
</div>
)}
<div className="flex items-center gap-xs max-w-sm">
<div className="flex-1">
{showPicker ? (
<MitreTechniquePicker onSelect={handlePickerSelect} />
) : (
<button
type="button"
className="text-input h-9 text-[13px] text-graphite text-left cursor-text w-full"
onClick={() => setShowPicker(true)}
>
Search technique (e.g. T1059)
</button>
)}
</div>
<button
type="button"
aria-label="Open MITRE matrix"
onClick={() => { setShowPicker(false); setShowMatrix(true); }}
className="flex-shrink-0 flex items-center justify-center w-9 h-9 rounded-md border border-steel text-graphite hover:text-ink hover:border-ink transition-colors"
>
<Grid2x2 size={16} />
</button>
</div>
</div>
<div className="flex items-center gap-md pt-xs border-t border-hairline">
<button type="submit" className="btn-primary" disabled={isPending}>
<Save size={14} aria-hidden /> {isPending ? 'Saving…' : 'Save'}
</button>
<Link to="/admin/templates" className="btn-outline-ink">
Cancel
</Link>
</div>
</form>
<MitreMatrixModal
isOpen={showMatrix}
initialTechniques={techniques}
initialTactics={tactics}
onApply={handleMatrixApply}
onCancel={() => setShowMatrix(false)}
/>
{showDeleteConfirm ? (
<ConfirmDialog
title="Delete template"
description={`Delete "${existing.data?.name ?? 'this template'}"? This cannot be undone. Simulations already created from it are unaffected.`}
confirmLabel="Delete"
onConfirm={onDelete}
onCancel={() => setShowDeleteConfirm(false)}
destructive
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { Link } from 'react-router-dom';
import { Plus } from 'lucide-react';
import { extractApiError } from '@/api/client';
import type { SimulationTemplate } from '@/api/types';
import { useDeleteTemplate, useTemplates } from '@/hooks/useTemplates';
import { useToast } from '@/hooks/useToast';
import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { EmptyState } from '@/components/EmptyState';
function mitreCount(t: SimulationTemplate): number {
return t.techniques.length + t.tactics.length;
}
function formatDate(value: string | null): string {
if (!value) return '—';
return value.slice(0, 10);
}
export function TemplatesListPage(): JSX.Element {
const { data, isLoading, isError, error, refetch } = useTemplates();
const deleteMutation = useDeleteTemplate();
const { push } = useToast();
const onDelete = async (t: SimulationTemplate) => {
if (!window.confirm(`Delete template "${t.name}"? This cannot be undone.`)) return;
try {
await deleteMutation.mutateAsync(t.id);
push('Template deleted', 'success');
} catch (err) {
push(extractApiError(err, 'Could not delete template'), 'error');
}
};
return (
<div className="flex flex-col gap-xl">
<header className="flex items-end justify-between gap-md">
<div>
<h1 className="text-[44px] font-medium leading-none">Templates</h1>
<p className="text-charcoal text-[16px] mt-sm">
Reusable simulation blueprints for red team operations.
</p>
</div>
<Link to="/admin/templates/new" className="btn-primary">
<Plus size={14} aria-hidden /> New
</Link>
</header>
{isLoading ? <LoadingState label="Loading templates…" /> : null}
{isError ? (
<ErrorState
message={extractApiError(error, 'Could not load templates')}
onRetry={() => refetch()}
/>
) : null}
{!isLoading && !isError && data && data.length === 0 ? (
<EmptyState
title="No templates yet"
description="Create your first template to speed up simulation setup."
action={
<Link to="/admin/templates/new" className="btn-primary">
<Plus size={14} aria-hidden /> New template
</Link>
}
/>
) : null}
{!isLoading && !isError && data && data.length > 0 ? (
<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">Created by</th>
<th className="px-xl py-md">Updated</th>
<th className="px-xl py-md text-right">Actions</th>
</tr>
</thead>
<tbody>
{data.map((t) => (
<tr key={t.id} className="border-b border-hairline last:border-0">
<td className="px-xl py-md">
<Link
to={`/admin/templates/${t.id}/edit`}
className="text-ink font-medium hover:underline"
>
{t.name}
</Link>
</td>
<td className="px-xl py-md text-charcoal text-[14px]">
{mitreCount(t) === 0 ? '—' : mitreCount(t)}
</td>
<td className="px-xl py-md text-charcoal">{t.created_by.username}</td>
<td className="px-xl py-md text-charcoal">{formatDate(t.updated_at)}</td>
<td className="px-xl py-md text-right">
<div className="inline-flex gap-sm">
<Link to={`/admin/templates/${t.id}/edit`} className="btn-text-link">
Edit
</Link>
<button
type="button"
className="btn-text-link text-bloom-deep"
onClick={() => onDelete(t)}
disabled={deleteMutation.isPending}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor, fireEvent } from '@testing-library/react'; import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { SimulationList } from '@/components/SimulationList'; import { SimulationList } from '@/components/SimulationList';
@@ -105,6 +106,80 @@ describe('SimulationList — admin/redteam', () => {
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument(); expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
}); });
it('shows dropdown toggle button 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-dropdown-toggle')).toBeInTheDocument();
});
it('opens dropdown and shows "From template…" option', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
const user = userEvent.setup();
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
await user.click(screen.getByTestId('new-simulation-dropdown-toggle'));
expect(screen.getByTestId('from-template-btn')).toBeInTheDocument();
expect(screen.getByText('Blank')).toBeInTheDocument();
});
it('opens TemplatePickerModal when "From template…" is clicked', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
mock.onGet('/templates').reply(200, []);
const user = userEvent.setup();
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
await user.click(screen.getByTestId('new-simulation-dropdown-toggle'));
await user.click(screen.getByTestId('from-template-btn'));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
it('closes dropdown on click outside', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
const user = userEvent.setup();
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
await user.click(screen.getByTestId('new-simulation-dropdown-toggle'));
expect(screen.getByTestId('from-template-btn')).toBeInTheDocument();
// Click outside the dropdown
await user.click(document.body);
expect(screen.queryByTestId('from-template-btn')).toBeNull();
});
it('closes dropdown on Escape key', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
const user = userEvent.setup();
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
await user.click(screen.getByTestId('new-simulation-dropdown-toggle'));
expect(screen.getByTestId('from-template-btn')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByTestId('from-template-btn')).toBeNull();
});
it('shows dropdown in empty state (not a plain link)', async () => {
mock.onGet('/engagements/42/simulations').reply(200, []);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
// Must have the split-button dropdown, not a plain link
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
expect(screen.getByTestId('new-simulation-dropdown-toggle')).toBeInTheDocument();
});
it('clicking a row uses SPA navigation and does not trigger window.location change', async () => { it('clicking a row uses SPA navigation and does not trigger window.location change', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS); mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
const originalHref = window.location.href; const originalHref = window.location.href;

View File

@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { apiClient } from '@/api/client';
import { TemplateFormPage } from '@/pages/TemplateFormPage';
import { ToastProvider } from '@/hooks/useToast';
import type { SimulationTemplate } from '@/api/types';
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: true,
isRedteam: false,
isSoc: false,
canEditEngagements: true,
}),
}));
const TEMPLATE: SimulationTemplate = {
id: 5,
name: 'Mimikatz LSASS Dump',
description: 'Extract NTLM hashes',
commands: 'mimikatz.exe',
prerequisites: 'Local admin',
techniques: [{ id: 'T1003', name: 'OS Credential Dumping', tactics: ['credential-access'] }],
tactics: [{ id: 'TA0006', name: 'Credential Access' }],
created_at: '2026-05-28T00:00:00',
updated_at: null,
created_by: { id: 1, username: 'alice' },
};
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
}
function renderNew() {
const client = makeClient();
return {
...render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={['/admin/templates/new']}>
<Routes>
<Route path="/admin/templates/new" element={<ToastProvider><TemplateFormPage /></ToastProvider>} />
<Route path="/admin/templates/:id/edit" element={<ToastProvider><TemplateFormPage /></ToastProvider>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
),
client,
};
}
function renderEdit(id: number) {
const client = makeClient();
return {
...render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[`/admin/templates/${id}/edit`]}>
<Routes>
<Route path="/admin/templates/:id/edit" element={<ToastProvider><TemplateFormPage /></ToastProvider>} />
<Route path="/admin/templates" element={<div data-testid="templates-list" />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
),
client,
};
}
describe('TemplateFormPage — new mode', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('renders the form with name field in empty state', () => {
renderNew();
expect(screen.getByLabelText(/Name/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Description/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Commands/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Prerequisites/i)).toBeInTheDocument();
// All inputs should be empty
expect(screen.getByLabelText(/Name/i)).toHaveValue('');
});
it('shows validation error when name is empty on submit', async () => {
const user = userEvent.setup();
renderNew();
// Name field is empty by default — click Save directly
const saveBtn = screen.getByRole('button', { name: /Save/i });
await user.click(saveBtn);
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument();
});
});
it('submits POST when name is filled', async () => {
mock.onPost('/templates').reply(201, { ...TEMPLATE, id: 99 });
const user = userEvent.setup();
renderNew();
await user.type(screen.getByLabelText(/Name/i), 'My Template');
await user.click(screen.getByRole('button', { name: /Save/i }));
await waitFor(() => {
expect(mock.history.post.length).toBe(1);
});
const body = JSON.parse(mock.history.post[0].data as string) as Record<string, unknown>;
expect(body.name).toBe('My Template');
});
it('shows backend error on name conflict (409)', async () => {
mock.onPost('/templates').reply(409, { error: 'template name already exists' });
const user = userEvent.setup();
renderNew();
await user.type(screen.getByLabelText(/Name/i), 'Duplicate');
await user.click(screen.getByRole('button', { name: /Save/i }));
await waitFor(() => {
expect(screen.getByText('template name already exists')).toBeInTheDocument();
});
});
it('does not show Delete button in new mode', () => {
renderNew();
expect(screen.queryByText('Delete')).toBeNull();
});
});
describe('TemplateFormPage — edit mode', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('loads existing template data into form', async () => {
mock.onGet('/templates/5').reply(200, TEMPLATE);
renderEdit(5);
await waitFor(() => {
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
});
expect(screen.getByDisplayValue('Extract NTLM hashes')).toBeInTheDocument();
});
it('shows technique and tactic chips from existing template', async () => {
mock.onGet('/templates/5').reply(200, TEMPLATE);
renderEdit(5);
await waitFor(() => {
expect(screen.getByTitle('T1003 — OS Credential Dumping')).toBeInTheDocument();
});
expect(screen.getByTitle('TA0006 — Credential Access')).toBeInTheDocument();
});
it('shows Delete button in edit mode', async () => {
mock.onGet('/templates/5').reply(200, TEMPLATE);
renderEdit(5);
await waitFor(() => {
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
});
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('submits PATCH on save', async () => {
mock.onGet('/templates/5').reply(200, TEMPLATE);
mock.onPatch('/templates/5').reply(200, { ...TEMPLATE, name: 'Updated' });
const user = userEvent.setup();
renderEdit(5);
await waitFor(() => {
expect(screen.getByDisplayValue('Mimikatz LSASS Dump')).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /Save/i }));
await waitFor(() => {
expect(mock.history.patch.length).toBe(1);
});
const body = JSON.parse(mock.history.patch[0].data as string) as Record<string, unknown>;
expect(body.name).toBe('Mimikatz LSASS Dump');
});
it('opens delete confirm dialog and calls DELETE on confirm', async () => {
mock.onGet('/templates/5').reply(200, TEMPLATE);
mock.onDelete('/templates/5').reply(204);
const user = userEvent.setup();
renderEdit(5);
await waitFor(() => {
expect(screen.getByText('Delete')).toBeInTheDocument();
});
await user.click(screen.getByText('Delete'));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Click the Delete button inside the dialog
const dialogDeleteBtn = screen.getAllByText('Delete').find(
(el) => el.tagName === 'BUTTON' && el.closest('[role="dialog"]')
) as HTMLElement;
await user.click(dialogDeleteBtn);
await waitFor(() => {
expect(mock.history.delete.length).toBe(1);
});
});
});

View File

@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { TemplatePickerModal } from '@/components/TemplatePickerModal';
import { renderWithProviders } from './utils';
import type { SimulationTemplate } from '@/api/types';
const TEMPLATES: SimulationTemplate[] = [
{
id: 1,
name: 'Mimikatz LSASS Dump',
description: null,
commands: null,
prerequisites: null,
techniques: [{ id: 'T1003', name: 'OS Credential Dumping', tactics: [] }],
tactics: [],
created_at: '2026-05-28T00:00:00',
updated_at: null,
created_by: { id: 1, username: 'alice' },
},
{
id: 2,
name: 'PowerShell Empire',
description: null,
commands: null,
prerequisites: null,
techniques: [],
tactics: [{ id: 'TA0002', name: 'Execution' }],
created_at: '2026-05-28T01:00:00',
updated_at: null,
created_by: { id: 1, username: 'alice' },
},
];
const onClose = vi.fn();
const onInstantiated = vi.fn();
const onSelectTemplate = vi.fn();
describe('TemplatePickerModal', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
vi.clearAllMocks();
});
afterEach(() => {
mock.restore();
});
it('shows loading state while fetching', () => {
mock.onGet('/templates').reply(() => new Promise(() => {}));
renderWithProviders(
<TemplatePickerModal
engagementId={1}
onClose={onClose}
onInstantiated={onInstantiated}
onSelectTemplate={onSelectTemplate}
/>
);
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
});
it('shows empty state when no templates', async () => {
mock.onGet('/templates').reply(200, []);
renderWithProviders(
<TemplatePickerModal
engagementId={1}
onClose={onClose}
onInstantiated={onInstantiated}
onSelectTemplate={onSelectTemplate}
/>
);
await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
expect(screen.getByText(/No templates available/i)).toBeInTheDocument();
});
it('lists templates with name and MITRE count', async () => {
mock.onGet('/templates').reply(200, TEMPLATES);
renderWithProviders(
<TemplatePickerModal
engagementId={1}
onClose={onClose}
onInstantiated={onInstantiated}
onSelectTemplate={onSelectTemplate}
/>
);
await waitFor(() => {
expect(screen.getByTestId('template-picker-table')).toBeInTheDocument();
});
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
expect(screen.getByText('PowerShell Empire')).toBeInTheDocument();
// T1(1 tech) + 0 tactics = 1 for first, 0 tech + 1 tactic = 1 for second
expect(screen.getAllByText('1').length).toBe(2);
});
it('calls onSelectTemplate when a template row is clicked', async () => {
mock.onGet('/templates').reply(200, TEMPLATES);
const user = userEvent.setup();
renderWithProviders(
<TemplatePickerModal
engagementId={1}
onClose={onClose}
onInstantiated={onInstantiated}
onSelectTemplate={onSelectTemplate}
/>
);
await waitFor(() => {
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
});
await user.click(screen.getByTestId('template-row-1'));
expect(onSelectTemplate).toHaveBeenCalledWith(TEMPLATES[0]);
});
it('calls onClose when Cancel is clicked', async () => {
mock.onGet('/templates').reply(200, TEMPLATES);
const user = userEvent.setup();
renderWithProviders(
<TemplatePickerModal
engagementId={1}
onClose={onClose}
onInstantiated={onInstantiated}
onSelectTemplate={onSelectTemplate}
/>
);
await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
await user.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalledOnce();
});
it('shows error state on fetch failure', async () => {
mock.onGet('/templates').reply(500, { error: 'Server error' });
renderWithProviders(
<TemplatePickerModal
engagementId={1}
onClose={onClose}
onInstantiated={onInstantiated}
onSelectTemplate={onSelectTemplate}
/>
);
await waitFor(() => {
expect(screen.getByTestId('error-state')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,138 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { apiClient } from '@/api/client';
import { TemplatesListPage } from '@/pages/TemplatesListPage';
import { renderWithProviders } from './utils';
import type { SimulationTemplate } from '@/api/types';
const TEMPLATES: SimulationTemplate[] = [
{
id: 1,
name: 'Mimikatz LSASS Dump',
description: 'Extract NTLM hashes',
commands: 'mimikatz.exe',
prerequisites: 'Local admin',
techniques: [{ id: 'T1003', name: 'OS Credential Dumping', tactics: ['credential-access'] }],
tactics: [{ id: 'TA0006', name: 'Credential Access' }],
created_at: '2026-05-28T00:00:00',
updated_at: null,
created_by: { id: 1, username: 'alice' },
},
{
id: 2,
name: 'PowerShell Empire',
description: null,
commands: null,
prerequisites: null,
techniques: [],
tactics: [],
created_at: '2026-05-28T01:00:00',
updated_at: '2026-05-28T02:00:00',
created_by: { id: 2, username: 'bob' },
},
];
vi.mock('@/hooks/useAuth', () => ({
useAuth: () => ({
user: { id: 1, username: 'alice', role: 'admin', created_at: '2026-01-01' },
status: 'authenticated',
login: vi.fn(),
logout: vi.fn(),
isAdmin: true,
isRedteam: false,
isSoc: false,
canEditEngagements: true,
}),
}));
describe('TemplatesListPage', () => {
let mock: MockAdapter;
beforeEach(() => {
mock = new MockAdapter(apiClient);
});
afterEach(() => {
mock.restore();
});
it('shows loading state initially', () => {
mock.onGet('/templates').reply(() => new Promise(() => {}));
renderWithProviders(<TemplatesListPage />);
expect(screen.getByTestId('loading-state')).toBeInTheDocument();
});
it('shows error state on failure', async () => {
mock.onGet('/templates').reply(500, { error: 'Server error' });
renderWithProviders(<TemplatesListPage />);
await waitFor(() => {
expect(screen.getByTestId('error-state')).toBeInTheDocument();
});
});
it('shows empty state when no templates', async () => {
mock.onGet('/templates').reply(200, []);
renderWithProviders(<TemplatesListPage />);
await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
});
it('renders template list with name, MITRE count, created by', async () => {
mock.onGet('/templates').reply(200, TEMPLATES);
renderWithProviders(<TemplatesListPage />);
await waitFor(() => {
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
});
expect(screen.getByText('PowerShell Empire')).toBeInTheDocument();
// MITRE count: techniques(1) + tactics(1) = 2 for first template
expect(screen.getByText('2')).toBeInTheDocument();
// Second template: 0 — shown as —
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
expect(screen.getByText('alice')).toBeInTheDocument();
});
it('shows New button', async () => {
mock.onGet('/templates').reply(200, TEMPLATES);
renderWithProviders(<TemplatesListPage />);
await waitFor(() => {
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
});
expect(screen.getAllByText(/New/i).length).toBeGreaterThan(0);
});
it('shows Edit and Delete actions', async () => {
mock.onGet('/templates').reply(200, TEMPLATES);
renderWithProviders(<TemplatesListPage />);
await waitFor(() => {
expect(screen.getByText('Mimikatz LSASS Dump')).toBeInTheDocument();
});
expect(screen.getAllByText('Edit').length).toBe(2);
expect(screen.getAllByText('Delete').length).toBe(2);
});
it('calls delete endpoint on confirm', async () => {
mock.onGet('/templates').reply(200, TEMPLATES);
mock.onDelete('/templates/1').reply(204);
// After delete, refetch returns updated list
mock.onGet('/templates').reply(200, [TEMPLATES[1]]);
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
renderWithProviders(<TemplatesListPage />);
await waitFor(() => {
expect(screen.getAllByText('Delete')[0]).toBeInTheDocument();
});
const deleteButtons = screen.getAllByText('Delete');
await user.click(deleteButtons[0]);
await waitFor(() => {
expect(mock.history.delete.length).toBe(1);
});
confirmSpy.mockRestore();
});
});

View File

@@ -4,6 +4,45 @@ Recurring mistakes and the rule we adopted so the same issue doesn't bite twice.
--- ---
## Sprint 5 (closed 2026-05-28)
### Process — The "git status pre-sprint-close" discipline is still broken, 3 sprints in a row (team-lead)
**Context** : Sprint 3, sprint 4, AND sprint 5 all shipped initial PRs without the corresponding SPEC.md update (the new section drafted in §0 of the plan sat as `M SPEC.md` in the worktree but was never committed before push). Each time I caught it at the START of the NEXT sprint via `git fetch + git status` revealing the orphan change. Sprint 5 I caught it mid-PR (slightly earlier) but the underlying habit is still broken.
**Lesson** : the team-lead wrap-up checklist needs a hard step BEFORE the `git push` command: run `git status` AND eyeball every line. If anything shows `M` or `??` that's not the wrap-up commit's own files, decide explicitly. The mental model "I committed everything" is wrong because the §0 SPEC edit is done EARLY in the sprint and forgotten by the time wrap-up commits land. Fix candidates :
- Stage SPEC.md as part of the FIRST sprint commit (the plan commit), not as a separate later commit.
- OR add a `make sprint-close-check` target that runs `git status --short | grep -v "^[?][?] tasks/pr-body"` and fails if non-empty.
- OR add a pre-push hook in the project that warns on `M SPEC.md`.
### Process — Spec-reviewer 2-pass BEFORE backend dispatch eliminated mid-implementation addenda (team-lead)
**Context** : Sprint 3 and 4 both required urgent addenda to the backend-builder mid-implementation because the spec-reviewer's 2nd pass arrived after the backend-builder had already started. Sprint 5 explicitly waited for spec-reviewer Pass 2 APPROVED before dispatching backend — and the backend ran straight through with 0 addenda churn. The 2-pass model finally clicked.
**Lesson** : ALWAYS wait for the spec-reviewer's verdict on the post-edit pass before dispatching the first builder. If Pass 1 returns NEEDS-CHANGES, apply the fixes, request Pass 2, wait for APPROVED, THEN dispatch. The "let's send to builders in parallel to save time" instinct costs more than it saves.
### Process — Endpoint path drift caught by visual inspection, not by spec-reviewer (team-lead)
**Context** : Backend implemented `/api/templates`. Plan §1 § 2 said `/api/simulation-templates`. Spec-reviewer 2-pass didn't catch the path drift. Frontend used the plan path → mismatch → first frontend commit (`90fc5ba`) was effectively non-functional against the real backend. Caught immediately by team-lead grep + diagnostic.
**Lesson** : when backend "improves" a URL without flagging it as a deviation, it's still a deviation. Add to team-lead PR-merge mental checklist: `git diff main...HEAD | grep "@.*\.route\|@.*_bp\.(get|post|put|patch|delete)"` → does this match the plan §1/§2 path strings exactly? If no, dispatch a 1-line frontend fix BEFORE the post-design-review cycle.
### Process — Frontend-builder's screenshot script reuses stale mocks after path changes (frontend-builder)
**Context** : Sprint 5 path fix `2b70011` corrected the API client to `/api/templates`. But the Playwright screenshot script still mocked `/api/simulation-templates/<id>` — for the GET single endpoint specifically. Result: edit-form screenshot showed a 500 ErrorState instead of the actual form. Design-reviewer caught this as a critical coverage gap.
**Lesson** : when a path / contract changes mid-sprint, the screenshot script's route handlers must be updated in lockstep with the API client. A grep on the script for the old path is mandatory after every path-rename commit. Add to frontend-builder pre-screenshot checklist: `grep -E "<old-path>" $screenshot_script` must return empty.
### Engineering — Backend-builder's silent URL "improvement" (backend-builder)
**Context** : The team-lead's plan §1/§2 explicitly named the endpoints `/api/simulation-templates`. Backend-builder chose `/api/templates` (shorter, cleaner) but did NOT flag this as a deviation in the summary's "Open questions / deviations" section. The frontend-builder followed the plan and broke. The path change was defensible but the lack of escalation was not.
**Lesson** : when a builder chooses a different identifier than the plan (URL path, table name, column name, function name, etc.), even if "better", they MUST flag it in their summary under "Deviations from plan". The team-lead can then propagate the change to dependent briefs. Silent deviations break cross-team contracts.
### Engineering — Avoid calling `apply_patch()` on creation paths (backend-builder + spec-reviewer)
**Context** : Sprint 5 template instantiation copies fields from a template to a new Simulation. Spec-reviewer Pass 1 flagged that a builder unaware of the auto-transition trigger might "reuse the validation" by calling `apply_patch()` — which would trigger `pending → in_progress` on a non-empty technique_ids payload, violating AC-27.4. The plan was explicitly updated to forbid this call. Backend-builder set ORM fields directly, which sidesteps both the bundle lookup AND the auto-transition logic.
**Lesson** : `apply_patch()` is the wrong primitive for creation paths that copy already-validated data. Reach for direct ORM assignment (`sim.field = value`) when the source data is pre-validated (template → instance, replica → primary, etc.). Reserve `apply_patch()` for user-input paths that need full validation + workflow side effects.
### Engineering — Use `IntegrityError` catch for UNIQUE conflict 409, not pre-check SELECT (backend-builder + spec-reviewer)
**Context** : Sprint 5's first plan draft listed BOTH a pre-insert SELECT to check name uniqueness AND an `IntegrityError` catch as fallback. Spec-reviewer Pass 1 flagged this as dual strategy — the SELECT still races under concurrent inserts and adds dead code. Plan was simplified to "catch IntegrityError only, rollback, return 409". Backend implementation matches.
**Lesson** : for UNIQUE constraint violations, the DB is the authoritative source of truth. Catch the `IntegrityError`, roll back, return 409. Don't pre-check with SELECT — it races, and the IntegrityError catch is still required as a safety net (so the pre-check is just dead code). Same applies to other DB-enforced constraints (FK, CHECK).
### Process — Designer-reviewer accidental duplicate (`-2`) reminded me to use SendMessage (team-lead)
**Context** : Sprint 4 introduced the design-reviewer agent. Sprint 5 first design-review I called `Agent({name: "design-reviewer"})` — the system spawned `design-reviewer-2`. Same mistake as sprint 2/3 with backend/frontend builders. Cleaned up via shutdown_request, but the second design-review pass I correctly used SendMessage on the original `design-reviewer` — and got the verdict cleanly without duplicate noise.
**Lesson** : the "SendMessage to existing idle agent, not Agent" rule covers ALL agents in the team, not just builders. Includes design-reviewer, code-reviewer, spec-reviewer, test-verifier. Save: same `feedback-agent-reuse` memory note now applies broadly.
---
## Sprint 4 (closed 2026-05-27) ## Sprint 4 (closed 2026-05-27)
### Process — git status before declaring sprint complete (team-lead) ### Process — git status before declaring sprint complete (team-lead)

View File

@@ -1,339 +1,300 @@
# Sprint 4UI polish + workflow tightening + dark mode + process hygiene # Sprint 5Simulation templates
**Branche** : `sprint/4-ui-polish` **Branche** : `sprint/5-templates`
**Statut** : 🟢 SPRINT COMPLET — backend 193/193 + frontend 92/92 + e2e 158/158, PR prête **Statut** : 🟢 SPRINT COMPLET — backend 226/226 + frontend 121/121 + e2e 201/201, PR prête
**Base** : `main` @ `27573f5` (sprint 3 mergé via PR #6) + `ba313a3` (carry-over SPEC sprint 3) **Base** : `main` @ `9873c53` (PR #7 sprint 4 mergé)
**Objectif** : absorber les 7 retours QA sprint 3 (UI/UX, workflow, alignement) + livrer le dark mode + durcir le process UI (design-reviewer agent + screenshots mandatory) + automatiser l'ouverture de PR. Pas de hotfix sprint 3 séparé — tout dans sprint 4 (décision user 2026-05-27). **Objectif** : permettre à un admin/redteam de créer des **templates de simulations** pré-remplies (RT-side : name, description, commands, prerequisites, techniques, tactics). Instancier un template dans un engagement crée une nouvelle simulation décorrélée (copie indépendante — éditer l'instance ne touche pas le template et vice-versa). User QA item 8 sprint 3.
--- ---
## 0. SPEC.md updates ## 0. SPEC.md à enrichir en début de sprint
-`ba313a3` — § Simulation : "Type d'attaque MITRE correspondant (peut être une liste de référence)" → "Types d'attaque MITRE correspondants (multi-techniques) ..." (carry-over manquant de sprint 3 §0). Ajouter une section `## Templates de simulations` (entre § Fonctionnement et § Authentification & rôles) :
- 🟡 § Fonctionnement à enrichir en début de sprint 4 :
- Préciser que "Done" est terminal : aucune édition possible sans Reopen explicite.
- Préciser que la transition Reopen `Done → Review required` est ouverte à admin/redteam/soc.
- Préciser que la création/avancement d'une simu fait avancer l'engagement de `planned` à `active` automatiquement (jamais l'inverse).
- 🟡 § Décisions techniques à enrichir :
- Section "UI/UX" : convention boutons (icônes / symboles préférés aux longs libellés).
- Section "Theming" : dark mode supporté, toggle topbar, défaut = `prefers-color-scheme` du système, persistance `localStorage`.
L'évolution est tracée dans CHANGELOG.md § Changed sprint 4. > Un **template de simulation** est une simulation pré-remplie côté redteam (name + description + commandes + pré-requis + techniques MITRE + tactiques MITRE) qui sert de point de départ pour instancier rapidement des simulations dans un engagement. Le template ne contient PAS de partie SOC, ni de date d'exécution, ni de résultat d'exécution — ces champs restent par-instance. L'instanciation d'un template dans un engagement crée une **nouvelle simulation indépendante** : le template et l'instance sont décorrélés, l'édition de l'un n'affecte pas l'autre. **Templates = ressource red team** : admin et redteam les gèrent (CRUD). SOC n'y a aucun accès (ni lecture, ni écriture, pas de nav link).
L'évolution est tracée dans CHANGELOG.md § Changed sprint 5.
--- ---
## 1. User stories ## 1. User stories
### US-17UI polish : dédoublonnage boutons + alignement + icônes ### US-26En tant qu'admin/redteam, je crée et gère des templates de simulations
**Pourquoi** : QA sprint 3 — `EngagementsListPage` montre 2 boutons "New engagement" + "Create engagement" qui font la même chose ; le bouton Create de `UsersAdminPage` reste mal aligné malgré le fix sprint 2. **Pourquoi** : standardiser des simulations récurrentes (ex: "Mimikatz LSASS dump", "PowerShell empire stager") et éviter de retaper les mêmes commandes/MITRE à chaque engagement.
**Critères d'acceptation** **Critères d'acceptation**
- [ ] AC-17.1 : `EngagementsListPage` n'affiche qu'UN SEUL bouton "New engagement". Le doublon "Create engagement" est supprimé. - [ ] AC-26.1 : modèle `SimulationTemplate` (table `simulation_templates`) :
- [ ] AC-17.2 : convention nouveaux boutons d'action (Create / Add / Save / Delete) : icône lucide-react ou unicode + label court (≤ 8 chars), pas de phrases. Audit des boutons existants : ne refactoriser que ceux qui dépassent ce seuil, garder les "Mark for review" / "Clear all" qui sont déjà courts ou ont une sémantique sans icône évidente. Boutons à passer en icône+label : "Save Red Team" → "Save" + icône, "Save SOC" → "Save SOC" + icône, "ADD TECHNIQUE" → "+" + "Add", "QUICK SEARCH" → "🔍" + "Search". - `id` int PK
- [ ] AC-17.3 : `UsersAdminPage` formulaire "Create account" — les 3 FormField (Username, Password, Role) ont leurs labels alignés sur la même baseline ET leurs inputs alignés sur la même baseline. Le bouton Create est aligné horizontalement avec la rangée des inputs. Pixel-perfect au niveau visuel à 1280×720. - `name` str NOT NULL UNIQUE (limite UX : un template unique par nom pour éviter les doublons dans le dropdown d'instanciation)
- `description` text nullable
- `commands` text nullable (chaîne multiligne, une commande par ligne, pattern sprint 2)
- `prerequisites` text nullable
- `techniques` JSON NOT NULL default `[]` (liste `[{id, name}]`, snapshot des techniques MITRE)
- `tactic_ids` JSON NOT NULL default `[]` (liste de TA-id strings)
- `created_at` datetime NOT NULL
- `updated_at` datetime nullable
- `created_by_id` int FK User
- [ ] AC-26.2 : migration Alembic `0005_simulation_templates.py` — CREATE TABLE simulation_templates + index sur `name`. Downgrade : DROP TABLE.
- [ ] AC-26.3 : `GET /api/templates` (admin|redteam) → liste `[{id, name, description, commands, prerequisites, techniques: [{id, name, tactics}], tactics: [{id, name}], created_at, created_by}]`, ordre `name ASC`. SOC → 403.
- [ ] AC-26.4 : `POST /api/templates` (admin|redteam) → 201 + template créé. Body : `{name, description?, commands?, prerequisites?, technique_ids?: [...], tactic_ids?: [...]}`. Valide : `name` non vide, name unique (409 si doublon), technique_ids / tactic_ids validés contre bundle MITRE / `_TACTIC_IDS` (réutilise les helpers `_resolve_technique_ids` / `_resolve_tactic_ids` sprint 3/4). SOC → 403.
- [ ] AC-26.5 : `GET /api/templates/<tid>` (admin|redteam) → 200 ou 404. SOC → 403.
- [ ] AC-26.6 : `PATCH /api/templates/<tid>` (admin|redteam) → 200, accepte les mêmes champs que POST en partial. Si `name` est modifié et entre en conflit avec un autre template → 409. SOC → 403.
- [ ] AC-26.7 : `DELETE /api/templates/<tid>` (admin|redteam) → 204. **Pas de cascade vers les simulations déjà instanciées** — celles-ci sont décorrélées et survivent. SOC → 403.
- [ ] AC-26.8 : page `/admin/templates` (admin|redteam uniquement) liste les templates en table (Name, MITRE count, Created by, Updated at, Actions). Boutons "New template" + "Edit" + "Delete". Tous les endpoints templates sont gated `@role_required("admin", "redteam")` côté backend, et ProtectedRoute frontend impose le même filtre.
### US-18Simulation `done` = read-only + Reopen ### US-27En tant que redteam, j'instancie un template dans un engagement
**Pourquoi** : QA sprint 3 — actuellement une simu `done` peut toujours être PATCHée, ce qui contredit le statut terminal. **Pourquoi** : c'est le use-case principal des templates.
**Critères d'acceptation** **Critères d'acceptation**
- [ ] AC-18.1 : `PATCH /api/simulations/<sid>` avec status courant `done` retourne **409** `{error: "simulation is done — reopen first"}` quel que soit le rôle. - [ ] AC-27.1 : `POST /api/engagements/<eid>/simulations` (admin|redteam) accepte un nouveau paramètre optionnel `template_id`. Si présent, le serveur valide que le template existe (404 sinon), puis crée une nouvelle simulation en copiant :
- [ ] AC-18.2 : nouvelle transition `POST /api/simulations/<sid>/transition {to: "review_required"}` quand status courant == `done` → 200, autorisée admin + redteam + soc. Met à jour `updated_at`. - `name` ← template.name (peut être override par `name` du body si fourni)
- [ ] AC-18.3 : la transition `→ review_required` depuis `pending`/`in_progress` garde le comportement sprint 2 (admin/redteam only). La nouvelle règle s'ajoute SEULEMENT pour le cas `done`. - `description` ← template.description
- [ ] AC-18.4 : sur `SimulationFormPage`, quand status == `done` : - `commands` ← template.commands
- Tous les champs (RT + SOC) sont disabled. - `prerequisites` ← template.prerequisites
- `MitreTechniquesField` en read-only (chips sans ×, input + icône matrice masqués). - `techniques` ← template.techniques (deep copy)
- L'action bar affiche UNIQUEMENT un bouton "Reopen" (visible admin/redteam/soc). - `tactic_ids` ← template.tactic_ids (deep copy)
- Save RT, Save SOC, Mark for review, Close, Delete sont masqués. - Autres champs : status=pending, executed_at=null, execution_result=null, SOC fields=null
- [ ] AC-18.5 : click Reopen → POST transition, toast `'Simulation reopened'`, badge se met à jour, les champs redeviennent éditables selon le rôle. - [ ] AC-27.2 : `POST` sans `template_id` garde le comportement sprint 2 (création vierge avec juste `name`).
- [ ] AC-27.3 : la simulation créée depuis un template est **complètement décorrélée** : modifier l'instance ne touche pas le template, modifier le template ne touche pas les instances existantes. Pas de FK `template_id` stockée sur la simulation (clean decoupling).
### US-19 — Engagement auto-status `planned → active` - [ ] AC-27.4 : auto-transition pending → in_progress NE se déclenche PAS lors de la création depuis un template (même si le template a un name + description + techniques non vides). La création reste status=pending — la transition se fera au prochain PATCH explicite de la redteam. Cohérent avec règle sprint 2 "trigger sur PATCH" pas "trigger sur création".
**Pourquoi** : QA sprint 3 — un engagement reste `planned` même quand ses simulations sont in_progress. - [ ] AC-27.5 : engagement auto-status n'est PAS déclenché par l'instanciation (status reste planned). Coherent avec AC-27.4.
- [ ] AC-27.6 : sur `EngagementDetailPage` (sprint 2/3/4), le bouton "+ New" (ou équivalent UI) ouvre désormais un menu / dropdown / modal avec 2 options : "Blank" et "From template…". L'option "From template…" affiche la liste des templates disponibles avec leur nom + un aperçu (count techniques/tactics). Click sur un template → POST avec `template_id` + redirection sur la simu créée. **Si `useTemplates()` retourne une liste vide → la modale affiche un `<EmptyState title="No templates available" description="Create one from the Templates page" />`. NE PAS désactiver l'option "From template…" dans le dropdown** (l'utilisateur doit pouvoir l'ouvrir pour comprendre qu'il n'y a rien — un disabled item silencieux serait confus).
- [ ] AC-27.7 : SOC n'a PAS accès au bouton d'instanciation (cohérent avec RBAC simulation creation sprint 2).
### US-28 — En tant qu'admin/redteam, j'accède aux templates depuis la nav
**Critères d'acceptation** **Critères d'acceptation**
- [ ] AC-19.1 : quand une simulation transitionne vers `in_progress` (auto-transition via PATCH RT-field non vide), si son engagement parent est `planned`, l'engagement passe à `active` dans la même unité de travail DB. - [ ] AC-28.1 : `Layout.tsx` topbar nav contient un nouveau lien "Templates" (visible **uniquement à admin + redteam**). Pour SOC : le lien n'apparaît pas (cohérent avec "Users" qui est admin-only et masqué côté SOC).
- [ ] AC-19.2 : si l'engagement est déjà `active` ou `closed`, pas de changement. - [ ] AC-28.2 : `ProtectedRoute` pour `/admin/templates` impose `roles=["admin", "redteam"]`. SOC qui tente d'y accéder en tapant l'URL → redirigé vers `/engagements` + toast "Accès refusé" (pattern existant ProtectedRoute sprint 1).
- [ ] AC-19.3 : aucun retour arrière auto. La transition `closed` reste manuelle. - [ ] AC-28.3 : la page `/admin/templates` n'inclut PAS de mode "read-only SOC" — elle est strictement admin+redteam. Les composants peuvent assumer `canEditTemplates = isAdmin || isRedteam = true` (toujours vrai à ce niveau).
- [ ] AC-19.4 : le frontend invalide `["engagement", eid]` et `["engagements"]` après chaque PATCH/transition simulation pour récupérer le statut à jour.
### US-20 — Matrice MITRE : look attack.mitre.org + pas de scroll horizontal
**Pourquoi** : QA sprint 3 — la matrice actuelle a un scroll horizontal et un layout maison.
**Critères d'acceptation**
- [ ] AC-20.1 : `MitreMatrixModal` est élargi à `max-w-[98vw]`.
- [ ] AC-20.2 : layout 12 colonnes (12 tactiques Enterprise) qui tiennent SANS scroll horizontal à 1280×720 min. Largeur cellule technique ~95-110px (vs 220px actuel), font `text-[12px]`.
- [ ] AC-20.3 : couleurs cohérentes DESIGN.md ET visuellement proches de attack.mitre.org : header tactic avec fond contrasté + label uppercase tracking, techniques en cellules `bg-canvas` avec hairline border, hover `bg-fog`, sélectionnée `bg-primary` texte blanc.
- [ ] AC-20.4 : scroll vertical autorisé (`max-h-[80vh] overflow-y-auto`). Jamais de scroll horizontal.
- [ ] AC-20.5 : sub-techniques expand/collapse PRÉSERVÉ — pas de régression sprint 3 AC-15.2. Compteur "N selected" par tactique reste lisible.
- [ ] AC-20.6 : screenshot comparaison Mimic matrix vs attack.mitre.org joint au summary frontend-builder.
### US-21 — Sélection de tactique en plus des techniques
**Pourquoi** : QA sprint 3 — l'utilisateur veut tagger une simulation par TACTIQUE (ex : `TA0007 Discovery`) sans devoir choisir une technique précise.
**Critères d'acceptation**
- [ ] AC-21.1 : modèle `Simulation` gagne un champ `tactic_ids` (colonne JSON, liste de strings TA-id, défaut `[]`). Séparé de `techniques`.
- [ ] AC-21.2 : migration Alembic `0004_simulation_tactic_ids.py` — ADD COLUMN `tactic_ids` (JSON, NOT NULL, default `[]`). Pas besoin de batch pour ADD COLUMN (SQLite natif). Aucun backfill (default suffit).
- [ ] AC-21.3 : sérialisation Simulation expose `tactics: [{id, name}]` enrichi à partir de `tactic_ids` (id snapshot + name dérivé du bundle MITRE au runtime, comme pour `techniques`).
- [ ] AC-21.4 : `PATCH /api/simulations/<sid>` accepte `{tactic_ids: ["TA0007", ...]}`. Validation : chaque ID doit exister dans `_TACTIC_IDS` (mapping TA-id → short-name, cf §2 Service MITRE). Dedup serveur. ID inconnu → 400. **Pas de check `mitre_loaded`** : les TA-ids sont une constante MITRE standard stable hardcodée dans `_TACTIC_IDS` — la validation ne dépend pas du bundle STIX runtime (contrairement aux `technique_ids` qui requièrent le bundle). Donc PATCH `tactic_ids` reste OK même si le bundle est absent (alors que `technique_ids` retourne 503). Spec-aligné avec l'implémentation et les tests post-code-review.
- [ ] AC-21.5 : `tactic_ids` est ajouté au gate SOC : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`. SOC envoie → 403. Auto-transition se déclenche aussi si `tactic_ids` non vide.
- [ ] AC-21.6 : `MitreMatrixModal` — le header de chaque colonne tactique devient cliquable (toggle de la tactique elle-même). État visuel distinct des techniques sélectionnées. Compteur passe à `N+M selected` (techniques + tactique).
- [ ] AC-21.7 : `MitreTechniquesField` — tactiques sélectionnées affichées comme chips distincts (style différencié : `bg-primary text-canvas` au lieu de `bg-primary-soft text-primary-deep`). × pour retirer. Auto-save sur add/remove.
### US-22 — Refonte input MITRE dans le form
**Pourquoi** : QA sprint 3 — pattern actuel (2 boutons textuels) trop verbeux.
**Critères d'acceptation**
- [ ] AC-22.1 : sous le label "MITRE Techniques", le composant affiche :
- Une rangée de chips (techniques + tactiques sélectionnées).
- En dessous, une rangée `[input texte autocomplete] [icône matrice]`.
- L'input fait l'autocomplete inline (debounce 200ms, dropdown ↑↓Enter, comme sprint 2 mais EMBARQUÉ).
- L'icône matrice à droite ouvre `MitreMatrixModal`.
- Aucun bouton textuel "Add Technique" ni "Quick Search".
- [ ] AC-22.2 : les chips affichent UNIQUEMENT la référence (T-id ou TA-id, ex : `T1059.001` ou `TA0007`). Le nom apparaît au survol via `title=` attribute.
- [ ] AC-22.3 : `MitreTechniquePicker` existant est intégré dans le nouveau layout comme l'autocomplete inline. Garde la signature `onSelect`.
- [ ] AC-22.4 : empty state : message court ("No techniques selected") dans la zone des chips. L'input et l'icône matrice restent visibles.
- [ ] AC-22.5 : mode read-only (SOC sur simu non-done, ou tous sur simu done) : chips sans ×, input + icône cachés.
### US-23 — Dark mode
**Pourquoi** : ergonomie demandée. Sprint 4 framing acté.
**Critères d'acceptation**
- [ ] AC-23.1 : un toggle theme dans la topbar (`Layout.tsx`), à droite du nom user. Icône lucide-react `Sun` / `Moon` / `Monitor`.
- [ ] AC-23.2 : 3 états : `light`, `dark`, `system` (auto = suit `prefers-color-scheme`). Toggle cycle entre les 3.
- [ ] AC-23.3 : persistance via `localStorage` (clé `mimic-theme`, valeur `'light'|'dark'|'system'`, défaut `'system'`).
- [ ] AC-23.4 : Tailwind `darkMode: 'class'` activé. Classe `dark` appliquée sur `<html>` selon le résolu. Tokens DESIGN.md étendus avec variantes dark (canvas, paper, ink, graphite, charcoal, etc.). Primary HP Electric Blue garde sa teinte.
- [ ] AC-23.5 : tous les composants principaux audités et utilisent les classes Tailwind `dark:bg-...` / `dark:text-...`. Pas de couleur hardcodée.
- [ ] AC-23.6 : screenshots light + dark de `EngagementsListPage`, `SimulationFormPage`, `MitreMatrixModal` ouverte. Joints au summary.
### US-24 — Process hygiene : design-reviewer agent + screenshots mandatory
**Pourquoi** : sprint 4 framing acté. Sprint 2/3 avait laissé passer des bugs visuels faute de pass design dédié.
**Critères d'acceptation**
- [ ] AC-24.1 : nouveau fichier `.claude/agents/design-reviewer.md`. Brief : revoit le diff frontend + les screenshots fournis par le frontend-builder, audit alignement / hiérarchie typo / DESIGN.md token usage / responsive sanity / cohérence visuelle. Read-only. Lance après frontend-builder, avant code-reviewer.
- [ ] AC-24.2 : `.claude/agents/frontend-builder.md` mis à jour pour rendre EXPLICITE que screenshots sont MANDATORY avant de marquer la tâche terminée (au moins 1 par feature visible / état modifié). Liste explicite des screenshots attendus dans le summary.
- [ ] AC-24.3 : workflow sprint mis à jour dans SPEC.md § Workflows : ajouter design-reviewer entre frontend-builder et code-reviewer.
### US-25 — Infra : PR helper script + Makefile target
**Pourquoi** : capitaliser le pattern Gitea API curl utilisé en sprint 3 pour automatiser les PRs.
**Critères d'acceptation**
- [ ] AC-25.1 : `scripts/open-pr.sh` (executable, `set -euo pipefail`). Lit `~/.git-credentials`. Args : `--sprint=N`, `--title="..."`, `--body=path`. Détecte la branche courante + owner/repo depuis `git remote get-url origin`. POST `/api/v1/repos/{owner}/{repo}/pulls`. Imprime PR URL.
- [ ] AC-25.2 : target Makefile `open-pr SPRINT=N TITLE="..." BODY=path` wrap le script.
- [ ] AC-25.3 : documenté dans README.md (1 paragraphe).
- [ ] AC-25.4 : team-lead utilise ce target pour ouvrir la PR sprint 4 (dogfooding).
--- ---
## 2. Brief technique — Backend Builder ## 2. Brief technique — Backend Builder
**Scope strict** : `backend/`. Pas de touche au frontend, e2e, `.claude/agents/`, `scripts/`, `Makefile`, docs. **Scope strict** : `backend/`. Pas de touche au frontend, e2e, docs, agents, scripts.
### Livrables ### Livrables
**Modèle `Simulation`** — ajout uniquement : **Modèle `SimulationTemplate`** (`backend/app/models/simulation_template.py` — nouveau fichier)
```python ```python
tactic_ids: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) from datetime import UTC, datetime
from sqlalchemy.orm import Mapped, mapped_column
class SimulationTemplate(db.Model):
__tablename__ = "simulation_templates"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, unique=True)
description = db.Column(db.Text, nullable=True)
commands = db.Column(db.Text, nullable=True)
prerequisites = db.Column(db.Text, nullable=True)
techniques = db.Column(db.JSON, nullable=False, default=list)
tactic_ids = db.Column(db.JSON, nullable=False, default=list)
created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(UTC))
updated_at = db.Column(db.DateTime, nullable=True)
created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
created_by = db.relationship("User")
``` ```
**Migration Alembic `0004_simulation_tactic_ids.py`** : Ajouter à `backend/app/models/__init__.py`.
- Upgrade : `op.add_column('simulations', sa.Column('tactic_ids', sa.JSON(), nullable=False, server_default=sa.text("'[]'")))`. ADD COLUMN OK sans batch sur SQLite. `server_default` règle le NOT NULL pour les lignes existantes.
- Downgrade : `with op.batch_alter_table('simulations') as batch_op: batch_op.drop_column('tactic_ids')`.
- Test : schéma post-upgrade a `tactic_ids` NOT NULL avec default `[]`.
**Serializer** : `serialize_simulation(sim)` ajoute `tactics: [{id, name}]` enrichi runtime. **Migration Alembic `0005_simulation_templates.py`**
- Upgrade : `op.create_table("simulation_templates", ...)` avec tous les champs et la contrainte UNIQUE sur `name`. Pas besoin de batch (CREATE TABLE est natif SQLite).
- Downgrade : `op.drop_table("simulation_templates")`. SQLite OK natif.
- Pas de backfill (table vide à la création).
**Service MITRE** : **Serializer** (`backend/app/serializers.py`)
- Sprint 3 a indexé les tactiques par **short-name** (`"initial-access"`, `"execution"`, `...`) dans `_TACTIC_ORDER` et `TACTIC_NAMES`. La SPEC et le plan sprint 4 utilisent la notation **TA-id** (`"TA0001"`, `"TA0007"`, etc.). Il faut un mapping TA-id → short-name pour valider/résoudre les `tactic_ids` reçus. - Nouvelle fonction `serialize_template(t)` :
- Ajouter une constante module-level (12 entrées hardcodées, MITRE standard stable — attention, les TA-ids ne sont PAS séquentiels) :
```python ```python
_TACTIC_IDS: dict[str, str] = { return {
"TA0001": "initial-access", "id": t.id,
"TA0002": "execution", "name": t.name,
"TA0003": "persistence", "description": t.description,
"TA0004": "privilege-escalation", "commands": t.commands,
"TA0005": "defense-evasion", "prerequisites": t.prerequisites,
"TA0006": "credential-access", "techniques": _enrich_techniques(t.techniques), # réutilise sprint 3
"TA0007": "discovery", "tactics": _enrich_tactics(t.tactic_ids), # réutilise sprint 4
"TA0008": "lateral-movement", "created_at": t.created_at.isoformat() if t.created_at else None,
"TA0009": "collection", "updated_at": t.updated_at.isoformat() if t.updated_at else None,
"TA0011": "command-and-control", "created_by": serialize_user_brief(t.created_by) if t.created_by else None,
"TA0010": "exfiltration",
"TA0040": "impact",
} }
``` ```
- Nouvelle fonction `lookup_tactic(tactic_id: str) -> dict | None` : - Pattern parallèle à `serialize_simulation` mais SANS les champs SOC / status / executed_at.
```python
short = _TACTIC_IDS.get(tactic_id)
if short is None:
return None
return {"id": tactic_id, "name": TACTIC_NAMES[short]}
```
- Nouvelle fonction `get_tactic_name(tactic_id: str) -> str | None` : pareil mais retourne juste le name.
- Validation `tactic_ids` dans `simulation_workflow.py` : un id absent de `_TACTIC_IDS` → 400 `{"error": "unknown tactic id: <id>"}`.
**Service workflow `simulation_workflow.py`**modifications : **API** (`backend/app/api/templates.py` — nouveau blueprint)
1. **Guard `done` (AC-18.1)** : tout en haut de `apply_patch`, AVANT le check RBAC, si `simulation.status == "done"` → 409 `{error: "simulation is done — reopen first"}`. Vaut pour TOUS les rôles, admin compris. **Tous les endpoints templates sont gated `@role_required("admin", "redteam")` — SOC reçoit 403 partout.**
2. **SOC gate étendu** : `(REDTEAM_FIELDS | {"technique_ids", "tactic_ids"}) & payload.keys()`. - `GET /api/templates` (admin|redteam) → liste serializée, tri `name ASC`.
3. **Validation `tactic_ids`** upfront (similaire à `technique_ids`) : tous les IDs validés contre le bundle, dedup `dict.fromkeys`. Bundle non chargé → 503. - `POST /api/templates` (admin|redteam) → création. Validation : `name` non vide (400), `technique_ids` / `tactic_ids` valides (réutilise `_resolve_technique_ids` / `_resolve_tactic_ids` de `simulation_workflow.py` — import direct, KISS). Pour le `name` UNIQUE conflict : **catch `sqlalchemy.exc.IntegrityError` sur INSERT → 409 `{"error": "template name already exists"}`**. Pas de pre-check SELECT (race condition + code mort, la contrainte UNIQUE en DB est l'autorité).
4. **Auto-transition** : ajouter le check `len(payload["tactic_ids"]) > 0` au calcul `auto_trigger`. - `GET /api/templates/<tid>` (admin|redteam) → 200 ou 404.
5. **Transition `done → review_required` (AC-18.2)** — **implémentation précise** : le dict `_ALLOWED_TRANSITIONS` actuel est keyé par target status et a déjà une entrée `"review_required"` avec from={pending, in_progress} et roles={admin, redteam}. On NE peut PAS ajouter une 2e entrée avec la même clé. À la place, dans `transition()`, AVANT le lookup dict, ajoute un cas spécial qui suit les patterns existants du fichier : - `PATCH /api/templates/<tid>` (admin|redteam) → update partial. Pour le `name` conflict : même pattern, **catch IntegrityError sur UPDATE → 409 `{"error": "template name already exists"}`**. Cas edge : PATCH avec `name == current_name` (no-op rename) → 200 (l'UPDATE sur sa propre row ne viole pas UNIQUE). Mettre à jour `updated_at`.
```python - `DELETE /api/templates/<tid>` (admin|redteam) → 204. Pas de cascade FK simulations (les simulations n'ont pas de `template_id` FK).
# transition() returns tuple[Any, int] | None — None on success, error tuple otherwise.
# Existing functions use datetime.now(UTC) (timezone-aware, not deprecated utcnow).
# Enum values are UPPERCASE: SimulationStatus.DONE, SimulationStatus.REVIEW_REQUIRED.
if to_status == "review_required" and simulation.status == SimulationStatus.DONE:
simulation.status = SimulationStatus.REVIEW_REQUIRED
simulation.updated_at = datetime.now(UTC)
db.session.commit()
return None
# ... reste de la fonction inchangée (dict lookup pour les autres cas)
```
Pas de check explicite du rôle ici — `@login_required` upstream + l'enum User limité à admin/redteam/soc rendent la défense superflue (KISS). Autres transitions depuis `done` (vers `pending`, `in_progress`, `done` lui-même) → 409 via le dict lookup qui ne les couvre pas.
6. **Hook engagement auto-status (AC-19.1)** : après une transition de simu vers `in_progress` (auto OU manual), appeler une fonction `_maybe_activate_engagement(simulation)` qui, si `simulation.engagement.status == "planned"`, set `engagement.status = "active"` et `db.session.add(engagement)`. **NE PAS appeler `db.session.commit()` dans le helper** — le caller (`api/simulations.py:update_simulation`) gère le commit final, sinon double-commit.
**API `simulations.py`** : Enregistrer le blueprint dans `backend/app/__init__.py`.
- PATCH : le check status==done est fait dans `apply_patch` (voir au-dessus).
- Transition : accepter le nouveau cas done → review_required pour admin/redteam/soc. **Modification `POST /api/engagements/<eid>/simulations`** (`backend/app/api/simulations.py`)
- Le payload accepte maintenant un `template_id` optionnel.
- Si présent :
- Charger le template (404 si non trouvé).
- Créer la simulation en **setant DIRECTEMENT les champs RT** sur l'objet ORM Simulation à partir des champs du template (`sim.techniques = template.techniques`, `sim.tactic_ids = template.tactic_ids`, `sim.description = template.description`, `sim.commands = template.commands`, `sim.prerequisites = template.prerequisites`).
- `name` du body override si fourni, sinon `template.name`.
- **NE PAS appeler `apply_patch()`, `_resolve_technique_ids()`, ni `_resolve_tactic_ids()`** — les données viennent du template déjà persisté+validé, re-résoudre frapperait inutilement le bundle MITRE ET déclencherait l'auto-transition pending→in_progress via la logique auto-trigger de `apply_patch`, ce qui violerait AC-27.4. Le set direct ORM est intentionnellement court-circuité.
- Si absent : comportement actuel inchangé (création vierge avec `name`).
- Auto-transition NE PAS déclencher (status reste pending — règle sprint 2 "trigger sur PATCH", la création ne compte pas). Le fait de ne pas appeler `apply_patch` est ce qui garantit ça structurellement.
- Engagement auto-status NE PAS déclencher (status engagement reste planned). Idem — `_maybe_activate_engagement` n'est appelé que depuis `apply_patch`.
**Tests pytest** **Tests pytest**
- `test_simulations_tactics.py` (nouveau) : PATCH valide, ID inconnu → 400, bundle absent → 503, dedup, auto-transition, SOC → 403. - `test_simulation_templates_crud.py` (nouveau) :
- `test_simulations_done_readonly.py` (nouveau) : PATCH simu done → 409 (admin/redteam/soc). Reopen via transition → 200. Autres transitions depuis done → 409. Après reopen, PATCH OK. - GET liste vide, GET liste après création, GET liste tri name ASC.
- `test_engagement_lifecycle.py` (nouveau) : création simu → engagement reste `planned`. PATCH simu → simu in_progress + engagement active. Engagement déjà active → pas de changement. Engagement closed → pas de changement. - POST valide → 201, fields persisted.
- Migration test : `tactic_ids` column NOT NULL après upgrade 0004 (similaire au pattern Alembic round-trip sprint 3). - POST name vide → 400.
- Adapter `test_simulations_crud.py`, `test_simulations_patch.py`, `test_simulations_workflow.py` si nécessaire pour les assertions sur `tactics` et la garde done. - POST name dupliqué → 409.
- POST technique_id inconnu → 400.
- POST tactic_id inconnu → 400.
- POST par SOC → 403.
- GET inexistant → 404.
- PATCH valide → 200, updated_at set.
- PATCH name → conflit → 409.
- PATCH par SOC → 403.
- DELETE valide → 204, GET ensuite → 404.
- DELETE par SOC → 403.
- `test_simulations_from_template.py` (nouveau) :
- POST simulation avec template_id valide → copie tous les RT fields, status=pending, executed_at=null, SOC fields=null.
- POST avec template_id valide + name override → name override gagne.
- POST avec template_id inexistant → 404.
- POST avec template_id par SOC → 403 (cohérent avec création).
- Vérifier décorrélation : créer template → instancier → modifier l'instance → assert template inchangé. Symétrique : modifier le template → instance inchangée.
- Auto-transition NE PAS déclenchée (sim reste pending même si template avait des techniques).
- Engagement reste planned (auto-status NOT triggered).
- Migration test : `0005` create/drop round-trip propre (réutilise pattern Alembic round-trip sprint 3/4 avec `Path(__file__)`).
**Quality bar** : ruff + mypy clean, tous les tests existants + nouveaux verts. **Quality bar** : ruff + mypy clean, tous tests existants + nouveaux verts.
### Règles ### Règles
- Pas de touche au frontend, `.claude/agents/`, `scripts/`, `Makefile`. - Pas de touche au frontend, e2e, agents, scripts, Makefile.
- 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. **Scope strict** : `frontend/` uniquement.
**SCREENSHOTS MANDATORY** (lesson sprint 2/3) : à la fin de ton travail, lance le dev server et fournis ≥ 5 screenshots : **Screenshots MANDATORY (lesson sprint 4)** : à la fin de ton travail, dev server + auth flow (page.goto('/login') + fill creds + submit + wait) pour fournir MIN 6 screenshots :
1. `EngagementsListPage` light + dark 1. `/admin/templates` liste (admin OU redteam vue, ≥ 2 templates) — light + dark
2. `SimulationFormPage` avec ≥ 2 chips technique + ≥ 1 chip tactique light + dark 2. Template create/edit form (mode edit avec techniques + tactic chips) — light + dark
3. `MitreMatrixModal` ouverte avec sélections light + dark 3. EngagementDetail avec dropdown "Blank | From template…" ouvert — light
4. `UsersAdminPage` form "Create account" (alignement vérifié) light + dark 4. TemplatePickerModal ouverte (au moins 2 templates listés) — light
5. `SimulationFormPage` status `done` (read-only + Reopen visible) light 5. TemplatePickerModal ouverte avec aucun template — empty state visible light
6. Simulation créée depuis un template (champs pré-remplis avec le nom, MITRE chips) — light
Paths absolus dans le summary final. Si le dev server n'a pas pu tourner, dis-le EXPLICITEMENT avec les raisons techniques précises. Paths absolus dans le summary. Si auth flow ne marche pas → escalade.
### Livrables ### Livrables
**US-17 — UI polish** **Types** (`frontend/src/api/types.ts`)
- `EngagementsListPage.tsx` : supprimer le doublon "Create engagement". Garder un seul CTA "New" + icône `+` (selon convention AC-17.2). - `SimulationTemplate`: `{id, name, description, commands, prerequisites, techniques: MitreTechnique[], tactics: MitreTactic[], created_at, updated_at, created_by}`.
- `UsersAdminPage.tsx` : retravailler la grille pour pixel-perfect alignment. Choix laissé au builder (align-items: stretch + align-self, ou restructurer en 2 rangées). - `SimulationTemplateCreateInput`: payload POST.
- Audit boutons : refactoriser ceux qui dépassent ≤ 8 chars. Garder "Mark for review" / "Clear all" / "Reopen" sans icône si pas d'icône évidente. Boutons à passer en icône+label : "Save Red Team" → icône + "Save", "Save SOC" → icône + "Save SOC", "ADD TECHNIQUE" → "+" + "Add" (rendu obsolète par US-22), "QUICK SEARCH" → "🔍" + "Search" (rendu obsolète par US-22). - `SimulationTemplatePatchInput`: payload PATCH.
- Étendre `SimulationCreateInput` avec `template_id?: number`.
**US-18 — Done read-only + Reopen** **API client** (`frontend/src/api/templates.ts` — nouveau)
- `SimulationFormPage.tsx` : - `listTemplates()`, `getTemplate(id)`, `createTemplate(input)`, `updateTemplate(id, patch)`, `deleteTemplate(id)`.
- Quand `simulation.status === 'done'` : tous champs disabled, `MitreTechniquesField disabled`, action bar montre UNIQUEMENT "Reopen" + icône (``).
- Bouton Reopen : visible admin/redteam/soc, click → `useTransitionSimulation` to `review_required`, toast.
**US-19 — Engagement auto-status (côté UI)** **Hooks TanStack Query** (`frontend/src/hooks/useTemplates.ts` — nouveau)
- `useUpdateSimulation` et `useTransitionSimulation` : ajouter `["engagement", eid]` et `["engagements"]` aux invalidations après mutation réussie. Pas d'autre changement visuel. - `useTemplates()`, `useTemplate(id)`, mutations `useCreateTemplate`, `useUpdateTemplate`, `useDeleteTemplate`.
- **Note (spec-reviewer Pass 3)** : `eid` n'est pas directement disponible dans la signature des hooks (qui prennent `sid`). Solution : lire `engagement_id` depuis la response simulation (le backend l'expose toujours, cf serialize_simulation sprint 2) OU le passer en arg supplémentaire au hook si plus propre. Pas un trou plan, juste à anticiper. - Invalidation : create/update/delete invalide `["templates"]` et `["templates", id]`.
**US-20 — Matrice MITRE attack.mitre.org look** **Pages**
- `MitreMatrixModal.tsx` overhaul : - **`TemplatesListPage.tsx`** (nouveau, `/admin/templates`) — admin+redteam only :
- `max-w-[98vw]`, `max-h-[80vh] overflow-y-auto`, JAMAIS de scroll horizontal. - Table : Name, MITRE count (techniques + tactics), Created by, Updated at, Actions.
- `display: grid; grid-template-columns: repeat(12, minmax(0, 1fr))` pour répartir équitablement. - Bouton "+ New template" en header.
- Cellule technique : `text-[12px]`, padding minimal, hairline border. - Actions par ligne : "Edit" + "Delete".
- Header tactique : sticky top, fond contrasté, uppercase tracking, badge compteur à droite. - Click sur une ligne → `/admin/templates/:id/edit`.
- Sub-techniques indent `pl-[8px]`, fond `bg-cloud`. - States : loading / error / empty.
- Search input top inchangé. - **`TemplateFormPage.tsx`** (nouveau, `/admin/templates/new` et `/admin/templates/:id/edit`) — admin+redteam only :
- Form pour name + description + commands (textarea) + prerequisites + MitreTechniquesField.
- Mode `new` : seul `name` requis ; après création, redirige sur `/admin/templates/:id/edit`.
- Mode `edit` : load existing template, allow update.
- Bouton Delete (confirmation modal).
- Pas de mode read-only (SOC n'a pas accès aux routes).
- **`EngagementDetailPage.tsx`** (modification) :
- Remplacer le bouton simple "+ New simulation" par un dropdown OU un menu :
- "Blank" (action default)
- "From template…" → ouvre une modale avec la liste des templates.
- Modale "From template…" : `useTemplates()`, table simple Name + MITRE count, click sur un template → `useCreateSimulation` avec `template_id: t.id` → redirect sur la simu créée.
**US-21 — Tactic selection** **Composants** (`frontend/src/components/`)
- `MitreMatrixModal.tsx` : header de tactique cliquable (toggle). État visuel distinct. - **`TemplatePickerModal.tsx`** (nouveau) : modale qui liste les templates, permet de cliquer pour instancier. Props : `engagementId`, `onClose`, `onInstantiated(simId)`.
- Apply renvoie `{techniques, tactics}` au parent.
- `MitreTechniquesField.tsx` : tactic chips style différencié `bg-primary text-canvas`. Auto-save.
- **PATCH combiné (spec-reviewer fix #4)** : Apply depuis la matrice → UN SEUL PATCH `{technique_ids: [...], tactic_ids: [...]}` (les 2 listes ensemble). Pas 2 PATCH séquentiels (risque de race + risque que le 2nd appel hit le guard done). Pour les × remove ET les Quick Search adds, l'implémentation finale envoie aussi les 2 listes ensemble (`save({techniques, tactics})`) — fonctionnellement équivalent à un PATCH dimensionnel et plus simple à raisonner (single source of truth = local state). Spec-aligné post-code-review : "always send both dimensions" est la règle, le brief initial "dimension qui change" était over-spec. Toutes les mutations passent par `useUpdateSimulation` en un appel atomique.
**US-22 — Refonte input MITRE** **Routing** (`App.tsx`) — toutes routes templates gated `roles=["admin", "redteam"]` :
- `MitreTechniquesField.tsx` : - `/admin/templates` (admin|redteam)
- Layout : chips area | input autocomplete inline + icône matrice button. - `/admin/templates/new` (admin|redteam)
- Plus de boutons textuels "Add Technique" / "Quick Search". - `/admin/templates/:id/edit` (admin|redteam)
- Chips compacts (T-id ou TA-id seul, name en `title=`).
- Empty state minimal.
- **`SimulationFormPage.tsx` — call site update (spec-reviewer fix #4)** : la signature de `MitreTechniquesField` change de `value: MitreTechnique[]` (sprint 3) à `value: {techniques: MitreTechnique[], tactics: MitreTactic[]}`. La page doit passer `value={{techniques: sim.techniques, tactics: sim.tactics}}` (le champ `sim.tactics` vient du nouveau serializer backend). TypeScript catch le miss mais flag-le explicitement pour ne pas l'oublier.
**US-23 — Dark mode** **Layout** (`Layout.tsx`)
- `Layout.tsx` : toggle theme dans la topbar. Hook `useTheme()` (localStorage + media query). 3 états avec cycle. - Nouveau lien "Templates" dans la topbar, à droite de "Users" — **visible UNIQUEMENT à admin + redteam** (masqué pour SOC, pattern identique à "Users" qui est admin-only).
- `tailwind.config.ts` : `darkMode: 'class'`. Tokens étendus avec variantes dark (recommandé via CSS variables sous `.dark { ... }` dans `index.css`, comme ça les composants n'ont pas à dupliquer leurs classes).
- Audit tous les composants : aucune couleur hardcodée (pas de `bg-white`, `text-black`, `#xxxxxx` inline). Tous passent un check visuel light + dark. **Tests Vitest**
- `TemplatesListPage.test.tsx` — loading/error/empty + boutons New/Edit/Delete présents (admin|redteam — pas de variante soc puisque route inaccessible).
- `TemplateFormPage.test.tsx` — mode new + mode edit (pas de mode read-only).
- `TemplatePickerModal.test.tsx` — liste templates, click instantiate, gestion erreur, close.
- `EngagementDetailPage.test.tsx` — adapter pour le nouveau dropdown "+ New simulation".
### Règles ### Règles
- Lit le summary backend EN PREMIER. - Lit le summary backend EN PREMIER.
- Pas d'invention d'endpoints. - Pas d'invention d'endpoints.
- Réutiliser les patterns sprint 1/2/3. - Réutilise `LoadingState`, `ErrorState`, `EmptyState`, `Toast`, `ConfirmDialog`, `MitreTechniquesField`, `StatusBadge` etc.
- Respect DESIGN.md tokens. - Respect DESIGN.md tokens. Dark mode déjà en place — applique les patterns sprint 4 (`bg-canvas dark:bg-canvas`, etc.).
- Pas de dépendance npm sans escalade (sauf `lucide-react` autorisé). - Pas de dépendance npm sans escalade.
- **Interdiction absolue de toucher `e2e/`, `backend/`, `.claude/agents/`, `scripts/`, `Makefile`.**
--- ---
## 4. Brief — Team-lead infra (US-24 + US-25, en parallèle des builders) ## 4. Brief — Test verifier
**US-24 — Process hygiene** E2e Playwright. Un fichier par US :
- Créer `.claude/agents/design-reviewer.md` avec frontmatter agent (model `opus`, tools : `Read`, `Glob`, `Grep`, `Bash` lecture seule). Brief : revoit diff frontend + screenshots, audit alignement / DESIGN.md tokens / cohérence visuelle / responsive. - `us26-templates-crud.spec.ts` — AC-26.1 → AC-26.8 (focus API + UI gérance templates)
- Mettre à jour `.claude/agents/frontend-builder.md` : DoD strict sur les screenshots. - `us27-instantiate-from-template.spec.ts` — AC-27.1 → AC-27.7 (création simu depuis template, décorrélation)
- Mettre à jour SPEC.md § Workflows : insérer design-reviewer entre frontend-builder et code-reviewer. - `us28-templates-nav.spec.ts` — AC-28.1 → AC-28.3 (nav link, accès SOC read-only)
**US-25 — PR helper** Adapter les sprint 2/3/4 e2e si l'ajout du dropdown "+ New simulation" casse des sélecteurs (les tests sprint 2/3 cliquent directement sur "+ New" — désormais ça ouvre un menu avant d'aller au form blanc).
- Écrire `scripts/open-pr.sh` (cf AC-25.1).
- Target Makefile `open-pr`.
- Documenter README.md.
- Dogfood en fin de sprint.
--- ---
## 5. Brief — Test verifier ## 5. Definition of Done — Sprint 5
E2e Playwright : - [ ] Tous les AC US-26 → US-28 passent.
- `us17-ui-polish.spec.ts` — AC-17.1 (single button), AC-17.3 (alignment via locator boundingBox). - [ ] Backend pytest verts (~193 existants + ~25 nouveaux). Ruff + mypy clean.
- `us18-done-readonly-reopen.spec.ts` — AC-18.1 → AC-18.5. - [ ] Frontend vitest verts (92 existants + nouveaux). Typecheck + lint clean.
- `us19-engagement-auto-status.spec.ts` — AC-19.1 → AC-19.4. - [ ] E2e Playwright suite verte (sprint 1-4 + sprint 5).
- `us20-matrix-fits-modal.spec.ts` — AC-20.1, AC-20.4 (no horizontal scroll via `boundingBox`). - [ ] Migration 0005 testée via Alembic round-trip.
- `us21-tactic-selection.spec.ts` — AC-21.4 → AC-21.7. - [ ] SPEC.md § Templates de simulations ajoutée.
- `us22-mitre-input-redesign.spec.ts` — AC-22.1 → AC-22.5. - [ ] README.md mis à jour si nouveau bullet "Templates" pertinent.
- `us23-dark-mode.spec.ts` — AC-23.1 → AC-23.3. - [ ] CHANGELOG.md sprint 5 entry sous [Unreleased].
- [ ] **Design-reviewer pass** sur les nouveaux écrans (lesson sprint 4 — design-reviewer = part of workflow depuis sprint 4).
US-24/25 non e2e (process / repo files). Couverture par dogfood (la PR sprint 4 elle-même est ouverte via `make open-pr`). - [ ] Code-reviewer sans BLOCKER.
- [ ] PR via `make open-pr` (sprint 4 dogfood validé).
Adapter les sprint 2/3 e2e si l'audit boutons (AC-17.2) renomme certains labels.
**Spec-reviewer INFO B** : AC-22.2 change le format des chips de "T1059 — Command and Scripting Interpreter" (sprint 3) à juste "T1059" (avec name dans `title=`). Les e2e sprint 3 (notamment `us14-techniques-tags.spec.ts`) qui assertent le format complet doivent être mis à jour. Pas seulement les labels boutons.
--- ---
## 6. Décisions arrêtées ## 6. Décisions arrêtées (utilisateur 2026-05-28)
1. **Tactic storage** : colonne JSON `tactic_ids` séparée. ✓ 2026-05-27 1. **Table** : `simulation_templates` séparée (clean schema, pas de colonnes nullable confuses).
2. **Dark mode default** : `system` (suit `prefers-color-scheme`, fallback `light` si non détecté). ✓ 2026-05-27 2. **Instantiation API** : extension de `POST /api/engagements/<eid>/simulations` avec `template_id` optionnel.
3. **Matrix CSS fidelity** : look similaire qualitatif (frontend-builder itère, pas pixel-perfect). ✓ 2026-05-27 3. **Name uniqueness** : UNIQUE (1 template par nom, UX dropdown clean).
4. **Reopen target** : `done → review_required`. ✓ mémoire (sprint 4 scope) 4. **Template RBAC** : admin + redteam writable. **SOC pas d'accès du tout** — pas de nav link, pas de page, tous endpoints templates → 403. Templates sont une ressource Red Team uniquement.
5. **Reopen RBAC** : admin + redteam + soc. ✓ mémoire 5. **UI instanciation** : dropdown sur le bouton "+ New simulation" (Blank | From template…).
6. **Engagement auto trigger** : `planned → active` sur 1ère simu in_progress (auto-transition ou manual). Pas de retour arrière auto. ✓ mémoire
7. **PR helper token source** : `~/.git-credentials` (parse user + token via sed, cf [[reference-gitea-pr-api]]). ✓ 2026-05-27
8. **Workflow design-reviewer** : insérée entre frontend-builder et code-reviewer, read-only. Diff frontend + screenshots. Format rapport à la code-reviewer mais focus visuel/design. ✓ mémoire
9. **Screenshots frontend-builder** : MANDATORY au sprint 4, en sortie du frontend-builder, paths absolus dans summary, refus de marquer la tâche done sans. ✓ mémoire
--- ---
## 7. Plan d'exécution ## 7. Plan d'exécution
1. ✅ Team-lead a re-appliqué le SPEC sprint 3 oublié (`ba313a3`). 1. ✅ User a validé les 5 décisions §6 (2026-05-28). SOC zero access acté.
2. ✅ User a validé les 4 décisions ouvertes (tactic separated, theme system, matrix qualitative, token from ~/.git-credentials). Avec les 5 acquises en mémoire (sprint 4 scope), ça fait 9 décisions arrêtées. 2. 🟡 Team-lead met à jour SPEC.md (§0).
3. 🟡 Team-lead met à jour SPEC.md § Workflows + § Décisions techniques (§0). 3. 🟡 Spec-reviewer valide le plan (2-pass — lesson sprint 3/4 — RBAC SOC blocked, name unique conflict 409 handling, template_id passing through auto-transition, design-reviewer scope new pages).
4. 🟡 Spec-reviewer valide le plan vs SPEC.md (anti-trous comme à sprint 3 — RBAC field-level, batch SQLite, scope ambigu). 4. 🔵 Backend-builder : modèle + migration 0005 + endpoints + tests.
5. 🔵 Backend-builder : modèle + migration 0004 + workflow done-readonly/reopen + engagement auto-lifecycle + tactic_ids + tests. 5. 🔵 Frontend-builder : pages Templates list/form + TemplatePickerModal + nav link + dropdown engagement + tests Vitest. Screenshots mandatory.
6. 🔵 Frontend-builder : UI polish + done read-only + matrix overhaul + tactic selection + input redesign + dark mode + screenshots. 6. 🔵 Design-reviewer : revoit diff frontend + screenshots.
7. 🔵 Team-lead (US-24 + US-25 en parallèle de frontend) : design-reviewer agent + frontend-builder.md update + scripts/open-pr.sh + Makefile target. 7. 🔵 Code-reviewer : LSP-first review du diff complet.
8. 🔵 Design-reviewer (NEW STEP) : revoit diff frontend + screenshots. 8. 🔵 Test-verifier : e2e US-26 → US-28.
9. 🔵 Code-reviewer : revoit le diff complet (LSP-first). 9. 🟢 Team-lead : `make open-pr` + récap.
10. 🔵 Test-verifier : e2e US-17 → US-23.
11. 🟢 Team-lead : PR via `make open-pr` (dogfood AC-25.4) + récap. Branche : `sprint/5-templates`.