feat(backend): c2 callback history + task import (sprint 8 M4)

Command source decision: extended C2TaskStatus with command: str | None
(default None). Added command_name to _GET_TASK_QUERY so get_task() returns
command in a single round-trip — no separate history fetch needed on import.
4-line change, zero cascading test impact.

adapter.py:
- C2TaskStatus: add command: str | None = None field
- C2HistoricalTask: new dataclass (display_id, command, params, status,
  completed, timestamp) for history rows
- C2TaskPage.items: typed as list[C2HistoricalTask] (was list[dict])

mythic.py:
- _GET_TASK_QUERY: add command_name field
- _LIST_CALLBACK_TASKS_QUERY: new query (order_by id desc, limit/offset)
- _COUNT_CALLBACK_TASKS_QUERY: new aggregate query for total
- get_task(): surfaces command_name as status.command
- list_callback_tasks(): two _post() calls (tasks + count), allow_redirects=False

fake.py:
- _FAKE_HISTORY: frozen deterministic history (cb1=12, cb2=0, cb3=5 tasks)
- list_callback_tasks(): serves from _FAKE_HISTORY, pagination applied
- get_task(): returns command from _tasks dict

api/c2.py:
- GET /api/engagements/<eid>/c2/callbacks/<cid>/history: page+page_size
  defaults 1/25, cap 100, reject <1, 502 on adapter error
- POST /api/simulations/<sid>/c2/import: idempotent per (sim,mythic_id) pair,
  source=import, completed tasks get output+mapping_applied, incomplete tasks
  stored for poll-on-read pickup, auto-transition pending→in_progress

60 new tests (456 total); pytest/ruff/mypy all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-06-10 20:09:29 +02:00
parent b83316f715
commit 8f23f59601
8 changed files with 1146 additions and 8 deletions

View File

@@ -0,0 +1,75 @@
"""FakeAdapter M4 tests — list_callback_tasks pagination."""
from __future__ import annotations
import pytest
from backend.app.services.c2.adapter import C2HistoricalTask
from backend.app.services.c2.fake import FakeAdapter
@pytest.fixture()
def adapter() -> FakeAdapter:
return FakeAdapter()
class TestFakeAdapterListCallbackTasks:
def test_callback_1_returns_12_total(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert page.total == 12
def test_callback_2_returns_0_tasks(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=2, page=1, page_size=25)
assert page.total == 0
assert page.items == []
def test_callback_3_returns_5_tasks(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=3, page=1, page_size=25)
assert page.total == 5
assert len(page.items) == 5
def test_items_are_c2_historical_task_instances(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
for item in page.items:
assert isinstance(item, C2HistoricalTask)
def test_pagination_page1(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=5)
assert len(page.items) == 5
assert page.page == 1
assert page.page_size == 5
def test_pagination_page2(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=5)
assert len(page.items) == 5
assert page.page == 2
def test_pagination_last_page_partial(self, adapter):
# 12 tasks, page_size=5 → page 3 has 2 items.
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=5)
assert len(page.items) == 2
assert page.total == 12
def test_pagination_beyond_range_returns_empty(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=1, page=99, page_size=25)
assert len(page.items) == 0
assert page.total == 12
def test_history_is_deterministic_across_instances(self):
a1 = FakeAdapter()
a2 = FakeAdapter()
p1 = a1.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
p2 = a2.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert [t.display_id for t in p1.items] == [t.display_id for t in p2.items]
def test_completed_and_submitted_mix(self, adapter):
"""Callback 1 has alternating completed/submitted tasks (even=completed)."""
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=12)
completed = [t for t in page.items if t.completed]
submitted = [t for t in page.items if not t.completed]
assert len(completed) == 6
assert len(submitted) == 6
def test_unknown_callback_returns_empty(self, adapter):
page = adapter.list_callback_tasks(callback_display_id=999, page=1, page_size=25)
assert page.total == 0
assert page.items == []

View File

@@ -0,0 +1,167 @@
"""MythicAdapter M4 tests — list_callback_tasks, mocked HTTP."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error, C2HistoricalTask
from backend.app.services.c2.mythic import MythicAdapter
_BASE_URL = "https://mythic.lab:7443"
_GQL_URL = _BASE_URL + "/graphql"
_TOKEN = "fake-api-token"
@pytest.fixture()
def adapter():
return MythicAdapter(url=_BASE_URL, api_token=_TOKEN, verify_tls=False)
def _task_list_payload(tasks: list[dict]) -> dict:
return {"data": {"task": tasks}}
def _count_payload(count: int) -> dict:
return {"data": {"task_aggregate": {"aggregate": {"count": count}}}}
class TestMythicAdapterListCallbackTasks:
def test_returns_tasks_from_graphql(self, adapter):
tasks_payload = _task_list_payload([
{
"display_id": 7,
"command_name": "whoami",
"params": "",
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
])
count_payload = _count_payload(1)
with rm_module.Mocker() as m:
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
page = adapter.list_callback_tasks(callback_display_id=1, page=1, page_size=25)
assert page.total == 1
assert len(page.items) == 1
item = page.items[0]
assert isinstance(item, C2HistoricalTask)
assert item.display_id == 7
assert item.command == "whoami"
assert item.completed is True
def test_pagination_offset_calculation(self, adapter):
"""page=2, page_size=10 → offset=10 must be sent to Mythic."""
tasks_payload = _task_list_payload([])
count_payload = _count_payload(0)
with rm_module.Mocker() as m:
m.post(_GQL_URL, [{"json": tasks_payload}, {"json": count_payload}])
adapter.list_callback_tasks(callback_display_id=1, page=2, page_size=10)
# First request is the task list; check variables.
first_body = m.request_history[0].json()
variables = first_body.get("variables", {})
assert variables.get("offset") == 10
assert variables.get("limit") == 10
def test_sends_apitoken_header(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(0)},
])
adapter.list_callback_tasks(callback_display_id=1)
for req in m.request_history:
assert req.headers.get("apitoken") == _TOKEN
def test_empty_task_list(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(0)},
])
page = adapter.list_callback_tasks(callback_display_id=1)
assert page.total == 0
assert page.items == []
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.ConnectionError("refused"))
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
def test_http_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=500, text="error")
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
def test_no_redirect_followed(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.list_callback_tasks(callback_display_id=1)
# Both requests (tasks + count) should each only make one attempt.
for req in m.request_history:
assert req.method == "POST"
def test_page_and_page_size_in_response(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, [
{"json": _task_list_payload([])},
{"json": _count_payload(50)},
])
page = adapter.list_callback_tasks(callback_display_id=1, page=3, page_size=10)
assert page.page == 3
assert page.page_size == 10
assert page.total == 50
class TestMythicAdapterGetTaskCommandField:
"""Ensure command_name is surfaced via get_task() C2TaskStatus.command."""
def test_get_task_returns_command(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"command_name": "shell",
"status": "completed",
"completed": True,
"timestamp": "2026-06-10T12:00:00Z",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.command == "shell"
def test_get_task_command_none_when_missing(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"command_name": None,
"status": "submitted",
"completed": False,
"timestamp": None,
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.command is None

View File

@@ -0,0 +1,215 @@
"""Tests for GET /api/engagements/<id>/c2/callbacks/<cid>/history."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask.testing import FlaskClient
from backend.app.services.c2.adapter import C2Error
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _history(client: FlaskClient, token: str, eid: int, cid: int, **params):
return client.get(
f"/api/engagements/{eid}/c2/callbacks/{cid}/history",
headers=_h(token),
query_string=params,
)
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestHistoryHappyPath:
def test_returns_200(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 200
def test_response_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
body = resp.get_json()
assert "tasks" in body
assert "total" in body
assert "page" in body
assert "page_size" in body
def test_task_shape(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
task = resp.get_json()["tasks"][0]
for field in ("display_id", "command", "params", "status", "completed", "timestamp"):
assert field in task, f"missing field: {field}"
def test_default_page_is_1(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["page"] == 1
def test_default_page_size_is_25(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["page_size"] == 25
def test_callback_1_has_12_total(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.get_json()["total"] == 12
def test_callback_2_has_0_tasks(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 2)
body = resp.get_json()
assert body["total"] == 0
assert body["tasks"] == []
def test_pagination_page_size_applied(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=1, page_size=5)
body = resp.get_json()
assert len(body["tasks"]) == 5
assert body["page_size"] == 5
def test_redteam_can_view_history(
self, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, redteam_token, eng["id"], 1)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
class TestHistoryValidation:
def test_400_page_size_too_large(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=101)
assert resp.status_code == 400
def test_400_page_zero(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=0)
assert resp.status_code == 400
def test_400_page_size_zero(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=0)
assert resp.status_code == 400
def test_400_page_negative(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page=-1)
assert resp.status_code == 400
def test_400_page_size_100_is_ok(self, client: FlaskClient, admin_token: str) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1, page_size=100)
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# Authorization / error cases
# ---------------------------------------------------------------------------
class TestHistoryErrors:
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, soc_token, eng["id"], 1)
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 503
def test_404_engagement_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _history(client, admin_token, 9999, 1)
assert resp.status_code == 404
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 404
def test_502_adapter_error(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, callback_display_id, page=1, page_size=25):
raise C2Error("upstream error")
monkeypatch.setattr(fake_mod.FakeAdapter, "list_callback_tasks", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
resp = _history(client, admin_token, eng["id"], 1)
assert resp.status_code == 502
assert "upstream error" in resp.get_json().get("error", "")

View File

@@ -0,0 +1,418 @@
"""Tests for POST /api/simulations/<id>/c2/import."""
from __future__ import annotations
import pytest
from cryptography.fernet import Fernet
from flask import Flask
from flask.testing import FlaskClient
from backend.app.extensions import db
from backend.app.models.c2_task import C2Task, C2TaskSource
from backend.app.models.simulation import Simulation, SimulationStatus
from backend.app.services.c2.adapter import C2Error, C2TaskStatus
from backend.tests.conftest import auth_headers as _h
_FERNET_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def set_encryption_key(monkeypatch):
monkeypatch.setenv("MIMIC_ENCRYPTION_KEY", _FERNET_KEY)
@pytest.fixture(autouse=True)
def use_fake_adapter(monkeypatch):
monkeypatch.setenv("MIMIC_C2_ADAPTER", "fake")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_engagement(client: FlaskClient, token: str) -> dict:
resp = client.post(
"/api/engagements",
headers=_h(token),
json={"name": "Op Alpha", "start_date": "2026-06-10"},
)
assert resp.status_code == 201
return resp.get_json()
def _put_config(client: FlaskClient, token: str, eid: int) -> None:
resp = client.put(
f"/api/engagements/{eid}/c2-config",
headers=_h(token),
json={"url": "https://c2.internal:7443", "api_token": "s3cr3t", "verify_tls": True},
)
assert resp.status_code == 200
def _make_sim(client: FlaskClient, token: str, eid: int) -> dict:
resp = client.post(
f"/api/engagements/{eid}/simulations",
headers=_h(token),
json={"name": "Sim Alpha"},
)
assert resp.status_code == 201
return resp.get_json()
def _import(client: FlaskClient, token: str, sid: int, task_display_ids: list, callback_display_id: int = 1):
return client.post(
f"/api/simulations/{sid}/c2/import",
headers=_h(token),
json={"callback_display_id": callback_display_id, "task_display_ids": task_display_ids},
)
def _make_completed_get_task(monkeypatch, command: str = "whoami"):
"""Patch FakeAdapter.get_task to return completed=True with a command."""
from datetime import UTC, datetime
from backend.app.services.c2 import fake as fake_mod
def _completed(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="completed",
completed=True,
completed_at=datetime.now(UTC),
command=command,
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _completed)
def _output(self, task_display_id: int) -> str:
return f"output for {task_display_id}"
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task_output", _output)
def _advance_to_review_required(client, token, sid):
client.patch(f"/api/simulations/{sid}", headers=_h(token), json={"name": "Sim Alpha"})
client.post(f"/api/simulations/{sid}/transition", headers=_h(token), json={"to": "review_required"})
def _advance_to_done(client, admin_token, soc_token, sid):
_advance_to_review_required(client, admin_token, sid)
client.post(f"/api/simulations/{sid}/transition", headers=_h(soc_token), json={"to": "done"})
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
class TestImportHappyPath:
def test_imports_two_completed_tasks(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch, command="whoami")
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100, 101])
assert resp.status_code == 200
body = resp.get_json()
assert body["imported"] == 2
assert body["skipped"] == 0
with app.app_context():
rows = C2Task.query.filter_by(simulation_id=sim["id"]).all()
assert len(rows) == 2
def test_imported_tasks_have_source_import(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.source == C2TaskSource.IMPORT
def test_completed_tasks_get_mapping_applied(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.mapping_applied is True
def test_idempotent_import_counts_skipped(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
# First import.
_import(client, admin_token, sim["id"], [100, 101])
# Second import with one overlap.
resp = _import(client, admin_token, sim["id"], [100, 102])
body = resp.get_json()
assert body["imported"] == 1
assert body["skipped"] == 1
def test_auto_transition_pending_to_in_progress(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
assert sim["status"] == "pending"
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_no_transition_when_already_in_progress(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
# Advance to in_progress manually.
client.patch(
f"/api/simulations/{sim['id']}",
headers=_h(admin_token),
json={"name": "Sim Alpha"},
)
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.IN_PROGRESS
def test_no_transition_when_review_required(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_review_required(client, admin_token, sim["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
updated = db.session.get(Simulation, sim["id"])
assert updated is not None
assert updated.status == SimulationStatus.REVIEW_REQUIRED
def test_incomplete_task_stored_without_mapping(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""An incomplete task is stored as-is; mapping_applied stays False."""
from backend.app.services.c2 import fake as fake_mod
def _submitted(self, task_display_id: int) -> C2TaskStatus:
return C2TaskStatus(
display_id=task_display_id,
status="submitted",
completed=False,
command="shell",
)
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _submitted)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [200])
assert resp.status_code == 200
assert resp.get_json()["imported"] == 1
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.completed is False
assert task.mapping_applied is False
assert task.output is None
def test_command_stored_from_get_task(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""Command field on the stored row comes from adapter.get_task().command."""
_make_completed_get_task(monkeypatch, command="net user /domain")
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100])
with app.app_context():
task = C2Task.query.filter_by(simulation_id=sim["id"]).first()
assert task is not None
assert task.command == "net user /domain"
def test_redteam_can_import(
self, monkeypatch, client: FlaskClient, admin_token: str, redteam_token: str
) -> None:
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, redteam_token, sim["id"], [100])
assert resp.status_code == 200
def test_no_transition_when_all_skipped(
self, app: Flask, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
"""If imported=0 (all skipped), do not transition pending→in_progress."""
_make_completed_get_task(monkeypatch)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_import(client, admin_token, sim["id"], [100]) # first import
_import(client, admin_token, sim["id"], []) # empty — should 400 before this matters
# Reset to pending state via a fresh sim (can't undo, just verify the 0-skipped case).
# We test: importing same task again = skipped=1, imported=0 → no double-transition.
resp = _import(client, admin_token, sim["id"], [100])
body = resp.get_json()
assert body["imported"] == 0
assert body["skipped"] == 1
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
class TestImportValidation:
def test_400_empty_task_display_ids(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [])
assert resp.status_code == 400
def test_400_non_int_task_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/import",
headers=_h(admin_token),
json={"callback_display_id": 1, "task_display_ids": ["not-an-int"]},
)
assert resp.status_code == 400
def test_400_missing_callback_display_id(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = client.post(
f"/api/simulations/{sim['id']}/c2/import",
headers=_h(admin_token),
json={"task_display_ids": [100]},
)
assert resp.status_code == 400
def test_409_done_simulation(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
_advance_to_done(client, admin_token, soc_token, sim["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 409
def test_404_simulation_not_found(
self, client: FlaskClient, admin_token: str
) -> None:
resp = _import(client, admin_token, 9999, [100])
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Authorization / error cases
# ---------------------------------------------------------------------------
class TestImportErrors:
def test_403_soc(
self, client: FlaskClient, admin_token: str, soc_token: str
) -> None:
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, soc_token, sim["id"], [100])
assert resp.status_code == 403
def test_503_no_key(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
monkeypatch.delenv("MIMIC_ENCRYPTION_KEY", raising=False)
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 503
def test_404_no_c2_config(
self, client: FlaskClient, admin_token: str
) -> None:
eng = _make_engagement(client, admin_token)
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 404
def test_502_adapter_error_on_get_task(
self, monkeypatch, client: FlaskClient, admin_token: str
) -> None:
from backend.app.services.c2 import fake as fake_mod
def _boom(self, task_display_id: int) -> C2TaskStatus:
raise C2Error("Mythic unreachable")
monkeypatch.setattr(fake_mod.FakeAdapter, "get_task", _boom)
eng = _make_engagement(client, admin_token)
_put_config(client, admin_token, eng["id"])
sim = _make_sim(client, admin_token, eng["id"])
resp = _import(client, admin_token, sim["id"], [100])
assert resp.status_code == 502
assert "Mythic unreachable" in resp.get_json().get("error", "")