feat(backend): c2 poll-on-read + output mapping (sprint 8 M3)

- adapter.py: add completed_at field to C2TaskStatus dataclass
- mythic.py: implement get_task() (GraphQL task query) and
  get_task_output() (response query + decode_response_text concat)
- fake.py: deterministic state progression via per-instance call counter;
  get_task_output raises C2Error until completed
- mapping.py: apply_task_to_simulation() idempotent output mapper
  (mapping_applied anchor prevents double-writes)
- migration 0007: add mapping_applied BOOLEAN NOT NULL DEFAULT false to c2_task
- c2_task model: mapping_applied column added
- api/c2.py: GET /api/simulations/<id>/c2/tasks poll-on-read endpoint;
  refreshes incomplete tasks from C2, fetches output on completion,
  applies mapping, skips re-polling for completed tasks; best-effort
  (C2Error on individual task skipped, returns 200 with stale status)
- 51 new tests (396 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 19:56:06 +02:00
parent 5ff6ae8940
commit 873e52a2a1
12 changed files with 1142 additions and 10 deletions

View File

@@ -0,0 +1,188 @@
"""MythicAdapter M3 tests — get_task and get_task_output, mocked HTTP."""
from __future__ import annotations
import pytest
import requests
import requests_mock as rm_module
from backend.app.services.c2.adapter import C2Error
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)
class TestMythicAdapterGetTask:
def test_returns_status_for_incomplete_task(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"status": "processing",
"completed": False,
"timestamp": None,
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(7)
assert status.display_id == 7
assert status.status == "processing"
assert status.completed is False
assert status.completed_at is None
def test_returns_completed_at_for_completed_task(self, adapter):
payload = {
"data": {
"task": [
{
"display_id": 7,
"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.completed is True
assert status.completed_at is not None
assert status.completed_at.year == 2026
def test_raises_when_task_not_found(self, adapter):
payload = {"data": {"task": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
with pytest.raises(C2Error, match="not found"):
adapter.get_task(999)
def test_sends_apitoken_header(self, adapter):
payload = {
"data": {
"task": [
{"display_id": 1, "status": "submitted", "completed": False, "timestamp": None}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
adapter.get_task(1)
assert m.last_request.headers.get("apitoken") == _TOKEN
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.get_task(1)
def test_no_redirect_followed(self, adapter):
"""get_task must not follow HTTP redirects."""
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=301, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.get_task(1)
assert len(m.request_history) == 1
def test_invalid_timestamp_does_not_crash(self, adapter):
"""A malformed timestamp field falls back to completed_at=None without raising."""
payload = {
"data": {
"task": [
{
"display_id": 5,
"status": "completed",
"completed": True,
"timestamp": "not-a-date",
}
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
status = adapter.get_task(5)
assert status.completed is True
assert status.completed_at is None
class TestMythicAdapterGetTaskOutput:
def test_returns_decoded_output(self, adapter):
import base64
encoded = base64.b64encode(b"Administrator\r\n").decode()
payload = {
"data": {
"response": [{"response_text": encoded}]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert "Administrator" in output
def test_concatenates_multiple_responses(self, adapter):
import base64
r1 = base64.b64encode(b"line one\n").decode()
r2 = base64.b64encode(b"line two\n").decode()
payload = {
"data": {
"response": [{"response_text": r1}, {"response_text": r2}]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert "line one" in output
assert "line two" in output
def test_returns_empty_string_when_no_responses(self, adapter):
payload = {"data": {"response": []}}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert output == ""
def test_skips_empty_response_text(self, adapter):
import base64
encoded = base64.b64encode(b"real output").decode()
payload = {
"data": {
"response": [
{"response_text": ""},
{"response_text": encoded},
]
}
}
with rm_module.Mocker() as m:
m.post(_GQL_URL, json=payload)
output = adapter.get_task_output(7)
assert output == "real output"
def test_network_error_raises_c2error(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, exc=requests.exceptions.Timeout("timeout"))
with pytest.raises(C2Error):
adapter.get_task_output(7)
def test_no_redirect_followed(self, adapter):
with rm_module.Mocker() as m:
m.post(_GQL_URL, status_code=302, headers={"Location": "https://evil.example/"})
with pytest.raises(C2Error):
adapter.get_task_output(1)
assert len(m.request_history) == 1