"""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"