196 lines
6.4 KiB
Python
196 lines
6.4 KiB
Python
|
|
"""Tests for creating simulations from a template (POST /api/engagements/<eid>/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_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
|