feat(backend): sprint 4 — tactic_ids + done guard + engagement auto-status

- Simulation model: add tactic_ids JSON column (nullable=False, default=[])
- Migration 0004: ADD COLUMN tactic_ids (server_default='[]', no batch needed)
- mitre.py: add _TACTIC_IDS map, lookup_tactic(), get_tactic_name()
- simulation_workflow.py: done guard (409) before RBAC; SOC gate += tactic_ids;
  _resolve_tactic_ids() validates against hardcoded map; auto-transition += tactic_ids;
  transition done→review_required is Reopen (all 3 roles); _maybe_activate_engagement hook
- serializers.py: _enrich_tactics() → serialize_simulation adds tactics:[{id,name}]
- test_simulations_tactics.py: valid/invalid/dedup/SOC gate/auto-transition/no-bundle
- test_simulations_done_readonly.py: 409 all roles, Reopen all roles, invalid transitions, after-reopen ok
- test_engagement_lifecycle.py: planned→active on auto-transition, already active/closed unchanged, migration 0004 round-trip
- Updated test_simulations_patch.py + test_simulations_workflow.py for AC-18 behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-27 19:52:02 +02:00
parent 0f6ae857b3
commit d5ab1fd26f
11 changed files with 765 additions and 10 deletions

View File

@@ -0,0 +1,178 @@
"""Sprint 4 — engagement auto-status planned→active (AC-19)."""
from __future__ import annotations
from flask.testing import FlaskClient
from backend.tests.conftest import auth_headers as _h
def _make_engagement(client: FlaskClient, token: str, **kwargs) -> dict:
payload = {"name": "Eng", "start_date": "2026-01-01", **kwargs}
resp = client.post("/api/engagements", headers=_h(token), json=payload)
assert resp.status_code == 201
return resp.get_json()
def _get_engagement(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.get(f"/api/engagements/{eid}", headers=_h(token))
assert resp.status_code == 200
return resp.get_json()
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim"},
)
assert resp.status_code == 201
return resp.get_json()
def _patch_sim(client: FlaskClient, token: str, sid: int, payload: dict) -> dict:
resp = client.patch(f"/api/simulations/{sid}", headers=_h(token), json=payload)
assert resp.status_code == 200
return resp.get_json()
# ---------------------------------------------------------------------------
# AC-19.1 — Auto-activate engagement on first sim in_progress
# ---------------------------------------------------------------------------
def test_sim_creation_does_not_activate_engagement(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
_make_sim(client, redteam_token, eng["id"])
eng_data = _get_engagement(client, redteam_token, eng["id"])
assert eng_data["status"] == "planned"
def test_patch_rt_field_activates_planned_engagement(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
assert sim["status"] == "pending"
sim_data = _patch_sim(client, redteam_token, sim["id"], {"description": "started"})
assert sim_data["status"] == "in_progress"
eng_data = _get_engagement(client, redteam_token, eng["id"])
assert eng_data["status"] == "active"
def test_patch_tactic_ids_activates_planned_engagement(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_patch_sim(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
eng_data = _get_engagement(client, redteam_token, eng["id"])
assert eng_data["status"] == "active"
# ---------------------------------------------------------------------------
# AC-19.2 — Already active → stays active (no change)
# ---------------------------------------------------------------------------
def test_patch_rt_field_does_not_change_active_engagement(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
# First patch triggers activation.
_patch_sim(client, redteam_token, sim["id"], {"description": "started"})
# Second patch: engagement should remain active (no state change).
_patch_sim(client, redteam_token, sim["id"], {"description": "updated"})
eng_data = _get_engagement(client, redteam_token, eng["id"])
assert eng_data["status"] == "active"
# ---------------------------------------------------------------------------
# AC-19.3 — Engagement in closed state → not touched
# ---------------------------------------------------------------------------
def test_patch_does_not_reopen_closed_engagement(
client: FlaskClient, redteam_token: str, admin_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
# Manually close the engagement via API.
close_resp = client.patch(
f"/api/engagements/{eng['id']}",
headers=_h(admin_token),
json={"status": "closed"},
)
assert close_resp.status_code == 200
# PATCH a sim field that would normally trigger in_progress.
_patch_sim(client, redteam_token, sim["id"], {"description": "new work"})
eng_data = _get_engagement(client, redteam_token, eng["id"])
assert eng_data["status"] == "closed"
# ---------------------------------------------------------------------------
# Migration 0004 — tactic_ids column NOT NULL after upgrade
# ---------------------------------------------------------------------------
def test_migration_0004_tactic_ids_not_null_after_upgrade() -> None:
"""Alembic round-trip: tactic_ids column is NOT NULL after migration 0004."""
import importlib
import sqlalchemy as _sa
from alembic.operations import Operations
from alembic.runtime.migration import MigrationContext
engine = _sa.create_engine("sqlite:///:memory:")
# Create post-0003 schema (simulations with techniques column).
with engine.begin() as conn:
conn.execute(_sa.text(
"CREATE TABLE simulations ("
" id INTEGER PRIMARY KEY,"
" techniques TEXT NOT NULL DEFAULT '[]'"
")"
))
conn.execute(_sa.text(
"INSERT INTO simulations (id, techniques) VALUES (1, '[]')"
))
with engine.begin() as conn:
ctx = MigrationContext.configure(conn, opts={"as_sql": False})
ops = Operations(ctx)
import alembic.op as _op_module
_op_module._proxy = ops # type: ignore[attr-defined]
spec = importlib.util.spec_from_file_location(
"mig_0004",
"/home/user/Documents/01_Projects/mimic/.claude/worktrees/sprint-4-ui-polish/backend/migrations/versions/0004_simulation_tactic_ids.py",
)
assert spec is not None and spec.loader is not None
mig = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mig) # type: ignore[union-attr]
mig.upgrade()
insp = _sa.inspect(engine)
cols = {c["name"]: c for c in insp.get_columns("simulations")}
assert "tactic_ids" in cols, "tactic_ids column must exist after upgrade"
assert cols["tactic_ids"]["nullable"] is False, "tactic_ids must be NOT NULL"
# Existing row should have server_default applied.
with engine.connect() as conn:
row = conn.execute(_sa.text("SELECT tactic_ids FROM simulations WHERE id=1")).fetchone()
assert row is not None
import json
assert json.loads(row[0]) == []

View File

@@ -0,0 +1,191 @@
"""Sprint 4 — done read-only + Reopen tests (AC-18)."""
from __future__ import annotations
import pytest
from flask.testing import FlaskClient
from backend.tests.conftest import auth_headers as _h
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Eng", "start_date": "2026-01-01"},
)
assert resp.status_code == 201
return resp.get_json()
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim"},
)
assert resp.status_code == 201
return resp.get_json()
def _advance_to_done(client: FlaskClient, redteam_token: str, soc_token: str, sid: int) -> None:
client.post(
f"/api/simulations/{sid}/transition",
headers=_h(redteam_token),
json={"to": "review_required"},
)
client.post(
f"/api/simulations/{sid}/transition",
headers=_h(soc_token),
json={"to": "done"},
)
def _patch(client: FlaskClient, token: str, sid: int, payload: dict):
return client.patch(
f"/api/simulations/{sid}",
headers=_h(token),
json=payload,
)
def _transition(client: FlaskClient, token: str, sid: int, to: str):
return client.post(
f"/api/simulations/{sid}/transition",
headers=_h(token),
json={"to": to},
)
# ---------------------------------------------------------------------------
# AC-18.1 — PATCH on done → 409 for all roles
# ---------------------------------------------------------------------------
def test_patch_done_sim_admin_returns_409(
client: FlaskClient, redteam_token: str, soc_token: str, admin_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_advance_to_done(client, redteam_token, soc_token, sim["id"])
resp = _patch(client, admin_token, sim["id"], {"name": "renamed"})
assert resp.status_code == 409
assert resp.get_json()["error"] == "simulation is done — reopen first"
def test_patch_done_sim_redteam_returns_409(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_advance_to_done(client, redteam_token, soc_token, sim["id"])
resp = _patch(client, redteam_token, sim["id"], {"description": "x"})
assert resp.status_code == 409
def test_patch_done_sim_soc_returns_409(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_advance_to_done(client, redteam_token, soc_token, sim["id"])
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "afterthought"})
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# AC-18.2 — Reopen: done → review_required, all 3 roles
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("role", ["redteam", "soc", "admin"])
def test_reopen_done_sim_allowed_for_all_roles(
client: FlaskClient,
redteam_token: str,
soc_token: str,
admin_token: str,
role: str,
) -> None:
token = {"redteam": redteam_token, "soc": soc_token, "admin": admin_token}[role]
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_advance_to_done(client, redteam_token, soc_token, sim["id"])
resp = _transition(client, token, sim["id"], "review_required")
assert resp.status_code == 200
assert resp.get_json()["status"] == "review_required"
# ---------------------------------------------------------------------------
# AC-18.3 — Other transitions from done → 409
# ---------------------------------------------------------------------------
def test_transition_done_to_done_rejected(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_advance_to_done(client, redteam_token, soc_token, sim["id"])
resp = _transition(client, redteam_token, sim["id"], "done")
assert resp.status_code == 409
def test_transition_done_to_in_progress_rejected(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_advance_to_done(client, redteam_token, soc_token, sim["id"])
resp = _transition(client, redteam_token, sim["id"], "in_progress")
assert resp.status_code == 409
def test_transition_done_to_pending_rejected(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_advance_to_done(client, redteam_token, soc_token, sim["id"])
resp = _transition(client, redteam_token, sim["id"], "pending")
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# After reopen, PATCH is allowed again
# ---------------------------------------------------------------------------
def test_patch_allowed_after_reopen(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_advance_to_done(client, redteam_token, soc_token, sim["id"])
_transition(client, redteam_token, sim["id"], "review_required")
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "re-reviewed"})
assert resp.status_code == 200
assert resp.get_json()["soc_comment"] == "re-reviewed"
# ---------------------------------------------------------------------------
# AC-18.3 — Normal review_required path (pending/in_progress) unchanged
# ---------------------------------------------------------------------------
def test_transition_review_required_from_in_progress_still_needs_redteam(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
# Auto-advance to in_progress.
_patch(client, redteam_token, sim["id"], {"description": "active"})
resp = _transition(client, soc_token, sim["id"], "review_required")
assert resp.status_code == 403

View File

@@ -230,9 +230,10 @@ def test_soc_can_patch_when_review_required(
assert body["incident_number"] == "INC-001"
def test_soc_can_patch_when_done(
def test_patch_when_done_returns_409(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
"""Done is terminal — PATCH is rejected for ALL roles (AC-18.1)."""
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
client.post(
@@ -247,7 +248,8 @@ def test_soc_can_patch_when_done(
)
resp = _patch(client, soc_token, sim["id"], {"soc_comment": "Final note"})
assert resp.status_code == 200
assert resp.status_code == 409
assert resp.get_json()["error"] == "simulation is done — reopen first"
def test_soc_cannot_edit_redteam_fields(

View File

@@ -0,0 +1,237 @@
"""Sprint 4 — tactic_ids PATCH tests (AC-21)."""
from __future__ import annotations
import pathlib
import pytest
from flask.testing import FlaskClient
from backend.app.services import mitre as mitre_svc
from backend.tests.conftest import auth_headers as _h
_FIXTURE_BUNDLE = {
"type": "bundle",
"objects": [
{
"type": "attack-pattern",
"name": "Command and Scripting Interpreter",
"external_references": [{"source_name": "mitre-attack", "external_id": "T1059"}],
"kill_chain_phases": [{"phase_name": "execution", "kill_chain_name": "mitre-attack"}],
},
],
}
@pytest.fixture(autouse=True)
def _reset_mitre():
original_loaded = mitre_svc.mitre_loaded
original_index = list(mitre_svc._index)
original_tactics = dict(mitre_svc._tactics_by_technique)
original_names = dict(mitre_svc._name_by_id)
original_matrix = list(mitre_svc._matrix)
yield
mitre_svc.mitre_loaded = original_loaded
mitre_svc._index = original_index
mitre_svc._tactics_by_technique = original_tactics
mitre_svc._name_by_id = original_names
mitre_svc._matrix = original_matrix
@pytest.fixture()
def bundle_file(tmp_path: pathlib.Path) -> pathlib.Path:
import json
p = tmp_path / "enterprise-attack.json"
p.write_text(json.dumps(_FIXTURE_BUNDLE), encoding="utf-8")
return p
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Eng", "start_date": "2026-01-01"},
)
assert resp.status_code == 201
return resp.get_json()
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim"},
)
assert resp.status_code == 201
return resp.get_json()
def _patch(client: FlaskClient, token: str, sid: int, payload: dict):
return client.patch(
f"/api/simulations/{sid}",
headers=_h(token),
json=payload,
)
# ---------------------------------------------------------------------------
# tactic_ids happy path
# ---------------------------------------------------------------------------
def test_patch_tactic_ids_valid(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
assert resp.status_code == 200
data = resp.get_json()
assert data["tactics"] == [{"id": "TA0007", "name": "Discovery"}]
def test_patch_tactic_ids_multiple(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0001", "TA0002"]})
assert resp.status_code == 200
tactics = resp.get_json()["tactics"]
ids = [t["id"] for t in tactics]
assert "TA0001" in ids
assert "TA0002" in ids
def test_patch_tactic_ids_empty_clears(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": []})
assert resp.status_code == 200
assert resp.get_json()["tactics"] == []
def test_patch_tactic_ids_dedup(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007", "TA0007"]})
assert resp.status_code == 200
tactics = resp.get_json()["tactics"]
assert len(tactics) == 1
# ---------------------------------------------------------------------------
# tactic_ids error paths
# ---------------------------------------------------------------------------
def test_patch_tactic_ids_unknown_returns_400(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA9999"]})
assert resp.status_code == 400
assert "unknown tactic id" in resp.get_json()["error"]
def test_patch_tactic_ids_not_a_list_returns_400(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": "TA0007"})
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# SOC gate
# ---------------------------------------------------------------------------
def test_soc_cannot_patch_tactic_ids(
client: FlaskClient, redteam_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
# Advance to review_required so SOC can act.
client.post(
f"/api/simulations/{sim['id']}/transition",
headers=_h(redteam_token),
json={"to": "review_required"},
)
resp = _patch(client, soc_token, sim["id"], {"tactic_ids": ["TA0007"]})
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# Auto-transition via tactic_ids
# ---------------------------------------------------------------------------
def test_patch_tactic_ids_triggers_auto_transition(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
assert sim["status"] == "pending"
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
assert resp.status_code == 200
assert resp.get_json()["status"] == "in_progress"
def test_patch_empty_tactic_ids_no_auto_transition(
client: FlaskClient, redteam_token: str
) -> None:
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": []})
assert resp.status_code == 200
assert resp.get_json()["status"] == "pending"
# ---------------------------------------------------------------------------
# tactic_ids not affected by MITRE bundle loaded state
# (validation uses hardcoded _TACTIC_IDS, not the live bundle)
# ---------------------------------------------------------------------------
def test_patch_tactic_ids_works_without_bundle(
client: FlaskClient, redteam_token: str
) -> None:
"""tactic_ids validation is hardcoded — bundle state is irrelevant."""
mitre_svc.mitre_loaded = False
mitre_svc._index = []
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"tactic_ids": ["TA0007"]})
assert resp.status_code == 200
def test_patch_technique_ids_bundle_not_loaded_returns_503(
client: FlaskClient, redteam_token: str
) -> None:
"""technique_ids still needs the bundle (different from tactic_ids)."""
mitre_svc.mitre_loaded = False
mitre_svc._index = []
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
resp = _patch(client, redteam_token, sim["id"], {"technique_ids": ["T1059"]})
assert resp.status_code == 503

View File

@@ -407,7 +407,7 @@ def test_migration_0003_techniques_not_null_after_upgrade() -> None:
spec = importlib.util.spec_from_file_location(
"mig_0003",
"/home/user/Documents/01_Projects/mimic/.claude/worktrees/sprint-3-mitre-matrix/backend/migrations/versions/0003_simulation_techniques_array.py",
"/home/user/Documents/01_Projects/mimic/.claude/worktrees/sprint-4-ui-polish/backend/migrations/versions/0003_simulation_techniques_array.py",
)
assert spec is not None and spec.loader is not None
mig = importlib.util.module_from_spec(spec)

View File

@@ -150,16 +150,18 @@ def test_transition_unknown_status_rejected(
assert resp.status_code == 409
def test_transition_review_required_from_done_rejected(
def test_transition_review_required_from_done_is_reopen(
client: FlaskClient, redteam_token: str
) -> None:
"""done → review_required is the Reopen path, now allowed (AC-18.2)."""
eng = _make_engagement(client, redteam_token)
sim = _make_sim(client, redteam_token, eng["id"])
_transition(client, redteam_token, sim["id"], "review_required")
_transition(client, redteam_token, sim["id"], "done")
resp = _transition(client, redteam_token, sim["id"], "review_required")
assert resp.status_code == 409
assert resp.status_code == 200
assert resp.get_json()["status"] == "review_required"
# ---------------------------------------------------------------------------