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:
188
backend/tests/test_c2_adapter_mythic_m3.py
Normal file
188
backend/tests/test_c2_adapter_mythic_m3.py
Normal 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
|
||||
Reference in New Issue
Block a user