Files
mimic/backend/tests/test_simulations_tactics.py

238 lines
7.6 KiB
Python
Raw Normal View History

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