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,110 @@
"""FakeAdapter M3 state-progression tests — get_task and get_task_output."""
from __future__ import annotations
import pytest
from backend.app.services.c2.adapter import C2Error
from backend.app.services.c2.fake import FakeAdapter
@pytest.fixture()
def adapter() -> FakeAdapter:
return FakeAdapter()
@pytest.fixture()
def adapter_with_task(adapter: FakeAdapter) -> tuple[FakeAdapter, int]:
tid = adapter.create_task(callback_display_id=1, command="whoami")
return adapter, tid
class TestFakeAdapterGetTaskProgression:
def test_first_call_returns_submitted(self, adapter_with_task):
a, tid = adapter_with_task
status = a.get_task(tid)
assert status.status == "submitted"
assert status.completed is False
def test_second_call_returns_completed(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid) # first call
status = a.get_task(tid) # second call
assert status.status == "completed"
assert status.completed is True
def test_subsequent_calls_stay_completed(self, adapter_with_task):
a, tid = adapter_with_task
for _ in range(5):
a.get_task(tid)
status = a.get_task(tid)
assert status.completed is True
def test_unknown_task_id_returns_submitted_on_first_call(self, adapter):
"""A task ID not created by this instance still goes through submitted→completed."""
status = adapter.get_task(9999)
assert status.display_id == 9999
assert status.status == "submitted"
assert status.completed is False
def test_call_counters_are_per_task(self, adapter):
"""Two tasks have independent state — completing one does not affect the other."""
t1 = adapter.create_task(callback_display_id=1, command="whoami")
t2 = adapter.create_task(callback_display_id=1, command="ipconfig")
# Advance t1 to completed via two calls.
adapter.get_task(t1)
adapter.get_task(t1)
# t2 first call should still be submitted.
s2 = adapter.get_task(t2)
assert s2.status == "submitted"
assert s2.completed is False
def test_instances_are_isolated(self):
"""Per-instance counters — different FakeAdapter instances don't share state."""
a1 = FakeAdapter()
a2 = FakeAdapter()
t1 = a1.create_task(1, "cmd")
t2 = a2.create_task(1, "cmd")
a1.get_task(t1)
a1.get_task(t1) # a1's task is now completed
# a2's task with same display_id (both start at 1000) should be independent.
assert t1 == t2 == 1000
s2 = a2.get_task(t2)
assert s2.status == "submitted"
class TestFakeAdapterGetTaskOutput:
def test_raises_before_completed(self, adapter_with_task):
a, tid = adapter_with_task
with pytest.raises(C2Error, match="task not completed"):
a.get_task_output(tid)
def test_raises_after_first_get_task_call_only(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid) # first call — still submitted
with pytest.raises(C2Error, match="task not completed"):
a.get_task_output(tid)
def test_returns_output_after_completed(self, adapter_with_task):
a, tid = adapter_with_task
a.get_task(tid)
a.get_task(tid) # now completed
output = a.get_task_output(tid)
assert "whoami" in output
assert str(tid) in output
def test_output_format(self, adapter):
tid = adapter.create_task(callback_display_id=2, command="ipconfig /all")
adapter.get_task(tid)
adapter.get_task(tid)
output = adapter.get_task_output(tid)
assert output == f"output for task {tid}: ipconfig /all\n"
def test_unknown_task_raises_c2error(self, adapter):
"""Task ID never created and never polled — not completed → C2Error."""
with pytest.raises(C2Error, match="task not completed"):
adapter.get_task_output(9999)