feat: paginated commands endpoint for attacker profiles

New GET /attackers/{uuid}/commands?limit=&offset=&service= endpoint
serves commands with server-side pagination and optional service filter.
AttackerDetail frontend fetches commands from this endpoint with
page controls. Service badge filter now drives both the API query
and the local fingerprint filter.
This commit is contained in:
2026-04-14 01:45:19 -04:00
parent 8c249f6987
commit f3bb0b31ae
7 changed files with 194 additions and 8 deletions

View File

@@ -204,6 +204,66 @@ class TestGetAttackerDetail:
assert isinstance(result["commands"], list)
# ─── GET /attackers/{uuid}/commands ──────────────────────────────────────────
class TestGetAttackerCommands:
@pytest.mark.asyncio
async def test_returns_paginated_commands(self):
from decnet.web.router.attackers.api_get_attacker_commands import get_attacker_commands
sample = _sample_attacker()
cmds = [
{"service": "ssh", "decky": "decky-01", "command": "id", "timestamp": "2026-04-01T10:00:00"},
{"service": "ssh", "decky": "decky-01", "command": "whoami", "timestamp": "2026-04-01T10:01:00"},
]
with patch("decnet.web.router.attackers.api_get_attacker_commands.repo") as mock_repo:
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
mock_repo.get_attacker_commands = AsyncMock(return_value={"total": 2, "data": cmds})
result = await get_attacker_commands(
uuid="att-uuid-1", limit=50, offset=0, service=None,
current_user="test-user",
)
assert result["total"] == 2
assert len(result["data"]) == 2
assert result["limit"] == 50
assert result["offset"] == 0
@pytest.mark.asyncio
async def test_service_filter_forwarded(self):
from decnet.web.router.attackers.api_get_attacker_commands import get_attacker_commands
sample = _sample_attacker()
with patch("decnet.web.router.attackers.api_get_attacker_commands.repo") as mock_repo:
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=sample)
mock_repo.get_attacker_commands = AsyncMock(return_value={"total": 0, "data": []})
await get_attacker_commands(
uuid="att-uuid-1", limit=50, offset=0, service="ssh",
current_user="test-user",
)
mock_repo.get_attacker_commands.assert_awaited_once_with(
uuid="att-uuid-1", limit=50, offset=0, service="ssh",
)
@pytest.mark.asyncio
async def test_404_on_unknown_uuid(self):
from decnet.web.router.attackers.api_get_attacker_commands import get_attacker_commands
with patch("decnet.web.router.attackers.api_get_attacker_commands.repo") as mock_repo:
mock_repo.get_attacker_by_uuid = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await get_attacker_commands(
uuid="nonexistent", limit=50, offset=0, service=None,
current_user="test-user",
)
assert exc_info.value.status_code == 404
# ─── Auth enforcement ────────────────────────────────────────────────────────
class TestAttackersAuth:

View File

@@ -30,6 +30,7 @@ class DummyRepo(BaseRepository):
async def get_attacker_by_uuid(self, u): await super().get_attacker_by_uuid(u)
async def get_attackers(self, **kw): await super().get_attackers(**kw)
async def get_total_attackers(self, **kw): await super().get_total_attackers(**kw)
async def get_attacker_commands(self, **kw): await super().get_attacker_commands(**kw)
@pytest.mark.asyncio
async def test_base_repo_coverage():
@@ -59,3 +60,4 @@ async def test_base_repo_coverage():
await dr.get_attacker_by_uuid("a")
await dr.get_attackers()
await dr.get_total_attackers()
await dr.get_attacker_commands(uuid="a")