feat(web): expose attribution state on AttackerDetail backend (Phase 6)
GET /api/v1/attackers/{uuid}/attribution
Returns the merger output for an attacker's identity:
{
"identity_uuid": "abc..." | null,
"primitives": [
{primitive, current_value, state, confidence,
observation_count, last_change_ts, last_observation_ts},
...
]
}
Pre-attribution-worker: identity_uuid=null, primitives=[]. Surfacing
identity_uuid keeps the cross-attacker rollup story visible to the
frontend ahead of v1's clusterer landing.
api_events SSE relay also subscribes to attribution.> and forwards
to the AttackerDetail page filtered on payload.identity_uuid (the
identity is resolved at stream open from the URL's attacker_uuid;
attribution payloads are identity-keyed, not attacker-keyed). New
SSE event names: attribution.state_changed,
attribution.multi_actor_suspected.
Frontend (AttackerDetail.tsx badge rendering, useAttackerStream
consumer) deferred — there's already WIP on AttackerDetail.tsx in
the working tree; merging the badge logic is a separate commit
once that lands.
Tests: 4 endpoint scenarios — 401 unauth, 404 unknown attacker,
200 empty (no stub), 200 with primitive-ordered rows.
This commit is contained in:
113
tests/api/attackers/test_attribution_endpoint.py
Normal file
113
tests/api/attackers/test_attribution_endpoint.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Phase 6 — GET /api/v1/attackers/{uuid}/attribution.
|
||||
|
||||
Pins the contract: 401 unauth, 404 unknown attacker, 200 with empty
|
||||
``primitives`` for an attacker with no stub identity yet, 200 with
|
||||
populated ``primitives`` after the attribution worker has run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.web.dependencies import repo as _repo
|
||||
|
||||
_V1 = "/api/v1/attackers"
|
||||
_OTHER_UUID = "00000000-0000-0000-0000-000000000099"
|
||||
|
||||
|
||||
async def _seed_attacker(ip: str = "10.0.0.5") -> str:
|
||||
return await _repo.upsert_attacker({
|
||||
"ip": ip,
|
||||
"first_seen": datetime.now(timezone.utc),
|
||||
"last_seen": datetime.now(timezone.utc),
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attribution_unauthenticated(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""No Bearer token → 401, full stop."""
|
||||
auid = await _seed_attacker()
|
||||
resp = await client.get(f"{_V1}/{auid}/attribution")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attribution_unknown_attacker_returns_404(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
resp = await client.get(
|
||||
f"{_V1}/{_OTHER_UUID}/attribution",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attribution_no_stub_yet(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
"""Attacker exists but the attribution worker hasn't seen any
|
||||
observations yet → 200 with identity_uuid=None and empty list."""
|
||||
auid = await _seed_attacker(ip="10.0.0.10")
|
||||
resp = await client.get(
|
||||
f"{_V1}/{auid}/attribution",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["identity_uuid"] is None
|
||||
assert body["primitives"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attribution_returns_state_rows(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
"""After stub identity + state writes, the endpoint surfaces
|
||||
every per-primitive row, primitive-ordered."""
|
||||
auid = await _seed_attacker(ip="10.0.0.11")
|
||||
iuid = await _repo.ensure_stub_identity_for_attacker(auid)
|
||||
assert iuid is not None
|
||||
for primitive, state in [
|
||||
("motor.input_modality", "stable"),
|
||||
("cognitive.feedback_loop_engagement", "drifting"),
|
||||
]:
|
||||
await _repo.upsert_attribution_state({
|
||||
"identity_uuid": iuid,
|
||||
"primitive": primitive,
|
||||
"current_value": "x",
|
||||
"state": state,
|
||||
"confidence": 0.85,
|
||||
"observation_count": 5,
|
||||
"last_change_ts": 1714000000.0,
|
||||
"last_observation_ts": 1714000000.0,
|
||||
})
|
||||
|
||||
resp = await client.get(
|
||||
f"{_V1}/{auid}/attribution",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["identity_uuid"] == iuid
|
||||
primitives = body["primitives"]
|
||||
assert len(primitives) == 2
|
||||
# Primitive-ordered.
|
||||
assert [p["primitive"] for p in primitives] == [
|
||||
"cognitive.feedback_loop_engagement",
|
||||
"motor.input_modality",
|
||||
]
|
||||
# Schema sanity.
|
||||
expected_keys = {
|
||||
"primitive", "current_value", "state", "confidence",
|
||||
"observation_count", "last_change_ts", "last_observation_ts",
|
||||
}
|
||||
for p in primitives:
|
||||
assert set(p.keys()) == expected_keys
|
||||
states = {p["primitive"]: p["state"] for p in primitives}
|
||||
assert states["motor.input_modality"] == "stable"
|
||||
assert states["cognitive.feedback_loop_engagement"] == "drifting"
|
||||
Reference in New Issue
Block a user