- 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>
189 lines
6.1 KiB
Python
189 lines
6.1 KiB
Python
"""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
|