merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/api/artifacts/__init__.py
Normal file
0
tests/api/artifacts/__init__.py
Normal file
158
tests/api/artifacts/test_get_artifact.py
Normal file
158
tests/api/artifacts/test_get_artifact.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Tests for GET /api/v1/artifacts/{decky}/{stored_as}.
|
||||
|
||||
Verifies admin-gating, 404 on missing files, 400 on malformed inputs, and
|
||||
that path traversal attempts cannot escape DECNET_ARTIFACTS_ROOT.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
_DECKY = "test-decky-01"
|
||||
_VALID_STORED_AS = "2026-04-18T02:22:56Z_abc123def456_payload.bin"
|
||||
_PAYLOAD = b"attacker-drop-bytes\x00\x01\x02\xff"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def artifacts_root(tmp_path, monkeypatch):
|
||||
"""Point the artifact endpoint at a tmp dir and seed one valid file."""
|
||||
root = tmp_path / "artifacts"
|
||||
(root / _DECKY / "ssh").mkdir(parents=True)
|
||||
(root / _DECKY / "ssh" / _VALID_STORED_AS).write_bytes(_PAYLOAD)
|
||||
|
||||
# Patch the module-level constant (captured at import time).
|
||||
from decnet.web.router.artifacts import api_get_artifact
|
||||
monkeypatch.setattr(api_get_artifact, "ARTIFACTS_ROOT", root)
|
||||
return root
|
||||
|
||||
|
||||
async def test_admin_downloads_artifact(client: httpx.AsyncClient, auth_token: str, artifacts_root):
|
||||
res = await client.get(
|
||||
f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.content == _PAYLOAD
|
||||
assert res.headers["content-type"] == "application/octet-stream"
|
||||
|
||||
|
||||
async def test_viewer_forbidden(client: httpx.AsyncClient, viewer_token: str, artifacts_root):
|
||||
res = await client.get(
|
||||
f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
async def test_unauthenticated_rejected(client: httpx.AsyncClient, artifacts_root):
|
||||
res = await client.get(f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_missing_file_returns_404(client: httpx.AsyncClient, auth_token: str, artifacts_root):
|
||||
missing = "2026-04-18T02:22:56Z_000000000000_nope.bin"
|
||||
res = await client.get(
|
||||
f"/api/v1/artifacts/{_DECKY}/{missing}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_decky", [
|
||||
"UPPERCASE",
|
||||
"has_underscore",
|
||||
"has.dot",
|
||||
"-leading-hyphen",
|
||||
"",
|
||||
"a/b",
|
||||
])
|
||||
async def test_bad_decky_rejected(client: httpx.AsyncClient, auth_token: str, artifacts_root, bad_decky):
|
||||
res = await client.get(
|
||||
f"/api/v1/artifacts/{bad_decky}/{_VALID_STORED_AS}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
# FastAPI returns 404 for routes that fail to match (e.g. `a/b` splits the
|
||||
# path param); malformed-but-matching cases yield our 400.
|
||||
assert res.status_code in (400, 404)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_stored_as", [
|
||||
"not-a-timestamp_abc123def456_payload.bin",
|
||||
"2026-04-18T02:22:56Z_SHORT_payload.bin",
|
||||
"2026-04-18T02:22:56Z_abc123def456_",
|
||||
"random-string",
|
||||
"",
|
||||
])
|
||||
async def test_bad_stored_as_rejected(client: httpx.AsyncClient, auth_token: str, artifacts_root, bad_stored_as):
|
||||
res = await client.get(
|
||||
f"/api/v1/artifacts/{_DECKY}/{bad_stored_as}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code in (400, 404)
|
||||
|
||||
|
||||
async def test_path_traversal_blocked(client: httpx.AsyncClient, auth_token: str, artifacts_root, tmp_path):
|
||||
"""A file placed outside the artifacts root must be unreachable even if a
|
||||
caller crafts a URL-encoded `..` in the stored_as segment."""
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_bytes(b"top-secret")
|
||||
# The regex for stored_as forbids slashes, `..`, etc. Any encoding trick
|
||||
# that reaches the handler must still fail the regex → 400.
|
||||
for payload in (
|
||||
"..%2Fsecret.txt",
|
||||
"..",
|
||||
"../../etc/passwd",
|
||||
"%2e%2e/%2e%2e/etc/passwd",
|
||||
):
|
||||
res = await client.get(
|
||||
f"/api/v1/artifacts/{_DECKY}/{payload}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
# Either 400 (our validator) or 404 (FastAPI didn't match the route) is fine;
|
||||
# what's NOT fine is 200 with secret bytes.
|
||||
assert res.status_code != 200
|
||||
assert b"top-secret" not in res.content
|
||||
|
||||
|
||||
async def test_content_disposition_is_attachment(client: httpx.AsyncClient, auth_token: str, artifacts_root):
|
||||
res = await client.get(
|
||||
f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
cd = res.headers.get("content-disposition", "")
|
||||
assert "attachment" in cd.lower()
|
||||
assert _VALID_STORED_AS in cd
|
||||
assert res.headers.get("x-content-type-options") == "nosniff"
|
||||
|
||||
|
||||
async def test_smtp_service_serves_from_smtp_subdir(
|
||||
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch,
|
||||
):
|
||||
"""?service=smtp routes to {root}/{decky}/smtp/ instead of .../ssh/."""
|
||||
root = tmp_path / "artifacts-smtp"
|
||||
(root / _DECKY / "smtp").mkdir(parents=True)
|
||||
eml = "2026-04-18T02:22:56Z_abc123def456_msg.eml"
|
||||
(root / _DECKY / "smtp" / eml).write_bytes(b"From: a\r\n\r\nhi")
|
||||
from decnet.web.router.artifacts import api_get_artifact
|
||||
monkeypatch.setattr(api_get_artifact, "ARTIFACTS_ROOT", root)
|
||||
res = await client.get(
|
||||
f"/api/v1/artifacts/{_DECKY}/{eml}?service=smtp",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.content == b"From: a\r\n\r\nhi"
|
||||
|
||||
|
||||
async def test_unknown_service_rejected(
|
||||
client: httpx.AsyncClient, auth_token: str, artifacts_root,
|
||||
):
|
||||
res = await client.get(
|
||||
f"/api/v1/artifacts/{_DECKY}/{_VALID_STORED_AS}?service=rdp",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
# Regex matches (lowercase alpha) but _ALLOWED_SERVICES rejects → 400.
|
||||
assert res.status_code == 400
|
||||
@@ -3,6 +3,7 @@ import pytest
|
||||
from hypothesis import given, strategies as st, settings
|
||||
import httpx
|
||||
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
||||
from decnet.web.limiter import limiter as _login_limiter
|
||||
from ..conftest import _FUZZ_SETTINGS
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -42,9 +43,85 @@ async def test_login_failure(client: httpx.AsyncClient) -> None:
|
||||
)
|
||||
async def test_fuzz_login(client: httpx.AsyncClient, username: str, password: str) -> None:
|
||||
"""Fuzz the login endpoint with random strings (including non-ASCII)."""
|
||||
# Hypothesis runs hundreds of cases within one test; the rate limiter
|
||||
# doesn't care it's fuzzing and would 429 after ~10. Clear per-case.
|
||||
_login_limiter.reset()
|
||||
_payload: dict[str, str] = {"username": username, "password": password}
|
||||
try:
|
||||
_response: httpx.Response = await client.post("/api/v1/auth/login", json=_payload)
|
||||
assert _response.status_code in (200, 401, 422)
|
||||
assert _response.status_code in (200, 401, 422, 429)
|
||||
except (UnicodeEncodeError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
|
||||
# ─── Rate-limit enforcement ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_ip_bucket_trips_after_10_failures(client: httpx.AsyncClient) -> None:
|
||||
"""10 failed attempts from one IP → 11th returns 429 with Retry-After."""
|
||||
for i in range(10):
|
||||
r = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": DECNET_ADMIN_USER, "password": f"wrong-{i}"},
|
||||
)
|
||||
assert r.status_code == 401, f"attempt {i}: got {r.status_code}"
|
||||
r = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": DECNET_ADMIN_USER, "password": "still-wrong"},
|
||||
)
|
||||
assert r.status_code == 429
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_successful_attempts_count_against_bucket(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
"""Successful logins are also counted — bucket does not reset on success.
|
||||
10 successes → 11th returns 429 (whether right or wrong password)."""
|
||||
for i in range(10):
|
||||
r = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
|
||||
)
|
||||
assert r.status_code == 200, f"attempt {i}: got {r.status_code}"
|
||||
r = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
|
||||
)
|
||||
assert r.status_code == 429
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_username_key_extracts_from_body() -> None:
|
||||
"""Per-username bucket key function: valid body → distinct key per
|
||||
user. Malformed body → single shared bucket (intentional: garbage
|
||||
traffic throttles as one actor)."""
|
||||
from decnet.web.limiter import login_username_key
|
||||
|
||||
class _Req:
|
||||
def __init__(self, body: bytes) -> None:
|
||||
self._body = body
|
||||
|
||||
async def body(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
assert await login_username_key(_Req(b'{"username":"alice","password":"x"}')) == "login-user:alice"
|
||||
assert await login_username_key(_Req(b'{"username":"bob","password":"y"}')) == "login-user:bob"
|
||||
# Malformed or missing username → single bucket
|
||||
assert await login_username_key(_Req(b"not json at all")) == "login-user:__unparseable__"
|
||||
assert await login_username_key(_Req(b'{"password":"x"}')) == "login-user:__unparseable__"
|
||||
assert await login_username_key(_Req(b"")) == "login-user:__unparseable__"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_login_route_has_both_rate_limits() -> None:
|
||||
"""Contract test: the login handler must import both key functions
|
||||
and have been wrapped by slowapi. Guards against someone removing
|
||||
one decorator and not noticing."""
|
||||
from decnet.web.router.auth import api_login as _login_mod
|
||||
|
||||
assert hasattr(_login_mod, "login_ip_key")
|
||||
assert hasattr(_login_mod, "login_username_key")
|
||||
# slowapi wraps the handler; unwrapped original lives at __wrapped__.
|
||||
assert getattr(_login_mod.login, "__wrapped__", None) is not None
|
||||
|
||||
0
tests/api/campaigns/__init__.py
Normal file
0
tests/api/campaigns/__init__.py
Normal file
111
tests/api/campaigns/test_events_stream.py
Normal file
111
tests/api/campaigns/test_events_stream.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""SSE events stream — GET /api/v1/campaigns/events.
|
||||
|
||||
Mirror of :mod:`tests.api.identities.test_events_stream`. Drives the
|
||||
generator directly to dodge the full httpx streaming roundtrip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.bus import app as _bus_app
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.web.api import app
|
||||
|
||||
_V1 = "/api/v1/campaigns"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _fake_app_bus(monkeypatch):
|
||||
bus = FakeBus()
|
||||
|
||||
async def _get() -> FakeBus:
|
||||
if not bus._connected:
|
||||
await bus.connect()
|
||||
return bus
|
||||
|
||||
monkeypatch.setattr(_bus_app, "get_app_bus", _get)
|
||||
from decnet.web.router.campaigns import api_events as _ev
|
||||
monkeypatch.setattr(_ev, "get_app_bus", _get)
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_campaign_events_unauthenticated_401():
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test",
|
||||
) as ac:
|
||||
r = await ac.get(f"{_V1}/events")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_campaign_events_emits_snapshot_and_live_event(_fake_app_bus):
|
||||
"""Snapshot on connect + live forwarding under ``campaign.>``."""
|
||||
from decnet.web.router.campaigns import api_events as _ev
|
||||
|
||||
class _FakeRequest:
|
||||
async def is_disconnected(self) -> bool:
|
||||
return False
|
||||
|
||||
response = await _ev.api_campaigns_events(
|
||||
request=_FakeRequest(), # type: ignore[arg-type]
|
||||
user={"role": "admin", "uuid": "00000000-0000-0000-0000-000000000000"},
|
||||
)
|
||||
gen = response.body_iterator
|
||||
|
||||
def _as_text(frame) -> str:
|
||||
return frame if isinstance(frame, str) else frame.decode()
|
||||
|
||||
async def _publish_after_snapshot() -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
await _fake_app_bus.publish(
|
||||
_topics.campaign(_topics.CAMPAIGN_FORMED),
|
||||
{"campaign_uuid": "c-1", "identity_uuids": ["i-1"]},
|
||||
event_type=_topics.CAMPAIGN_FORMED,
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
await _fake_app_bus.publish(
|
||||
_topics.campaign(_topics.CAMPAIGN_IDENTITY_ASSIGNED),
|
||||
{"campaign_uuid": "c-1", "identity_uuid": "i-2"},
|
||||
event_type=_topics.CAMPAIGN_IDENTITY_ASSIGNED,
|
||||
)
|
||||
|
||||
pub_task = asyncio.create_task(_publish_after_snapshot())
|
||||
|
||||
async def _drive():
|
||||
saw = {"snapshot": False, "formed": False, "identity.assigned": False}
|
||||
for _ in range(8):
|
||||
frame = _as_text(await gen.__anext__())
|
||||
for key in saw:
|
||||
if f"event: {key}" in frame:
|
||||
saw[key] = True
|
||||
if all(saw.values()):
|
||||
break
|
||||
return saw
|
||||
|
||||
try:
|
||||
seen = await asyncio.wait_for(_drive(), timeout=5.0)
|
||||
finally:
|
||||
pub_task.cancel()
|
||||
try:
|
||||
await pub_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
await gen.aclose()
|
||||
|
||||
assert seen["snapshot"]
|
||||
assert seen["formed"]
|
||||
assert seen["identity.assigned"]
|
||||
|
||||
|
||||
def test_sse_name_maps_dotted_leaves():
|
||||
from decnet.web.router.campaigns.api_events import _sse_name_for
|
||||
assert _sse_name_for("campaign.formed") == "formed"
|
||||
assert _sse_name_for("campaign.identity.assigned") == "identity.assigned"
|
||||
assert _sse_name_for("campaign.merged") == "merged"
|
||||
assert _sse_name_for("campaign.unmerged") == "unmerged"
|
||||
assert _sse_name_for("system.bus.health") == "system.bus.health"
|
||||
0
tests/api/canary/__init__.py
Normal file
0
tests/api/canary/__init__.py
Normal file
363
tests/api/canary/test_canary_tokens_api.py
Normal file
363
tests/api/canary/test_canary_tokens_api.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""End-to-end coverage for /api/v1/canary/* via the live FastAPI app.
|
||||
|
||||
The planter's docker-exec call is patched so we don't need a real
|
||||
docker daemon; everything else (DB, repo, instrumenters, generators,
|
||||
storage) runs for real.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
_BASE = "/api/v1/canary"
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, rc: int = 0, stderr: bytes = b"") -> None:
|
||||
self.returncode = rc
|
||||
self._stderr = stderr
|
||||
|
||||
async def communicate(self, input: bytes | None = None) -> tuple[bytes, bytes]:
|
||||
return b"", self._stderr
|
||||
|
||||
def kill(self) -> None: # pragma: no cover
|
||||
pass
|
||||
|
||||
|
||||
def _patch_subprocess(rc: int = 0, stderr: bytes = b""):
|
||||
async def _fake(*argv, **kw): # noqa: ANN001
|
||||
return _FakeProc(rc, stderr)
|
||||
return patch.object(asyncio, "create_subprocess_exec", _fake)
|
||||
|
||||
|
||||
def _hdr(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------- blob upload ---------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blob_upload_dedupes(
|
||||
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
||||
files = {"file": ("notes.txt", b"hello canary", "text/plain")}
|
||||
res = await client.post(f"{_BASE}/blobs", files=files, headers=_hdr(auth_token))
|
||||
assert res.status_code == 201, res.text
|
||||
first = res.json()
|
||||
# Re-uploading the same bytes returns the same uuid.
|
||||
files2 = {"file": ("notes-rename.txt", b"hello canary", "text/plain")}
|
||||
res2 = await client.post(f"{_BASE}/blobs", files=files2, headers=_hdr(auth_token))
|
||||
assert res2.status_code == 201
|
||||
assert res2.json()["uuid"] == first["uuid"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blob_upload_rejects_empty(
|
||||
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
||||
files = {"file": ("empty.txt", b"", "text/plain")}
|
||||
res = await client.post(f"{_BASE}/blobs", files=files, headers=_hdr(auth_token))
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blob_list_carries_token_count(
|
||||
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
||||
files = {"file": ("x.txt", b"some text", "text/plain")}
|
||||
blob = (await client.post(
|
||||
f"{_BASE}/blobs", files=files, headers=_hdr(auth_token),
|
||||
)).json()
|
||||
# Initially zero references.
|
||||
res = await client.get(f"{_BASE}/blobs", headers=_hdr(auth_token))
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["total"] == 1 and body["blobs"][0]["token_count"] == 0
|
||||
# Bind a token to bump the count.
|
||||
with _patch_subprocess(rc=0):
|
||||
tok_res = await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": "web1", "kind": "http",
|
||||
"placement_path": "/etc/x.conf", "blob_uuid": blob["uuid"],
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert tok_res.status_code == 201, tok_res.text
|
||||
res = await client.get(f"{_BASE}/blobs", headers=_hdr(auth_token))
|
||||
assert res.json()["blobs"][0]["token_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blob_delete_refuses_when_referenced(
|
||||
client: httpx.AsyncClient, auth_token: str, tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
||||
files = {"file": ("x.txt", b"more text", "text/plain")}
|
||||
blob = (await client.post(
|
||||
f"{_BASE}/blobs", files=files, headers=_hdr(auth_token),
|
||||
)).json()
|
||||
with _patch_subprocess(rc=0):
|
||||
await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": "web1", "kind": "http",
|
||||
"placement_path": "/etc/x.conf", "blob_uuid": blob["uuid"],
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
res = await client.delete(
|
||||
f"{_BASE}/blobs/{blob['uuid']}", headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blob_delete_404_for_missing(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
res = await client.delete(
|
||||
f"{_BASE}/blobs/00000000-0000-0000-0000-000000000000",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ---------------- token lifecycle ----------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_token_requires_xor_blob_or_generator(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
res = await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={"decky_name": "w", "kind": "http", "placement_path": "/x"},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 400
|
||||
res = await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": "w", "kind": "http", "placement_path": "/x",
|
||||
"generator": "aws_creds", "blob_uuid": "u",
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_token_rejects_relative_path(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
res = await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": "w", "kind": "http",
|
||||
"placement_path": "relative/path", "generator": "env_file",
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_token_with_unknown_generator(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
res = await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": "w", "kind": "http",
|
||||
"placement_path": "/x", "generator": "bogus",
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_token_with_missing_blob(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
res = await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": "w", "kind": "http",
|
||||
"placement_path": "/x",
|
||||
"blob_uuid": "00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_list_filter_by_decky(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
||||
with _patch_subprocess(rc=0):
|
||||
for decky in ("web1", "web2"):
|
||||
await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": decky, "kind": "http",
|
||||
"placement_path": "/x", "generator": "env_file",
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
res = await client.get(
|
||||
f"{_BASE}/tokens?decky_name=web1", headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["total"] == 1
|
||||
assert body["tokens"][0]["decky_name"] == "web1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_detail_404(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
res = await client.get(
|
||||
f"{_BASE}/tokens/00000000-0000-0000-0000-000000000000",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_token_404(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
) -> None:
|
||||
res = await client.delete(
|
||||
f"{_BASE}/tokens/00000000-0000-0000-0000-000000000000",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_token_succeeds(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
||||
with _patch_subprocess(rc=0):
|
||||
created = (await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": "web1", "kind": "http",
|
||||
"placement_path": "/etc/x.env", "generator": "env_file",
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)).json()
|
||||
res = await client.delete(
|
||||
f"{_BASE}/tokens/{created['uuid']}",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
detail = (await client.get(
|
||||
f"{_BASE}/tokens/{created['uuid']}", headers=_hdr(auth_token),
|
||||
)).json()
|
||||
assert detail["state"] == "revoked"
|
||||
|
||||
|
||||
# ---------------- preview -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_synthesised_token(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
||||
with _patch_subprocess(rc=0):
|
||||
created = (await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": "web1", "kind": "http",
|
||||
"placement_path": "/etc/x.env", "generator": "env_file",
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)).json()
|
||||
res = await client.get(
|
||||
f"{_BASE}/tokens/{created['uuid']}/preview",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 200
|
||||
# Slug round-trips into the previewed bytes (env_file embeds it
|
||||
# in API_BASE_URL).
|
||||
assert created["callback_token"].encode() in res.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_404(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
res = await client.get(
|
||||
f"{_BASE}/tokens/00000000-0000-0000-0000-000000000000/preview",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ---------------- triggers list ------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triggers_list_for_token(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
||||
with _patch_subprocess(rc=0):
|
||||
created = (await client.post(
|
||||
f"{_BASE}/tokens",
|
||||
json={
|
||||
"decky_name": "web1", "kind": "http",
|
||||
"placement_path": "/etc/x.env", "generator": "env_file",
|
||||
},
|
||||
headers=_hdr(auth_token),
|
||||
)).json()
|
||||
# No triggers yet.
|
||||
res = await client.get(
|
||||
f"{_BASE}/tokens/{created['uuid']}/triggers",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["total"] == 0
|
||||
# 404 for a missing token.
|
||||
res = await client.get(
|
||||
f"{_BASE}/tokens/00000000-0000-0000-0000-000000000000/triggers",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ---------------- auth ----------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_writes_rejected(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
for path, method in [
|
||||
(f"{_BASE}/tokens", "POST"),
|
||||
(f"{_BASE}/blobs", "POST"),
|
||||
]:
|
||||
res = await client.request(
|
||||
method, path, json={}, files={} if method == "POST" else None,
|
||||
)
|
||||
# Either 401 from the auth dep or 422 from missing body — the
|
||||
# important property is "not anonymous".
|
||||
assert res.status_code in (401, 403, 422), f"{path} {method} -> {res.status_code}"
|
||||
0
tests/api/config/__init__.py
Normal file
0
tests/api/config/__init__.py
Normal file
1
tests/api/config/conftest.py
Normal file
1
tests/api/config/conftest.py
Normal file
@@ -0,0 +1 @@
|
||||
# viewer_token fixture is now in tests/api/conftest.py (shared across all API tests)
|
||||
84
tests/api/config/test_deploy_limit.py
Normal file
84
tests/api/config/test_deploy_limit.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from decnet.web.dependencies import repo
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def contract_test_mode(monkeypatch):
|
||||
"""Skip actual Docker deployment in tests."""
|
||||
monkeypatch.setenv("DECNET_CONTRACT_TEST", "true")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_network():
|
||||
"""Mock network detection so deploy doesn't call `ip addr show`."""
|
||||
with patch("decnet.web.router.fleet.api_deploy_deckies.get_host_ip", return_value="192.168.1.100"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_respects_limit(client, auth_token, mock_state_file):
|
||||
"""Deploy should reject if the *submitted* INI exceeds the limit.
|
||||
The INI is the source of truth — prior state is fully replaced — so the
|
||||
check runs on the new decky count alone."""
|
||||
await repo.set_state("config_limits", {"deployment_limit": 1})
|
||||
await repo.set_state("deployment", mock_state_file)
|
||||
|
||||
ini = """[decky-a]
|
||||
services = ssh
|
||||
|
||||
[decky-b]
|
||||
services = ssh
|
||||
"""
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
# 2 new deckies > limit of 1
|
||||
assert resp.status_code == 409
|
||||
assert "limit" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_replaces_prior_state(client, auth_token, mock_state_file):
|
||||
"""Submitting an INI with 1 decky must not silently re-include the 2
|
||||
deckies from prior state (that caused the 'Address already in use'
|
||||
regression when stale decky2/decky3 redeployed on stale IPs)."""
|
||||
await repo.set_state("config_limits", {"deployment_limit": 10})
|
||||
await repo.set_state("deployment", mock_state_file)
|
||||
|
||||
ini = """[only-decky]
|
||||
services = ssh
|
||||
"""
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
persisted = await repo.get_state("deployment")
|
||||
names = [d["name"] for d in persisted["config"]["deckies"]]
|
||||
assert names == ["only-decky"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_within_limit(client, auth_token, mock_state_file):
|
||||
"""Deploy should succeed when within limit."""
|
||||
await repo.set_state("config_limits", {"deployment_limit": 100})
|
||||
await repo.set_state("deployment", mock_state_file)
|
||||
|
||||
ini = """[decky-new]
|
||||
services = ssh
|
||||
"""
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
# Should not fail due to limit
|
||||
if resp.status_code == 409:
|
||||
assert "limit" not in resp.json()["detail"].lower()
|
||||
else:
|
||||
assert resp.status_code == 200
|
||||
69
tests/api/config/test_get_config.py
Normal file
69
tests/api/config/test_get_config.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_config_defaults_admin(client, auth_token):
|
||||
"""Admin gets full config with users list and defaults."""
|
||||
resp = await client.get(
|
||||
"/api/v1/config",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["role"] == "admin"
|
||||
assert data["deployment_limit"] == 10
|
||||
assert data["global_mutation_interval"] == "30m"
|
||||
assert "users" in data
|
||||
assert isinstance(data["users"], list)
|
||||
assert len(data["users"]) >= 1
|
||||
# Ensure no password_hash leaked
|
||||
for user in data["users"]:
|
||||
assert "password_hash" not in user
|
||||
assert "uuid" in user
|
||||
assert "username" in user
|
||||
assert "role" in user
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_config_viewer_no_users(client, auth_token, viewer_token):
|
||||
"""Viewer gets config without users list — server-side gating."""
|
||||
resp = await client.get(
|
||||
"/api/v1/config",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["role"] == "viewer"
|
||||
assert data["deployment_limit"] == 10
|
||||
assert data["global_mutation_interval"] == "30m"
|
||||
assert "users" not in data
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_config_returns_stored_values(client, auth_token):
|
||||
"""Config returns stored values after update."""
|
||||
await client.put(
|
||||
"/api/v1/config/deployment-limit",
|
||||
json={"deployment_limit": 42},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
await client.put(
|
||||
"/api/v1/config/global-mutation-interval",
|
||||
json={"global_mutation_interval": "7d"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/config",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["deployment_limit"] == 42
|
||||
assert data["global_mutation_interval"] == "7d"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_config_unauthenticated(client):
|
||||
resp = await client.get("/api/v1/config")
|
||||
assert resp.status_code == 401
|
||||
76
tests/api/config/test_reinit.py
Normal file
76
tests/api/config/test_reinit.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import pytest
|
||||
|
||||
from decnet.web.dependencies import repo
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_developer_mode(monkeypatch):
|
||||
monkeypatch.setattr("decnet.web.router.config.api_reinit.DECNET_DEVELOPER", True)
|
||||
monkeypatch.setattr("decnet.web.router.config.api_get_config.DECNET_DEVELOPER", True)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reinit_purges_data(client, auth_token):
|
||||
"""Admin can purge all logs, bounties, and attackers in developer mode."""
|
||||
# Seed some data
|
||||
await repo.add_log({
|
||||
"decky": "d1", "service": "ssh", "event_type": "connect",
|
||||
"attacker_ip": "1.2.3.4", "raw_line": "test", "fields": "{}",
|
||||
})
|
||||
await repo.add_bounty({
|
||||
"decky": "d1", "service": "ssh", "attacker_ip": "1.2.3.4",
|
||||
"bounty_type": "credential", "payload": '{"user":"root"}',
|
||||
})
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/v1/config/reinit",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["deleted"]["logs"] >= 1
|
||||
assert data["deleted"]["bounties"] >= 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reinit_viewer_forbidden(client, auth_token, viewer_token):
|
||||
resp = await client.delete(
|
||||
"/api/v1/config/reinit",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reinit_forbidden_without_developer_mode(client, auth_token, monkeypatch):
|
||||
monkeypatch.setattr("decnet.web.router.config.api_reinit.DECNET_DEVELOPER", False)
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/v1/config/reinit",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "developer mode" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_config_includes_developer_mode(client, auth_token):
|
||||
"""Admin config response includes developer_mode when enabled."""
|
||||
resp = await client.get(
|
||||
"/api/v1/config",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["developer_mode"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_config_excludes_developer_mode_when_disabled(client, auth_token, monkeypatch):
|
||||
monkeypatch.setattr("decnet.web.router.config.api_get_config.DECNET_DEVELOPER", False)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/config",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert "developer_mode" not in resp.json()
|
||||
77
tests/api/config/test_update_config.py
Normal file
77
tests/api/config/test_update_config.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_deployment_limit_admin(client, auth_token):
|
||||
resp = await client.put(
|
||||
"/api/v1/config/deployment-limit",
|
||||
json={"deployment_limit": 50},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Deployment limit updated"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_deployment_limit_out_of_range(client, auth_token):
|
||||
resp = await client.put(
|
||||
"/api/v1/config/deployment-limit",
|
||||
json={"deployment_limit": 0},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
resp = await client.put(
|
||||
"/api/v1/config/deployment-limit",
|
||||
json={"deployment_limit": 501},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_deployment_limit_viewer_forbidden(client, auth_token, viewer_token):
|
||||
resp = await client.put(
|
||||
"/api/v1/config/deployment-limit",
|
||||
json={"deployment_limit": 50},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_global_mutation_interval_admin(client, auth_token):
|
||||
resp = await client.put(
|
||||
"/api/v1/config/global-mutation-interval",
|
||||
json={"global_mutation_interval": "7d"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Global mutation interval updated"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_global_mutation_interval_invalid(client, auth_token):
|
||||
resp = await client.put(
|
||||
"/api/v1/config/global-mutation-interval",
|
||||
json={"global_mutation_interval": "abc"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
resp = await client.put(
|
||||
"/api/v1/config/global-mutation-interval",
|
||||
json={"global_mutation_interval": "0m"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_global_mutation_interval_viewer_forbidden(client, auth_token, viewer_token):
|
||||
resp = await client.put(
|
||||
"/api/v1/config/global-mutation-interval",
|
||||
json={"global_mutation_interval": "7d"},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
188
tests/api/config/test_user_management.py
Normal file
188
tests/api/config/test_user_management.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user(client, auth_token):
|
||||
resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "newuser", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "newuser"
|
||||
assert data["role"] == "viewer"
|
||||
assert data["must_change_password"] is True
|
||||
assert "password_hash" not in data
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_duplicate(client, auth_token):
|
||||
await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "dupuser", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "dupuser", "password": "securepass456", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_user_viewer_forbidden(client, auth_token, viewer_token):
|
||||
resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "blocked", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_user(client, auth_token):
|
||||
# Create a user to delete
|
||||
create_resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "todelete", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
user_uuid = create_resp.json()["uuid"]
|
||||
|
||||
resp = await client.delete(
|
||||
f"/api/v1/config/users/{user_uuid}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_self_forbidden(client, auth_token):
|
||||
# Get own UUID from config
|
||||
config_resp = await client.get(
|
||||
"/api/v1/config",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
users = config_resp.json()["users"]
|
||||
admin_uuid = next(u["uuid"] for u in users if u["role"] == "admin")
|
||||
|
||||
resp = await client.delete(
|
||||
f"/api/v1/config/users/{admin_uuid}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_nonexistent_user(client, auth_token):
|
||||
resp = await client.delete(
|
||||
"/api/v1/config/users/00000000-0000-0000-0000-000000000000",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_user_role(client, auth_token):
|
||||
create_resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "roletest", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
user_uuid = create_resp.json()["uuid"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/v1/config/users/{user_uuid}/role",
|
||||
json={"role": "admin"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify role changed
|
||||
config_resp = await client.get(
|
||||
"/api/v1/config",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
updated = next(u for u in config_resp.json()["users"] if u["uuid"] == user_uuid)
|
||||
assert updated["role"] == "admin"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_own_role_forbidden(client, auth_token):
|
||||
config_resp = await client.get(
|
||||
"/api/v1/config",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
admin_uuid = next(u["uuid"] for u in config_resp.json()["users"] if u["role"] == "admin")
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/v1/config/users/{admin_uuid}/role",
|
||||
json={"role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reset_user_password(client, auth_token):
|
||||
create_resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "resetme", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
user_uuid = create_resp.json()["uuid"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/v1/config/users/{user_uuid}/reset-password",
|
||||
json={"new_password": "newpass12345"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify must_change_password is set
|
||||
config_resp = await client.get(
|
||||
"/api/v1/config",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
updated = next(u for u in config_resp.json()["users"] if u["uuid"] == user_uuid)
|
||||
assert updated["must_change_password"] is True
|
||||
|
||||
# Verify new password works
|
||||
login_resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "resetme", "password": "newpass12345"},
|
||||
)
|
||||
assert login_resp.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_all_user_endpoints_viewer_forbidden(client, auth_token, viewer_token):
|
||||
"""Viewer cannot access any user management endpoints."""
|
||||
resp = await client.post(
|
||||
"/api/v1/config/users",
|
||||
json={"username": "x", "password": "securepass123", "role": "viewer"},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
resp = await client.delete(
|
||||
"/api/v1/config/users/fake-uuid",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
resp = await client.put(
|
||||
"/api/v1/config/users/fake-uuid/role",
|
||||
json={"role": "admin"},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
resp = await client.put(
|
||||
"/api/v1/config/users/fake-uuid/reset-password",
|
||||
json={"new_password": "securepass123"},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -12,6 +12,18 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
from sqlalchemy.pool import StaticPool
|
||||
import os as _os
|
||||
|
||||
|
||||
def pytest_ignore_collect(collection_path, config):
|
||||
"""Skip test_schemathesis.py unless fuzz marker is selected.
|
||||
|
||||
Its module-level code starts a subprocess server and mutates
|
||||
decnet.web.auth.SECRET_KEY, which poisons other test suites.
|
||||
"""
|
||||
if collection_path.name == "test_schemathesis.py":
|
||||
markexpr = config.getoption("markexpr", default="")
|
||||
if "fuzz" not in markexpr:
|
||||
return True
|
||||
|
||||
# Must be set before any decnet import touches decnet.env
|
||||
os.environ["DECNET_JWT_SECRET"] = "test-secret-key-at-least-32-chars-long!!"
|
||||
os.environ["DECNET_ADMIN_PASSWORD"] = "test-password-123"
|
||||
@@ -20,10 +32,33 @@ from decnet.web.api import app
|
||||
from decnet.web.dependencies import repo
|
||||
from decnet.web.db.models import User
|
||||
from decnet.web.auth import get_password_hash
|
||||
from decnet.web.limiter import limiter as _login_limiter
|
||||
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
||||
import decnet.config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_login_rate_limiter() -> None:
|
||||
"""Rate-limit buckets are process-wide; clear before each test so
|
||||
prior tests don't consume another test's budget."""
|
||||
_login_limiter.reset()
|
||||
yield
|
||||
_login_limiter.reset()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_sse_limits() -> None:
|
||||
"""SSE connection counters are module-level dicts; reset between
|
||||
tests so leftover slots don't leak across cases."""
|
||||
from decnet.web import sse_limits
|
||||
sse_limits._reset_for_tests()
|
||||
yield
|
||||
sse_limits._reset_for_tests()
|
||||
|
||||
VIEWER_USERNAME = "testviewer"
|
||||
VIEWER_PASSWORD = "viewer-pass-123"
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
async def setup_db(monkeypatch) -> AsyncGenerator[None, None]:
|
||||
# StaticPool holds one connection forever — :memory: stays alive for the whole test
|
||||
@@ -38,6 +73,26 @@ async def setup_db(monkeypatch) -> AsyncGenerator[None, None]:
|
||||
monkeypatch.setattr(repo, "engine", engine)
|
||||
monkeypatch.setattr(repo, "session_factory", session_factory)
|
||||
|
||||
# Reset per-request TTL caches so they don't leak across tests
|
||||
from decnet.web.router.health import api_get_health as _h
|
||||
from decnet.web.router.config import api_get_config as _c
|
||||
from decnet.web.router.stats import api_get_stats as _s
|
||||
from decnet.web.router.logs import api_get_logs as _l
|
||||
from decnet.web.router.attackers import api_get_attackers as _a
|
||||
from decnet.web.router.bounty import api_get_bounties as _b
|
||||
from decnet.web.router.logs import api_get_histogram as _lh
|
||||
from decnet.web.router.fleet import api_get_deckies as _d
|
||||
from decnet.web import dependencies as _deps
|
||||
_h._reset_db_cache()
|
||||
_c._reset_state_cache()
|
||||
_deps._reset_user_cache()
|
||||
_s._reset_stats_cache()
|
||||
_l._reset_total_cache()
|
||||
_a._reset_total_cache()
|
||||
_b._reset_bounty_cache()
|
||||
_lh._reset_histogram_cache()
|
||||
_d._reset_deckies_cache()
|
||||
|
||||
# Create schema
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
@@ -76,6 +131,30 @@ async def auth_token(client: httpx.AsyncClient) -> str:
|
||||
resp2 = await client.post("/api/v1/auth/login", json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD})
|
||||
return resp2.json()["access_token"]
|
||||
|
||||
@pytest.fixture
|
||||
async def viewer_token(client, setup_db):
|
||||
"""Seed a viewer user and return their auth token."""
|
||||
async with repo.session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.username == VIEWER_USERNAME)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
session.add(User(
|
||||
uuid=str(_uuid.uuid4()),
|
||||
username=VIEWER_USERNAME,
|
||||
password_hash=get_password_hash(VIEWER_PASSWORD),
|
||||
role="viewer",
|
||||
must_change_password=False,
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
resp = await client.post("/api/v1/auth/login", json={
|
||||
"username": VIEWER_USERNAME,
|
||||
"password": VIEWER_PASSWORD,
|
||||
})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_state_file(monkeypatch, tmp_path) -> Path:
|
||||
state_file = tmp_path / "decnet-state.json"
|
||||
|
||||
0
tests/api/credential_reuse/__init__.py
Normal file
0
tests/api/credential_reuse/__init__.py
Normal file
228
tests/api/credential_reuse/test_get_credential_reuse.py
Normal file
228
tests/api/credential_reuse/test_get_credential_reuse.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Read-only ``/credential-reuse`` API tests.
|
||||
|
||||
Mirrors ``tests/api/credentials/test_get_credentials.py`` for the
|
||||
JWT-gated list + detail endpoints. The endpoints are read-only — no
|
||||
body parsing, so no 400 contract per ``feedback_schemathesis_400``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from hypothesis import given, settings, strategies as st
|
||||
|
||||
from ..conftest import _FUZZ_SETTINGS
|
||||
|
||||
|
||||
def _sha256(s: str) -> str:
|
||||
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _seed_reuse(repo, sha: str = None, secret_kind: str = "plaintext"):
|
||||
"""Seed two credential rows then upsert a CredentialReuse row.
|
||||
|
||||
The repo's upsert_credential_reuse recomputes target_count from the
|
||||
underlying credentials table, so both seeds matter.
|
||||
"""
|
||||
sha = sha or _sha256("hunter2")
|
||||
base = {
|
||||
"attacker_ip": "10.0.0.5",
|
||||
"service": "ssh",
|
||||
"principal": "root",
|
||||
"secret_kind": secret_kind,
|
||||
"secret_sha256": sha,
|
||||
"secret_b64": "aHVudGVyMg==",
|
||||
"secret_printable": "hunter2",
|
||||
"fields": {},
|
||||
}
|
||||
await repo.upsert_credential({**base, "decky_name": "d1", "service": "ssh"})
|
||||
await repo.upsert_credential({**base, "decky_name": "d2", "service": "ftp"})
|
||||
await repo.upsert_credential_reuse(
|
||||
secret_sha256=sha, secret_kind=secret_kind, principal="root",
|
||||
attacker_uuid=None, attacker_ip="10.0.0.5",
|
||||
decky="d1", service="ssh", attempt_count=1,
|
||||
)
|
||||
row = await repo.upsert_credential_reuse(
|
||||
secret_sha256=sha, secret_kind=secret_kind, principal="root",
|
||||
attacker_uuid=None, attacker_ip="10.0.0.5",
|
||||
decky="d2", service="ftp", attempt_count=1,
|
||||
)
|
||||
return row["id"]
|
||||
|
||||
|
||||
# ─── /credential-reuse (list) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_credential_reuse_empty(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
resp = await client.get(
|
||||
"/api/v1/credential-reuse",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 0
|
||||
assert body["data"] == []
|
||||
assert body["limit"] == 50
|
||||
assert body["offset"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_credential_reuse_returns_seeded_row(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
from decnet.web.dependencies import repo
|
||||
reuse_id = await _seed_reuse(repo)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/credential-reuse",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert len(body["data"]) == 1
|
||||
row = body["data"][0]
|
||||
assert row["id"] == reuse_id
|
||||
assert row["target_count"] == 2
|
||||
assert row["secret_kind"] == "plaintext"
|
||||
# JSON list columns are decoded for the API consumer.
|
||||
assert isinstance(row["deckies"], list)
|
||||
assert sorted(row["deckies"]) == ["d1", "d2"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_credential_reuse_pagination(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
resp = await client.get(
|
||||
"/api/v1/credential-reuse?limit=1&offset=0",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["limit"] == 1
|
||||
assert resp.json()["offset"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_credential_reuse_secret_kind_filter(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
from decnet.web.dependencies import repo
|
||||
await _seed_reuse(repo, sha=_sha256("p1"), secret_kind="plaintext")
|
||||
await _seed_reuse(repo, sha=_sha256("h1"), secret_kind="ntlm_hash")
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/credential-reuse",
|
||||
params={"secret_kind": "ntlm_hash"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
assert body["data"][0]["secret_kind"] == "ntlm_hash"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_credential_reuse_min_target_count_filter(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
from decnet.web.dependencies import repo
|
||||
await _seed_reuse(repo)
|
||||
# min_target_count=99 — nothing should match.
|
||||
resp = await client.get(
|
||||
"/api/v1/credential-reuse",
|
||||
params={"min_target_count": 99},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_credential_reuse_requires_auth(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
resp = await client.get("/api/v1/credential-reuse")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ─── /credential-reuse/{id} (detail) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_credential_reuse_by_id(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
from decnet.web.dependencies import repo
|
||||
reuse_id = await _seed_reuse(repo)
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/credential-reuse/{reuse_id}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
row = resp.json()
|
||||
assert row["id"] == reuse_id
|
||||
assert row["target_count"] == 2
|
||||
assert isinstance(row["deckies"], list)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_credential_reuse_404_for_unknown_id(
|
||||
client: httpx.AsyncClient, auth_token: str,
|
||||
) -> None:
|
||||
resp = await client.get(
|
||||
"/api/v1/credential-reuse/00000000-0000-0000-0000-000000000000",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_credential_reuse_detail_requires_auth(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
resp = await client.get(
|
||||
"/api/v1/credential-reuse/00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ─── fuzz ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.fuzz
|
||||
@pytest.mark.anyio
|
||||
@settings(**_FUZZ_SETTINGS)
|
||||
@given(
|
||||
limit=st.integers(min_value=-2000, max_value=5000),
|
||||
offset=st.integers(min_value=-2000, max_value=5000),
|
||||
min_target_count=st.integers(min_value=-50, max_value=2147483700),
|
||||
secret_kind=st.one_of(st.none(), st.text(max_size=64)),
|
||||
)
|
||||
async def test_fuzz_list_credential_reuse(
|
||||
client: httpx.AsyncClient,
|
||||
auth_token: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
min_target_count: int,
|
||||
secret_kind,
|
||||
) -> None:
|
||||
params: dict = {
|
||||
"limit": limit, "offset": offset, "min_target_count": min_target_count,
|
||||
}
|
||||
if secret_kind is not None:
|
||||
params["secret_kind"] = secret_kind
|
||||
try:
|
||||
resp = await client.get(
|
||||
"/api/v1/credential-reuse",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 422)
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
0
tests/api/credentials/__init__.py
Normal file
0
tests/api/credentials/__init__.py
Normal file
85
tests/api/credentials/test_get_credentials.py
Normal file
85
tests/api/credentials/test_get_credentials.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import pytest
|
||||
import httpx
|
||||
from hypothesis import given, settings, strategies as st
|
||||
from ..conftest import _FUZZ_SETTINGS
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_credentials_empty(client: httpx.AsyncClient, auth_token: str):
|
||||
resp = await client.get(
|
||||
"/api/v1/credentials",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "total" in data
|
||||
assert "data" in data
|
||||
assert isinstance(data["data"], list)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_credentials_pagination(client: httpx.AsyncClient, auth_token: str):
|
||||
resp = await client.get(
|
||||
"/api/v1/credentials?limit=1&offset=0",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["limit"] == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_credentials_requires_auth(client: httpx.AsyncClient):
|
||||
resp = await client.get("/api/v1/credentials")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_credentials_filter_passthrough(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
# Filter values that match no rows should still 200 with empty data.
|
||||
resp = await client.get(
|
||||
"/api/v1/credentials",
|
||||
params={"service": "ssh", "attacker_ip": "10.0.0.1", "search": "nope"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["data"] == []
|
||||
|
||||
|
||||
@pytest.mark.fuzz
|
||||
@pytest.mark.anyio
|
||||
@settings(**_FUZZ_SETTINGS)
|
||||
@given(
|
||||
limit=st.integers(min_value=-2000, max_value=5000),
|
||||
offset=st.integers(min_value=-2000, max_value=5000),
|
||||
service=st.one_of(st.none(), st.text(max_size=256)),
|
||||
attacker_ip=st.one_of(st.none(), st.text(max_size=64)),
|
||||
search=st.one_of(st.none(), st.text(max_size=2048)),
|
||||
)
|
||||
async def test_fuzz_credentials_query(
|
||||
client: httpx.AsyncClient,
|
||||
auth_token: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
service,
|
||||
attacker_ip,
|
||||
search,
|
||||
) -> None:
|
||||
params: dict = {"limit": limit, "offset": offset}
|
||||
if service is not None:
|
||||
params["service"] = service
|
||||
if attacker_ip is not None:
|
||||
params["attacker_ip"] = attacker_ip
|
||||
if search is not None:
|
||||
params["search"] = search
|
||||
try:
|
||||
resp = await client.get(
|
||||
"/api/v1/credentials",
|
||||
params=params,
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 422)
|
||||
except UnicodeEncodeError:
|
||||
pass
|
||||
193
tests/api/fleet/test_deploy_automode.py
Normal file
193
tests/api/fleet/test_deploy_automode.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""POST /deckies/deploy auto-mode: master + swarm hosts → shard to workers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.dependencies import repo
|
||||
from decnet.web.db.models import SwarmDeployResponse, SwarmHostResult
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def contract_test_mode(monkeypatch):
|
||||
monkeypatch.setenv("DECNET_CONTRACT_TEST", "true")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_network():
|
||||
with patch("decnet.web.router.fleet.api_deploy_deckies.get_host_ip", return_value="192.168.1.100"):
|
||||
with patch("decnet.web.router.fleet.api_deploy_deckies.detect_interface", return_value="eth0"):
|
||||
with patch("decnet.web.router.fleet.api_deploy_deckies.detect_subnet", return_value=("192.168.1.0/24", "192.168.1.1")):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_automode_unihost_when_no_swarm_hosts(client, auth_token, monkeypatch):
|
||||
"""No swarm hosts enrolled → local unihost deploy."""
|
||||
monkeypatch.setenv("DECNET_MODE", "master")
|
||||
for row in await repo.list_swarm_hosts():
|
||||
await repo.delete_swarm_host(row["uuid"])
|
||||
await repo.set_state("deployment", None)
|
||||
|
||||
ini = "[decky-solo]\nservices = ssh\n"
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["mode"] == "unihost"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_automode_shards_when_swarm_host_enrolled(client, auth_token, monkeypatch):
|
||||
"""Master + one active swarm host → swarm mode, dispatch invoked."""
|
||||
monkeypatch.setenv("DECNET_MODE", "master")
|
||||
await repo.set_state("deployment", None)
|
||||
|
||||
for row in await repo.list_swarm_hosts():
|
||||
await repo.delete_swarm_host(row["uuid"])
|
||||
|
||||
from datetime import datetime, timezone
|
||||
await repo.add_swarm_host({
|
||||
"uuid": "host-A",
|
||||
"name": "worker-a",
|
||||
"address": "10.0.0.50",
|
||||
"agent_port": 8765,
|
||||
"status": "active",
|
||||
"client_cert_fingerprint": "x" * 64,
|
||||
"updater_cert_fingerprint": None,
|
||||
"cert_bundle_path": "/tmp/worker-a",
|
||||
"enrolled_at": datetime.now(timezone.utc),
|
||||
"notes": "",
|
||||
})
|
||||
|
||||
fake_response = SwarmDeployResponse(results=[
|
||||
SwarmHostResult(host_uuid="host-A", host_name="worker-a", ok=True, detail={})
|
||||
])
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.fleet.api_deploy_deckies.dispatch_decnet_config",
|
||||
new=AsyncMock(return_value=fake_response),
|
||||
) as mock_dispatch:
|
||||
ini = "[decky-01]\nservices = ssh\n[decky-02]\nservices = http\n"
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["mode"] == "swarm"
|
||||
assert mock_dispatch.await_count == 1
|
||||
dispatched_config = mock_dispatch.await_args.args[0]
|
||||
assert dispatched_config.mode == "swarm"
|
||||
assert all(d.host_uuid == "host-A" for d in dispatched_config.deckies)
|
||||
|
||||
await repo.delete_swarm_host("host-A")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_automode_resets_stale_host_uuid(client, auth_token, monkeypatch):
|
||||
"""Deckies carried over from prior state must not be dispatched to a host
|
||||
uuid that no longer exists — reset + round-robin against live hosts."""
|
||||
monkeypatch.setenv("DECNET_MODE", "master")
|
||||
for row in await repo.list_swarm_hosts():
|
||||
await repo.delete_swarm_host(row["uuid"])
|
||||
|
||||
from datetime import datetime, timezone
|
||||
await repo.add_swarm_host({
|
||||
"uuid": "host-LIVE",
|
||||
"name": "live",
|
||||
"address": "10.0.0.60",
|
||||
"agent_port": 8765,
|
||||
"status": "active",
|
||||
"client_cert_fingerprint": "a" * 64,
|
||||
"updater_cert_fingerprint": None,
|
||||
"cert_bundle_path": "/tmp/live",
|
||||
"enrolled_at": datetime.now(timezone.utc),
|
||||
"notes": "",
|
||||
})
|
||||
|
||||
# Prior state: decky-old is assigned to a now-decommissioned host.
|
||||
await repo.set_state("deployment", {
|
||||
"config": {
|
||||
"mode": "swarm",
|
||||
"interface": "eth0",
|
||||
"subnet": "192.168.1.0/24",
|
||||
"gateway": "192.168.1.1",
|
||||
"deckies": [{
|
||||
"name": "decky-old",
|
||||
"ip": "192.168.1.50",
|
||||
"services": ["ssh"],
|
||||
"distro": "debian",
|
||||
"base_image": "debian:bookworm-slim",
|
||||
"hostname": "decky-old",
|
||||
"host_uuid": "ghost-uuid",
|
||||
}],
|
||||
},
|
||||
"compose_path": "",
|
||||
})
|
||||
|
||||
fake_response = SwarmDeployResponse(results=[
|
||||
SwarmHostResult(host_uuid="host-LIVE", host_name="live", ok=True, detail={})
|
||||
])
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.fleet.api_deploy_deckies.dispatch_decnet_config",
|
||||
new=AsyncMock(return_value=fake_response),
|
||||
) as mock_dispatch:
|
||||
ini = "[decky-new]\nservices = ssh\n"
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": ini},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
dispatched = mock_dispatch.await_args.args[0]
|
||||
# Both the carried-over decky and the new one must point at the live host.
|
||||
assert {d.host_uuid for d in dispatched.deckies} == {"host-LIVE"}
|
||||
|
||||
await repo.delete_swarm_host("host-LIVE")
|
||||
await repo.set_state("deployment", None)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_automode_flips_ipvlan_for_opted_in_host(client, auth_token, monkeypatch):
|
||||
"""A host enrolled with use_ipvlan=True must receive a DecnetConfig with
|
||||
ipvlan=True in its shard — sharding is per-host, so _worker_config flips it."""
|
||||
from decnet.web.router.swarm.api_deploy_swarm import _worker_config
|
||||
from decnet.config import DecnetConfig, DeckyConfig
|
||||
|
||||
base = DecnetConfig(
|
||||
mode="swarm", interface="eth0", subnet="192.168.1.0/24", gateway="192.168.1.1",
|
||||
ipvlan=False,
|
||||
deckies=[DeckyConfig(
|
||||
name="decky-1", ip="192.168.1.10", services=["ssh"],
|
||||
distro="debian", base_image="debian:bookworm-slim", hostname="decky-1",
|
||||
host_uuid="h1",
|
||||
)],
|
||||
)
|
||||
opted_in = {"uuid": "h1", "name": "w1", "use_ipvlan": True}
|
||||
opted_out = {"uuid": "h1", "name": "w1", "use_ipvlan": False}
|
||||
assert _worker_config(base, base.deckies, opted_in).ipvlan is True
|
||||
assert _worker_config(base, base.deckies, opted_out).ipvlan is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deployment_mode_endpoint(client, auth_token, monkeypatch):
|
||||
monkeypatch.setenv("DECNET_MODE", "master")
|
||||
for row in await repo.list_swarm_hosts():
|
||||
await repo.delete_swarm_host(row["uuid"])
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/system/deployment-mode",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["role"] == "master"
|
||||
assert body["mode"] == "unihost"
|
||||
assert body["swarm_host_count"] == 0
|
||||
@@ -14,7 +14,8 @@ class TestMutateDecky:
|
||||
assert resp.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_mutation(self, client: httpx.AsyncClient, auth_token: str):
|
||||
async def test_successful_mutation(self, client: httpx.AsyncClient, auth_token: str, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
|
||||
with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=True):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/decky-01/mutate",
|
||||
@@ -24,7 +25,8 @@ class TestMutateDecky:
|
||||
assert "Successfully mutated" in resp.json()["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_mutation_returns_404(self, client: httpx.AsyncClient, auth_token: str):
|
||||
async def test_failed_mutation_returns_404(self, client: httpx.AsyncClient, auth_token: str, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("DECNET_CONTRACT_TEST", raising=False)
|
||||
with patch("decnet.web.router.fleet.api_mutate_decky.mutate_decky", return_value=False):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/decky-01/mutate",
|
||||
|
||||
0
tests/api/health/__init__.py
Normal file
0
tests/api/health/__init__.py
Normal file
198
tests/api/health/test_get_health.py
Normal file
198
tests/api/health/test_get_health.py
Normal file
@@ -0,0 +1,198 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.web.router.health.api_get_health import _reset_docker_cache
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_docker_cache():
|
||||
_reset_docker_cache()
|
||||
yield
|
||||
_reset_docker_cache()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_requires_auth(client: httpx.AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_response_schema(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker:
|
||||
# All workers running
|
||||
for name in ("ingestion_worker", "collector_worker", "attacker_worker", "sniffer_worker"):
|
||||
task = MagicMock(spec=asyncio.Task)
|
||||
task.done.return_value = False
|
||||
mock_tasks.return_value = {name: task for name in
|
||||
("ingestion_worker", "collector_worker", "attacker_worker", "sniffer_worker")}
|
||||
mock_client = MagicMock()
|
||||
mock_docker.return_value = mock_client
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
data = resp.json()
|
||||
assert "status" in data
|
||||
assert data["status"] in ("healthy", "degraded", "unhealthy")
|
||||
assert "components" in data
|
||||
expected_components = {"database", "ingestion_worker", "collector_worker",
|
||||
"attacker_worker", "sniffer_worker", "docker"}
|
||||
assert set(data["components"].keys()) == expected_components
|
||||
for comp in data["components"].values():
|
||||
assert comp["status"] in ("ok", "failing")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_database_ok(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker:
|
||||
_make_all_running(mock_tasks)
|
||||
mock_docker.return_value = MagicMock()
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
assert resp.json()["components"]["database"]["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_all_healthy(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker:
|
||||
_make_all_running(mock_tasks)
|
||||
mock_docker.return_value = MagicMock()
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "healthy"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_degraded_sniffer_only(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker:
|
||||
tasks = _make_running_tasks()
|
||||
tasks["sniffer_worker"] = None # sniffer not started
|
||||
mock_tasks.return_value = tasks
|
||||
mock_docker.return_value = MagicMock()
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "degraded"
|
||||
assert resp.json()["components"]["sniffer_worker"]["status"] == "failing"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_unhealthy_returns_503(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker:
|
||||
tasks = _make_running_tasks()
|
||||
tasks["ingestion_worker"] = None # critical worker down
|
||||
mock_tasks.return_value = tasks
|
||||
mock_docker.return_value = MagicMock()
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["status"] == "unhealthy"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_degraded_when_attacker_down(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker:
|
||||
tasks = _make_running_tasks()
|
||||
tasks["attacker_worker"] = None # non-critical
|
||||
mock_tasks.return_value = tasks
|
||||
mock_docker.return_value = MagicMock()
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "degraded"
|
||||
assert resp.json()["components"]["attacker_worker"]["status"] == "failing"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_degraded_when_collector_down(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker:
|
||||
tasks = _make_running_tasks()
|
||||
tasks["collector_worker"] = None # non-critical
|
||||
mock_tasks.return_value = tasks
|
||||
mock_docker.return_value = MagicMock()
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "degraded"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_docker_failing(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env", side_effect=Exception("connection refused")):
|
||||
_make_all_running(mock_tasks)
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
comp = resp.json()["components"]["docker"]
|
||||
assert comp["status"] == "failing"
|
||||
assert "connection refused" in comp["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_database_failing(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
from decnet.web.dependencies import repo as real_repo
|
||||
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker, \
|
||||
patch.object(real_repo, "get_total_logs", new=AsyncMock(side_effect=Exception("disk full"))):
|
||||
_make_all_running(mock_tasks)
|
||||
mock_docker.return_value = MagicMock()
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
comp = resp.json()["components"]["database"]
|
||||
assert comp["status"] == "failing"
|
||||
assert "disk full" in comp["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_health_worker_exited_with_exception(client: httpx.AsyncClient, auth_token: str) -> None:
|
||||
with patch("decnet.web.api.get_background_tasks") as mock_tasks, \
|
||||
patch("docker.from_env") as mock_docker:
|
||||
tasks = _make_running_tasks()
|
||||
dead_task = MagicMock(spec=asyncio.Task)
|
||||
dead_task.done.return_value = True
|
||||
dead_task.cancelled.return_value = False
|
||||
dead_task.exception.return_value = RuntimeError("segfault")
|
||||
tasks["collector_worker"] = dead_task
|
||||
mock_tasks.return_value = tasks
|
||||
mock_docker.return_value = MagicMock()
|
||||
|
||||
resp = await client.get("/api/v1/health", headers={"Authorization": f"Bearer {auth_token}"})
|
||||
|
||||
comp = resp.json()["components"]["collector_worker"]
|
||||
assert comp["status"] == "failing"
|
||||
assert "segfault" in comp["detail"]
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_running_tasks() -> dict[str, MagicMock]:
|
||||
tasks = {}
|
||||
for name in ("ingestion_worker", "collector_worker", "attacker_worker", "sniffer_worker"):
|
||||
t = MagicMock(spec=asyncio.Task)
|
||||
t.done.return_value = False
|
||||
tasks[name] = t
|
||||
return tasks
|
||||
|
||||
|
||||
def _make_all_running(mock_tasks: MagicMock) -> None:
|
||||
mock_tasks.return_value = _make_running_tasks()
|
||||
0
tests/api/identities/__init__.py
Normal file
0
tests/api/identities/__init__.py
Normal file
126
tests/api/identities/test_events_stream.py
Normal file
126
tests/api/identities/test_events_stream.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""SSE events stream — GET /api/v1/identities/events.
|
||||
|
||||
Mirrors :mod:`tests.api.topology.test_events_stream` — the route is
|
||||
thin glue, so we drive the generator directly to dodge the full
|
||||
httpx streaming roundtrip under ASGITransport.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.bus import app as _bus_app
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.web.api import app
|
||||
|
||||
_V1 = "/api/v1/identities"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _fake_app_bus(monkeypatch):
|
||||
bus = FakeBus()
|
||||
|
||||
async def _get() -> FakeBus:
|
||||
if not bus._connected:
|
||||
await bus.connect()
|
||||
return bus
|
||||
|
||||
monkeypatch.setattr(_bus_app, "get_app_bus", _get)
|
||||
from decnet.web.router.identities import api_events as _ev
|
||||
monkeypatch.setattr(_ev, "get_app_bus", _get)
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_events_unauthenticated_401():
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test",
|
||||
) as ac:
|
||||
r = await ac.get(f"{_V1}/events")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identity_events_emits_snapshot_and_live_event(_fake_app_bus):
|
||||
"""Generator yields a snapshot frame on connect, then forwards
|
||||
bus events under ``identity.>`` as named SSE events."""
|
||||
from decnet.web.router.identities import api_events as _ev
|
||||
|
||||
class _FakeRequest:
|
||||
async def is_disconnected(self) -> bool:
|
||||
return False
|
||||
|
||||
response = await _ev.api_identities_events(
|
||||
request=_FakeRequest(), # type: ignore[arg-type]
|
||||
user={"role": "admin", "uuid": "00000000-0000-0000-0000-000000000000"},
|
||||
)
|
||||
gen = response.body_iterator
|
||||
|
||||
def _as_text(frame) -> str:
|
||||
return frame if isinstance(frame, str) else frame.decode()
|
||||
|
||||
async def _publish_after_snapshot() -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
await _fake_app_bus.publish(
|
||||
_topics.identity(_topics.IDENTITY_FORMED),
|
||||
{
|
||||
"identity_uuid": "id-1",
|
||||
"observation_uuids": ["obs-1", "obs-2"],
|
||||
},
|
||||
event_type=_topics.IDENTITY_FORMED,
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
await _fake_app_bus.publish(
|
||||
_topics.identity(_topics.IDENTITY_UNMERGED),
|
||||
{"resurrected_uuid": "id-2", "former_winner_uuid": "id-1"},
|
||||
event_type=_topics.IDENTITY_UNMERGED,
|
||||
)
|
||||
|
||||
pub_task = asyncio.create_task(_publish_after_snapshot())
|
||||
|
||||
async def _drive() -> tuple[bool, bool, bool]:
|
||||
saw_snapshot = False
|
||||
saw_formed = False
|
||||
saw_unmerged = False
|
||||
for _ in range(8):
|
||||
frame = _as_text(await gen.__anext__())
|
||||
if "event: snapshot" in frame:
|
||||
saw_snapshot = True
|
||||
if "event: formed" in frame:
|
||||
saw_formed = True
|
||||
if "event: unmerged" in frame:
|
||||
saw_unmerged = True
|
||||
if saw_snapshot and saw_formed and saw_unmerged:
|
||||
break
|
||||
return saw_snapshot, saw_formed, saw_unmerged
|
||||
|
||||
try:
|
||||
saw_snapshot, saw_formed, saw_unmerged = await asyncio.wait_for(
|
||||
_drive(), timeout=5.0,
|
||||
)
|
||||
finally:
|
||||
pub_task.cancel()
|
||||
try:
|
||||
await pub_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
await gen.aclose()
|
||||
|
||||
assert saw_snapshot
|
||||
assert saw_formed
|
||||
assert saw_unmerged
|
||||
|
||||
|
||||
def test_sse_name_maps_dotted_leaves():
|
||||
"""``observation.linked`` survives the topic-to-event-name mapping
|
||||
intact so the frontend can switch on the full dotted leaf."""
|
||||
from decnet.web.router.identities.api_events import _sse_name_for
|
||||
assert _sse_name_for("identity.formed") == "formed"
|
||||
assert _sse_name_for("identity.observation.linked") == "observation.linked"
|
||||
assert _sse_name_for("identity.merged") == "merged"
|
||||
assert _sse_name_for("identity.unmerged") == "unmerged"
|
||||
# Non-identity topics pass through unchanged.
|
||||
assert _sse_name_for("system.bus.health") == "system.bus.health"
|
||||
0
tests/api/orchestrator/__init__.py
Normal file
0
tests/api/orchestrator/__init__.py
Normal file
237
tests/api/orchestrator/test_events_stream.py
Normal file
237
tests/api/orchestrator/test_events_stream.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""SSE events stream + list — /api/v1/orchestrator/events{,/stream}."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.bus import app as _bus_app
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.web.api import app
|
||||
|
||||
_V1 = "/api/v1/orchestrator"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _fake_app_bus(monkeypatch):
|
||||
bus = FakeBus()
|
||||
|
||||
async def _get() -> FakeBus:
|
||||
if not bus._connected:
|
||||
await bus.connect()
|
||||
return bus
|
||||
|
||||
monkeypatch.setattr(_bus_app, "get_app_bus", _get)
|
||||
from decnet.web.router.orchestrator import api_events as _ev
|
||||
monkeypatch.setattr(_ev, "get_app_bus", _get)
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_events_unauthenticated_401():
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test",
|
||||
) as ac:
|
||||
r = await ac.get(f"{_V1}/events")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stream_unauthenticated_401():
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test",
|
||||
) as ac:
|
||||
r = await ac.get(f"{_V1}/events/stream")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_returns_paginated_envelope():
|
||||
from decnet.web.router.orchestrator.api_list_events import (
|
||||
list_orchestrator_events,
|
||||
)
|
||||
|
||||
rows = [{"uuid": f"e-{n}", "kind": "traffic"} for n in range(3)]
|
||||
with patch(
|
||||
"decnet.web.router.orchestrator.api_list_events.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_orchestrator_events = AsyncMock(return_value=rows)
|
||||
mock_repo.count_orchestrator_events = AsyncMock(return_value=3)
|
||||
|
||||
result = await list_orchestrator_events(
|
||||
limit=50, offset=0, kind=None,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result == {"total": 3, "limit": 50, "offset": 0, "data": rows}
|
||||
mock_repo.list_orchestrator_events.assert_awaited_once_with(
|
||||
limit=50, offset=0, kind=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_forwards_kind_filter():
|
||||
from decnet.web.router.orchestrator.api_list_events import (
|
||||
list_orchestrator_events,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.orchestrator.api_list_events.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_orchestrator_events = AsyncMock(return_value=[])
|
||||
mock_repo.count_orchestrator_events = AsyncMock(return_value=0)
|
||||
|
||||
await list_orchestrator_events(
|
||||
limit=10, offset=20, kind="file",
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
mock_repo.list_orchestrator_events.assert_awaited_once_with(
|
||||
limit=10, offset=20, kind="file",
|
||||
)
|
||||
mock_repo.count_orchestrator_events.assert_awaited_once_with(kind="file")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_email_kind_dispatches_to_emails_table():
|
||||
from decnet.web.router.orchestrator.api_list_events import (
|
||||
list_orchestrator_events,
|
||||
)
|
||||
|
||||
raw_emails = [{
|
||||
"uuid": "em-1",
|
||||
"ts": "2026-04-26T12:00:00+00:00",
|
||||
"mail_decky_uuid": "mailhost-uuid",
|
||||
"thread_id": "thr-1",
|
||||
"message_id": "<m1@corp.com>",
|
||||
"in_reply_to": None,
|
||||
"sender_email": "john@corp.com",
|
||||
"recipient_email": "sarah@corp.com",
|
||||
"subject": "Q3 budget",
|
||||
"language": "en",
|
||||
"eml_path": "/var/spool/decnet-emails/thr-1/m1.eml",
|
||||
"success": True,
|
||||
"payload": "{\"model\":\"llama3.1\"}",
|
||||
}]
|
||||
with patch(
|
||||
"decnet.web.router.orchestrator.api_list_events.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_orchestrator_emails = AsyncMock(return_value=raw_emails)
|
||||
mock_repo.count_orchestrator_emails = AsyncMock(return_value=1)
|
||||
# The events-table methods MUST NOT be touched when kind=email.
|
||||
mock_repo.list_orchestrator_events = AsyncMock(return_value=[])
|
||||
mock_repo.count_orchestrator_events = AsyncMock(return_value=999)
|
||||
|
||||
result = await list_orchestrator_events(
|
||||
limit=50, offset=0, kind="email",
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["total"] == 1
|
||||
mock_repo.list_orchestrator_events.assert_not_awaited()
|
||||
mock_repo.count_orchestrator_events.assert_not_awaited()
|
||||
|
||||
[row] = result["data"]
|
||||
assert row["kind"] == "email"
|
||||
assert row["protocol"] == "smtp"
|
||||
assert row["action"] == "Q3 budget"
|
||||
assert row["src_decky_uuid"] == "john@corp.com"
|
||||
assert row["dst_decky_uuid"] == "sarah@corp.com"
|
||||
assert row["success"] is True
|
||||
# Email-only extras must ride along so the inspector + future
|
||||
# per-email view can read them without a second round-trip.
|
||||
assert row["thread_id"] == "thr-1"
|
||||
assert row["language"] == "en"
|
||||
assert row["mail_decky_uuid"] == "mailhost-uuid"
|
||||
assert row["message_id"] == "<m1@corp.com>"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_rejects_unknown_kind():
|
||||
"""Regex on the Query() must not accept anything outside the
|
||||
{traffic, file, email} set."""
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test",
|
||||
) as ac:
|
||||
# 401 (auth) takes precedence over 422 here, so we just check
|
||||
# the validation gate exists by checking against an authed
|
||||
# request would need a token. Instead, hit the regex via the
|
||||
# validator directly.
|
||||
r = await ac.get(f"{_V1}/events?kind=garbage")
|
||||
# Either 401 (auth first) or 422 (validation first); both are
|
||||
# rejections — what we forbid is a 200.
|
||||
assert r.status_code != 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stream_emits_snapshot_and_live_events(_fake_app_bus):
|
||||
from decnet.web.router.orchestrator import api_events as _ev
|
||||
|
||||
class _FakeRequest:
|
||||
async def is_disconnected(self) -> bool:
|
||||
return False
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.orchestrator.api_events.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_orchestrator_events = AsyncMock(return_value=[])
|
||||
response = await _ev.api_orchestrator_events(
|
||||
request=_FakeRequest(), # type: ignore[arg-type]
|
||||
user={"role": "admin", "uuid": "00000000-0000-0000-0000-000000000000"},
|
||||
)
|
||||
|
||||
gen = response.body_iterator
|
||||
|
||||
def _as_text(frame) -> str:
|
||||
return frame if isinstance(frame, str) else frame.decode()
|
||||
|
||||
async def _publish_after_snapshot() -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
await _fake_app_bus.publish(
|
||||
_topics.orchestrator(_topics.ORCHESTRATOR_TRAFFIC, "decky-1"),
|
||||
{"action": "exec:uptime", "success": True},
|
||||
event_type=_topics.ORCHESTRATOR_TRAFFIC,
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
await _fake_app_bus.publish(
|
||||
_topics.orchestrator(_topics.ORCHESTRATOR_FILE, "decky-1"),
|
||||
{"action": "file:create", "success": True},
|
||||
event_type=_topics.ORCHESTRATOR_FILE,
|
||||
)
|
||||
|
||||
pub_task = asyncio.create_task(_publish_after_snapshot())
|
||||
|
||||
async def _drive():
|
||||
saw = {"snapshot": False, "traffic": False, "file": False}
|
||||
for _ in range(8):
|
||||
frame = _as_text(await gen.__anext__())
|
||||
for key in saw:
|
||||
if f"event: {key}" in frame:
|
||||
saw[key] = True
|
||||
if all(saw.values()):
|
||||
break
|
||||
return saw
|
||||
|
||||
try:
|
||||
seen = await asyncio.wait_for(_drive(), timeout=5.0)
|
||||
finally:
|
||||
pub_task.cancel()
|
||||
try:
|
||||
await pub_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
await gen.aclose()
|
||||
|
||||
assert seen["snapshot"]
|
||||
assert seen["traffic"]
|
||||
assert seen["file"]
|
||||
|
||||
|
||||
def test_sse_name_maps_topic_to_kind():
|
||||
from decnet.web.router.orchestrator.api_events import _sse_name_for
|
||||
assert _sse_name_for("orchestrator.traffic.decky-1") == "traffic"
|
||||
assert _sse_name_for("orchestrator.file.decky-1") == "file"
|
||||
assert _sse_name_for("system.bus.health") == "system.bus.health"
|
||||
0
tests/api/realism/__init__.py
Normal file
0
tests/api/realism/__init__.py
Normal file
109
tests/api/realism/test_config_api.py
Normal file
109
tests/api/realism/test_config_api.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""GET/PUT /api/v1/realism/config — operator-tunable weights."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from decnet.realism import planner
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_planner():
|
||||
yield
|
||||
planner.reset_to_defaults()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_defaults_when_no_row():
|
||||
from decnet.web.router.realism.api_config import get_config
|
||||
|
||||
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(return_value=None)
|
||||
|
||||
result = await get_config(user={"uuid": "u", "role": "viewer"})
|
||||
|
||||
assert result["canary_probability"] == pytest.approx(0.03)
|
||||
assert result["user_class_weights"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_hydrates_from_db_row():
|
||||
from decnet.web.router.realism.api_config import get_config
|
||||
|
||||
stored = json.dumps({"canary_probability": 0.10})
|
||||
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(
|
||||
return_value={"key": "weights", "value": stored},
|
||||
)
|
||||
result = await get_config(user={"uuid": "u", "role": "viewer"})
|
||||
|
||||
assert result["canary_probability"] == pytest.approx(0.10)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_serves_defaults_when_stored_payload_invalid():
|
||||
"""Stored JSON parsed but failed planner validation: log + serve
|
||||
defaults rather than 500."""
|
||||
from decnet.web.router.realism.api_config import get_config
|
||||
|
||||
stored = json.dumps({"canary_probability": 9.0})
|
||||
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
|
||||
mock_repo.get_realism_config = AsyncMock(
|
||||
return_value={"key": "weights", "value": stored},
|
||||
)
|
||||
result = await get_config(user={"uuid": "u", "role": "viewer"})
|
||||
|
||||
assert result["canary_probability"] == pytest.approx(0.03)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_persists_and_returns_snapshot():
|
||||
from decnet.web.router.realism.api_config import put_config
|
||||
|
||||
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
|
||||
mock_repo.set_realism_config = AsyncMock()
|
||||
|
||||
result = await put_config(
|
||||
body={"canary_probability": 0.20},
|
||||
user={"uuid": "u", "role": "admin", "username": "anti"},
|
||||
)
|
||||
|
||||
assert result["canary_probability"] == pytest.approx(0.20)
|
||||
mock_repo.set_realism_config.assert_awaited_once()
|
||||
args, _ = mock_repo.set_realism_config.call_args
|
||||
assert args[0] == "weights"
|
||||
persisted = json.loads(args[1])
|
||||
assert persisted["canary_probability"] == pytest.approx(0.20)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_returns_400_on_invalid_payload():
|
||||
from decnet.web.router.realism.api_config import put_config
|
||||
|
||||
with patch("decnet.web.router.realism.api_config.repo") as mock_repo:
|
||||
mock_repo.set_realism_config = AsyncMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await put_config(
|
||||
body={"canary_probability": 9.0},
|
||||
user={"uuid": "u", "role": "admin", "username": "anti"},
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
# No DB write on validation failure.
|
||||
mock_repo.set_realism_config.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_rejects_non_dict_body():
|
||||
from decnet.web.router.realism.api_config import put_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await put_config(
|
||||
body=[1, 2, 3], # type: ignore[arg-type]
|
||||
user={"uuid": "u", "role": "admin", "username": "anti"},
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
170
tests/api/realism/test_personas_api.py
Normal file
170
tests/api/realism/test_personas_api.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""GET/PUT /api/v1/realism/personas — global persona pool CRUD."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.realism import personas_pool as global_pool
|
||||
from decnet.web.router.realism.api_personas import (
|
||||
list_personas,
|
||||
replace_personas,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_pool():
|
||||
global_pool.reset_cache()
|
||||
yield
|
||||
global_pool.reset_cache()
|
||||
|
||||
|
||||
_VALID = [
|
||||
{
|
||||
"name": "John Smith",
|
||||
"email": "john@corp.com",
|
||||
"role": "COO",
|
||||
"tone": "formal",
|
||||
"mannerisms": ["uses 'Best regards'"],
|
||||
},
|
||||
{
|
||||
"name": "Sarah Johnson",
|
||||
"email": "sarah@corp.com",
|
||||
"role": "PM",
|
||||
"tone": "direct",
|
||||
"mannerisms": ["uses bullets"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_returns_empty_when_no_pool(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"DECNET_REALISM_PERSONAS", str(tmp_path / "missing.json"),
|
||||
)
|
||||
result = await list_personas(user={"uuid": "u", "role": "viewer"})
|
||||
assert result["personas"] == []
|
||||
assert result["path"].endswith("missing.json")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_returns_existing_pool(tmp_path, monkeypatch):
|
||||
pool = tmp_path / "pool.json"
|
||||
pool.write_text(json.dumps(_VALID))
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(pool))
|
||||
|
||||
result = await list_personas(user={"uuid": "u", "role": "viewer"})
|
||||
assert len(result["personas"]) == 2
|
||||
assert {p["email"] for p in result["personas"]} == {
|
||||
"john@corp.com", "sarah@corp.com",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_writes_canonical_file(tmp_path, monkeypatch):
|
||||
dest = tmp_path / "pool.json"
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
|
||||
|
||||
result = await replace_personas(
|
||||
body={"personas": _VALID},
|
||||
user={"uuid": "u", "role": "admin", "username": "anti"},
|
||||
)
|
||||
assert len(result["personas"]) == 2
|
||||
assert dest.exists()
|
||||
written = json.loads(dest.read_text())
|
||||
assert {p["email"] for p in written} == {
|
||||
"john@corp.com", "sarah@corp.com",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_with_empty_list_clears_pool(tmp_path, monkeypatch):
|
||||
"""Operator deliberately wiping the pool is allowed — empty list is
|
||||
valid and means "no fleet personas, skip fleet mail deckies"."""
|
||||
dest = tmp_path / "pool.json"
|
||||
dest.write_text(json.dumps(_VALID))
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
|
||||
|
||||
result = await replace_personas(
|
||||
body={"personas": []},
|
||||
user={"uuid": "u", "role": "admin", "username": "anti"},
|
||||
)
|
||||
assert result["personas"] == []
|
||||
assert json.loads(dest.read_text()) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_rejects_non_list_payload(tmp_path, monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
|
||||
monkeypatch.setenv(
|
||||
"DECNET_REALISM_PERSONAS", str(tmp_path / "pool.json"),
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await replace_personas(
|
||||
body={"personas": "not-a-list"},
|
||||
user={"uuid": "u", "role": "admin", "username": "anti"},
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_rejects_all_invalid_payload(tmp_path, monkeypatch):
|
||||
"""Sending a non-empty list where *every* entry is invalid is almost
|
||||
certainly an operator schema mistake — fail loudly rather than
|
||||
silently writing an empty pool."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
monkeypatch.setenv(
|
||||
"DECNET_REALISM_PERSONAS", str(tmp_path / "pool.json"),
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await replace_personas(
|
||||
body={"personas": [{"name": "broken", "email": "no-at-symbol"}]},
|
||||
user={"uuid": "u", "role": "admin", "username": "anti"},
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
assert "validation" in exc.value.detail.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_drops_partially_invalid_entries(tmp_path, monkeypatch):
|
||||
"""One bad apple doesn't kill the request — invalid entries get
|
||||
dropped, valid ones land, response shows what stuck."""
|
||||
dest = tmp_path / "pool.json"
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
|
||||
|
||||
result = await replace_personas(
|
||||
body={"personas": [
|
||||
_VALID[0],
|
||||
{"name": "broken", "email": "no-at-symbol"},
|
||||
_VALID[1],
|
||||
]},
|
||||
user={"uuid": "u", "role": "admin", "username": "anti"},
|
||||
)
|
||||
assert len(result["personas"]) == 2
|
||||
assert {p["email"] for p in result["personas"]} == {
|
||||
"john@corp.com", "sarah@corp.com",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_then_put_round_trips_through_pool(tmp_path, monkeypatch):
|
||||
"""The worker reads the same file the API writes — verify the
|
||||
write-then-read cycle leaves the pool in the expected state."""
|
||||
dest = tmp_path / "pool.json"
|
||||
monkeypatch.setenv("DECNET_REALISM_PERSONAS", str(dest))
|
||||
|
||||
await replace_personas(
|
||||
body={"personas": _VALID},
|
||||
user={"uuid": "u", "role": "admin", "username": "anti"},
|
||||
)
|
||||
listed = await list_personas(user={"uuid": "u", "role": "viewer"})
|
||||
assert {p["email"] for p in listed["personas"]} == {
|
||||
"john@corp.com", "sarah@corp.com",
|
||||
}
|
||||
# And the worker's loader sees the same data.
|
||||
loaded = global_pool.load()
|
||||
assert {p.email for p in loaded} == {
|
||||
"john@corp.com", "sarah@corp.com",
|
||||
}
|
||||
146
tests/api/realism/test_synthetic_files_api.py
Normal file
146
tests/api/realism/test_synthetic_files_api.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""GET /api/v1/realism/synthetic-files — paginated browser API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from decnet.web.db.models.realism import SYNTHETIC_FILE_BODY_LIMIT
|
||||
|
||||
|
||||
def _row(**over):
|
||||
base = {
|
||||
"uuid": "sf-1",
|
||||
"decky_uuid": "d-1",
|
||||
"path": "/home/admin/notes.txt",
|
||||
"persona": "admin",
|
||||
"content_class": "note",
|
||||
"created_at": "2026-04-27T10:00:00+00:00",
|
||||
"last_modified": "2026-04-27T10:00:00+00:00",
|
||||
"edit_count": 0,
|
||||
"content_hash": "deadbeef" * 8,
|
||||
"last_body": "hello world",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_returns_paginated_envelope():
|
||||
from decnet.web.router.realism.api_synthetic_files import (
|
||||
list_synthetic_files,
|
||||
)
|
||||
|
||||
rows = [_row(uuid=f"sf-{i}") for i in range(3)]
|
||||
with patch(
|
||||
"decnet.web.router.realism.api_synthetic_files.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_synthetic_files = AsyncMock(return_value=rows)
|
||||
mock_repo.count_synthetic_files = AsyncMock(return_value=3)
|
||||
|
||||
result = await list_synthetic_files(
|
||||
limit=50, offset=0,
|
||||
decky_uuid=None, persona=None, content_class=None,
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["total"] == 3
|
||||
assert result["limit"] == 50
|
||||
assert result["offset"] == 0
|
||||
assert len(result["data"]) == 3
|
||||
# List view drops the body to keep the payload small.
|
||||
for r in result["data"]:
|
||||
assert "last_body" not in r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_forwards_filters_to_repo():
|
||||
from decnet.web.router.realism.api_synthetic_files import (
|
||||
list_synthetic_files,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.realism.api_synthetic_files.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.list_synthetic_files = AsyncMock(return_value=[])
|
||||
mock_repo.count_synthetic_files = AsyncMock(return_value=0)
|
||||
|
||||
await list_synthetic_files(
|
||||
limit=10, offset=20,
|
||||
decky_uuid="d-7", persona="alice", content_class="todo",
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
mock_repo.list_synthetic_files.assert_awaited_once_with(
|
||||
decky_uuid="d-7", persona="alice", content_class="todo",
|
||||
limit=10, offset=20,
|
||||
)
|
||||
mock_repo.count_synthetic_files.assert_awaited_once_with(
|
||||
decky_uuid="d-7", persona="alice", content_class="todo",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_detail_returns_body_with_truncated_false():
|
||||
from decnet.web.router.realism.api_synthetic_files import (
|
||||
get_synthetic_file,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.realism.api_synthetic_files.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_synthetic_file = AsyncMock(return_value=_row(
|
||||
last_body="short body",
|
||||
))
|
||||
|
||||
result = await get_synthetic_file(
|
||||
uuid="sf-1",
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert result["last_body"] == "short body"
|
||||
assert result["truncated"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_detail_marks_truncated_when_at_cap():
|
||||
from decnet.web.router.realism.api_synthetic_files import (
|
||||
get_synthetic_file,
|
||||
)
|
||||
|
||||
body = "X" * SYNTHETIC_FILE_BODY_LIMIT
|
||||
with patch(
|
||||
"decnet.web.router.realism.api_synthetic_files.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_synthetic_file = AsyncMock(return_value=_row(
|
||||
last_body=body,
|
||||
))
|
||||
|
||||
result = await get_synthetic_file(
|
||||
uuid="sf-1",
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert len(result["last_body"]) == SYNTHETIC_FILE_BODY_LIMIT
|
||||
assert result["truncated"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_detail_404_when_missing():
|
||||
from decnet.web.router.realism.api_synthetic_files import (
|
||||
get_synthetic_file,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"decnet.web.router.realism.api_synthetic_files.repo"
|
||||
) as mock_repo:
|
||||
mock_repo.get_synthetic_file = AsyncMock(return_value=None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await get_synthetic_file(
|
||||
uuid="missing",
|
||||
user={"uuid": "u", "role": "viewer"},
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
@@ -10,6 +10,24 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
# ── Stream endpoint tests ─────────────────────────────────────────────────────
|
||||
|
||||
_EMPTY_STATS = {"total_logs": 0, "unique_attackers": 0, "active_deckies": 0, "deployed_deckies": 0}
|
||||
|
||||
|
||||
def _mock_repo_prefetch(mock_repo, *, crash_on_logs: bool = True) -> None:
|
||||
"""
|
||||
Set up the three prefetch calls that now run in the endpoint function
|
||||
(outside the generator) to return valid dummy data.
|
||||
|
||||
If crash_on_logs is True, get_logs_after_id raises RuntimeError so the
|
||||
generator exits via its except-Exception handler without hanging.
|
||||
"""
|
||||
mock_repo.get_max_log_id = AsyncMock(return_value=0)
|
||||
mock_repo.get_stats_summary = AsyncMock(return_value=_EMPTY_STATS)
|
||||
mock_repo.get_log_histogram = AsyncMock(return_value=[])
|
||||
if crash_on_logs:
|
||||
mock_repo.get_logs_after_id = AsyncMock(side_effect=RuntimeError("test crash"))
|
||||
|
||||
|
||||
class TestStreamEvents:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_returns_401(self, client: httpx.AsyncClient):
|
||||
@@ -18,25 +36,22 @@ class TestStreamEvents:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_sends_initial_stats(self, client: httpx.AsyncClient, auth_token: str):
|
||||
# We force the generator to exit immediately by making the first awaitable raise
|
||||
# Prefetch calls (get_max_log_id, get_stats_summary, get_log_histogram) now
|
||||
# run in the endpoint function before the generator is created. Mock them
|
||||
# all. Crash get_logs_after_id so the generator exits without hanging.
|
||||
with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo:
|
||||
mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration)
|
||||
|
||||
# This will hit the 'except Exception' or just exit the generator
|
||||
_mock_repo_prefetch(mock_repo)
|
||||
resp = await client.get(
|
||||
"/api/v1/stream",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
params={"lastEventId": "0"},
|
||||
)
|
||||
# It might return a 200 with an empty/error stream or a 500 depending on how SSE-starlette handles generator failure
|
||||
# But the important thing is that it FINISHES.
|
||||
assert resp.status_code in (200, 500)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_with_query_token(self, client: httpx.AsyncClient, auth_token: str):
|
||||
# Apply the same crash-fix to avoid hanging
|
||||
with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo:
|
||||
mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration)
|
||||
_mock_repo_prefetch(mock_repo)
|
||||
resp = await client.get(
|
||||
"/api/v1/stream",
|
||||
params={"token": auth_token, "lastEventId": "0"},
|
||||
|
||||
0
tests/api/swarm_mgmt/__init__.py
Normal file
0
tests/api/swarm_mgmt/__init__.py
Normal file
380
tests/api/swarm_mgmt/test_enroll_bundle.py
Normal file
380
tests/api/swarm_mgmt/test_enroll_bundle.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""Agent-enrollment bundle flow: POST → .sh → .tgz (one-shot, TTL, races)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import pathlib
|
||||
import tarfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.swarm import pki
|
||||
from decnet.web.router.swarm_mgmt import api_enroll_bundle as mod
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_bundle_state(tmp_path: pathlib.Path, monkeypatch):
|
||||
"""Point BUNDLE_DIR + CA into tmp, clear the in-memory registry."""
|
||||
monkeypatch.setattr(mod, "BUNDLE_DIR", tmp_path / "bundles")
|
||||
monkeypatch.setattr(pki, "DEFAULT_CA_DIR", tmp_path / "ca")
|
||||
mod._BUNDLES.clear()
|
||||
if mod._SWEEPER_TASK is not None and not mod._SWEEPER_TASK.done():
|
||||
mod._SWEEPER_TASK.cancel()
|
||||
mod._SWEEPER_TASK = None
|
||||
yield
|
||||
# Cleanup sweeper task between tests so they don't accumulate.
|
||||
if mod._SWEEPER_TASK is not None and not mod._SWEEPER_TASK.done():
|
||||
mod._SWEEPER_TASK.cancel()
|
||||
mod._SWEEPER_TASK = None
|
||||
|
||||
|
||||
async def _post(client, auth_token, **overrides):
|
||||
body = {
|
||||
"master_host": "10.0.0.50",
|
||||
"agent_name": "worker-a",
|
||||
"with_updater": True,
|
||||
}
|
||||
body.update(overrides)
|
||||
return await client.post(
|
||||
"/api/v1/swarm/enroll-bundle",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json=body,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_bundle_returns_one_liner(client, auth_token):
|
||||
resp = await _post(client, auth_token)
|
||||
assert resp.status_code == 201, resp.text
|
||||
body = resp.json()
|
||||
assert body["token"]
|
||||
assert body["host_uuid"]
|
||||
assert body["command"].startswith("curl -fsSL ")
|
||||
assert body["command"].endswith(" | sudo bash")
|
||||
assert "&&" not in body["command"] # single pipe, no chaining
|
||||
assert body["token"] in body["command"]
|
||||
expires = datetime.fromisoformat(body["expires_at"].replace("Z", "+00:00"))
|
||||
now = datetime.now(timezone.utc)
|
||||
assert timedelta(minutes=4) < expires - now <= timedelta(minutes=5)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bundle_urls_use_master_host_not_request_base(client, auth_token):
|
||||
"""URLs baked into the bootstrap must target the operator-supplied
|
||||
master_host, not the dashboard's request.base_url (which may be loopback
|
||||
behind a proxy)."""
|
||||
resp = await _post(client, auth_token, master_host="10.20.30.40", agent_name="urltest")
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert "10.20.30.40" in body["command"]
|
||||
assert "127.0.0.1" not in body["command"]
|
||||
assert "testserver" not in body["command"]
|
||||
|
||||
token = body["token"]
|
||||
sh = (await client.get(f"/api/v1/swarm/enroll-bundle/{token}.sh")).text
|
||||
assert "10.20.30.40" in sh
|
||||
assert "127.0.0.1" not in sh
|
||||
assert "testserver" not in sh
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_use_ipvlan_opt_in_persists_and_bakes_into_ini(client, auth_token):
|
||||
"""use_ipvlan=True must persist on the SwarmHost row AND bake `ipvlan = true`
|
||||
into the agent's decnet.ini so locally-initiated deploys also use IPvlan."""
|
||||
from decnet.web.dependencies import repo
|
||||
|
||||
resp = await _post(client, auth_token, agent_name="ipv-node", use_ipvlan=True)
|
||||
assert resp.status_code == 201
|
||||
host_uuid = resp.json()["host_uuid"]
|
||||
token = resp.json()["token"]
|
||||
|
||||
row = await repo.get_swarm_host_by_uuid(host_uuid)
|
||||
assert row["use_ipvlan"] is True
|
||||
|
||||
tgz = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||
assert tgz.status_code == 200
|
||||
with tarfile.open(fileobj=io.BytesIO(tgz.content), mode="r:gz") as tar:
|
||||
ini = tar.extractfile("etc/decnet/decnet.ini").read().decode()
|
||||
assert "ipvlan = true" in ini
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_use_ipvlan_default_false(client, auth_token):
|
||||
from decnet.web.dependencies import repo
|
||||
|
||||
resp = await _post(client, auth_token, agent_name="macv-node")
|
||||
assert resp.status_code == 201
|
||||
row = await repo.get_swarm_host_by_uuid(resp.json()["host_uuid"])
|
||||
assert row["use_ipvlan"] is False
|
||||
|
||||
tgz = await client.get(f"/api/v1/swarm/enroll-bundle/{resp.json()['token']}.tgz")
|
||||
with tarfile.open(fileobj=io.BytesIO(tgz.content), mode="r:gz") as tar:
|
||||
ini = tar.extractfile("etc/decnet/decnet.ini").read().decode()
|
||||
assert "ipvlan = false" in ini
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_duplicate_agent_name_409(client, auth_token):
|
||||
r1 = await _post(client, auth_token, agent_name="dup-node")
|
||||
assert r1.status_code == 201
|
||||
r2 = await _post(client, auth_token, agent_name="dup-node")
|
||||
assert r2.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_non_admin_forbidden(client, viewer_token):
|
||||
resp = await _post(client, viewer_token)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_no_auth_401(client):
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm/enroll-bundle",
|
||||
json={"master_host": "10.0.0.50", "agent_name": "worker-a"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_row_address_backfilled_from_tgz_source_ip(client, auth_token):
|
||||
"""SwarmHosts.address starts blank at enroll time and is populated from
|
||||
the agent's source IP when it curls the .tgz."""
|
||||
from decnet.web.dependencies import repo
|
||||
resp = await _post(client, auth_token, agent_name="addr-test",
|
||||
master_host="192.168.1.5")
|
||||
host_uuid = resp.json()["host_uuid"]
|
||||
token = resp.json()["token"]
|
||||
|
||||
row = await repo.get_swarm_host_by_uuid(host_uuid)
|
||||
assert row["address"] == "" # placeholder until first tgz fetch
|
||||
|
||||
tgz = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||
assert tgz.status_code == 200
|
||||
|
||||
row = await repo.get_swarm_host_by_uuid(host_uuid)
|
||||
# The TestClient client.host depends on httpx's ASGITransport — any
|
||||
# non-empty value proves the backfill path ran.
|
||||
assert row["address"] != ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updater_opt_out_excludes_updater_artifacts(client, auth_token):
|
||||
import io, tarfile
|
||||
token = (await _post(client, auth_token, agent_name="noup", with_updater=False)).json()["token"]
|
||||
resp = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||
tf = tarfile.open(fileobj=io.BytesIO(resp.content), mode="r:gz")
|
||||
names = set(tf.getnames())
|
||||
assert not any(n.startswith("home/.decnet/updater/") for n in names)
|
||||
sh_token = (await _post(client, auth_token, agent_name="noup2", with_updater=False)).json()["token"]
|
||||
sh = (await client.get(f"/api/v1/swarm/enroll-bundle/{sh_token}.sh")).text
|
||||
assert 'WITH_UPDATER="false"' in sh
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_systemd_units_shipped_and_installed(client, auth_token):
|
||||
import io, tarfile
|
||||
post = await _post(client, auth_token, agent_name="svc-test", master_host="10.9.8.7")
|
||||
token = post.json()["token"]
|
||||
resp = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||
assert resp.status_code == 200
|
||||
tf = tarfile.open(fileobj=io.BytesIO(resp.content), mode="r:gz")
|
||||
names = set(tf.getnames())
|
||||
assert "etc/systemd/system/decnet-agent.service" in names
|
||||
assert "etc/systemd/system/decnet-forwarder.service" in names
|
||||
assert "etc/systemd/system/decnet-engine.service" in names
|
||||
# Per-host microservices get their own systemd units now.
|
||||
# Profiler is master-only (uses the master DB) and must NOT ship.
|
||||
for unit in ("decnet-collector", "decnet-prober", "decnet-sniffer"):
|
||||
assert f"etc/systemd/system/{unit}.service" in names, unit
|
||||
assert "etc/systemd/system/decnet-profiler.service" not in names
|
||||
|
||||
fwd = tf.extractfile("etc/systemd/system/decnet-forwarder.service").read().decode()
|
||||
assert "--master-host 10.9.8.7" in fwd
|
||||
assert "DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.forwarder.log" in fwd
|
||||
|
||||
agent_unit = tf.extractfile("etc/systemd/system/decnet-agent.service").read().decode()
|
||||
assert "--no-forwarder" in agent_unit
|
||||
assert "DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.agent.log" in agent_unit
|
||||
|
||||
sh_token = (await _post(client, auth_token, agent_name="svc-test2",
|
||||
master_host="10.9.8.7")).json()["token"]
|
||||
sh = (await client.get(f"/api/v1/swarm/enroll-bundle/{sh_token}.sh")).text
|
||||
assert "systemctl daemon-reload" in sh
|
||||
# Agent + forwarder + per-host microservices always enabled; updater
|
||||
# conditional on WITH_UPDATER.
|
||||
for unit in (
|
||||
"decnet-agent.service", "decnet-forwarder.service",
|
||||
"decnet-collector.service", "decnet-prober.service",
|
||||
"decnet-sniffer.service",
|
||||
):
|
||||
assert unit in sh, unit
|
||||
assert "decnet-updater.service" in sh
|
||||
|
||||
ini = tf.extractfile("etc/decnet/decnet.ini").read().decode()
|
||||
assert "log-directory = /var/log/decnet" in ini
|
||||
assert "log-file-path" not in ini
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updater_opt_in_ships_cert_and_enables_systemd_unit(client, auth_token):
|
||||
import io, tarfile
|
||||
token = (await _post(client, auth_token, agent_name="up", with_updater=True)).json()["token"]
|
||||
resp = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||
tf = tarfile.open(fileobj=io.BytesIO(resp.content), mode="r:gz")
|
||||
names = set(tf.getnames())
|
||||
assert "home/.decnet/updater/updater.crt" in names
|
||||
assert "home/.decnet/updater/updater.key" in names
|
||||
assert "etc/systemd/system/decnet-updater.service" in names
|
||||
key_info = tf.getmember("home/.decnet/updater/updater.key")
|
||||
assert (key_info.mode & 0o777) == 0o600
|
||||
|
||||
updater_unit = tf.extractfile("etc/systemd/system/decnet-updater.service").read().decode()
|
||||
assert "DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.updater.log" in updater_unit
|
||||
assert "Restart=on-failure" in updater_unit
|
||||
|
||||
sh_token = (await _post(client, auth_token, agent_name="up2", with_updater=True)).json()["token"]
|
||||
sh = (await client.get(f"/api/v1/swarm/enroll-bundle/{sh_token}.sh")).text
|
||||
assert 'WITH_UPDATER="true"' in sh
|
||||
assert "decnet-updater.service" in sh
|
||||
# Old --daemon path is gone — updater is now a systemd service.
|
||||
assert "decnet updater --daemon" not in sh
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invalid_agent_name_422(client, auth_token):
|
||||
# Uppercase / underscore not allowed by the regex.
|
||||
resp = await _post(client, auth_token, agent_name="Bad_Name")
|
||||
assert resp.status_code in (400, 422)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_bootstrap_contains_expected(client, auth_token):
|
||||
post = await _post(client, auth_token, agent_name="alpha", master_host="master.example")
|
||||
token = post.json()["token"]
|
||||
|
||||
resp = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.sh")
|
||||
assert resp.status_code == 200
|
||||
text = resp.text
|
||||
assert text.startswith("#!/usr/bin/env bash")
|
||||
assert "alpha" in text
|
||||
assert "master.example" in text
|
||||
assert f"/api/v1/swarm/enroll-bundle/{token}.tgz" in text
|
||||
# Script does NOT try to self-read with $0 (that would break under `curl | bash`).
|
||||
assert 'tail -n +' not in text and 'awk' not in text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_bootstrap_is_idempotent_until_tgz_served(client, auth_token):
|
||||
token = (await _post(client, auth_token, agent_name="beta")).json()["token"]
|
||||
for _ in range(3):
|
||||
assert (await client.get(f"/api/v1/swarm/enroll-bundle/{token}.sh")).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_tgz_contents(client, auth_token, tmp_path):
|
||||
token = (await _post(
|
||||
client, auth_token,
|
||||
agent_name="gamma", master_host="10.1.2.3",
|
||||
services_ini="[general]\nnet = 10.0.0.0/24\n",
|
||||
)).json()["token"]
|
||||
|
||||
resp = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("application/gzip")
|
||||
|
||||
tf = tarfile.open(fileobj=io.BytesIO(resp.content), mode="r:gz")
|
||||
names = set(tf.getnames())
|
||||
|
||||
# Required files
|
||||
assert "etc/decnet/decnet.ini" in names
|
||||
assert "home/.decnet/agent/ca.crt" in names
|
||||
assert "home/.decnet/agent/worker.crt" in names
|
||||
assert "home/.decnet/agent/worker.key" in names
|
||||
assert "services.ini" in names
|
||||
assert "decnet/cli/__init__.py" in names # source shipped
|
||||
assert "pyproject.toml" in names
|
||||
|
||||
# Excluded paths must NOT be shipped
|
||||
for bad in names:
|
||||
assert not bad.startswith("tests/"), f"leaked test file: {bad}"
|
||||
assert not bad.startswith("development/"), f"leaked dev file: {bad}"
|
||||
assert not bad.startswith("wiki-checkout/"), f"leaked wiki file: {bad}"
|
||||
assert "__pycache__" not in bad
|
||||
assert not bad.endswith(".pyc")
|
||||
assert "node_modules" not in bad
|
||||
# Dev-host env leaks would bake absolute master paths into the agent.
|
||||
assert not bad.endswith(".env"), f"leaked env file: {bad}"
|
||||
assert ".env.local" not in bad, f"leaked env file: {bad}"
|
||||
assert ".env.example" not in bad, f"leaked env file: {bad}"
|
||||
# Master-only trees: agents don't run the FastAPI master app, the
|
||||
# React frontend, the mutator (swarm-wide respawn scheduler), or
|
||||
# the profiler (rebuilds profiles against the master DB).
|
||||
assert not bad.startswith("decnet_web/"), f"leaked frontend: {bad}"
|
||||
assert not bad.startswith("decnet/web/"), f"leaked master-api: {bad}"
|
||||
assert not bad.startswith("decnet/mutator/"), f"leaked mutator: {bad}"
|
||||
assert not bad.startswith("decnet/profiler/"), f"leaked profiler: {bad}"
|
||||
|
||||
# INI content is correct
|
||||
ini = tf.extractfile("etc/decnet/decnet.ini").read().decode()
|
||||
assert "mode = agent" in ini
|
||||
assert "master-host = 10.1.2.3" in ini
|
||||
|
||||
# Key is mode 0600
|
||||
key_info = tf.getmember("home/.decnet/agent/worker.key")
|
||||
assert (key_info.mode & 0o777) == 0o600
|
||||
|
||||
# Services INI is there
|
||||
assert tf.extractfile("services.ini").read().decode().startswith("[general]")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tgz_is_one_shot(client, auth_token):
|
||||
token = (await _post(client, auth_token, agent_name="delta")).json()["token"]
|
||||
r1 = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||
assert r1.status_code == 200
|
||||
r2 = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||
assert r2.status_code == 404
|
||||
# .sh also invalidated after .tgz served (the host is up; replay is pointless)
|
||||
r3 = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.sh")
|
||||
assert r3.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unknown_token_404(client):
|
||||
assert (await client.get("/api/v1/swarm/enroll-bundle/not-a-real-token.sh")).status_code == 404
|
||||
assert (await client.get("/api/v1/swarm/enroll-bundle/not-a-real-token.tgz")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ttl_expiry_returns_404(client, auth_token, monkeypatch):
|
||||
token = (await _post(client, auth_token, agent_name="epsilon")).json()["token"]
|
||||
|
||||
# Jump the clock 6 minutes into the future.
|
||||
future = datetime.now(timezone.utc) + timedelta(minutes=6)
|
||||
monkeypatch.setattr(mod, "_now", lambda: future)
|
||||
|
||||
assert (await client.get(f"/api/v1/swarm/enroll-bundle/{token}.sh")).status_code == 404
|
||||
assert (await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_concurrent_tgz_exactly_one_wins(client, auth_token):
|
||||
token = (await _post(client, auth_token, agent_name="zeta")).json()["token"]
|
||||
url = f"/api/v1/swarm/enroll-bundle/{token}.tgz"
|
||||
r1, r2 = await asyncio.gather(client.get(url), client.get(url))
|
||||
statuses = sorted([r1.status_code, r2.status_code])
|
||||
assert statuses == [200, 404]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_row_persisted_after_enroll(client, auth_token):
|
||||
from decnet.web.dependencies import repo
|
||||
resp = await _post(client, auth_token, agent_name="eta")
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
row = await repo.get_swarm_host_by_uuid(body["host_uuid"])
|
||||
assert row is not None
|
||||
assert row["name"] == "eta"
|
||||
assert row["status"] == "enrolled"
|
||||
215
tests/api/swarm_mgmt/test_teardown_host.py
Normal file
215
tests/api/swarm_mgmt/test_teardown_host.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""POST /swarm/hosts/{uuid}/teardown — per-host and per-decky remote teardown."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.router.swarm_mgmt import api_teardown_host as mod
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self, *a, **kw):
|
||||
_FakeAgent.calls.append(("init", kw.get("host", a[0] if a else None)))
|
||||
self._host = kw.get("host", a[0] if a else None)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return None
|
||||
|
||||
async def teardown(self, decky_id: Optional[str] = None) -> dict:
|
||||
_FakeAgent.calls.append(("teardown", decky_id))
|
||||
return {"status": "torn_down", "decky_id": decky_id}
|
||||
|
||||
|
||||
class _FailingAgent(_FakeAgent):
|
||||
async def teardown(self, decky_id: Optional[str] = None) -> dict:
|
||||
raise RuntimeError("network unreachable")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_agent(monkeypatch):
|
||||
_FakeAgent.calls = []
|
||||
monkeypatch.setattr(mod, "AgentClient", _FakeAgent)
|
||||
return _FakeAgent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def failing_agent(monkeypatch):
|
||||
_FailingAgent.calls = []
|
||||
monkeypatch.setattr(mod, "AgentClient", _FailingAgent)
|
||||
return _FailingAgent
|
||||
|
||||
|
||||
async def _seed_host(repo, *, name="worker-a", uuid="h-1") -> str:
|
||||
await repo.add_swarm_host({
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"address": "10.0.0.9",
|
||||
"agent_port": 8765,
|
||||
"status": "active",
|
||||
"client_cert_fingerprint": "f" * 64,
|
||||
"cert_bundle_path": "",
|
||||
"use_ipvlan": False,
|
||||
"enrolled_at": datetime.now(timezone.utc),
|
||||
"last_heartbeat": None,
|
||||
})
|
||||
return uuid
|
||||
|
||||
|
||||
async def _seed_shard(repo, *, host_uuid: str, decky_name: str) -> None:
|
||||
await repo.upsert_decky_shard({
|
||||
"decky_name": decky_name,
|
||||
"host_uuid": host_uuid,
|
||||
"services": json.dumps(["ssh"]),
|
||||
"state": "running",
|
||||
"last_error": None,
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_teardown_all_deckies_on_host(client, auth_token, fake_agent):
|
||||
from decnet.web.dependencies import repo
|
||||
uuid = await _seed_host(repo, name="tear-all", uuid="tear-all-uuid")
|
||||
await _seed_shard(repo, host_uuid=uuid, decky_name="decky1")
|
||||
await _seed_shard(repo, host_uuid=uuid, decky_name="decky2")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/swarm/hosts/{uuid}/teardown",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
body = resp.json()
|
||||
assert body["accepted"] is True
|
||||
assert body["decky_id"] is None
|
||||
|
||||
await mod.drain_pending()
|
||||
|
||||
assert ("teardown", None) in fake_agent.calls
|
||||
remaining = await repo.list_decky_shards(uuid)
|
||||
assert remaining == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_teardown_single_decky(client, auth_token, fake_agent):
|
||||
from decnet.web.dependencies import repo
|
||||
uuid = await _seed_host(repo, name="tear-one", uuid="tear-one-uuid")
|
||||
await _seed_shard(repo, host_uuid=uuid, decky_name="decky-keep")
|
||||
await _seed_shard(repo, host_uuid=uuid, decky_name="decky-drop")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/swarm/hosts/{uuid}/teardown",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"decky_id": "decky-drop"},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
assert resp.json()["decky_id"] == "decky-drop"
|
||||
|
||||
await mod.drain_pending()
|
||||
|
||||
assert ("teardown", "decky-drop") in fake_agent.calls
|
||||
remaining = {s["decky_name"] for s in await repo.list_decky_shards(uuid)}
|
||||
assert remaining == {"decky-keep"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_teardown_returns_immediately_and_marks_tearing_down(
|
||||
client, auth_token, monkeypatch
|
||||
):
|
||||
"""The 202 must fire before the background agent call completes —
|
||||
otherwise multiple queued teardowns still serialize on the UI."""
|
||||
import asyncio as _asyncio
|
||||
from decnet.web.dependencies import repo
|
||||
|
||||
gate = _asyncio.Event()
|
||||
|
||||
class _SlowAgent(_FakeAgent):
|
||||
async def teardown(self, decky_id=None):
|
||||
await gate.wait()
|
||||
return {"status": "torn_down"}
|
||||
|
||||
monkeypatch.setattr(mod, "AgentClient", _SlowAgent)
|
||||
|
||||
uuid = await _seed_host(repo, name="slow", uuid="slow-uuid")
|
||||
await _seed_shard(repo, host_uuid=uuid, decky_name="decky-slow")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/swarm/hosts/{uuid}/teardown",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"decky_id": "decky-slow"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
|
||||
# Agent is still blocked — shard should be in 'tearing_down', not gone.
|
||||
shards = {s["decky_name"]: s for s in await repo.list_decky_shards(uuid)}
|
||||
assert shards["decky-slow"]["state"] == "tearing_down"
|
||||
|
||||
gate.set()
|
||||
await mod.drain_pending()
|
||||
|
||||
remaining = {s["decky_name"] for s in await repo.list_decky_shards(uuid)}
|
||||
assert remaining == set()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_teardown_unknown_host_404(client, auth_token, fake_agent):
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm/hosts/does-not-exist/teardown",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_teardown_agent_failure_marks_shard_failed(
|
||||
client, auth_token, failing_agent
|
||||
):
|
||||
"""Background-task failure: the shard must NOT be deleted and its
|
||||
state flips to teardown_failed with the error recorded so the UI
|
||||
surfaces it."""
|
||||
from decnet.web.dependencies import repo
|
||||
uuid = await _seed_host(repo, name="tear-fail", uuid="tear-fail-uuid")
|
||||
await _seed_shard(repo, host_uuid=uuid, decky_name="survivor")
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/swarm/hosts/{uuid}/teardown",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={},
|
||||
)
|
||||
# Acceptance is unconditional — the failure happens in the background.
|
||||
assert resp.status_code == 202
|
||||
|
||||
await mod.drain_pending()
|
||||
|
||||
shards = {s["decky_name"]: s for s in await repo.list_decky_shards(uuid)}
|
||||
assert "survivor" in shards
|
||||
assert shards["survivor"]["state"] == "teardown_failed"
|
||||
assert "network unreachable" in (shards["survivor"]["last_error"] or "")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_teardown_non_admin_forbidden(client, viewer_token, fake_agent):
|
||||
from decnet.web.dependencies import repo
|
||||
uuid = await _seed_host(repo, name="tear-guard", uuid="tear-guard-uuid")
|
||||
resp = await client.post(
|
||||
f"/api/v1/swarm/hosts/{uuid}/teardown",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_teardown_no_auth_401(client, fake_agent):
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm/hosts/whatever/teardown",
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
0
tests/api/swarm_updates/__init__.py
Normal file
0
tests/api/swarm_updates/__init__.py
Normal file
151
tests/api/swarm_updates/conftest.py
Normal file
151
tests/api/swarm_updates/conftest.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Shared fixtures for /api/v1/swarm-updates tests.
|
||||
|
||||
The tests never talk to a real worker — ``UpdaterClient`` is monkeypatched
|
||||
to a recording fake. That keeps the tests fast and lets us assert call
|
||||
shapes (tarball-once, per-host dispatch, include_self ordering) without
|
||||
standing up TLS endpoints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.web.dependencies import repo
|
||||
|
||||
|
||||
async def _add_host(
|
||||
name: str,
|
||||
address: str = "10.0.0.1",
|
||||
*,
|
||||
with_updater: bool = True,
|
||||
status: str = "enrolled",
|
||||
) -> dict[str, Any]:
|
||||
uuid = str(_uuid.uuid4())
|
||||
await repo.add_swarm_host({
|
||||
"uuid": uuid,
|
||||
"name": name,
|
||||
"address": address,
|
||||
"agent_port": 8765,
|
||||
"status": status,
|
||||
"client_cert_fingerprint": "abc123",
|
||||
"updater_cert_fingerprint": "def456" if with_updater else None,
|
||||
"cert_bundle_path": f"/tmp/{name}",
|
||||
"enrolled_at": datetime.now(timezone.utc),
|
||||
"notes": None,
|
||||
})
|
||||
return {"uuid": uuid, "name": name, "address": address}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def add_host():
|
||||
return _add_host
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_updater(monkeypatch):
|
||||
"""Install a fake ``UpdaterClient`` + tar builder into every route module.
|
||||
|
||||
The returned ``Fake`` exposes hooks so individual tests decide per-host
|
||||
behaviour: response codes, exceptions, update-self outcomes, etc.
|
||||
"""
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, body: dict[str, Any] | None = None):
|
||||
self.status_code = status_code
|
||||
self._body = body or {}
|
||||
self.content = b"payload"
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._body
|
||||
|
||||
class FakeUpdaterClient:
|
||||
calls: list[tuple[str, str, dict]] = [] # (host_name, method, kwargs)
|
||||
health_responses: dict[str, dict[str, Any]] = {}
|
||||
update_responses: dict[str, FakeResponse | BaseException] = {}
|
||||
update_self_responses: dict[str, FakeResponse | BaseException] = {}
|
||||
rollback_responses: dict[str, FakeResponse | BaseException] = {}
|
||||
|
||||
def __init__(self, host=None, **_kw):
|
||||
self._name = host["name"] if host else "?"
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return None
|
||||
|
||||
async def health(self):
|
||||
FakeUpdaterClient.calls.append((self._name, "health", {}))
|
||||
resp = FakeUpdaterClient.health_responses.get(self._name)
|
||||
if isinstance(resp, BaseException):
|
||||
raise resp
|
||||
return resp or {"status": "ok", "releases": []}
|
||||
|
||||
async def update(self, tarball, sha=""):
|
||||
FakeUpdaterClient.calls.append((self._name, "update", {"tarball": tarball, "sha": sha}))
|
||||
resp = FakeUpdaterClient.update_responses.get(self._name, FakeResponse(200, {"probe": "ok"}))
|
||||
if isinstance(resp, BaseException):
|
||||
raise resp
|
||||
return resp
|
||||
|
||||
async def update_self(self, tarball, sha=""):
|
||||
FakeUpdaterClient.calls.append((self._name, "update_self", {"tarball": tarball, "sha": sha}))
|
||||
resp = FakeUpdaterClient.update_self_responses.get(self._name, FakeResponse(200))
|
||||
if isinstance(resp, BaseException):
|
||||
raise resp
|
||||
return resp
|
||||
|
||||
async def rollback(self):
|
||||
FakeUpdaterClient.calls.append((self._name, "rollback", {}))
|
||||
resp = FakeUpdaterClient.rollback_responses.get(self._name, FakeResponse(200, {"status": "rolled back"}))
|
||||
if isinstance(resp, BaseException):
|
||||
raise resp
|
||||
return resp
|
||||
|
||||
# Reset class-level state each test — fixtures are function-scoped but
|
||||
# the class dicts survive otherwise.
|
||||
FakeUpdaterClient.calls = []
|
||||
FakeUpdaterClient.health_responses = {}
|
||||
FakeUpdaterClient.update_responses = {}
|
||||
FakeUpdaterClient.update_self_responses = {}
|
||||
FakeUpdaterClient.rollback_responses = {}
|
||||
|
||||
for mod in (
|
||||
"decnet.web.router.swarm_updates.api_list_host_releases",
|
||||
"decnet.web.router.swarm_updates.api_push_update",
|
||||
"decnet.web.router.swarm_updates.api_push_update_self",
|
||||
"decnet.web.router.swarm_updates.api_rollback_host",
|
||||
):
|
||||
monkeypatch.setattr(f"{mod}.UpdaterClient", FakeUpdaterClient)
|
||||
|
||||
# Stub the tarball builders so tests don't spend seconds re-tarring the
|
||||
# repo on every assertion. The byte contents don't matter for the route
|
||||
# contract — the updater side is faked.
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.swarm_updates.api_push_update.tar_working_tree",
|
||||
lambda root, extra_excludes=None: b"tarball-bytes",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.swarm_updates.api_push_update.detect_git_sha",
|
||||
lambda root: "deadbeef",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.swarm_updates.api_push_update_self.tar_working_tree",
|
||||
lambda root, extra_excludes=None: b"tarball-bytes",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.swarm_updates.api_push_update_self.detect_git_sha",
|
||||
lambda root: "deadbeef",
|
||||
)
|
||||
|
||||
return {"client": FakeUpdaterClient, "Response": FakeResponse}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def connection_drop_exc():
|
||||
"""A realistic 'updater re-exec mid-response' exception."""
|
||||
return httpx.RemoteProtocolError("server disconnected")
|
||||
69
tests/api/swarm_updates/test_list_host_releases.py
Normal file
69
tests/api/swarm_updates/test_list_host_releases.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""GET /api/v1/swarm-updates/hosts — per-host updater health fan-out."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_admin_lists_reachable_and_unreachable_hosts(
|
||||
client, auth_token, add_host, fake_updater,
|
||||
):
|
||||
await add_host("alpha", "10.0.0.1")
|
||||
await add_host("beta", "10.0.0.2")
|
||||
|
||||
fake_updater["client"].health_responses = {
|
||||
"alpha": {
|
||||
"status": "ok",
|
||||
"agent_status": "ok",
|
||||
"releases": [
|
||||
{"slot": "active", "sha": "aaaa111", "healthy": True},
|
||||
{"slot": "prev", "sha": "0000000", "healthy": True},
|
||||
],
|
||||
},
|
||||
"beta": RuntimeError("TLS handshake failed"),
|
||||
}
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/swarm-updates/hosts",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
hosts = {h["host_name"]: h for h in resp.json()["hosts"]}
|
||||
assert hosts["alpha"]["reachable"] is True
|
||||
assert hosts["alpha"]["current_sha"] == "aaaa111"
|
||||
assert hosts["alpha"]["previous_sha"] == "0000000"
|
||||
assert hosts["beta"]["reachable"] is False
|
||||
assert "TLS handshake" in hosts["beta"]["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decommissioned_and_agent_only_hosts_are_excluded(
|
||||
client, auth_token, add_host, fake_updater,
|
||||
):
|
||||
await add_host("good", "10.0.0.1", with_updater=True)
|
||||
await add_host("gone", "10.0.0.2", with_updater=True, status="decommissioned")
|
||||
await add_host("agentonly", "10.0.0.3", with_updater=False)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/v1/swarm-updates/hosts",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
names = {h["host_name"] for h in resp.json()["hosts"]}
|
||||
assert names == {"good"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_viewer_is_forbidden(client, viewer_token, add_host, fake_updater):
|
||||
await add_host("alpha")
|
||||
resp = await client.get(
|
||||
"/api/v1/swarm-updates/hosts",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unauth_returns_401(client):
|
||||
resp = await client.get("/api/v1/swarm-updates/hosts")
|
||||
assert resp.status_code == 401
|
||||
176
tests/api/swarm_updates/test_push_update.py
Normal file
176
tests/api/swarm_updates/test_push_update.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""POST /api/v1/swarm-updates/push — happy paths, rollback, validation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_to_single_host_success(client, auth_token, add_host, fake_updater):
|
||||
h = await add_host("alpha")
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"host_uuids": [h["uuid"]]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["sha"] == "deadbeef"
|
||||
assert body["tarball_bytes"] == len(b"tarball-bytes")
|
||||
assert body["results"][0]["status"] == "updated"
|
||||
assert body["results"][0]["host_name"] == "alpha"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_reports_rollback_on_409(client, auth_token, add_host, fake_updater):
|
||||
h = await add_host("alpha")
|
||||
Resp = fake_updater["Response"]
|
||||
fake_updater["client"].update_responses = {
|
||||
"alpha": Resp(409, {"error": "probe timed out", "stderr": "boom", "rolled_back": True}),
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"host_uuids": [h["uuid"]]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["status"] == "rolled-back"
|
||||
assert result["http_status"] == 409
|
||||
assert result["stderr"] == "boom"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_all_aggregates_mixed_results(client, auth_token, add_host, fake_updater):
|
||||
await add_host("alpha", "10.0.0.1")
|
||||
await add_host("beta", "10.0.0.2")
|
||||
Resp = fake_updater["Response"]
|
||||
fake_updater["client"].update_responses = {
|
||||
"alpha": Resp(200, {"probe": "ok"}),
|
||||
"beta": RuntimeError("connect timeout"),
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"all": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
statuses = {r["host_name"]: r["status"] for r in resp.json()["results"]}
|
||||
assert statuses == {"alpha": "updated", "beta": "failed"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tarball_built_once_across_multi_host_push(
|
||||
client, auth_token, add_host, fake_updater, monkeypatch,
|
||||
):
|
||||
await add_host("alpha", "10.0.0.1")
|
||||
await add_host("beta", "10.0.0.2")
|
||||
calls = {"count": 0}
|
||||
|
||||
def counted(root, extra_excludes=None):
|
||||
calls["count"] += 1
|
||||
return b"tarball-bytes"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"decnet.web.router.swarm_updates.api_push_update.tar_working_tree", counted,
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"all": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_include_self_only_runs_update_self_on_success(
|
||||
client, auth_token, add_host, fake_updater,
|
||||
):
|
||||
await add_host("alpha", "10.0.0.1")
|
||||
await add_host("beta", "10.0.0.2")
|
||||
Resp = fake_updater["Response"]
|
||||
fake_updater["client"].update_responses = {
|
||||
"alpha": Resp(200, {"probe": "ok"}),
|
||||
"beta": Resp(409, {"error": "bad", "rolled_back": True}),
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"all": True, "include_self": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
results = {r["host_name"]: r for r in resp.json()["results"]}
|
||||
assert results["alpha"]["status"] == "self-updated"
|
||||
assert results["beta"]["status"] == "rolled-back"
|
||||
# update_self must NOT have been called on beta (rolled-back agent).
|
||||
methods_called = [(name, m) for name, m, _ in fake_updater["client"].calls]
|
||||
assert ("beta", "update_self") not in methods_called
|
||||
assert ("alpha", "update_self") in methods_called
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_include_self_tolerates_expected_connection_drop(
|
||||
client, auth_token, add_host, fake_updater, connection_drop_exc,
|
||||
):
|
||||
await add_host("alpha", "10.0.0.1")
|
||||
fake_updater["client"].update_self_responses = {
|
||||
"alpha": connection_drop_exc,
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"all": True, "include_self": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["results"][0]["status"] == "self-updated"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_host_and_all_are_mutually_exclusive(
|
||||
client, auth_token, add_host, fake_updater,
|
||||
):
|
||||
h = await add_host("alpha")
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"host_uuids": [h["uuid"]], "all": True},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_neither_host_nor_all_rejected(client, auth_token, fake_updater):
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unknown_host_uuid_returns_404(client, auth_token, fake_updater):
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"host_uuids": ["nonexistent"]},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_viewer_is_forbidden(client, viewer_token, add_host, fake_updater):
|
||||
h = await add_host("alpha")
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
json={"host_uuids": [h["uuid"]]},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
67
tests/api/swarm_updates/test_push_update_self.py
Normal file
67
tests/api/swarm_updates/test_push_update_self.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""POST /api/v1/swarm-updates/push-self — updater-only upgrade path."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_self_only_calls_update_self(client, auth_token, add_host, fake_updater):
|
||||
await add_host("alpha")
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push-self",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"all": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["results"][0]["status"] == "self-updated"
|
||||
methods = [m for _, m, _ in fake_updater["client"].calls]
|
||||
assert "update" not in methods
|
||||
assert "update_self" in methods
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_self_reports_failure(client, auth_token, add_host, fake_updater):
|
||||
await add_host("alpha")
|
||||
Resp = fake_updater["Response"]
|
||||
fake_updater["client"].update_self_responses = {
|
||||
"alpha": Resp(500, {"error": "pip failed", "stderr": "no module named typer"}),
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push-self",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"all": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["status"] == "self-failed"
|
||||
assert result["http_status"] == 500
|
||||
assert "typer" in (result["stderr"] or "")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_push_self_treats_connection_drop_as_success(
|
||||
client, auth_token, add_host, fake_updater, connection_drop_exc,
|
||||
):
|
||||
await add_host("alpha")
|
||||
fake_updater["client"].update_self_responses = {"alpha": connection_drop_exc}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push-self",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"all": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["results"][0]["status"] == "self-updated"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_viewer_is_forbidden(client, viewer_token, add_host, fake_updater):
|
||||
await add_host("alpha")
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/push-self",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
json={"all": True},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
86
tests/api/swarm_updates/test_rollback_host.py
Normal file
86
tests/api/swarm_updates/test_rollback_host.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""POST /api/v1/swarm-updates/rollback — single-host manual rollback."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rollback_happy_path(client, auth_token, add_host, fake_updater):
|
||||
h = await add_host("alpha")
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/rollback",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"host_uuid": h["uuid"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "rolled-back"
|
||||
assert body["host_name"] == "alpha"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rollback_404_when_no_previous(client, auth_token, add_host, fake_updater):
|
||||
h = await add_host("alpha")
|
||||
Resp = fake_updater["Response"]
|
||||
fake_updater["client"].rollback_responses = {
|
||||
"alpha": Resp(404, {"detail": "no previous release"}),
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/rollback",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"host_uuid": h["uuid"]},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert "no previous" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rollback_transport_failure_reported(client, auth_token, add_host, fake_updater):
|
||||
h = await add_host("alpha")
|
||||
fake_updater["client"].rollback_responses = {"alpha": RuntimeError("TLS handshake failed")}
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/rollback",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"host_uuid": h["uuid"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "failed"
|
||||
assert "TLS handshake" in body["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rollback_unknown_host(client, auth_token, fake_updater):
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/rollback",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"host_uuid": "nonexistent"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rollback_on_agent_only_host_rejected(
|
||||
client, auth_token, add_host, fake_updater,
|
||||
):
|
||||
h = await add_host("alpha", with_updater=False)
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/rollback",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"host_uuid": h["uuid"]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_viewer_is_forbidden(client, viewer_token, add_host, fake_updater):
|
||||
h = await add_host("alpha")
|
||||
resp = await client.post(
|
||||
"/api/v1/swarm-updates/rollback",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
json={"host_uuid": h["uuid"]},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
129
tests/api/test_error_handler.py
Normal file
129
tests/api/test_error_handler.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Tests for the generic Exception handler at decnet/web/api.py.
|
||||
|
||||
Mitigation target: threat model F1/I — "Production error handler suppresses
|
||||
tracebacks and internal details." Verifies that uncaught exceptions return
|
||||
an opaque 500 with a correlation id in prod, and include debug detail only
|
||||
when DECNET_DEVELOPER is on.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.env import DECNET_ADMIN_USER, DECNET_ADMIN_PASSWORD
|
||||
from decnet.web.api import app
|
||||
from decnet.web.dependencies import repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
||||
"""Override the shared client fixture to NOT re-raise app exceptions.
|
||||
|
||||
The default `httpx.ASGITransport` re-raises any uncaught exception
|
||||
from the app — which defeats the whole point of testing our
|
||||
generic exception handler. With `raise_app_exceptions=False`, the
|
||||
transport instead returns the HTTP response our handler built.
|
||||
"""
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
async def _admin_headers(client: httpx.AsyncClient) -> dict[str, str]:
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
|
||||
)
|
||||
token = resp.json()["access_token"]
|
||||
# Clear must_change_password so the token passes mutation-gated endpoints.
|
||||
await client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"old_password": DECNET_ADMIN_PASSWORD, "new_password": DECNET_ADMIN_PASSWORD},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
resp2 = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": DECNET_ADMIN_USER, "password": DECNET_ADMIN_PASSWORD},
|
||||
)
|
||||
return {"Authorization": f"Bearer {resp2.json()['access_token']}"}
|
||||
|
||||
|
||||
def _raise_boom(*_a, **_kw):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unhandled_exception_prod_shape_is_opaque(
|
||||
client: httpx.AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Prod mode (DECNET_DEVELOPER=False): 500 with opaque body + error_id.
|
||||
Must NOT include traceback or exception_type."""
|
||||
import decnet.web.api as _api
|
||||
monkeypatch.setattr(_api, "DECNET_DEVELOPER", False)
|
||||
|
||||
headers = await _admin_headers(client)
|
||||
monkeypatch.setattr(repo, "get_attacker_by_uuid", _raise_boom)
|
||||
|
||||
r = await client.get("/api/v1/attackers/any-uuid", headers=headers)
|
||||
|
||||
assert r.status_code == 500
|
||||
body = r.json()
|
||||
assert body.get("detail") == "Internal Server Error"
|
||||
assert "error_id" in body
|
||||
assert re.fullmatch(r"[0-9a-f]{32}", body["error_id"]), body["error_id"]
|
||||
assert "traceback" not in body
|
||||
assert "exception_type" not in body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unhandled_exception_dev_shape_includes_traceback(
|
||||
client: httpx.AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Dev mode (DECNET_DEVELOPER=True): body includes exception_type and
|
||||
traceback so failures are debuggable without tailing server logs."""
|
||||
import decnet.web.api as _api
|
||||
monkeypatch.setattr(_api, "DECNET_DEVELOPER", True)
|
||||
|
||||
headers = await _admin_headers(client)
|
||||
monkeypatch.setattr(repo, "get_attacker_by_uuid", _raise_boom)
|
||||
|
||||
r = await client.get("/api/v1/attackers/any-uuid", headers=headers)
|
||||
|
||||
assert r.status_code == 500
|
||||
body = r.json()
|
||||
assert body["detail"] == "Internal Server Error"
|
||||
assert "error_id" in body
|
||||
assert body["exception_type"] == "RuntimeError"
|
||||
assert "RuntimeError" in body["traceback"]
|
||||
assert "boom" in body["traceback"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unhandled_exception_logs_error_id(
|
||||
client: httpx.AsyncClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The same error_id returned to the client must appear in server logs,
|
||||
so operators can correlate a user's 500 report with the full traceback."""
|
||||
import decnet.web.api as _api
|
||||
monkeypatch.setattr(_api, "DECNET_DEVELOPER", False)
|
||||
|
||||
headers = await _admin_headers(client)
|
||||
monkeypatch.setattr(repo, "get_attacker_by_uuid", _raise_boom)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="api"):
|
||||
r = await client.get("/api/v1/attackers/any-uuid", headers=headers)
|
||||
|
||||
assert r.status_code == 500
|
||||
error_id = r.json()["error_id"]
|
||||
assert any(error_id in rec.getMessage() for rec in caplog.records), (
|
||||
f"error_id {error_id} not found in captured logs: "
|
||||
f"{[rec.getMessage() for rec in caplog.records]}"
|
||||
)
|
||||
116
tests/api/test_rbac.py
Normal file
116
tests/api/test_rbac.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""RBAC matrix tests — verify role enforcement on every API endpoint."""
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Read-only endpoints: viewer + admin should both get access ──────────
|
||||
|
||||
_VIEWER_ENDPOINTS = [
|
||||
("GET", "/api/v1/logs"),
|
||||
("GET", "/api/v1/logs/histogram"),
|
||||
("GET", "/api/v1/bounty"),
|
||||
("GET", "/api/v1/deckies"),
|
||||
("GET", "/api/v1/stats"),
|
||||
("GET", "/api/v1/attackers"),
|
||||
("GET", "/api/v1/config"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("method,path", _VIEWER_ENDPOINTS)
|
||||
async def test_viewer_can_access_read_endpoints(client, viewer_token, method, path):
|
||||
resp = await client.request(
|
||||
method, path, headers={"Authorization": f"Bearer {viewer_token}"}
|
||||
)
|
||||
assert resp.status_code == 200, f"{method} {path} returned {resp.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("method,path", _VIEWER_ENDPOINTS)
|
||||
async def test_admin_can_access_read_endpoints(client, auth_token, method, path):
|
||||
resp = await client.request(
|
||||
method, path, headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
assert resp.status_code == 200, f"{method} {path} returned {resp.status_code}"
|
||||
|
||||
|
||||
# ── Admin-only endpoints: viewer must get 403 ──────────────────────────
|
||||
|
||||
_ADMIN_ENDPOINTS = [
|
||||
("PUT", "/api/v1/config/deployment-limit", {"deployment_limit": 5}),
|
||||
("PUT", "/api/v1/config/global-mutation-interval", {"global_mutation_interval": "1d"}),
|
||||
("POST", "/api/v1/config/users", {"username": "rbac-test", "password": "pass123456", "role": "viewer"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("method,path,body", _ADMIN_ENDPOINTS)
|
||||
async def test_viewer_blocked_from_admin_endpoints(client, viewer_token, method, path, body):
|
||||
resp = await client.request(
|
||||
method, path,
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403, f"{method} {path} returned {resp.status_code}"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("method,path,body", _ADMIN_ENDPOINTS)
|
||||
async def test_admin_can_access_admin_endpoints(client, auth_token, method, path, body):
|
||||
resp = await client.request(
|
||||
method, path,
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200, f"{method} {path} returned {resp.status_code}"
|
||||
|
||||
|
||||
# ── Unauthenticated access: must get 401 ───────────────────────────────
|
||||
|
||||
_ALL_PROTECTED = [
|
||||
("GET", "/api/v1/logs"),
|
||||
("GET", "/api/v1/stats"),
|
||||
("GET", "/api/v1/deckies"),
|
||||
("GET", "/api/v1/bounty"),
|
||||
("GET", "/api/v1/attackers"),
|
||||
("GET", "/api/v1/config"),
|
||||
("PUT", "/api/v1/config/deployment-limit"),
|
||||
("POST", "/api/v1/config/users"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("method,path", _ALL_PROTECTED)
|
||||
async def test_unauthenticated_returns_401(client, method, path):
|
||||
resp = await client.request(method, path)
|
||||
assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}"
|
||||
|
||||
|
||||
# ── Fleet write endpoints: viewer must get 403 ─────────────────────────
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_viewer_blocked_from_deploy(client, viewer_token):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/deploy",
|
||||
json={"ini_content": "[decky-rbac-test]\nservices=ssh"},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_viewer_blocked_from_mutate(client, viewer_token):
|
||||
resp = await client.post(
|
||||
"/api/v1/deckies/test-decky/mutate",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_viewer_blocked_from_mutate_interval(client, viewer_token):
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/test-decky/mutate-interval",
|
||||
json={"mutate_interval": "5d"},
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
177
tests/api/test_rbac_contract.py
Normal file
177
tests/api/test_rbac_contract.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
RBAC contract test — every route is classified by server-side dependency
|
||||
introspection and exercised with a viewer JWT.
|
||||
|
||||
Covers THREAT_MODEL.md F2/E (mutation bypass via missing `require_admin`)
|
||||
and F5/E (mutation routes returning 403 for viewer). The 401-unauth half
|
||||
is covered by `test_schemathesis.py::test_auth_enforcement`.
|
||||
|
||||
We deliberately do NOT annotate role hints in the OpenAPI spec —
|
||||
classification stays server-side so an attacker reading /openapi.json
|
||||
can't enumerate admin routes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from decnet.web.api import app
|
||||
from decnet.web.dependencies import (
|
||||
require_admin,
|
||||
require_viewer,
|
||||
require_stream_viewer,
|
||||
get_current_user_unchecked,
|
||||
get_current_user,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route classification (runs at import time)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ADMIN_CALLS = {require_admin}
|
||||
_VIEWER_CALLS = {require_viewer, require_stream_viewer, get_current_user_unchecked, get_current_user}
|
||||
|
||||
|
||||
def _walk_deps(dependant) -> set:
|
||||
"""Recursively collect every dependency `call` in the tree."""
|
||||
calls: set = set()
|
||||
stack = list(dependant.dependencies)
|
||||
while stack:
|
||||
d = stack.pop()
|
||||
if d.call is not None:
|
||||
calls.add(d.call)
|
||||
stack.extend(d.dependencies)
|
||||
return calls
|
||||
|
||||
|
||||
def _classify(route: APIRoute) -> str:
|
||||
calls = _walk_deps(route.dependant)
|
||||
if calls & _ADMIN_CALLS:
|
||||
return "admin"
|
||||
if calls & _VIEWER_CALLS:
|
||||
return "viewer"
|
||||
return "open"
|
||||
|
||||
|
||||
def _is_sse(route: APIRoute) -> bool:
|
||||
"""SSE endpoints keep the connection open — authz fires before the stream
|
||||
starts, but httpx won't return until the server closes. Skip them here;
|
||||
F6 gets its own dedicated verification pass."""
|
||||
return route.path.endswith(("/events", "/stream", "/status-events"))
|
||||
|
||||
|
||||
def _collect() -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
|
||||
"""Return (admin_routes, viewer_routes) as (method, path, name) triples."""
|
||||
admin: list[tuple[str, str, str]] = []
|
||||
viewer: list[tuple[str, str, str]] = []
|
||||
for route in app.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
if _is_sse(route):
|
||||
continue
|
||||
cls = _classify(route)
|
||||
for method in sorted(route.methods - {"HEAD", "OPTIONS"}):
|
||||
entry = (method, route.path, route.name)
|
||||
if cls == "admin":
|
||||
admin.append(entry)
|
||||
elif cls == "viewer":
|
||||
viewer.append(entry)
|
||||
return admin, viewer
|
||||
|
||||
|
||||
ADMIN_ROUTES, VIEWER_ROUTES = _collect()
|
||||
|
||||
assert ADMIN_ROUTES, "no admin routes discovered — classifier is broken"
|
||||
assert VIEWER_ROUTES, "no viewer routes discovered — classifier is broken"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path-param substitution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ZERO_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
|
||||
def _substitute_path(path: str, route: APIRoute) -> str:
|
||||
"""Fill `{param}` placeholders with dummy values that satisfy path regex.
|
||||
|
||||
Authz (403) fires before route-handler execution, so the values don't
|
||||
need to match real DB rows — they only need to survive FastAPI's
|
||||
param-type coercion. Heuristic by param name keeps this independent
|
||||
of pydantic-version internals.
|
||||
"""
|
||||
out = path
|
||||
while "{" in out:
|
||||
start = out.index("{")
|
||||
end = out.index("}", start)
|
||||
name = out[start + 1 : end].lower()
|
||||
if "uuid" in name:
|
||||
value = _ZERO_UUID
|
||||
elif name.endswith("_id") or name == "id":
|
||||
value = "1"
|
||||
else:
|
||||
value = "x"
|
||||
out = out[:start] + value + out[end + 1 :]
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,name",
|
||||
ADMIN_ROUTES,
|
||||
ids=lambda t: f"{t[0]} {t[1]}" if isinstance(t, tuple) else str(t),
|
||||
)
|
||||
async def test_admin_route_rejects_viewer(client, viewer_token, method, path, name):
|
||||
"""Every admin-classified route must return 403 when hit with a viewer JWT.
|
||||
|
||||
If a route returns 422 instead, the `Depends(require_admin)` parameter
|
||||
is declared after a body/query param in the route signature — move it
|
||||
earlier so authz runs before schema validation. A 401 means the token
|
||||
was rejected outright (viewer user seeding broken → check conftest).
|
||||
"""
|
||||
url = _substitute_path(path, _route_lookup(method, path))
|
||||
kwargs = {"headers": {"Authorization": f"Bearer {viewer_token}"}}
|
||||
if method in _WRITE_METHODS:
|
||||
kwargs["json"] = {}
|
||||
resp = await client.request(method, url, **kwargs)
|
||||
assert resp.status_code == 403, (
|
||||
f"{method} {path} (name={name}): expected 403 for viewer, "
|
||||
f"got {resp.status_code} — body={resp.text[:200]!r}. "
|
||||
"If 422: move Depends(require_admin) before the body param in the signature. "
|
||||
"If 401: viewer token invalid."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,path,name",
|
||||
VIEWER_ROUTES,
|
||||
ids=lambda t: f"{t[0]} {t[1]}" if isinstance(t, tuple) else str(t),
|
||||
)
|
||||
async def test_viewer_route_does_not_reject_viewer(client, viewer_token, method, path, name):
|
||||
"""Viewer-accessible routes must not return 401/403 for a valid viewer JWT."""
|
||||
url = _substitute_path(path, _route_lookup(method, path))
|
||||
kwargs = {"headers": {"Authorization": f"Bearer {viewer_token}"}}
|
||||
if method in _WRITE_METHODS:
|
||||
kwargs["json"] = {}
|
||||
resp = await client.request(method, url, **kwargs)
|
||||
assert resp.status_code not in (401, 403), (
|
||||
f"{method} {path} (name={name}): viewer unexpectedly got {resp.status_code} "
|
||||
f"— body={resp.text[:200]!r}"
|
||||
)
|
||||
|
||||
|
||||
def _route_lookup(method: str, path: str) -> APIRoute:
|
||||
for route in app.routes:
|
||||
if isinstance(route, APIRoute) and route.path == path and method in route.methods:
|
||||
return route
|
||||
raise LookupError(f"{method} {path}")
|
||||
@@ -16,6 +16,35 @@ async def repo(tmp_path):
|
||||
return r
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_logs_bulk(repo):
|
||||
_batch = [
|
||||
{
|
||||
"decky": f"decky-{i:02d}",
|
||||
"service": "ssh",
|
||||
"event_type": "connect",
|
||||
"attacker_ip": f"10.0.0.{i}",
|
||||
"raw_line": f"row {i}",
|
||||
"fields": {"port": 22, "i": i},
|
||||
"msg": "bulk",
|
||||
}
|
||||
for i in range(1, 11)
|
||||
]
|
||||
await repo.add_logs(_batch)
|
||||
logs = await repo.get_logs(limit=50, offset=0)
|
||||
assert len(logs) == 10
|
||||
# fields dict was normalized to JSON string and round-trips
|
||||
_ips = {entry["attacker_ip"] for entry in logs}
|
||||
assert _ips == {f"10.0.0.{i}" for i in range(1, 11)}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_logs_empty_is_noop(repo):
|
||||
await repo.add_logs([])
|
||||
logs = await repo.get_logs(limit=10, offset=0)
|
||||
assert logs == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_and_get_log(repo):
|
||||
await repo.add_log({
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
"""
|
||||
Schemathesis contract tests.
|
||||
|
||||
Generates requests from the OpenAPI spec and verifies that no input causes a 5xx.
|
||||
|
||||
Currently scoped to `not_a_server_error` only — full response-schema conformance
|
||||
(including undocumented 401 responses) is blocked by DEBT-020 (missing error
|
||||
response declarations across all protected endpoints). Once DEBT-020 is resolved,
|
||||
replace the checks list with the default (remove the argument) for full compliance.
|
||||
Schemathesis contract tests — full compliance, all checks enabled.
|
||||
|
||||
Requires DECNET_DEVELOPER=true (set in tests/conftest.py) to expose /openapi.json.
|
||||
"""
|
||||
import pytest
|
||||
import schemathesis as st
|
||||
from hypothesis import settings, Verbosity
|
||||
from schemathesis.checks import not_a_server_error
|
||||
from schemathesis.specs.openapi.checks import (
|
||||
status_code_conformance,
|
||||
content_type_conformance,
|
||||
response_headers_conformance,
|
||||
response_schema_conformance,
|
||||
positive_data_acceptance,
|
||||
negative_data_rejection,
|
||||
missing_required_header,
|
||||
use_after_free,
|
||||
ensure_resource_availability,
|
||||
ignored_auth,
|
||||
)
|
||||
from hypothesis import settings, Verbosity, HealthCheck
|
||||
from decnet.web.auth import create_access_token
|
||||
|
||||
import subprocess
|
||||
@@ -24,53 +30,83 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Bind to port 0, let the OS pick a free port, return it."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
# Configuration for the automated live server
|
||||
|
||||
LIVE_PORT = _free_port()
|
||||
LIVE_SERVER_URL = f"http://127.0.0.1:{LIVE_PORT}"
|
||||
TEST_SECRET = "test-secret-for-automated-fuzzing"
|
||||
|
||||
# Standardize the secret for the test process too so tokens can be verified
|
||||
import decnet.web.auth
|
||||
decnet.web.auth.SECRET_KEY = TEST_SECRET
|
||||
|
||||
# Create a valid token for an admin-like user
|
||||
TEST_TOKEN = create_access_token({"uuid": "00000000-0000-0000-0000-000000000001"})
|
||||
|
||||
ALL_CHECKS = (
|
||||
not_a_server_error,
|
||||
status_code_conformance,
|
||||
content_type_conformance,
|
||||
response_headers_conformance,
|
||||
response_schema_conformance,
|
||||
positive_data_acceptance,
|
||||
negative_data_rejection,
|
||||
missing_required_header,
|
||||
# `unsupported_method` is intentionally omitted: it expects 405 for
|
||||
# any HTTP method not declared on a path, but FastAPI route tables
|
||||
# frequently collide static (`/topologies/services`) and
|
||||
# parameterized (`/topologies/{topology_id}`) siblings. A request
|
||||
# with an undeclared method on the static path falls through to
|
||||
# the parameterized route, where auth/RBAC fires first and returns
|
||||
# 401/403. That ordering is deliberate — leaking 405-vs-401 would
|
||||
# let unauthenticated callers enumerate which strings are valid
|
||||
# topology UUIDs. The check is incompatible with that design.
|
||||
use_after_free,
|
||||
ensure_resource_availability,
|
||||
)
|
||||
|
||||
AUTH_CHECKS = (
|
||||
not_a_server_error,
|
||||
ignored_auth,
|
||||
)
|
||||
|
||||
|
||||
@st.hook
|
||||
def before_call(context, case, *args):
|
||||
# Logged-in admin for all requests
|
||||
case.headers = case.headers or {}
|
||||
case.headers["Authorization"] = f"Bearer {TEST_TOKEN}"
|
||||
# Force SSE stream to close after the initial snapshot so the test doesn't hang
|
||||
if case.path and case.path.endswith("/stream"):
|
||||
case.query = case.query or {}
|
||||
case.query["maxOutput"] = 0
|
||||
|
||||
def wait_for_port(port, timeout=10):
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
|
||||
def wait_for_port(port: int, timeout: float = 10.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
if sock.connect_ex(('127.0.0.1', port)) == 0:
|
||||
if sock.connect_ex(("127.0.0.1", port)) == 0:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
def start_automated_server():
|
||||
# Use the current venv's uvicorn
|
||||
|
||||
def start_automated_server() -> subprocess.Popen:
|
||||
uvicorn_bin = "uvicorn" if os.name != "nt" else "uvicorn.exe"
|
||||
uvicorn_path = str(Path(sys.executable).parent / uvicorn_bin)
|
||||
|
||||
# Force developer and contract test modes for the sub-process
|
||||
env = os.environ.copy()
|
||||
env["DECNET_DEVELOPER"] = "true"
|
||||
env["DECNET_CONTRACT_TEST"] = "true"
|
||||
env["DECNET_JWT_SECRET"] = TEST_SECRET
|
||||
# Schemathesis fires thousands of examples per endpoint; the login
|
||||
# bucket (10/5min per IP) trips on the second example and turns
|
||||
# every subsequent valid request into a RejectedPositiveData
|
||||
# failure. Disable the limiter for the fuzz subprocess — same
|
||||
# rationale as the load-testing knob in decnet/web/limiter.py.
|
||||
env["DECNET_LIMITER_ENABLED"] = "false"
|
||||
|
||||
log_dir = Path(__file__).parent.parent.parent / "logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
@@ -78,13 +114,18 @@ def start_automated_server():
|
||||
log_file = open(log_dir / f"fuzz_server_{LIVE_PORT}_{ts}.log", "w")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[uvicorn_path, "decnet.web.api:app", "--host", "127.0.0.1", "--port", str(LIVE_PORT), "--log-level", "info"],
|
||||
[
|
||||
uvicorn_path,
|
||||
"decnet.web.api:app",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(LIVE_PORT),
|
||||
"--log-level", "info",
|
||||
],
|
||||
env=env,
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
)
|
||||
|
||||
# Register cleanup
|
||||
atexit.register(proc.terminate)
|
||||
atexit.register(log_file.close)
|
||||
|
||||
@@ -94,14 +135,47 @@ def start_automated_server():
|
||||
|
||||
return proc
|
||||
|
||||
# Stir up the server!
|
||||
|
||||
_server_proc = start_automated_server()
|
||||
|
||||
# Now Schemathesis can pull the schema from the real network port
|
||||
schema = st.openapi.from_url(f"{LIVE_SERVER_URL}/openapi.json")
|
||||
|
||||
|
||||
@pytest.mark.fuzz
|
||||
@st.pytest.parametrize(api=schema)
|
||||
@settings(max_examples=3000, deadline=None, verbosity=Verbosity.debug)
|
||||
@settings(
|
||||
max_examples=3000,
|
||||
deadline=None,
|
||||
verbosity=Verbosity.debug,
|
||||
suppress_health_check=[
|
||||
HealthCheck.filter_too_much,
|
||||
HealthCheck.too_slow,
|
||||
HealthCheck.data_too_large,
|
||||
],
|
||||
)
|
||||
def test_schema_compliance(case):
|
||||
case.call_and_validate()
|
||||
"""Full contract test: valid + invalid inputs, all response checks."""
|
||||
case.call_and_validate(checks=ALL_CHECKS)
|
||||
|
||||
|
||||
@pytest.mark.fuzz
|
||||
@st.pytest.parametrize(api=schema)
|
||||
@settings(
|
||||
max_examples=500,
|
||||
deadline=None,
|
||||
verbosity=Verbosity.normal,
|
||||
suppress_health_check=[
|
||||
HealthCheck.filter_too_much,
|
||||
HealthCheck.too_slow,
|
||||
],
|
||||
)
|
||||
def test_auth_enforcement(case):
|
||||
"""Verify every protected endpoint rejects requests with no token."""
|
||||
case.headers = {
|
||||
k: v for k, v in (case.headers or {}).items()
|
||||
if k.lower() != "authorization"
|
||||
}
|
||||
if case.path and case.path.endswith("/stream"):
|
||||
case.query = case.query or {}
|
||||
case.query["maxOutput"] = 0
|
||||
case.call_and_validate(checks=AUTH_CHECKS)
|
||||
|
||||
100
tests/api/test_schemathesis_agent.py
Normal file
100
tests/api/test_schemathesis_agent.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Schemathesis contract tests for the worker-side agent API
|
||||
(``decnet.agent.app``).
|
||||
|
||||
Uses schemathesis's ASGI transport. The agent's real security is
|
||||
transport-layer mTLS — out of scope here; we're validating schema
|
||||
conformance only.
|
||||
|
||||
The executor and heartbeat modules are stubbed so fuzzed requests don't
|
||||
actually deploy containers, tear down services, or self-destruct the host.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import schemathesis as st
|
||||
from schemathesis.specs.openapi.checks import (
|
||||
status_code_conformance,
|
||||
content_type_conformance,
|
||||
response_headers_conformance,
|
||||
response_schema_conformance,
|
||||
)
|
||||
from hypothesis import settings, HealthCheck
|
||||
|
||||
from decnet.agent import app as _agent_app_mod
|
||||
from decnet.agent import executor as _exec
|
||||
from decnet.agent import heartbeat as _heartbeat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Safety stubs — fuzzer must never touch real docker / systemd / disk.
|
||||
# Applied via autouse fixture (NOT module-level assignment) so the stubs
|
||||
# don't leak into tests/swarm/test_agent_app.py which imports the same
|
||||
# executor module.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _noop_deploy(*a, **kw):
|
||||
return {"status": "stub"}
|
||||
|
||||
async def _noop_teardown(*a, **kw):
|
||||
return {"status": "stub"}
|
||||
|
||||
async def _noop_self_destruct(*a, **kw):
|
||||
return {"status": "stub"}
|
||||
|
||||
async def _noop_status(*a, **kw):
|
||||
return {"deckies": [], "running": False, "deployed": False}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_agent_executor(monkeypatch):
|
||||
monkeypatch.setattr(_exec, "deploy", _noop_deploy)
|
||||
monkeypatch.setattr(_exec, "teardown", _noop_teardown)
|
||||
monkeypatch.setattr(_exec, "self_destruct", _noop_self_destruct)
|
||||
monkeypatch.setattr(_exec, "status", _noop_status)
|
||||
async def _noop_async(*a, **kw):
|
||||
return None
|
||||
monkeypatch.setattr(_heartbeat, "start", lambda *a, **kw: None)
|
||||
# stop() is awaited by the lifespan — must be a coroutine function.
|
||||
monkeypatch.setattr(_heartbeat, "stop", _noop_async)
|
||||
yield
|
||||
|
||||
# OpenAPI is disabled on the worker by default (narrow attack surface).
|
||||
# FastAPI only wires up /openapi.json during __init__; changing the attribute
|
||||
# after the fact is a no-op, so register the route explicitly for the fuzzer.
|
||||
_agent_app_mod.app.openapi_url = "/openapi.json"
|
||||
|
||||
@_agent_app_mod.app.get("/openapi.json", include_in_schema=False)
|
||||
async def _openapi_contract_test():
|
||||
return _agent_app_mod.app.openapi()
|
||||
|
||||
|
||||
SCHEMA = st.openapi.from_asgi("/openapi.json", _agent_app_mod.app)
|
||||
|
||||
pytestmark = pytest.mark.fuzz
|
||||
|
||||
CHECKS = (
|
||||
# Intentionally omit `not_a_server_error`: /mutate returns a documented
|
||||
# 501 Not Implemented, which that check flags as a failure regardless of
|
||||
# whether the status is in the schema. `status_code_conformance` already
|
||||
# catches *undocumented* 5xx responses.
|
||||
status_code_conformance,
|
||||
content_type_conformance,
|
||||
response_headers_conformance,
|
||||
response_schema_conformance,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.fuzz
|
||||
@SCHEMA.parametrize()
|
||||
@settings(
|
||||
max_examples=300,
|
||||
deadline=None,
|
||||
suppress_health_check=[
|
||||
HealthCheck.filter_too_much,
|
||||
HealthCheck.too_slow,
|
||||
HealthCheck.data_too_large,
|
||||
],
|
||||
)
|
||||
def test_agent_schema_compliance(case):
|
||||
"""Fuzz the agent routes against the worker OpenAPI schema."""
|
||||
case.call_and_validate(checks=CHECKS)
|
||||
67
tests/api/test_schemathesis_swarm.py
Normal file
67
tests/api/test_schemathesis_swarm.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Schemathesis contract tests for the swarm-controller API
|
||||
(``decnet.web.swarm_api``).
|
||||
|
||||
Uses schemathesis's ASGI transport so we don't have to stand up uvicorn
|
||||
with mTLS. The controller's transport-layer mTLS is out of scope here —
|
||||
we're validating schema/behavioral conformance of its routes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
# Must be set BEFORE importing the swarm_api module — the repo factory
|
||||
# reads DECNET_DB_TYPE at import time via dependencies.py.
|
||||
os.environ["DECNET_DB_TYPE"] = "sqlite"
|
||||
os.environ["DECNET_MODE"] = "master"
|
||||
os.environ.setdefault("DECNET_JWT_SECRET", "schemathesis-swarm-secret-32chars-min-pad")
|
||||
|
||||
import pytest
|
||||
import schemathesis as st
|
||||
from schemathesis.checks import not_a_server_error
|
||||
from schemathesis.specs.openapi.checks import (
|
||||
status_code_conformance,
|
||||
content_type_conformance,
|
||||
response_headers_conformance,
|
||||
response_schema_conformance,
|
||||
)
|
||||
from hypothesis import settings, HealthCheck
|
||||
|
||||
from decnet.web import swarm_api as _swarm_api
|
||||
|
||||
# OpenAPI is disabled by default on the controller (internal surface).
|
||||
# FastAPI only wires /openapi.json during __init__; toggling the attribute
|
||||
# post-hoc is a no-op, so register the route explicitly here.
|
||||
_swarm_api.app.openapi_url = "/openapi.json"
|
||||
|
||||
@_swarm_api.app.get("/openapi.json", include_in_schema=False)
|
||||
async def _openapi_contract_test():
|
||||
return _swarm_api.app.openapi()
|
||||
|
||||
|
||||
SCHEMA = st.openapi.from_asgi("/openapi.json", _swarm_api.app)
|
||||
|
||||
pytestmark = pytest.mark.fuzz
|
||||
|
||||
CHECKS = (
|
||||
not_a_server_error,
|
||||
status_code_conformance,
|
||||
content_type_conformance,
|
||||
response_headers_conformance,
|
||||
response_schema_conformance,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.fuzz
|
||||
@SCHEMA.parametrize()
|
||||
@settings(
|
||||
max_examples=200,
|
||||
deadline=None,
|
||||
suppress_health_check=[
|
||||
HealthCheck.filter_too_much,
|
||||
HealthCheck.too_slow,
|
||||
HealthCheck.data_too_large,
|
||||
],
|
||||
)
|
||||
def test_swarm_schema_compliance(case):
|
||||
"""Fuzz the swarm-controller routes against its OpenAPI schema."""
|
||||
case.call_and_validate(checks=CHECKS)
|
||||
60
tests/api/test_sse_limits.py
Normal file
60
tests/api/test_sse_limits.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Per-user SSE connection cap — F6/D mitigation."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from decnet.web import sse_limits
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slot_under_cap_enters_cleanly(monkeypatch):
|
||||
monkeypatch.setattr(sse_limits, "_MAX_PER_USER", 2)
|
||||
sse_limits._reset_for_tests()
|
||||
|
||||
async with sse_limits.sse_connection_slot("u1"):
|
||||
assert sse_limits.current_count("u1") == 1
|
||||
async with sse_limits.sse_connection_slot("u1"):
|
||||
assert sse_limits.current_count("u1") == 2
|
||||
|
||||
assert sse_limits.current_count("u1") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slot_over_cap_raises_429(monkeypatch):
|
||||
monkeypatch.setattr(sse_limits, "_MAX_PER_USER", 1)
|
||||
sse_limits._reset_for_tests()
|
||||
|
||||
async with sse_limits.sse_connection_slot("u1"):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
async with sse_limits.sse_connection_slot("u1"):
|
||||
pass
|
||||
assert exc.value.status_code == 429
|
||||
|
||||
# Released after the outer context exits → fresh slot works.
|
||||
async with sse_limits.sse_connection_slot("u1"):
|
||||
assert sse_limits.current_count("u1") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slot_per_user_isolation(monkeypatch):
|
||||
monkeypatch.setattr(sse_limits, "_MAX_PER_USER", 1)
|
||||
sse_limits._reset_for_tests()
|
||||
|
||||
async with sse_limits.sse_connection_slot("u1"):
|
||||
async with sse_limits.sse_connection_slot("u2"):
|
||||
assert sse_limits.current_count("u1") == 1
|
||||
assert sse_limits.current_count("u2") == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slot_decrements_on_exception(monkeypatch):
|
||||
monkeypatch.setattr(sse_limits, "_MAX_PER_USER", 1)
|
||||
sse_limits._reset_for_tests()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
async with sse_limits.sse_connection_slot("u1"):
|
||||
raise ValueError("boom")
|
||||
|
||||
assert sse_limits.current_count("u1") == 0
|
||||
# Slot is free again after exception path.
|
||||
async with sse_limits.sse_connection_slot("u1"):
|
||||
pass
|
||||
0
tests/api/topology/__init__.py
Normal file
0
tests/api/topology/__init__.py
Normal file
224
tests/api/topology/test_child_crud.py
Normal file
224
tests/api/topology/test_child_crud.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Phase 3 Step 4 — child CRUD: LAN / decky / edge."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.topology.config import TopologyConfig
|
||||
from decnet.topology.generator import generate
|
||||
from decnet.topology.persistence import persist, transition_status
|
||||
from decnet.topology.status import TopologyStatus
|
||||
from decnet.web.dependencies import repo as _repo
|
||||
|
||||
_V1 = "/api/v1/topologies"
|
||||
|
||||
|
||||
def _cfg(name: str = "draft") -> TopologyConfig:
|
||||
return TopologyConfig(
|
||||
name=name,
|
||||
depth=1,
|
||||
branching_factor=1,
|
||||
deckies_per_lan_min=1,
|
||||
deckies_per_lan_max=1,
|
||||
services_explicit=["ssh"],
|
||||
randomize_services=False,
|
||||
seed=0,
|
||||
)
|
||||
|
||||
|
||||
async def _seed(name: str = "draft") -> str:
|
||||
return await persist(_repo, generate(_cfg(name)))
|
||||
|
||||
|
||||
def _hdr(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ── LAN CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lan_create_ok(client, auth_token):
|
||||
topology_id = await _seed("lan-create")
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/lans",
|
||||
json={"name": "extra-lan"},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["name"] == "extra-lan"
|
||||
assert body["topology_id"] == topology_id
|
||||
assert body["subnet"] # allocator minted one
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lan_create_blocked_when_active(client, auth_token):
|
||||
topology_id = await _seed("lan-active")
|
||||
await transition_status(_repo, topology_id, TopologyStatus.DEPLOYING)
|
||||
await transition_status(_repo, topology_id, TopologyStatus.ACTIVE)
|
||||
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/lans",
|
||||
json={"name": "extra-lan"},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lan_patch_ok(client, auth_token):
|
||||
topology_id = await _seed("lan-patch")
|
||||
lans = await _repo.list_lans_for_topology(topology_id)
|
||||
lan_id = lans[0]["id"]
|
||||
|
||||
r = await client.patch(
|
||||
f"{_V1}/{topology_id}/lans/{lan_id}",
|
||||
json={"x": 123.0, "y": 456.0},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["x"] == 123.0
|
||||
assert body["y"] == 456.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lan_delete_ok(client, auth_token):
|
||||
topology_id = await _seed("lan-delete")
|
||||
# Add a throw-away LAN first (deleting the primary LAN would orphan its decky).
|
||||
created = await client.post(
|
||||
f"{_V1}/{topology_id}/lans",
|
||||
json={"name": "disposable"},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
lan_id = created.json()["id"]
|
||||
|
||||
r = await client.delete(
|
||||
f"{_V1}/{topology_id}/lans/{lan_id}",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_lan_requires_admin(client, viewer_token):
|
||||
topology_id = await _seed("lan-viewer")
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/lans",
|
||||
json={"name": "nope"},
|
||||
headers=_hdr(viewer_token),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ── Decky CRUD ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decky_create_ok(client, auth_token):
|
||||
topology_id = await _seed("decky-create")
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/deckies",
|
||||
json={"name": "test-decky", "services": ["ssh"]},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["name"] == "test-decky"
|
||||
assert body["services"] == ["ssh"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decky_patch_ok(client, auth_token):
|
||||
topology_id = await _seed("decky-patch")
|
||||
deckies = await _repo.list_topology_deckies(topology_id)
|
||||
decky_uuid = deckies[0]["uuid"]
|
||||
|
||||
r = await client.patch(
|
||||
f"{_V1}/{topology_id}/deckies/{decky_uuid}",
|
||||
json={"x": 50.0, "y": 60.0},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["x"] == 50.0
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decky_delete_ok(client, auth_token):
|
||||
topology_id = await _seed("decky-delete")
|
||||
created = await client.post(
|
||||
f"{_V1}/{topology_id}/deckies",
|
||||
json={"name": "transient", "services": []},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
decky_uuid = created.json()["uuid"]
|
||||
|
||||
r = await client.delete(
|
||||
f"{_V1}/{topology_id}/deckies/{decky_uuid}",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decky_delete_missing_404(client, auth_token):
|
||||
topology_id = await _seed("decky-missing")
|
||||
r = await client.delete(
|
||||
f"{_V1}/{topology_id}/deckies/not-a-uuid",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── Edge CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edge_create_and_delete(client, auth_token):
|
||||
topology_id = await _seed("edge-crud")
|
||||
# Add a second LAN so we can wire an extra edge (bridge) into it.
|
||||
new_lan = await client.post(
|
||||
f"{_V1}/{topology_id}/lans",
|
||||
json={"name": "bridge-target"},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
lan_id = new_lan.json()["id"]
|
||||
|
||||
deckies = await _repo.list_topology_deckies(topology_id)
|
||||
decky_uuid = deckies[0]["uuid"]
|
||||
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/edges",
|
||||
json={"decky_uuid": decky_uuid, "lan_id": lan_id, "is_bridge": True},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
edge_id = r.json()["id"]
|
||||
|
||||
r2 = await client.delete(
|
||||
f"{_V1}/{topology_id}/edges/{edge_id}",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r2.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edge_create_bad_refs_400(client, auth_token):
|
||||
topology_id = await _seed("edge-bad")
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/edges",
|
||||
json={"decky_uuid": "ghost", "lan_id": "also-ghost"},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_edge_requires_admin(client, viewer_token):
|
||||
topology_id = await _seed("edge-viewer")
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/edges",
|
||||
json={"decky_uuid": "x", "lan_id": "y"},
|
||||
headers=_hdr(viewer_token),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
140
tests/api/topology/test_events_stream.py
Normal file
140
tests/api/topology/test_events_stream.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""SSE events stream — GET /topologies/{id}/events (DEBT-030)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.bus import app as _bus_app
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.topology.config import TopologyConfig
|
||||
from decnet.topology.generator import generate
|
||||
from decnet.topology.persistence import persist, transition_status
|
||||
from decnet.topology.status import TopologyStatus
|
||||
from decnet.web.api import app
|
||||
from decnet.web.dependencies import repo as _repo
|
||||
|
||||
_V1 = "/api/v1/topologies"
|
||||
|
||||
|
||||
def _cfg(name: str) -> TopologyConfig:
|
||||
return TopologyConfig(
|
||||
name=name, depth=1, branching_factor=1,
|
||||
deckies_per_lan_min=1, deckies_per_lan_max=1,
|
||||
services_explicit=["ssh"], randomize_services=False, seed=0,
|
||||
)
|
||||
|
||||
|
||||
async def _seed_active(name: str) -> str:
|
||||
tid = await persist(_repo, generate(_cfg(name)))
|
||||
await transition_status(_repo, tid, TopologyStatus.DEPLOYING)
|
||||
await transition_status(_repo, tid, TopologyStatus.ACTIVE)
|
||||
return tid
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _fake_app_bus(monkeypatch):
|
||||
bus = FakeBus()
|
||||
|
||||
async def _get() -> FakeBus:
|
||||
if not bus._connected:
|
||||
await bus.connect()
|
||||
return bus
|
||||
|
||||
monkeypatch.setattr(_bus_app, "get_app_bus", _get)
|
||||
from decnet.web.router.topology import api_events as _ev
|
||||
from decnet.web.router.topology import api_mutations as _mu
|
||||
monkeypatch.setattr(_ev, "get_app_bus", _get)
|
||||
monkeypatch.setattr(_mu, "get_app_bus", _get)
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_events_unauthenticated_401():
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test",
|
||||
) as ac:
|
||||
r = await ac.get(f"{_V1}/any/events")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_events_missing_topology_404(auth_token, _fake_app_bus):
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test",
|
||||
) as ac:
|
||||
r = await ac.get(
|
||||
f"{_V1}/nope/events",
|
||||
params={"token": auth_token},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_events_emits_snapshot_and_live_event(auth_token, _fake_app_bus):
|
||||
"""Drive the generator directly — avoids the full httpx streaming
|
||||
roundtrip, which is painful under ASGITransport + an infinite SSE loop.
|
||||
|
||||
The route is thin glue: if the generator yields snapshot + mapped
|
||||
bus events, the handler works. Auth/404 paths are covered above.
|
||||
"""
|
||||
from decnet.web.router.topology import api_events as _ev
|
||||
|
||||
tid = await _seed_active("evt-live")
|
||||
|
||||
class _FakeRequest:
|
||||
async def is_disconnected(self) -> bool:
|
||||
return False
|
||||
|
||||
# Patch out the role gate so we can call the async endpoint directly.
|
||||
response = await _ev.api_topology_events(
|
||||
topology_id=tid,
|
||||
request=_FakeRequest(), # type: ignore[arg-type]
|
||||
user={"role": "admin", "uuid": "00000000-0000-0000-0000-000000000000"},
|
||||
)
|
||||
gen = response.body_iterator
|
||||
|
||||
def _as_text(frame) -> str:
|
||||
return frame if isinstance(frame, str) else frame.decode()
|
||||
|
||||
async def _publish_after_snapshot() -> None:
|
||||
# Wait for the generator to reach its blocking subscribe state.
|
||||
# We don't have a synchronization primitive, so a short sleep is
|
||||
# good enough — the test-level timeout catches any real hang.
|
||||
await asyncio.sleep(0.1)
|
||||
await _fake_app_bus.publish(
|
||||
_topics.topology_mutation(tid, _topics.MUTATION_APPLIED),
|
||||
{"mutation_id": "m1", "op": "add_lan"},
|
||||
event_type=_topics.MUTATION_APPLIED,
|
||||
)
|
||||
|
||||
pub_task = asyncio.create_task(_publish_after_snapshot())
|
||||
|
||||
async def _drive() -> tuple[bool, bool]:
|
||||
saw_snapshot = False
|
||||
saw_live = False
|
||||
# Bounded — real loop produces keepalive, snapshot, (waits), then
|
||||
# forwarded event. Max 5 iterations covers pathological orderings.
|
||||
for _ in range(5):
|
||||
frame = _as_text(await gen.__anext__())
|
||||
if "event: snapshot" in frame:
|
||||
saw_snapshot = True
|
||||
if "event: mutation.applied" in frame:
|
||||
saw_live = True
|
||||
break
|
||||
return saw_snapshot, saw_live
|
||||
|
||||
try:
|
||||
saw_snapshot, saw_live = await asyncio.wait_for(_drive(), timeout=5.0)
|
||||
finally:
|
||||
pub_task.cancel()
|
||||
try:
|
||||
await pub_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
await gen.aclose()
|
||||
|
||||
assert saw_snapshot
|
||||
assert saw_live
|
||||
148
tests/api/topology/test_models.py
Normal file
148
tests/api/topology/test_models.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Phase 3 Step 1 — parity between repo dict output and Pydantic DTOs.
|
||||
|
||||
These tests pin the contract that repo-hydrated dicts deserialize
|
||||
cleanly into the REST DTOs. If a repo-row shape drifts, the DTO test
|
||||
fails before any endpoint rides on the stale contract.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.topology.config import TopologyConfig
|
||||
from decnet.topology.generator import generate
|
||||
from decnet.topology.persistence import hydrate, persist, transition_status
|
||||
from decnet.topology.status import TopologyStatus
|
||||
from decnet.web.db.factory import get_repository
|
||||
from decnet.web.db.models import (
|
||||
DeckyRow,
|
||||
EdgeRow,
|
||||
LANRow,
|
||||
MutationEnqueueRequest,
|
||||
MutationRow,
|
||||
TopologyDetail,
|
||||
TopologyGenerateRequest,
|
||||
TopologyListResponse,
|
||||
TopologyStatusEventRow,
|
||||
TopologySummary,
|
||||
)
|
||||
from decnet.web.router.topology import topology_router
|
||||
|
||||
|
||||
def _cfg() -> TopologyConfig:
|
||||
return TopologyConfig(
|
||||
name="dto-parity",
|
||||
depth=1,
|
||||
branching_factor=1,
|
||||
deckies_per_lan_min=1,
|
||||
deckies_per_lan_max=1,
|
||||
services_explicit=["ssh"],
|
||||
randomize_services=False,
|
||||
seed=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def repo(tmp_path):
|
||||
r = get_repository(db_path=str(tmp_path / "dto.db"))
|
||||
await r.initialize()
|
||||
return r
|
||||
|
||||
|
||||
def test_router_skeleton_mounted():
|
||||
"""topology_router lives under /topologies and is import-safe."""
|
||||
assert topology_router.prefix == "/topologies"
|
||||
assert "topologies" in (topology_router.tags or [])
|
||||
|
||||
|
||||
def test_generate_request_accepts_cli_shape():
|
||||
"""TopologyGenerateRequest mirrors the CLI flags."""
|
||||
req = TopologyGenerateRequest(
|
||||
name="n",
|
||||
depth=2,
|
||||
branching_factor=2,
|
||||
deckies_per_lan_min=1,
|
||||
deckies_per_lan_max=3,
|
||||
services_explicit=["ssh", "ftp"],
|
||||
randomize_services=False,
|
||||
seed=7,
|
||||
)
|
||||
assert req.depth == 2
|
||||
assert req.services_explicit == ["ssh", "ftp"]
|
||||
|
||||
|
||||
def test_mutation_request_rejects_unknown_op():
|
||||
"""Literal guard is what gives the frontend a free 422 contract."""
|
||||
with pytest.raises(ValueError):
|
||||
MutationEnqueueRequest(op="teleport_lan", payload={})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_summary_accepts_repo_topology_row(repo):
|
||||
plan = generate(_cfg())
|
||||
tid = await persist(repo, plan)
|
||||
row = await repo.get_topology(tid)
|
||||
summary = TopologySummary(**row)
|
||||
assert summary.id == tid
|
||||
assert summary.version == 1
|
||||
# Defaults surface cleanly on a fresh topology.
|
||||
assert summary.needs_resync is False
|
||||
assert summary.target_host_uuid is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_summary_surfaces_needs_resync_flag(repo):
|
||||
"""When the heartbeat handler flags a topology for resync, the API
|
||||
list/detail views must expose it so operators can debug without
|
||||
shelling into the DB."""
|
||||
plan = generate(_cfg())
|
||||
tid = await persist(repo, plan)
|
||||
await repo.set_topology_resync(tid, True)
|
||||
row = await repo.get_topology(tid)
|
||||
summary = TopologySummary(**row)
|
||||
assert summary.needs_resync is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_detail_accepts_hydrated_shape(repo):
|
||||
plan = generate(_cfg())
|
||||
tid = await persist(repo, plan)
|
||||
hydrated = await hydrate(repo, tid)
|
||||
detail = TopologyDetail(
|
||||
topology=TopologySummary(**hydrated["topology"]),
|
||||
lans=[LANRow(**l) for l in hydrated["lans"]],
|
||||
deckies=[DeckyRow(**d) for d in hydrated["deckies"]],
|
||||
edges=[EdgeRow(**e) for e in hydrated["edges"]],
|
||||
)
|
||||
assert detail.topology.id == tid
|
||||
assert len(detail.lans) == len(hydrated["lans"])
|
||||
assert len(detail.deckies) == len(hydrated["deckies"])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mutation_row_accepts_repo_row(repo):
|
||||
plan = generate(_cfg())
|
||||
tid = await persist(repo, plan)
|
||||
mid = await repo.enqueue_topology_mutation(
|
||||
tid, "add_lan", {"name": "LAN-X"}
|
||||
)
|
||||
rows = await repo.list_topology_mutations(tid)
|
||||
assert rows and rows[0]["id"] == mid
|
||||
m = MutationRow(**rows[0])
|
||||
assert m.op == "add_lan"
|
||||
assert m.payload == {"name": "LAN-X"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_status_event_row_accepts_repo_row(repo):
|
||||
plan = generate(_cfg())
|
||||
tid = await persist(repo, plan)
|
||||
await transition_status(repo, tid, TopologyStatus.DEPLOYING)
|
||||
events = await repo.list_topology_status_events(tid)
|
||||
assert events
|
||||
TopologyStatusEventRow(**events[0])
|
||||
|
||||
|
||||
def test_list_response_envelope_shape():
|
||||
resp = TopologyListResponse(total=0, limit=50, offset=0, data=[])
|
||||
assert resp.total == 0
|
||||
assert resp.data == []
|
||||
203
tests/api/topology/test_mutations.py
Normal file
203
tests/api/topology/test_mutations.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Phase 3 Step 5 — live mutation queue endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus import app as _bus_app
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.topology.config import TopologyConfig
|
||||
from decnet.topology.generator import generate
|
||||
from decnet.topology.persistence import persist, transition_status
|
||||
from decnet.topology.status import TopologyStatus
|
||||
from decnet.web.dependencies import repo as _repo
|
||||
|
||||
_V1 = "/api/v1/topologies"
|
||||
|
||||
|
||||
def _cfg(name: str = "draft") -> TopologyConfig:
|
||||
return TopologyConfig(
|
||||
name=name,
|
||||
depth=1,
|
||||
branching_factor=1,
|
||||
deckies_per_lan_min=1,
|
||||
deckies_per_lan_max=1,
|
||||
services_explicit=["ssh"],
|
||||
randomize_services=False,
|
||||
seed=0,
|
||||
)
|
||||
|
||||
|
||||
async def _seed_active(name: str = "mutation-target") -> str:
|
||||
topology_id = await persist(_repo, generate(_cfg(name)))
|
||||
await transition_status(_repo, topology_id, TopologyStatus.DEPLOYING)
|
||||
await transition_status(_repo, topology_id, TopologyStatus.ACTIVE)
|
||||
return topology_id
|
||||
|
||||
|
||||
def _hdr(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ── POST /mutations ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enqueue_ok(client, auth_token):
|
||||
topology_id = await _seed_active("enq-ok")
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
json={"op": "add_lan", "payload": {"name": "new-lan"}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 202, r.text
|
||||
body = r.json()
|
||||
assert body["state"] == "pending"
|
||||
assert body["mutation_id"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enqueue_blocked_when_pending(client, auth_token):
|
||||
topology_id = await persist(_repo, generate(_cfg("enq-pending")))
|
||||
# stays in 'pending'
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
json={"op": "add_lan", "payload": {"name": "x"}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enqueue_unknown_op_rejected(client, auth_token):
|
||||
topology_id = await _seed_active("enq-bad-op")
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
json={"op": "frobnicate", "payload": {}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
# Literal-mismatch on MutationEnqueueRequest.op — the project's
|
||||
# validation handler leaves these as 422.
|
||||
assert r.status_code in (400, 422)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enqueue_missing_topology_404(client, auth_token):
|
||||
r = await client.post(
|
||||
f"{_V1}/nope/mutations",
|
||||
json={"op": "add_lan", "payload": {}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enqueue_requires_admin(client, viewer_token):
|
||||
topology_id = await _seed_active("enq-viewer")
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
json={"op": "add_lan", "payload": {"name": "x"}},
|
||||
headers=_hdr(viewer_token),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ── GET /mutations ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_empty(client, auth_token):
|
||||
topology_id = await _seed_active("list-empty")
|
||||
r = await client.get(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_after_enqueue(client, auth_token):
|
||||
topology_id = await _seed_active("list-after")
|
||||
await client.post(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
json={"op": "update_lan", "payload": {"id": "lan-1", "patch": {"x": 10}}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
|
||||
r = await client.get(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
rows = r.json()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["op"] == "update_lan"
|
||||
assert rows[0]["state"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_state_filter(client, auth_token):
|
||||
topology_id = await _seed_active("list-filter")
|
||||
await client.post(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
json={"op": "add_lan", "payload": {"name": "a"}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
r = await client.get(
|
||||
f"{_V1}/{topology_id}/mutations?state=applied",
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == [] # nothing has been marked applied yet
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_viewer_ok(client, viewer_token):
|
||||
topology_id = await _seed_active("list-viewer")
|
||||
r = await client.get(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
headers=_hdr(viewer_token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ── Bus publish on enqueue (DEBT-030) ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _fake_app_bus(monkeypatch):
|
||||
"""Replace the process-wide app bus with an in-process FakeBus."""
|
||||
bus = FakeBus()
|
||||
|
||||
async def _get() -> FakeBus:
|
||||
if not bus._connected:
|
||||
await bus.connect()
|
||||
return bus
|
||||
|
||||
monkeypatch.setattr(_bus_app, "get_app_bus", _get)
|
||||
# Also patch the re-export in the route module.
|
||||
from decnet.web.router.topology import api_mutations as _mod
|
||||
monkeypatch.setattr(_mod, "get_app_bus", _get)
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_enqueue_publishes_on_bus(client, auth_token, _fake_app_bus):
|
||||
topology_id = await _seed_active("enq-pub")
|
||||
sub = _fake_app_bus.subscribe(
|
||||
_topics.topology_mutation(topology_id, _topics.MUTATION_ENQUEUED),
|
||||
)
|
||||
async with sub:
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/mutations",
|
||||
json={"op": "add_lan", "payload": {"name": "pub-lan"}},
|
||||
headers=_hdr(auth_token),
|
||||
)
|
||||
assert r.status_code == 202
|
||||
mutation_id = r.json()["mutation_id"]
|
||||
import asyncio
|
||||
event = await asyncio.wait_for(sub.__aiter__().__anext__(), timeout=1.0)
|
||||
assert event.type == _topics.MUTATION_ENQUEUED
|
||||
assert event.payload["mutation_id"] == mutation_id
|
||||
assert event.payload["op"] == "add_lan"
|
||||
180
tests/api/topology/test_personas_api.py
Normal file
180
tests/api/topology/test_personas_api.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Per-topology persona endpoints — GET/PUT /topologies/{id}/personas."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.topology.config import TopologyConfig
|
||||
from decnet.topology.generator import generate
|
||||
from decnet.topology.persistence import persist
|
||||
from decnet.web.dependencies import repo as _repo
|
||||
|
||||
_V1 = "/api/v1/topologies"
|
||||
|
||||
|
||||
def _cfg(name: str = "personas") -> TopologyConfig:
|
||||
return TopologyConfig(
|
||||
name=name,
|
||||
depth=1,
|
||||
branching_factor=1,
|
||||
deckies_per_lan_min=1,
|
||||
deckies_per_lan_max=1,
|
||||
services_explicit=["ssh"],
|
||||
randomize_services=False,
|
||||
seed=0,
|
||||
)
|
||||
|
||||
|
||||
async def _seed(name: str = "personas") -> str:
|
||||
return await persist(_repo, generate(_cfg(name)))
|
||||
|
||||
|
||||
def _persona(email: str, name: str = "Jane Doe") -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"email": email,
|
||||
"role": "Admin",
|
||||
"tone": "formal",
|
||||
"mannerisms": ["uses bullet points"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_default_empty(client, auth_token):
|
||||
tid = await _seed("get-empty")
|
||||
r = await client.get(
|
||||
f"{_V1}/{tid}/personas",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["topology_id"] == tid
|
||||
assert body["personas"] == []
|
||||
assert body["language_default"] == "en"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_404(client, auth_token):
|
||||
r = await client.get(
|
||||
f"{_V1}/does-not-exist/personas",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_put_then_get(client, auth_token):
|
||||
tid = await _seed("put-roundtrip")
|
||||
payload = {"personas": [
|
||||
_persona("a@example.com", "Alice"),
|
||||
_persona("b@example.com", "Bob"),
|
||||
]}
|
||||
r = await client.put(
|
||||
f"{_V1}/{tid}/personas",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert len(r.json()["personas"]) == 2
|
||||
|
||||
r2 = await client.get(
|
||||
f"{_V1}/{tid}/personas",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
emails = [p["email"] for p in r2.json()["personas"]]
|
||||
assert emails == ["a@example.com", "b@example.com"]
|
||||
|
||||
# Persisted as JSON string in the topology row.
|
||||
topo = await _repo.get_topology(tid)
|
||||
assert isinstance(topo["email_personas"], str)
|
||||
stored = json.loads(topo["email_personas"])
|
||||
assert {p["email"] for p in stored} == {"a@example.com", "b@example.com"}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_put_empty_clears(client, auth_token):
|
||||
tid = await _seed("put-empty")
|
||||
await client.put(
|
||||
f"{_V1}/{tid}/personas",
|
||||
json={"personas": [_persona("x@example.com")]},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
r = await client.put(
|
||||
f"{_V1}/{tid}/personas",
|
||||
json={"personas": []},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["personas"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_put_non_list_400(client, auth_token):
|
||||
tid = await _seed("put-non-list")
|
||||
r = await client.put(
|
||||
f"{_V1}/{tid}/personas",
|
||||
json={"personas": "not a list"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_put_all_invalid_400(client, auth_token):
|
||||
tid = await _seed("put-all-bad")
|
||||
r = await client.put(
|
||||
f"{_V1}/{tid}/personas",
|
||||
json={"personas": [{"email": "no-at-sign"}, {"name": "no-email"}]},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_put_partial_invalid_keeps_valid(client, auth_token):
|
||||
"""Mirror the global-pool drop-invalid semantics.
|
||||
|
||||
The endpoint silently drops bad entries; operators discover what
|
||||
landed by reading back the GET.
|
||||
"""
|
||||
tid = await _seed("put-partial")
|
||||
r = await client.put(
|
||||
f"{_V1}/{tid}/personas",
|
||||
json={"personas": [
|
||||
_persona("good@example.com"),
|
||||
{"name": "missing email"},
|
||||
]},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert [p["email"] for p in body["personas"]] == ["good@example.com"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_put_404_on_missing_topology(client, auth_token):
|
||||
r = await client.put(
|
||||
f"{_V1}/does-not-exist/personas",
|
||||
json={"personas": [_persona("x@example.com")]},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_does_not_shadow_existing_topology_id(client, auth_token):
|
||||
"""Ensure the personas subroute is registered before the bare /{id}.
|
||||
|
||||
If the literal `/personas` segment got shadowed by the parameterized
|
||||
`/{id}` route, GET would return the topology body instead of 404 for
|
||||
a missing personas resource. Sanity-check the order.
|
||||
"""
|
||||
tid = await _seed("shadow-check")
|
||||
r = await client.get(
|
||||
f"{_V1}/{tid}/personas",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "personas" in r.json()
|
||||
169
tests/api/topology/test_reads.py
Normal file
169
tests/api/topology/test_reads.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Phase 3 Step 2 — read endpoints: list / get / status-events / catalog."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from sqlmodel import select as _ss_select
|
||||
|
||||
from decnet.topology.config import TopologyConfig
|
||||
from decnet.topology.generator import generate
|
||||
from decnet.topology.persistence import persist, transition_status
|
||||
from decnet.topology.status import TopologyStatus
|
||||
from decnet.web.db.models import Topology as _TopologyTable
|
||||
from decnet.web.dependencies import repo as _repo
|
||||
|
||||
_V1 = "/api/v1/topologies"
|
||||
_LIST = f"{_V1}/"
|
||||
|
||||
|
||||
def _cfg(name: str = "draft") -> TopologyConfig:
|
||||
return TopologyConfig(
|
||||
name=name,
|
||||
depth=1,
|
||||
branching_factor=1,
|
||||
deckies_per_lan_min=1,
|
||||
deckies_per_lan_max=1,
|
||||
services_explicit=["ssh"],
|
||||
randomize_services=False,
|
||||
seed=0,
|
||||
)
|
||||
|
||||
|
||||
async def _seed(name: str = "draft") -> str:
|
||||
return await persist(_repo, generate(_cfg(name)))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_empty_ok(client, auth_token):
|
||||
r = await client.get(_LIST, headers={"Authorization": f"Bearer {auth_token}"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 0
|
||||
assert body["data"] == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_requires_auth(client):
|
||||
r = await client.get(_LIST)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_viewer_allowed(client, viewer_token):
|
||||
r = await client.get(_LIST, headers={"Authorization": f"Bearer {viewer_token}"})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_with_topology_and_pagination(client, auth_token):
|
||||
tid1 = await _seed("alpha")
|
||||
await _seed("beta")
|
||||
r = await client.get(
|
||||
f"{_LIST}?limit=1&offset=0",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 2
|
||||
assert len(body["data"]) == 1
|
||||
assert body["data"][0]["id"] in {tid1, body["data"][0]["id"]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_topology_hydrated(client, auth_token):
|
||||
tid = await _seed("detail")
|
||||
r = await client.get(
|
||||
f"{_V1}/{tid}", headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["topology"]["id"] == tid
|
||||
assert body["topology"]["version"] == 1
|
||||
assert body["lans"], "seeded topology has at least one LAN"
|
||||
assert body["deckies"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_topology_404(client, auth_token):
|
||||
r = await client.get(
|
||||
f"{_V1}/does-not-exist",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_status_events_after_transition(client, auth_token):
|
||||
tid = await _seed("events")
|
||||
await transition_status(_repo, tid, TopologyStatus.DEPLOYING)
|
||||
r = await client.get(
|
||||
f"{_V1}/{tid}/status-events",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
rows = r.json()
|
||||
assert rows and rows[0]["to_status"] == "deploying"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_status_events_404_on_missing(client, auth_token):
|
||||
r = await client.get(
|
||||
f"{_V1}/nope/status-events",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_services_catalog(client, viewer_token):
|
||||
r = await client.get(
|
||||
f"{_V1}/services",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert isinstance(body["services"], list)
|
||||
assert "ssh" in body["services"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_next_subnet_starts_at_base(client, auth_token):
|
||||
r = await client.get(
|
||||
f"{_V1}/next-subnet?base=172.20",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["subnet"].startswith("172.20.")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_next_ip_skips_gateway_and_existing(client, auth_token):
|
||||
tid = await _seed("ipalloc")
|
||||
# Find a LAN and existing decky IPs from the seeded topology.
|
||||
r = await client.get(
|
||||
f"{_V1}/{tid}", headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
body = r.json()
|
||||
lan = body["lans"][0]
|
||||
taken = {
|
||||
(d.get("decky_config") or {}).get("ips_by_lan", {}).get(lan["name"])
|
||||
for d in body["deckies"]
|
||||
}
|
||||
taken.discard(None)
|
||||
r2 = await client.get(
|
||||
f"{_V1}/{tid}/lans/{lan['id']}/next-ip",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
ip = r2.json()["ip"]
|
||||
assert ip not in taken
|
||||
assert not ip.endswith(".1") # gateway skipped
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_next_ip_404_lan(client, auth_token):
|
||||
tid = await _seed("nopelan")
|
||||
r = await client.get(
|
||||
f"{_V1}/{tid}/lans/bogus/next-ip",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
319
tests/api/topology/test_writes.py
Normal file
319
tests/api/topology/test_writes.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Phase 3 Step 3 — write endpoints: create / delete / deploy."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.topology.config import TopologyConfig
|
||||
from decnet.topology.generator import generate
|
||||
from decnet.topology.persistence import persist, transition_status
|
||||
from decnet.topology.status import TopologyStatus
|
||||
from decnet.web.dependencies import repo as _repo
|
||||
|
||||
_V1 = "/api/v1/topologies"
|
||||
|
||||
|
||||
def _generate_payload(name: str = "from-api") -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"depth": 1,
|
||||
"branching_factor": 1,
|
||||
"deckies_per_lan_min": 1,
|
||||
"deckies_per_lan_max": 1,
|
||||
"services_explicit": ["ssh"],
|
||||
"randomize_services": False,
|
||||
"seed": 1,
|
||||
}
|
||||
|
||||
|
||||
def _cfg(name: str = "draft") -> TopologyConfig:
|
||||
return TopologyConfig(
|
||||
name=name,
|
||||
depth=1,
|
||||
branching_factor=1,
|
||||
deckies_per_lan_min=1,
|
||||
deckies_per_lan_max=1,
|
||||
services_explicit=["ssh"],
|
||||
randomize_services=False,
|
||||
seed=0,
|
||||
)
|
||||
|
||||
|
||||
async def _seed(name: str = "draft") -> str:
|
||||
return await persist(_repo, generate(_cfg(name)))
|
||||
|
||||
|
||||
# ── POST /topologies ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_ok(client, auth_token):
|
||||
r = await client.post(
|
||||
f"{_V1}/",
|
||||
json=_generate_payload(),
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == TopologyStatus.PENDING
|
||||
assert body["name"] == "from-api"
|
||||
|
||||
# Children were persisted.
|
||||
lans = await _repo.list_lans_for_topology(body["id"])
|
||||
assert len(lans) >= 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_requires_admin(client, viewer_token):
|
||||
r = await client.post(
|
||||
f"{_V1}/",
|
||||
json=_generate_payload(),
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_requires_auth(client):
|
||||
r = await client.post(f"{_V1}/", json=_generate_payload())
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_duplicate_name_is_409(client, auth_token):
|
||||
"""Re-using an existing topology name must return a clean 409, not
|
||||
bubble the raw MySQL IntegrityError up to a 500."""
|
||||
payload = _generate_payload()
|
||||
first = await client.post(
|
||||
f"{_V1}/",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
|
||||
second = await client.post(
|
||||
f"{_V1}/",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert second.status_code == 409, second.text
|
||||
assert payload["name"] in second.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_bad_body(client, auth_token):
|
||||
r = await client.post(
|
||||
f"{_V1}/",
|
||||
json={"name": "x"}, # missing required fields
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
# Project-wide validation handler: missing fields → 400 (not 422).
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── DELETE /topologies/{id} ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_pending_ok(client, auth_token):
|
||||
topology_id = await _seed("for-delete")
|
||||
r = await client.delete(
|
||||
f"{_V1}/{topology_id}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 204
|
||||
assert await _repo.get_topology(topology_id) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_active_blocked(client, auth_token):
|
||||
topology_id = await _seed("for-delete-active")
|
||||
await transition_status(_repo, topology_id, TopologyStatus.DEPLOYING)
|
||||
await transition_status(_repo, topology_id, TopologyStatus.ACTIVE)
|
||||
|
||||
r = await client.delete(
|
||||
f"{_V1}/{topology_id}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert await _repo.get_topology(topology_id) is not None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_missing_404(client, auth_token):
|
||||
r = await client.delete(
|
||||
f"{_V1}/does-not-exist",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_requires_admin(client, viewer_token):
|
||||
topology_id = await _seed("viewer-delete")
|
||||
r = await client.delete(
|
||||
f"{_V1}/{topology_id}",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ── POST /topologies/{id}/deploy ──────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_accepts_pending(client, auth_token):
|
||||
topology_id = await _seed("for-deploy")
|
||||
with patch(
|
||||
"decnet.web.router.topology.api_deploy_topology.deploy_topology",
|
||||
new=AsyncMock(return_value=None),
|
||||
) as mock_deploy:
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/deploy",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 202, r.text
|
||||
body = r.json()
|
||||
assert body["id"] == topology_id
|
||||
# BackgroundTasks run after the response, so the mock must have been invoked
|
||||
# by the time the client context exits.
|
||||
mock_deploy.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_non_pending_blocked(client, auth_token):
|
||||
topology_id = await _seed("for-deploy-blocked")
|
||||
await transition_status(_repo, topology_id, TopologyStatus.DEPLOYING)
|
||||
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/deploy",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_missing_404(client, auth_token):
|
||||
r = await client.post(
|
||||
f"{_V1}/missing/deploy",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deploy_requires_admin(client, viewer_token):
|
||||
topology_id = await _seed("viewer-deploy")
|
||||
r = await client.post(
|
||||
f"{_V1}/{topology_id}/deploy",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ── mode / target_host_uuid pairing (Step 1) ──────────────────────
|
||||
|
||||
|
||||
async def _seed_swarm_host(uuid_: str = "host-uuid-1", status: str = "enrolled") -> None:
|
||||
await _repo.add_swarm_host(
|
||||
{
|
||||
"uuid": uuid_,
|
||||
"name": f"host-{uuid_}",
|
||||
"address": "10.9.9.9",
|
||||
"agent_port": 8765,
|
||||
"status": status,
|
||||
"client_cert_fingerprint": "a" * 64,
|
||||
"cert_bundle_path": "/tmp/ignored",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_blank_agent_mode_ok(client, auth_token):
|
||||
await _seed_swarm_host("host-ok", status="active")
|
||||
r = await client.post(
|
||||
f"{_V1}/blank",
|
||||
json={"name": "blank-agent", "mode": "agent", "target_host_uuid": "host-ok"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["mode"] == "agent"
|
||||
assert body["target_host_uuid"] == "host-ok"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_blank_agent_without_host_is_400(client, auth_token):
|
||||
r = await client.post(
|
||||
f"{_V1}/blank",
|
||||
json={"name": "blank-agent-no-host", "mode": "agent"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "target_host_uuid" in r.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_blank_agent_unknown_host_is_400(client, auth_token):
|
||||
r = await client.post(
|
||||
f"{_V1}/blank",
|
||||
json={
|
||||
"name": "blank-agent-unknown",
|
||||
"mode": "agent",
|
||||
"target_host_uuid": "does-not-exist",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "unknown" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_blank_unihost_with_host_is_400(client, auth_token):
|
||||
await _seed_swarm_host("host-unused")
|
||||
r = await client.post(
|
||||
f"{_V1}/blank",
|
||||
json={
|
||||
"name": "blank-unihost-with-host",
|
||||
"mode": "unihost",
|
||||
"target_host_uuid": "host-unused",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_agent_mode_ok(client, auth_token):
|
||||
await _seed_swarm_host("host-gen")
|
||||
payload = {
|
||||
**_generate_payload("gen-agent"),
|
||||
"mode": "agent",
|
||||
"target_host_uuid": "host-gen",
|
||||
}
|
||||
r = await client.post(
|
||||
f"{_V1}/",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["mode"] == "agent"
|
||||
assert body["target_host_uuid"] == "host-gen"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_agent_unreachable_host_is_400(client, auth_token):
|
||||
await _seed_swarm_host("host-dead", status="unreachable")
|
||||
payload = {
|
||||
**_generate_payload("gen-agent-dead"),
|
||||
"mode": "agent",
|
||||
"target_host_uuid": "host-dead",
|
||||
}
|
||||
r = await client.post(
|
||||
f"{_V1}/",
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
0
tests/api/transcripts/__init__.py
Normal file
0
tests/api/transcripts/__init__.py
Normal file
203
tests/api/transcripts/test_get_transcript.py
Normal file
203
tests/api/transcripts/test_get_transcript.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Tests for GET /api/v1/transcripts/{decky}/{sid}.
|
||||
|
||||
Covers admin-gating, path traversal rejection, pagination over a shared
|
||||
JSONL day-shard, truncation-sentinel surfacing, and the mtime-keyed LRU
|
||||
index cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
_DECKY = "decky-test-01"
|
||||
_SID_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
_SID_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
_SHARD_NAME = "sessions-2026-04-18.jsonl"
|
||||
|
||||
|
||||
def _write_shard(root, decky, service, shard_name, lines):
|
||||
d = root / decky / service / "transcripts"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
path = d / shard_name
|
||||
with path.open("w") as f:
|
||||
for line in lines:
|
||||
f.write(json.dumps(line) + "\n")
|
||||
return path
|
||||
|
||||
|
||||
def _log_row(sid, decky, service, shard_path):
|
||||
return {
|
||||
"id": 1,
|
||||
"timestamp": "2026-04-18T02:22:56+00:00",
|
||||
"decky": decky,
|
||||
"service": service,
|
||||
"event_type": "session_recorded",
|
||||
"attacker_ip": "1.2.3.4",
|
||||
"raw_line": "",
|
||||
"msg": "",
|
||||
"fields": json.dumps({
|
||||
"sid": sid,
|
||||
"service": service,
|
||||
"shard_path": shard_path,
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shard(tmp_path, monkeypatch):
|
||||
root = tmp_path / "artifacts"
|
||||
lines_a = [
|
||||
{"sid": _SID_A, "hdr": {"version": 2, "width": 80, "height": 24, "timestamp": 0}},
|
||||
{"sid": _SID_A, "t": 0.1, "ch": "o", "d": "hello\n"},
|
||||
{"sid": _SID_A, "t": 0.2, "ch": "i", "d": "exit\n"},
|
||||
]
|
||||
lines_b = [
|
||||
{"sid": _SID_B, "hdr": {"version": 2, "width": 80, "height": 24, "timestamp": 1}},
|
||||
{"sid": _SID_B, "t": 0.1, "ch": "o", "d": "second\n"},
|
||||
{"sid": _SID_B, "trunc": True},
|
||||
]
|
||||
# Interleave so the shard resembles real concurrent appends.
|
||||
shard_path = _write_shard(root, _DECKY, "ssh", _SHARD_NAME,
|
||||
[lines_a[0], lines_b[0], lines_a[1], lines_b[1], lines_b[2], lines_a[2]])
|
||||
|
||||
from decnet.web.router.transcripts import api_get_transcript
|
||||
monkeypatch.setattr(api_get_transcript, "ARTIFACTS_ROOT", root)
|
||||
api_get_transcript._INDEX_CACHE.clear()
|
||||
return shard_path
|
||||
|
||||
|
||||
async def test_admin_reads_events(client: httpx.AsyncClient, auth_token: str, shard):
|
||||
row = _log_row(_SID_A, _DECKY, "ssh", str(shard))
|
||||
with patch("decnet.web.router.transcripts.api_get_transcript.repo") as mock_repo:
|
||||
mock_repo.get_session_log = AsyncMock(return_value=row)
|
||||
res = await client.get(
|
||||
f"/api/v1/transcripts/{_DECKY}/{_SID_A}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert body["sid"] == _SID_A
|
||||
assert body["service"] == "ssh"
|
||||
assert body["header"]["width"] == 80
|
||||
assert len(body["events"]) == 2
|
||||
assert body["truncated"] is False
|
||||
assert body["total"] == 2
|
||||
|
||||
|
||||
async def test_truncated_sentinel_surfaces(client: httpx.AsyncClient, auth_token: str, shard):
|
||||
row = _log_row(_SID_B, _DECKY, "ssh", str(shard))
|
||||
with patch("decnet.web.router.transcripts.api_get_transcript.repo") as mock_repo:
|
||||
mock_repo.get_session_log = AsyncMock(return_value=row)
|
||||
res = await client.get(
|
||||
f"/api/v1/transcripts/{_DECKY}/{_SID_B}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["truncated"] is True
|
||||
assert len(body["events"]) == 1
|
||||
|
||||
|
||||
async def test_paging_offset_limit(client: httpx.AsyncClient, auth_token: str, shard):
|
||||
row = _log_row(_SID_A, _DECKY, "ssh", str(shard))
|
||||
with patch("decnet.web.router.transcripts.api_get_transcript.repo") as mock_repo:
|
||||
mock_repo.get_session_log = AsyncMock(return_value=row)
|
||||
res = await client.get(
|
||||
f"/api/v1/transcripts/{_DECKY}/{_SID_A}?offset=1&limit=1",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["offset"] == 1
|
||||
assert body["limit"] == 1
|
||||
assert len(body["events"]) == 1
|
||||
assert body["has_more"] is False
|
||||
|
||||
|
||||
async def test_viewer_forbidden(client: httpx.AsyncClient, viewer_token: str, shard):
|
||||
row = _log_row(_SID_A, _DECKY, "ssh", str(shard))
|
||||
with patch("decnet.web.router.transcripts.api_get_transcript.repo") as mock_repo:
|
||||
mock_repo.get_session_log = AsyncMock(return_value=row)
|
||||
res = await client.get(
|
||||
f"/api/v1/transcripts/{_DECKY}/{_SID_A}",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
async def test_unauthenticated_rejected(client: httpx.AsyncClient, shard):
|
||||
res = await client.get(f"/api/v1/transcripts/{_DECKY}/{_SID_A}")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_404_when_sid_not_in_log(client: httpx.AsyncClient, auth_token: str, shard):
|
||||
with patch("decnet.web.router.transcripts.api_get_transcript.repo") as mock_repo:
|
||||
mock_repo.get_session_log = AsyncMock(return_value=None)
|
||||
res = await client.get(
|
||||
f"/api/v1/transcripts/{_DECKY}/{_SID_A}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_invalid_sid_rejected(client: httpx.AsyncClient, auth_token: str, shard):
|
||||
res = await client.get(
|
||||
f"/api/v1/transcripts/{_DECKY}/not-a-uuid",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_decky_mismatch_rejected(client: httpx.AsyncClient, auth_token: str, shard):
|
||||
# Log row claims a different decky than the URL — don't trust the URL.
|
||||
row = _log_row(_SID_A, "other-decky", "ssh", str(shard))
|
||||
with patch("decnet.web.router.transcripts.api_get_transcript.repo") as mock_repo:
|
||||
mock_repo.get_session_log = AsyncMock(return_value=row)
|
||||
res = await client.get(
|
||||
f"/api/v1/transcripts/{_DECKY}/{_SID_A}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_forged_shard_path_is_ignored_in_favour_of_scan(
|
||||
client: httpx.AsyncClient, auth_token: str, shard,
|
||||
):
|
||||
# A Log row with a shard_path basename that doesn't match
|
||||
# sessions-YYYY-MM-DD is silently ignored — the handler falls back
|
||||
# to scanning the decky's transcripts dir for a shard containing
|
||||
# the sid. The security invariant holds either way: only files
|
||||
# whose basename matches _SHARD_BASENAME_RE are ever opened, and
|
||||
# they always resolve under ARTIFACTS_ROOT/decky/<service>/
|
||||
# transcripts/.
|
||||
row = _log_row(_SID_A, _DECKY, "ssh", "/etc/passwd")
|
||||
with patch("decnet.web.router.transcripts.api_get_transcript.repo") as mock_repo:
|
||||
mock_repo.get_session_log = AsyncMock(return_value=row)
|
||||
res = await client.get(
|
||||
f"/api/v1/transcripts/{_DECKY}/{_SID_A}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
# Fallback located the real shard and returned it. /etc/passwd was
|
||||
# never opened (different basename shape, wrong dir).
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["sid"] == _SID_A
|
||||
# Sanity: the events came from the test shard, not from a system
|
||||
# file — our fixture events have string `d` fields that /etc/passwd
|
||||
# would never reproduce.
|
||||
assert all(isinstance(evt[2], str) for evt in body["events"])
|
||||
|
||||
|
||||
async def test_limit_ceiling_enforced(client: httpx.AsyncClient, auth_token: str, shard):
|
||||
res = await client.get(
|
||||
f"/api/v1/transcripts/{_DECKY}/{_SID_A}?limit=999999",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
# FastAPI Query validator returns 422 on range violations.
|
||||
assert res.status_code == 422
|
||||
0
tests/api/webhooks/__init__.py
Normal file
0
tests/api/webhooks/__init__.py
Normal file
276
tests/api/webhooks/test_crud.py
Normal file
276
tests/api/webhooks/test_crud.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""CRUD tests for /api/v1/webhooks — admin-gated subscription management."""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
PATH = "/api/v1/webhooks/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_requires_patterns(client: httpx.AsyncClient, auth_token: str):
|
||||
res = await client.post(
|
||||
PATH,
|
||||
json={"name": "wh1", "url": "https://example.com/x"},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 400, res.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_expands_simple_events(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
res = await client.post(
|
||||
PATH,
|
||||
json={
|
||||
"name": "wh-simple",
|
||||
"url": "https://example.com/x",
|
||||
"simple_events": ["AttackerDetail"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
body = res.json()
|
||||
assert body["topic_patterns"] == ["attacker.>"]
|
||||
# Create-path carries the secret for copy-out.
|
||||
assert body["secret"]
|
||||
assert len(body["secret"]) >= 16
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_strips_secret(client: httpx.AsyncClient, auth_token: str):
|
||||
await client.post(
|
||||
PATH,
|
||||
json={
|
||||
"name": "wh-list",
|
||||
"url": "https://example.com/x",
|
||||
"topic_patterns": ["system.>"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
res = await client.get(
|
||||
PATH, headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
rows = res.json()
|
||||
assert len(rows) >= 1
|
||||
for r in rows:
|
||||
assert "secret" not in r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_single_strips_secret(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
create = await client.post(
|
||||
PATH,
|
||||
json={
|
||||
"name": "wh-one",
|
||||
"url": "https://example.com/x",
|
||||
"topic_patterns": ["decky.*.state"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
uuid = create.json()["uuid"]
|
||||
|
||||
res = await client.get(
|
||||
PATH + uuid, headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert "secret" not in res.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_name_conflicts(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
payload = {
|
||||
"name": "wh-dup",
|
||||
"url": "https://example.com/x",
|
||||
"topic_patterns": ["system.>"],
|
||||
}
|
||||
first = await client.post(
|
||||
PATH, json=payload, headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
assert first.status_code == 201
|
||||
second = await client.post(
|
||||
PATH, json=payload, headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_merges_patterns(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
create = await client.post(
|
||||
PATH,
|
||||
json={
|
||||
"name": "wh-patch",
|
||||
"url": "https://example.com/x",
|
||||
"simple_events": ["AttackerDetail"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
uuid = create.json()["uuid"]
|
||||
res = await client.patch(
|
||||
PATH + uuid,
|
||||
json={"topic_patterns": ["custom.>"]},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
# simple_events was NOT passed → it's None → only raw patterns survive.
|
||||
assert res.json()["topic_patterns"] == ["custom.>"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_refuses_empty_patterns(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
create = await client.post(
|
||||
PATH,
|
||||
json={
|
||||
"name": "wh-empty",
|
||||
"url": "https://example.com/x",
|
||||
"simple_events": ["AttackerDetail"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
uuid = create.json()["uuid"]
|
||||
res = await client.patch(
|
||||
PATH + uuid,
|
||||
json={"simple_events": [], "topic_patterns": []},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_returns_message(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
create = await client.post(
|
||||
PATH,
|
||||
json={
|
||||
"name": "wh-del",
|
||||
"url": "https://example.com/x",
|
||||
"topic_patterns": ["system.>"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
uuid = create.json()["uuid"]
|
||||
res = await client.delete(
|
||||
PATH + uuid, headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"message": "Webhook deleted"}
|
||||
# Second delete → 404.
|
||||
res2 = await client.delete(
|
||||
PATH + uuid, headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
assert res2.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_url_warns_but_accepts(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
"""Plain http:// is allowed (operator-trust posture per WH-03) but
|
||||
surfaces a non-blocking advisory in the response's warnings list."""
|
||||
res = await client.post(
|
||||
PATH,
|
||||
json={
|
||||
"name": "wh-http",
|
||||
"url": "http://insecure.local/inbound",
|
||||
"topic_patterns": ["system.>"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
body = res.json()
|
||||
assert any("insecure_url" in w for w in body["warnings"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_https_url_has_no_warning(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
res = await client.post(
|
||||
PATH,
|
||||
json={
|
||||
"name": "wh-https",
|
||||
"url": "https://secure.example/inbound",
|
||||
"topic_patterns": ["system.>"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["warnings"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reenabling_clears_circuit_trip(
|
||||
client: httpx.AsyncClient, auth_token: str
|
||||
):
|
||||
"""Re-enabling via PATCH clears auto_disabled_at + consecutive_failures.
|
||||
|
||||
Simulates the full circuit-breaker lifecycle: create → tripped (via
|
||||
direct DB write, since we can't easily force N worker failures in an
|
||||
API-only test) → re-enable via PATCH → verify state cleared.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from decnet.web.dependencies import repo
|
||||
|
||||
create = await client.post(
|
||||
PATH,
|
||||
json={
|
||||
"name": "wh-trip",
|
||||
"url": "https://example.com/x",
|
||||
"topic_patterns": ["system.>"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert create.status_code == 201
|
||||
uuid = create.json()["uuid"]
|
||||
|
||||
# Simulate the circuit tripping — direct repo call.
|
||||
now = datetime.now(timezone.utc)
|
||||
await repo.record_webhook_failure(uuid, now, "503 service unavailable")
|
||||
await repo.record_webhook_failure(uuid, now, "503 service unavailable")
|
||||
await repo.trip_webhook_circuit(uuid, now)
|
||||
|
||||
pre = await client.get(
|
||||
f"{PATH}{uuid}", headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
assert pre.json()["enabled"] is False
|
||||
assert pre.json()["auto_disabled_at"] is not None
|
||||
assert pre.json()["consecutive_failures"] >= 1
|
||||
|
||||
# Re-enable via PATCH — should clear trip + counter + last_error.
|
||||
res = await client.patch(
|
||||
f"{PATH}{uuid}",
|
||||
json={"enabled": True},
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["enabled"] is True
|
||||
assert body["auto_disabled_at"] is None
|
||||
assert body["consecutive_failures"] == 0
|
||||
assert body["last_error"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_forbidden(client: httpx.AsyncClient, viewer_token: str):
|
||||
res = await client.get(
|
||||
PATH, headers={"Authorization": f"Bearer {viewer_token}"}
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unauthenticated_rejected(client: httpx.AsyncClient):
|
||||
res = await client.get(PATH)
|
||||
assert res.status_code == 401
|
||||
0
tests/api/workers/__init__.py
Normal file
0
tests/api/workers/__init__.py
Normal file
179
tests/api/workers/test_start_workers.py
Normal file
179
tests/api/workers/test_start_workers.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Tests for ``POST /api/v1/workers/{name}/start`` + ``start-all``.
|
||||
|
||||
Uses the shared ``client`` / ``auth_token`` / ``viewer_token`` fixtures
|
||||
from ``tests/api/conftest.py``. Stubs out ``systemd_control`` so tests
|
||||
never touch real systemctl.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Set
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.web.router.workers import api_list_workers as _list
|
||||
from decnet.web.router.workers import api_start_all_workers as _start_all
|
||||
from decnet.web.router.workers import api_start_worker as _start
|
||||
from decnet.web.services import systemd_control as _sc
|
||||
|
||||
|
||||
def _patch_installed(monkeypatch: Any, names: Set[str]) -> None:
|
||||
async def _stub() -> Set[str]:
|
||||
return set(names)
|
||||
|
||||
# Each module imported `systemd_control` directly; patch on the
|
||||
# module-level attribute so all three endpoints see the stub.
|
||||
for mod in (_start, _start_all, _list):
|
||||
monkeypatch.setattr(mod.systemd_control, "list_installed", _stub)
|
||||
|
||||
|
||||
def _patch_start(monkeypatch: Any, *, raises: _sc.SystemctlError | None = None) -> list[str]:
|
||||
calls: list[str] = []
|
||||
|
||||
async def _stub(name: str) -> None:
|
||||
calls.append(name)
|
||||
if raises is not None:
|
||||
raise raises
|
||||
|
||||
monkeypatch.setattr(_sc, "start", _stub)
|
||||
return calls
|
||||
|
||||
|
||||
def _patch_is_active(monkeypatch: Any, active: Set[str]) -> None:
|
||||
async def _stub(name: str) -> bool:
|
||||
return name in active
|
||||
|
||||
monkeypatch.setattr(_sc, "is_active", _stub)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_admin_happy_path(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"mutator", "bus"})
|
||||
calls = _patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/start",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body == {"accepted": True, "worker": "mutator", "action": "start"}
|
||||
assert calls == ["mutator"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_viewer_forbidden(
|
||||
client: httpx.AsyncClient, viewer_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"mutator"})
|
||||
_patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/start",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_unknown_name_404(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"mutator"})
|
||||
_patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/nosuch/start",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_not_installed_503(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, set()) # nothing installed
|
||||
_patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/start",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_systemctl_failure_502(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"mutator"})
|
||||
err = _sc.SystemctlError(
|
||||
unit="decnet-mutator.service",
|
||||
returncode=1,
|
||||
stderr="Failed to start decnet-mutator.service: unit not found",
|
||||
)
|
||||
_patch_start(monkeypatch, raises=err)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/start",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 502
|
||||
body = resp.json()
|
||||
assert "not found" in body["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_all_aggregates_success_running_and_failure(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"bus", "api", "mutator"})
|
||||
_patch_is_active(monkeypatch, {"bus"}) # bus is already running
|
||||
|
||||
async def _stub_start(name: str) -> None:
|
||||
if name == "mutator":
|
||||
raise _sc.SystemctlError(
|
||||
unit="decnet-mutator.service",
|
||||
returncode=1,
|
||||
stderr="Unit decnet-mutator.service is masked.",
|
||||
)
|
||||
# api starts cleanly
|
||||
|
||||
monkeypatch.setattr(_sc, "start", _stub_start)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/start-all",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["already_running"] == ["bus"]
|
||||
assert body["started"] == ["api"]
|
||||
assert len(body["failed"]) == 1
|
||||
assert body["failed"][0]["name"] == "mutator"
|
||||
assert "masked" in body["failed"][0]["reason"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_all_viewer_forbidden(
|
||||
client: httpx.AsyncClient, viewer_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"bus"})
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/start-all",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_all_skips_uninstalled(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, set()) # no units installed
|
||||
_patch_is_active(monkeypatch, set())
|
||||
_patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/start-all",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"started": [], "already_running": [], "failed": []}
|
||||
171
tests/api/workers/test_workers_api.py
Normal file
171
tests/api/workers/test_workers_api.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Tests for the Workers panel API endpoints.
|
||||
|
||||
Covers ``GET /api/v1/workers`` (viewer-readable, always surfaces every
|
||||
known worker) and ``POST /api/v1/workers/{name}/stop`` (admin-only,
|
||||
publishes a stop intent on the bus).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.web import worker_registry as _wr
|
||||
from decnet.web.router.workers import api_control_worker as _ctl
|
||||
from decnet.web.router.workers import api_list_workers as _list
|
||||
from decnet.web.worker_registry import KNOWN_WORKERS
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry() -> None:
|
||||
_wr.reset_registry_for_tests()
|
||||
yield
|
||||
_wr.reset_registry_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fake_bus(monkeypatch) -> FakeBus:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
|
||||
async def _stub_get_app_bus() -> FakeBus:
|
||||
return bus
|
||||
|
||||
# Patch the symbol the control endpoint imported into its namespace.
|
||||
monkeypatch.setattr(_ctl, "get_app_bus", _stub_get_app_bus)
|
||||
yield bus
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workers_viewer_sees_all_unknown(
|
||||
client: httpx.AsyncClient, viewer_token: str,
|
||||
) -> None:
|
||||
resp = await client.get(
|
||||
"/api/v1/workers",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
names = {w["name"] for w in body["workers"]}
|
||||
assert names == set(KNOWN_WORKERS)
|
||||
# No heartbeats have arrived in the test harness, so every row is unknown.
|
||||
for w in body["workers"]:
|
||||
assert w["status"] == "unknown"
|
||||
assert w["last_heartbeat_ts"] is None
|
||||
assert w["seconds_since"] is None
|
||||
assert "bus_connected" in body
|
||||
assert isinstance(body["bus_connected"], bool)
|
||||
# `installed` flag is always present + boolean.
|
||||
for w in body["workers"]:
|
||||
assert "installed" in w
|
||||
assert isinstance(w["installed"], bool)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workers_requires_auth(client: httpx.AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/workers")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workers_reports_bus_connected_false_when_no_bus(
|
||||
client: httpx.AsyncClient, viewer_token: str, monkeypatch,
|
||||
) -> None:
|
||||
async def _no_bus() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(_list, "get_app_bus", _no_bus)
|
||||
resp = await client.get(
|
||||
"/api/v1/workers",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["bus_connected"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workers_reports_bus_connected_true_with_fake_bus(
|
||||
client: httpx.AsyncClient, viewer_token: str, monkeypatch,
|
||||
) -> None:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
|
||||
async def _fake_bus() -> FakeBus:
|
||||
return bus
|
||||
|
||||
monkeypatch.setattr(_list, "get_app_bus", _fake_bus)
|
||||
try:
|
||||
resp = await client.get(
|
||||
"/api/v1/workers",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["bus_connected"] is True
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_worker_admin_publishes_on_bus(
|
||||
client: httpx.AsyncClient, auth_token: str, fake_bus: FakeBus,
|
||||
) -> None:
|
||||
topic = _topics.system_control("mutator")
|
||||
received: list[Any] = []
|
||||
|
||||
sub = fake_bus.subscribe(topic)
|
||||
await sub.__aenter__()
|
||||
|
||||
async def _drain() -> None:
|
||||
async for event in sub:
|
||||
received.append(event)
|
||||
return
|
||||
|
||||
import asyncio
|
||||
reader = asyncio.create_task(_drain())
|
||||
# Give the subscribe a tick so the publish lands on a live reader.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
try:
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/stop",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body == {"accepted": True, "worker": "mutator", "action": "stop"}
|
||||
|
||||
await asyncio.wait_for(reader, timeout=1.0)
|
||||
assert len(received) == 1
|
||||
ev = received[0]
|
||||
assert ev.topic == topic
|
||||
assert ev.payload["action"] == _topics.WORKER_CONTROL_STOP
|
||||
assert "requested_by" in ev.payload
|
||||
assert "ts" in ev.payload
|
||||
finally:
|
||||
await sub.__aexit__(None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_worker_viewer_forbidden(
|
||||
client: httpx.AsyncClient, viewer_token: str, fake_bus: FakeBus,
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/stop",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_worker_unknown_name_404(
|
||||
client: httpx.AsyncClient, auth_token: str, fake_bus: FakeBus,
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/nonsense/stop",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user