"""Tests for creating simulations from a template (POST /api/engagements//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