"""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