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:
237
backend/tests/test_simulations_tactics.py
Normal file
237
backend/tests/test_simulations_tactics.py
Normal 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
|
||||
Reference in New Issue
Block a user