merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/agent/__init__.py
Normal file
0
tests/agent/__init__.py
Normal file
79
tests/agent/test_topology_apply_recreate_deps.py
Normal file
79
tests/agent/test_topology_apply_recreate_deps.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""apply() must pass --always-recreate-deps to docker compose up.
|
||||
|
||||
Regression guard for the stale-netns-share bug: deckie service containers
|
||||
join the base via ``network_mode: container:<base>`` and Docker binds the
|
||||
share at service start. When compose recreates the base (e.g. ``ports:``
|
||||
changed after toggling ``forwards_l3``) but decides services are
|
||||
unchanged, the services keep a stale FD into the destroyed netns and
|
||||
end up with only ``lo``. Forcing dependent recreation removes the race.
|
||||
|
||||
Found on first VPS deploy 2026-04-28: external SSH to the dmz-gateway
|
||||
RST'd because the service's netns inode (37090) didn't match the base's
|
||||
(41477). After ``compose down`` + ``up`` the inodes matched and traffic
|
||||
flowed; this test guarantees agent re-applies do the same in one shot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.agent import topology_ops as _ops
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
def current(self) -> None:
|
||||
return None
|
||||
|
||||
def put(self, *a: Any, **kw: Any) -> None:
|
||||
pass
|
||||
|
||||
def clear(self, *a: Any, **kw: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_apply_passes_always_recreate_deps_to_compose(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
captured: list[tuple[str, ...]] = []
|
||||
|
||||
def _fake_compose(*args: str, compose_file: pathlib.Path, **kw: Any) -> None:
|
||||
captured.append(args)
|
||||
|
||||
monkeypatch.setattr(_ops, "_compose_with_retry", _fake_compose)
|
||||
monkeypatch.setattr(_ops, "create_bridge_network", lambda *a, **k: None)
|
||||
monkeypatch.setattr(_ops, "write_topology_compose", lambda *a, **k: None)
|
||||
monkeypatch.setattr(_ops, "_validate_topology", lambda *_: [])
|
||||
monkeypatch.setattr(_ops, "_validation_errors", lambda _: [])
|
||||
monkeypatch.setattr(_ops, "canonical_hash", lambda *_: "deadbeef")
|
||||
|
||||
class _StubDockerClient:
|
||||
@staticmethod
|
||||
def from_env() -> "_StubDockerClient":
|
||||
return _StubDockerClient()
|
||||
|
||||
monkeypatch.setattr(_ops, "docker", _StubDockerClient())
|
||||
|
||||
hydrated = {
|
||||
"topology": {"id": "11111111-2222-3333-4444-555555555555"},
|
||||
"lans": [{"name": "dmz", "subnet": "10.0.0.0/24", "is_dmz": True}],
|
||||
"deckies": [],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
_ops.apply(hydrated, "deadbeef", _FakeStore())
|
||||
)
|
||||
|
||||
assert captured, "compose was never invoked"
|
||||
args = captured[-1]
|
||||
assert "up" in args, f"expected `up` in compose args, got {args}"
|
||||
assert "--always-recreate-deps" in args, (
|
||||
"agent must pass --always-recreate-deps so service containers' "
|
||||
"netns shares stay fresh when their base is recreated. Without "
|
||||
"this flag, services end up with stale FDs into destroyed "
|
||||
"namespaces and external traffic hits closed ports on the live "
|
||||
f"base. Got: {args}"
|
||||
)
|
||||
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
|
||||
0
tests/asn/__init__.py
Normal file
0
tests/asn/__init__.py
Normal file
22
tests/asn/conftest.py
Normal file
22
tests/asn/conftest.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Per-package fixtures — sandbox the ASN provider into a tmp dir so no
|
||||
real /var/lib/decnet paths get touched and no real iptoasn URL gets
|
||||
fetched."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _asn_sandbox(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
monkeypatch.setenv("DECNET_ASN_ENABLED", "true")
|
||||
monkeypatch.setenv("DECNET_ASN_ROOT", str(tmp_path))
|
||||
import decnet.asn as _a
|
||||
import decnet.asn.factory as _f
|
||||
import decnet.asn.paths as _p
|
||||
monkeypatch.setattr(_p, "ASN_ROOT", tmp_path)
|
||||
_a._lookup = None
|
||||
_a._provider_name = None
|
||||
_f.reset_cache()
|
||||
return tmp_path
|
||||
74
tests/asn/test_lookup.py
Normal file
74
tests/asn/test_lookup.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""AsnLookup index tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
from decnet.asn.lookup import AsnInfo, AsnLookup
|
||||
|
||||
|
||||
def _ip(s: str) -> int:
|
||||
return int(ipaddress.IPv4Address(s))
|
||||
|
||||
|
||||
def _fixture_lookup() -> AsnLookup:
|
||||
return AsnLookup.from_ranges([
|
||||
(_ip("8.8.8.0"), _ip("8.8.8.255"), AsnInfo(15169, "GOOGLE")),
|
||||
(_ip("1.0.0.0"), _ip("1.0.0.255"), AsnInfo(13335, "CLOUDFLARENET")),
|
||||
(_ip("46.101.0.0"), _ip("46.101.255.255"), AsnInfo(14061, "DIGITALOCEAN")),
|
||||
])
|
||||
|
||||
|
||||
def test_asn_hits_known_ranges() -> None:
|
||||
lookup = _fixture_lookup()
|
||||
assert lookup.asn("8.8.8.8").asn == 15169
|
||||
assert lookup.asn("1.0.0.5").name == "CLOUDFLARENET"
|
||||
assert lookup.asn("46.101.10.20").asn == 14061
|
||||
|
||||
|
||||
def test_asn_misses_gap() -> None:
|
||||
lookup = _fixture_lookup()
|
||||
assert lookup.asn("9.0.0.0") is None
|
||||
|
||||
|
||||
def test_asn_private_returns_none() -> None:
|
||||
lookup = _fixture_lookup()
|
||||
for ip in ("10.0.0.1", "192.168.1.1", "172.16.0.1", "127.0.0.1", "0.0.0.0"):
|
||||
assert lookup.asn(ip) is None, ip
|
||||
|
||||
|
||||
def test_asn_ipv6_returns_none() -> None:
|
||||
lookup = _fixture_lookup()
|
||||
assert lookup.asn("2001:db8::1") is None
|
||||
assert lookup.asn("::1") is None
|
||||
|
||||
|
||||
def test_asn_invalid_returns_none() -> None:
|
||||
lookup = _fixture_lookup()
|
||||
assert lookup.asn("not-an-ip") is None
|
||||
assert lookup.asn("") is None
|
||||
|
||||
|
||||
def test_lookup_roundtrips_through_pickle(tmp_path: Path) -> None:
|
||||
lookup = _fixture_lookup()
|
||||
cache = tmp_path / "idx.pkl"
|
||||
lookup.save(cache)
|
||||
loaded = AsnLookup.load(cache)
|
||||
assert len(loaded) == len(lookup)
|
||||
assert loaded.asn("8.8.8.8").asn == 15169
|
||||
assert loaded.asn("8.8.8.8").name == "GOOGLE"
|
||||
|
||||
|
||||
def test_from_ranges_last_writer_wins_on_collision() -> None:
|
||||
lookup = AsnLookup.from_ranges([
|
||||
(_ip("1.0.0.0"), _ip("1.0.0.255"), AsnInfo(1, "first")),
|
||||
(_ip("1.0.0.0"), _ip("1.0.0.255"), AsnInfo(2, "second")),
|
||||
])
|
||||
assert lookup.asn("1.0.0.5").asn == 2
|
||||
|
||||
|
||||
def test_boundary_inclusive() -> None:
|
||||
lookup = _fixture_lookup()
|
||||
assert lookup.asn("8.8.8.0").asn == 15169
|
||||
assert lookup.asn("8.8.8.255").asn == 15169
|
||||
assert lookup.asn("8.8.9.0") is None
|
||||
57
tests/asn/test_parse.py
Normal file
57
tests/asn/test_parse.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Parser tests for the iptoasn TSV dump."""
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
from decnet.asn.iptoasn.parse import parse_file
|
||||
|
||||
|
||||
_FIXTURE_TSV = (
|
||||
"1.0.0.0\t1.0.0.255\t13335\tUS\tCLOUDFLARENET\n"
|
||||
"8.8.8.0\t8.8.8.255\t15169\tUS\tGOOGLE\n"
|
||||
# ASN 0 sentinel — must be skipped.
|
||||
"100.64.0.0\t100.127.255.255\t0\tNone\tNot routed\n"
|
||||
# Malformed addresses — skipped.
|
||||
"garbage\tnonsense\t12345\tXX\twhatever\n"
|
||||
# Reversed range (end < start) — skipped.
|
||||
"10.0.0.10\t10.0.0.5\t99999\tXX\tBackwards\n"
|
||||
# Valid row with empty description.
|
||||
"46.101.0.0\t46.101.255.255\t14061\tDE\t\n"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_plain_tsv(tmp_path: Path) -> None:
|
||||
fixture = tmp_path / "ip2asn-v4.tsv"
|
||||
fixture.write_text(_FIXTURE_TSV)
|
||||
ranges = list(parse_file(fixture))
|
||||
asns = {r[2].asn for r in ranges}
|
||||
assert asns == {13335, 15169, 14061}
|
||||
|
||||
|
||||
def test_parse_gzipped(tmp_path: Path) -> None:
|
||||
fixture = tmp_path / "ip2asn-v4.tsv.gz"
|
||||
with gzip.open(fixture, "wt", encoding="utf-8") as fh:
|
||||
fh.write(_FIXTURE_TSV)
|
||||
ranges = list(parse_file(fixture))
|
||||
asns = {r[2].asn for r in ranges}
|
||||
assert 13335 in asns and 15169 in asns
|
||||
|
||||
|
||||
def test_parse_range_boundaries(tmp_path: Path) -> None:
|
||||
fixture = tmp_path / "ip2asn-v4.tsv"
|
||||
fixture.write_text(_FIXTURE_TSV)
|
||||
ranges = [r for r in parse_file(fixture) if r[2].asn == 15169]
|
||||
assert len(ranges) == 1
|
||||
start, end, info = ranges[0]
|
||||
assert start == int(ipaddress.IPv4Address("8.8.8.0"))
|
||||
assert end == int(ipaddress.IPv4Address("8.8.8.255"))
|
||||
assert info.name == "GOOGLE"
|
||||
|
||||
|
||||
def test_parse_empty_description_kept(tmp_path: Path) -> None:
|
||||
fixture = tmp_path / "ip2asn-v4.tsv"
|
||||
fixture.write_text(_FIXTURE_TSV)
|
||||
ranges = [r for r in parse_file(fixture) if r[2].asn == 14061]
|
||||
assert ranges[0][2].name == ""
|
||||
50
tests/asn/test_profiler_integration.py
Normal file
50
tests/asn/test_profiler_integration.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""_build_record must thread ASN fields through to the upsert payload."""
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from decnet.correlation.parser import LogEvent
|
||||
from decnet.profiler.worker import _build_record
|
||||
|
||||
|
||||
def _evt(ip: str) -> LogEvent:
|
||||
return LogEvent(
|
||||
timestamp=datetime(2026, 4, 23, tzinfo=timezone.utc),
|
||||
attacker_ip=ip,
|
||||
decky="decky-01",
|
||||
service="ssh",
|
||||
event_type="conn",
|
||||
fields={},
|
||||
raw="",
|
||||
)
|
||||
|
||||
|
||||
def _seed(root: Path) -> None:
|
||||
target = root / "ip2asn-v4.tsv.gz"
|
||||
with gzip.open(target, "wt", encoding="utf-8") as fh:
|
||||
fh.write("8.8.8.0\t8.8.8.255\t15169\tUS\tGOOGLE\n")
|
||||
|
||||
|
||||
def test_build_record_includes_asn_when_resolved(tmp_path: Path) -> None:
|
||||
_seed(tmp_path)
|
||||
record = _build_record("8.8.8.8", [_evt("8.8.8.8")], None, [], [])
|
||||
assert record["asn"] == 15169
|
||||
assert record["as_name"] == "GOOGLE"
|
||||
assert record["asn_source"] == "iptoasn"
|
||||
|
||||
|
||||
def test_build_record_asn_none_for_private(tmp_path: Path) -> None:
|
||||
_seed(tmp_path)
|
||||
record = _build_record("10.0.0.1", [_evt("10.0.0.1")], None, [], [])
|
||||
assert record["asn"] is None
|
||||
assert record["as_name"] is None
|
||||
assert record["asn_source"] is None
|
||||
|
||||
|
||||
def test_build_record_asn_none_for_unannounced(tmp_path: Path) -> None:
|
||||
_seed(tmp_path)
|
||||
# 9.0.0.0 isn't in the seeded fixture range — no BGP origin we know of.
|
||||
record = _build_record("9.0.0.0", [_evt("9.0.0.0")], None, [], [])
|
||||
assert record["asn"] is None
|
||||
95
tests/asn/test_provider.py
Normal file
95
tests/asn/test_provider.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""IptoasnProvider + factory + public API tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _seed_fixture(root: Path, content: str = "8.8.8.0\t8.8.8.255\t15169\tUS\tGOOGLE\n") -> None:
|
||||
target = root / "ip2asn-v4.tsv.gz"
|
||||
with gzip.open(target, "wt", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
|
||||
|
||||
def test_factory_returns_iptoasn_by_default() -> None:
|
||||
from decnet.asn.factory import get_provider
|
||||
|
||||
provider = get_provider()
|
||||
assert provider.name == "iptoasn"
|
||||
|
||||
|
||||
def test_factory_rejects_unknown_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from decnet.asn import factory
|
||||
|
||||
monkeypatch.setenv("DECNET_ASN_PROVIDER", "nope")
|
||||
factory.reset_cache()
|
||||
with pytest.raises(ValueError):
|
||||
factory.get_provider()
|
||||
|
||||
|
||||
def test_provider_build_lookup_empty_when_no_files(tmp_path: Path) -> None:
|
||||
from decnet.asn.iptoasn.provider import IptoasnProvider
|
||||
|
||||
p = IptoasnProvider()
|
||||
lookup = p.build_lookup()
|
||||
assert len(lookup) == 0
|
||||
assert lookup.asn("8.8.8.8") is None
|
||||
|
||||
|
||||
def test_provider_build_lookup_reads_present_file(tmp_path: Path) -> None:
|
||||
from decnet.asn.iptoasn.provider import IptoasnProvider
|
||||
|
||||
_seed_fixture(tmp_path)
|
||||
p = IptoasnProvider()
|
||||
lookup = p.build_lookup()
|
||||
info = lookup.asn("8.8.8.8")
|
||||
assert info is not None
|
||||
assert info.asn == 15169
|
||||
assert info.name == "GOOGLE"
|
||||
|
||||
|
||||
def test_provider_uses_cache_when_fresh(tmp_path: Path) -> None:
|
||||
from decnet.asn.iptoasn.provider import IptoasnProvider
|
||||
|
||||
_seed_fixture(tmp_path)
|
||||
p = IptoasnProvider()
|
||||
a = p.build_lookup()
|
||||
assert (tmp_path / ".iptoasn_index.pkl").exists()
|
||||
|
||||
p2 = IptoasnProvider()
|
||||
b = p2.build_lookup()
|
||||
assert len(b) == len(a)
|
||||
|
||||
|
||||
def test_enrich_ip_short_circuits_when_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import decnet.asn as asn
|
||||
|
||||
monkeypatch.setenv("DECNET_ASN_ENABLED", "false")
|
||||
assert asn.enrich_ip("8.8.8.8") == (None, None, None)
|
||||
|
||||
|
||||
def test_enrich_ip_returns_asn_and_source(tmp_path: Path) -> None:
|
||||
from decnet.asn import enrich_ip
|
||||
|
||||
_seed_fixture(tmp_path)
|
||||
asn, name, src = enrich_ip("8.8.8.8")
|
||||
assert asn == 15169
|
||||
assert name == "GOOGLE"
|
||||
assert src == "iptoasn"
|
||||
|
||||
|
||||
def test_enrich_ip_private_returns_none(tmp_path: Path) -> None:
|
||||
from decnet.asn import enrich_ip
|
||||
|
||||
_seed_fixture(tmp_path)
|
||||
assert enrich_ip("192.168.1.1") == (None, None, None)
|
||||
|
||||
|
||||
def test_enrich_ip_unannounced_returns_none(tmp_path: Path) -> None:
|
||||
from decnet.asn import enrich_ip
|
||||
|
||||
_seed_fixture(tmp_path)
|
||||
# 9.0.0.0 isn't in our fixture range — no BGP announcement we know of.
|
||||
assert enrich_ip("9.0.0.0") == (None, None, None)
|
||||
0
tests/bus/__init__.py
Normal file
0
tests/bus/__init__.py
Normal file
59
tests/bus/conftest.py
Normal file
59
tests/bus/conftest.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Shared fixtures for decnet.bus tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
from typing import AsyncIterator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.bus.unix_client import UnixSocketBus
|
||||
from decnet.bus.unix_server import BusServer
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def fake_bus() -> AsyncIterator[FakeBus]:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
try:
|
||||
yield bus
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def unix_bus(tmp_path: pathlib.Path) -> AsyncIterator[tuple[BusServer, UnixSocketBus]]:
|
||||
"""Spin a BusServer on a tmp socket, yield (server, connected client).
|
||||
|
||||
Teardown closes both in the right order. No privileged group chown —
|
||||
the fixture passes ``group=None`` so the socket stays owned by the
|
||||
test-runner's process group.
|
||||
"""
|
||||
sock = tmp_path / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
await server.start()
|
||||
serve_task = asyncio.create_task(server.serve_forever())
|
||||
|
||||
client = UnixSocketBus(sock, client_name="test-client")
|
||||
await client.connect()
|
||||
|
||||
try:
|
||||
yield server, client
|
||||
finally:
|
||||
await client.close()
|
||||
serve_task.cancel()
|
||||
try:
|
||||
await serve_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await server.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bus_env_fake(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Point :func:`decnet.bus.factory.get_bus` at the in-process FakeBus."""
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "fake")
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "true")
|
||||
monkeypatch.delenv("DECNET_BUS_SOCKET", raising=False)
|
||||
135
tests/bus/test_app_singleton.py
Normal file
135
tests/bus/test_app_singleton.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Tests for the process-wide app-bus singleton.
|
||||
|
||||
Covers the retry-with-backoff behaviour of ``get_app_bus()`` — the
|
||||
regression guard against the "one-shot veto" bug where a startup race
|
||||
between ``decnet bus`` and the API's lifespan poisoned the singleton
|
||||
for the entire process lifetime.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import decnet.bus.app as app_module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_singleton() -> Any:
|
||||
"""Reset the module-level singleton state between tests."""
|
||||
app_module._shared = None
|
||||
app_module._last_failure_ts = 0.0
|
||||
yield
|
||||
app_module._shared = None
|
||||
app_module._last_failure_ts = 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_call_succeeds_when_bus_connectable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Happy path: connect succeeds, shared instance returned thereafter."""
|
||||
fake_bus = MagicMock()
|
||||
fake_bus.connect = AsyncMock()
|
||||
monkeypatch.setattr(app_module, "get_bus", lambda **_kw: fake_bus)
|
||||
|
||||
result = await app_module.get_app_bus()
|
||||
assert result is fake_bus
|
||||
fake_bus.connect.assert_awaited_once()
|
||||
|
||||
# Subsequent call returns cached instance, no second connect.
|
||||
result2 = await app_module.get_app_bus()
|
||||
assert result2 is fake_bus
|
||||
assert fake_bus.connect.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure_backoff_prevents_hot_retry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""After a failed connect, subsequent calls within the backoff
|
||||
window return None WITHOUT re-attempting connect — the cost of
|
||||
failure stays bounded."""
|
||||
fake_bus = MagicMock()
|
||||
fake_bus.connect = AsyncMock(side_effect=ConnectionError("socket gone"))
|
||||
monkeypatch.setattr(app_module, "get_bus", lambda **_kw: fake_bus)
|
||||
|
||||
assert await app_module.get_app_bus() is None
|
||||
assert fake_bus.connect.await_count == 1
|
||||
|
||||
# Second immediate call: still within backoff, no retry.
|
||||
assert await app_module.get_app_bus() is None
|
||||
assert fake_bus.connect.await_count == 1
|
||||
|
||||
# Third immediate call: same thing.
|
||||
assert await app_module.get_app_bus() is None
|
||||
assert fake_bus.connect.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_retried_after_backoff_expires(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Once the backoff window expires, the next call tries connect()
|
||||
again. This is the regression guard for the original 'one-shot veto'
|
||||
bug — the whole point of the fix."""
|
||||
fake_bus = MagicMock()
|
||||
# First attempt fails, second succeeds.
|
||||
fake_bus.connect = AsyncMock(
|
||||
side_effect=[ConnectionError("socket gone"), None]
|
||||
)
|
||||
monkeypatch.setattr(app_module, "get_bus", lambda **_kw: fake_bus)
|
||||
|
||||
assert await app_module.get_app_bus() is None
|
||||
assert fake_bus.connect.await_count == 1
|
||||
|
||||
# Simulate the backoff window elapsing by rewinding the recorded
|
||||
# failure timestamp into the past.
|
||||
app_module._last_failure_ts = time.monotonic() - (app_module._RETRY_BACKOFF + 0.1)
|
||||
|
||||
result = await app_module.get_app_bus()
|
||||
assert result is fake_bus
|
||||
assert fake_bus.connect.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close_app_bus_clears_backoff_window(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""close_app_bus() after a failure (or after a successful bus) must
|
||||
reset _last_failure_ts so the next get_app_bus() retries immediately
|
||||
— otherwise tests that bring the app-bus up/down/up in one process
|
||||
would see stale backoff."""
|
||||
fake_bus = MagicMock()
|
||||
fake_bus.connect = AsyncMock(side_effect=ConnectionError("x"))
|
||||
fake_bus.close = AsyncMock()
|
||||
monkeypatch.setattr(app_module, "get_bus", lambda **_kw: fake_bus)
|
||||
|
||||
assert await app_module.get_app_bus() is None
|
||||
assert app_module._last_failure_ts > 0.0
|
||||
|
||||
await app_module.close_app_bus()
|
||||
assert app_module._last_failure_ts == 0.0
|
||||
# Next call retries immediately (no backoff wait).
|
||||
fake_bus.connect.side_effect = None # make it succeed this time
|
||||
assert await app_module.get_app_bus() is fake_bus
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_callers_do_not_stampede_connect(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The lock must serialise concurrent callers so a just-started bus
|
||||
doesn't get hammered with N parallel connect attempts."""
|
||||
fake_bus = MagicMock()
|
||||
fake_bus.connect = AsyncMock()
|
||||
monkeypatch.setattr(app_module, "get_bus", lambda **_kw: fake_bus)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*[app_module.get_app_bus() for _ in range(10)]
|
||||
)
|
||||
assert all(r is fake_bus for r in results)
|
||||
assert fake_bus.connect.await_count == 1
|
||||
66
tests/bus/test_base.py
Normal file
66
tests/bus/test_base.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Unit tests for :mod:`decnet.bus.base` — wildcard matching and the Event envelope."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.base import EVENT_SCHEMA_VERSION, Event, matches
|
||||
|
||||
|
||||
class TestMatches:
|
||||
@pytest.mark.parametrize("pattern,topic", [
|
||||
("topology.abc.mutation.applied", "topology.abc.mutation.applied"),
|
||||
("topology.*.mutation.applied", "topology.abc.mutation.applied"),
|
||||
("topology.*.mutation.*", "topology.abc.mutation.applied"),
|
||||
("topology.>", "topology.abc.mutation.applied"),
|
||||
("topology.>", "topology.abc.status"),
|
||||
("decky.*.state", "decky.xyz.state"),
|
||||
("system.bus.health", "system.bus.health"),
|
||||
])
|
||||
def test_matches_positive(self, pattern: str, topic: str) -> None:
|
||||
assert matches(pattern, topic) is True
|
||||
|
||||
@pytest.mark.parametrize("pattern,topic", [
|
||||
("topology.abc.mutation.applied", "topology.abc.mutation.failed"),
|
||||
("topology.*", "topology.abc.mutation.applied"), # * is one token
|
||||
("topology.>", "topology"), # > needs ≥1 trailing
|
||||
("decky.*.state", "decky.state"), # missing middle token
|
||||
("decky.*.state", "decky.xyz.status"),
|
||||
("a.b.c", "a.b"),
|
||||
("a.b", "a.b.c"),
|
||||
])
|
||||
def test_matches_negative(self, pattern: str, topic: str) -> None:
|
||||
assert matches(pattern, topic) is False
|
||||
|
||||
|
||||
class TestEvent:
|
||||
def test_to_dict_round_trip(self) -> None:
|
||||
event = Event(topic="topology.abc.status", payload={"status": "active"}, type="status")
|
||||
data = event.to_dict()
|
||||
assert data["v"] == EVENT_SCHEMA_VERSION
|
||||
assert data["topic"] == "topology.abc.status"
|
||||
assert data["payload"] == {"status": "active"}
|
||||
assert data["type"] == "status"
|
||||
assert isinstance(data["id"], str)
|
||||
assert isinstance(data["ts"], float)
|
||||
|
||||
def test_from_dict_prefers_wire_fields_but_ignores_topic(self) -> None:
|
||||
# The wire topic is the authoritative one (passed from the transport);
|
||||
# a malicious "topic" field in the body must be ignored.
|
||||
data = {
|
||||
"v": 1, "id": "abc", "type": "status",
|
||||
"topic": "attacker.spoofed", # ignored
|
||||
"ts": 123.0,
|
||||
"payload": {"x": 1},
|
||||
}
|
||||
event = Event.from_dict("topology.abc.status", data)
|
||||
assert event.topic == "topology.abc.status"
|
||||
assert event.payload == {"x": 1}
|
||||
assert event.id == "abc"
|
||||
assert event.ts == 123.0
|
||||
|
||||
def test_from_dict_tolerates_missing_fields(self) -> None:
|
||||
event = Event.from_dict("system.log", {})
|
||||
assert event.topic == "system.log"
|
||||
assert event.payload == {}
|
||||
assert event.v == EVENT_SCHEMA_VERSION
|
||||
assert event.id # auto-generated
|
||||
103
tests/bus/test_closed_publish.py
Normal file
103
tests/bus/test_closed_publish.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Tests for graceful publish-on-closed-bus behaviour.
|
||||
|
||||
Regression guard for the 'publish on closed bus' log flood: when a
|
||||
worker's private bus closes (shutdown) but stream threads keep calling
|
||||
the publish closure, the bus must not raise a RuntimeError per call.
|
||||
First drop warns loudly (bus is critical infra); subsequent drops on
|
||||
the same instance are DEBUG to prevent the flood.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import pathlib
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.publish import make_thread_safe_publisher
|
||||
from decnet.bus.unix_client import UnixSocketBus
|
||||
|
||||
|
||||
def _make_closed_bus() -> UnixSocketBus:
|
||||
"""Build a UnixSocketBus and flip _closed without touching sockets.
|
||||
|
||||
We don't need a live connection to test the closed-publish path —
|
||||
the guard clause short-circuits before any I/O.
|
||||
"""
|
||||
bus = UnixSocketBus(pathlib.Path("/tmp/does-not-matter.sock"))
|
||||
bus._closed = True
|
||||
return bus
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_on_closed_bus_returns_silently(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""First post-close publish warns loudly; does not raise."""
|
||||
bus = _make_closed_bus()
|
||||
with caplog.at_level(logging.WARNING, logger="decnet.bus.client"):
|
||||
await bus.publish("system.log", {"x": 1})
|
||||
|
||||
assert any(
|
||||
rec.levelno == logging.WARNING
|
||||
and "publish on closed bus dropped" in rec.getMessage()
|
||||
for rec in caplog.records
|
||||
), f"expected one WARNING, got: {[(r.levelname, r.getMessage()) for r in caplog.records]}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subsequent_closed_publishes_downgrade_to_debug(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Only the first drop warns; the next N drops are DEBUG. This is
|
||||
the regression guard against the log flood."""
|
||||
bus = _make_closed_bus()
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="decnet.bus.client"):
|
||||
for _ in range(50):
|
||||
await bus.publish("system.log", {"x": 1})
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
debugs = [r for r in caplog.records if r.levelno == logging.DEBUG]
|
||||
assert len(warnings) == 1, (
|
||||
f"expected exactly 1 WARNING across 50 publishes, got {len(warnings)}"
|
||||
)
|
||||
assert len(debugs) >= 49, (
|
||||
f"expected >=49 DEBUG drops, got {len(debugs)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_safe_publisher_short_circuits_on_closed_bus() -> None:
|
||||
"""The sync shim returned by make_thread_safe_publisher must NOT
|
||||
marshal a coroutine onto the loop when the bus is already closed."""
|
||||
bus = _make_closed_bus()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
publisher = make_thread_safe_publisher(bus, loop)
|
||||
|
||||
# Patch run_coroutine_threadsafe so we can detect if the shim tries
|
||||
# to marshal anything.
|
||||
import decnet.bus.publish as pub_mod
|
||||
called = MagicMock()
|
||||
orig = asyncio.run_coroutine_threadsafe
|
||||
pub_mod.asyncio.run_coroutine_threadsafe = lambda coro, _loop: (called(), orig(coro, _loop))[1]
|
||||
|
||||
try:
|
||||
publisher("system.log", {"x": 1})
|
||||
publisher("system.log", {"x": 2})
|
||||
publisher("system.log", {"x": 3})
|
||||
finally:
|
||||
pub_mod.asyncio.run_coroutine_threadsafe = orig
|
||||
|
||||
called.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thread_safe_publisher_noop_when_bus_is_none() -> None:
|
||||
"""A None bus still yields a no-op callable (pre-existing contract)."""
|
||||
loop = asyncio.get_running_loop()
|
||||
publisher = make_thread_safe_publisher(None, loop)
|
||||
# Should not raise, return None.
|
||||
assert publisher("topic", {"x": 1}) is None
|
||||
77
tests/bus/test_control_listener.py
Normal file
77
tests/bus/test_control_listener.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Tests for :func:`run_control_listener`.
|
||||
|
||||
The listener is the worker-side half of the Workers panel stop flow:
|
||||
consume ``system.<worker>.control`` messages, set a shutdown event on a
|
||||
well-formed ``{"action": "stop"}``, and ignore everything else without
|
||||
raising.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.bus.publish import run_control_listener
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_listener_sets_shutdown_on_stop() -> None:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
shutdown = asyncio.Event()
|
||||
try:
|
||||
task = asyncio.create_task(run_control_listener(bus, "mutator", shutdown))
|
||||
# Give the subscribe() call a tick to register before we publish.
|
||||
await asyncio.sleep(0)
|
||||
await bus.publish(
|
||||
_topics.system_control("mutator"),
|
||||
{"action": _topics.WORKER_CONTROL_STOP, "requested_by": "admin"},
|
||||
event_type="control",
|
||||
)
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert shutdown.is_set()
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_listener_ignores_malformed() -> None:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
shutdown = asyncio.Event()
|
||||
try:
|
||||
task = asyncio.create_task(run_control_listener(bus, "mutator", shutdown))
|
||||
await asyncio.sleep(0)
|
||||
# Unknown action, non-dict-ish field, missing action — none of
|
||||
# these should raise or trigger shutdown.
|
||||
await bus.publish(
|
||||
_topics.system_control("mutator"),
|
||||
{"action": "bogus"}, event_type="control",
|
||||
)
|
||||
await bus.publish(
|
||||
_topics.system_control("mutator"),
|
||||
{"requested_by": "admin"}, event_type="control",
|
||||
)
|
||||
# Now send a real stop to unblock the task so the test terminates.
|
||||
await bus.publish(
|
||||
_topics.system_control("mutator"),
|
||||
{"action": _topics.WORKER_CONTROL_STOP}, event_type="control",
|
||||
)
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert shutdown.is_set()
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_listener_none_bus_awaits_shutdown() -> None:
|
||||
# With bus=None the listener degrades to awaiting the shutdown event
|
||||
# directly — callers can create_task() unconditionally.
|
||||
shutdown = asyncio.Event()
|
||||
task = asyncio.create_task(run_control_listener(None, "mutator", shutdown))
|
||||
await asyncio.sleep(0)
|
||||
assert not task.done()
|
||||
shutdown.set()
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
52
tests/bus/test_factory.py
Normal file
52
tests/bus/test_factory.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Tests for :func:`decnet.bus.factory.get_bus` dispatch."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.factory import _default_socket_path, get_bus
|
||||
from decnet.bus.fake import FakeBus, NullBus
|
||||
from decnet.bus.unix_client import UnixSocketBus
|
||||
|
||||
|
||||
def test_disabled_returns_null_bus(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "false")
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "unix") # ignored when disabled
|
||||
bus = get_bus()
|
||||
assert isinstance(bus, NullBus)
|
||||
|
||||
|
||||
def test_fake_dispatch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "true")
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "fake")
|
||||
bus = get_bus()
|
||||
assert isinstance(bus, FakeBus)
|
||||
|
||||
|
||||
def test_unix_dispatch(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "true")
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "unix")
|
||||
monkeypatch.setenv("DECNET_BUS_SOCKET", str(tmp_path / "b.sock"))
|
||||
bus = get_bus()
|
||||
assert isinstance(bus, UnixSocketBus)
|
||||
|
||||
|
||||
def test_unknown_type_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_ENABLED", "true")
|
||||
monkeypatch.setenv("DECNET_BUS_TYPE", "mqtt")
|
||||
with pytest.raises(ValueError, match="Unsupported bus type"):
|
||||
get_bus()
|
||||
|
||||
|
||||
def test_default_socket_path_honors_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DECNET_BUS_SOCKET", "/tmp/explicit.sock")
|
||||
assert _default_socket_path() == "/tmp/explicit.sock"
|
||||
|
||||
|
||||
def test_default_socket_path_falls_back_to_home(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("DECNET_BUS_SOCKET", raising=False)
|
||||
# Force /run/decnet to look unusable.
|
||||
monkeypatch.setattr("os.path.isdir", lambda p: False)
|
||||
path = _default_socket_path()
|
||||
assert path.endswith(".decnet/bus.sock")
|
||||
108
tests/bus/test_fake_bus.py
Normal file
108
tests/bus/test_fake_bus.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tests for :class:`decnet.bus.fake.FakeBus` and :class:`NullBus`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.fake import FakeBus, NullBus
|
||||
|
||||
|
||||
async def _collect(sub, n: int, timeout: float = 1.0) -> list:
|
||||
out = []
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
async for event in sub:
|
||||
out.append(event)
|
||||
if len(out) >= n:
|
||||
break
|
||||
except TimeoutError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
class TestFakeBus:
|
||||
async def test_publish_delivers_to_exact_match(self, fake_bus: FakeBus) -> None:
|
||||
sub = fake_bus.subscribe("topology.abc.status")
|
||||
async with sub:
|
||||
await fake_bus.publish("topology.abc.status", {"status": "active"})
|
||||
events = await _collect(sub, 1)
|
||||
assert len(events) == 1
|
||||
assert events[0].payload == {"status": "active"}
|
||||
|
||||
async def test_publish_delivers_to_wildcard(self, fake_bus: FakeBus) -> None:
|
||||
sub = fake_bus.subscribe("topology.*.mutation.*")
|
||||
async with sub:
|
||||
await fake_bus.publish("topology.t1.mutation.applied", {"id": 1})
|
||||
await fake_bus.publish("topology.t2.mutation.failed", {"id": 2})
|
||||
await fake_bus.publish("decky.x.state", {"state": "running"}) # should not match
|
||||
events = await _collect(sub, 2)
|
||||
assert len(events) == 2
|
||||
assert {e.payload["id"] for e in events} == {1, 2}
|
||||
|
||||
async def test_multiple_subscribers_each_get_copy(self, fake_bus: FakeBus) -> None:
|
||||
sub_a = fake_bus.subscribe("topology.>")
|
||||
sub_b = fake_bus.subscribe("topology.>")
|
||||
async with sub_a, sub_b:
|
||||
await fake_bus.publish("topology.abc.status", {"status": "active"})
|
||||
a = await _collect(sub_a, 1)
|
||||
b = await _collect(sub_b, 1)
|
||||
assert len(a) == 1
|
||||
assert len(b) == 1
|
||||
|
||||
async def test_subscription_close_unblocks_iter(self, fake_bus: FakeBus) -> None:
|
||||
sub = fake_bus.subscribe("topology.>")
|
||||
|
||||
async def consume() -> list:
|
||||
out = []
|
||||
async for event in sub:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
task = asyncio.create_task(consume())
|
||||
await asyncio.sleep(0.01) # let task block on queue.get()
|
||||
await sub.aclose()
|
||||
events = await asyncio.wait_for(task, timeout=0.5)
|
||||
assert events == []
|
||||
|
||||
async def test_close_is_idempotent(self, fake_bus: FakeBus) -> None:
|
||||
await fake_bus.close()
|
||||
await fake_bus.close() # second call must not raise
|
||||
|
||||
async def test_publish_on_closed_raises(self, fake_bus: FakeBus) -> None:
|
||||
await fake_bus.close()
|
||||
with pytest.raises(RuntimeError):
|
||||
await fake_bus.publish("x", {})
|
||||
with pytest.raises(RuntimeError):
|
||||
fake_bus.subscribe("x")
|
||||
|
||||
async def test_backpressure_drops_oldest(self) -> None:
|
||||
bus = FakeBus(queue_size=2)
|
||||
await bus.connect()
|
||||
try:
|
||||
sub = bus.subscribe("t")
|
||||
# Don't consume; publish 5 — queue holds at most 2, oldest dropped.
|
||||
for i in range(5):
|
||||
await bus.publish("t", {"i": i})
|
||||
events = await _collect(sub, 2, timeout=0.2)
|
||||
assert len(events) == 2
|
||||
# We kept the 2 most recent.
|
||||
assert events[-1].payload["i"] == 4
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
class TestNullBus:
|
||||
async def test_publish_is_noop(self) -> None:
|
||||
bus = NullBus()
|
||||
await bus.connect()
|
||||
await bus.publish("anything", {"x": 1})
|
||||
await bus.close()
|
||||
|
||||
async def test_subscribe_yields_nothing(self) -> None:
|
||||
bus = NullBus()
|
||||
sub = bus.subscribe("topology.>")
|
||||
async with sub:
|
||||
# Iteration must stop immediately.
|
||||
events = [e async for e in sub]
|
||||
assert events == []
|
||||
104
tests/bus/test_heartbeat.py
Normal file
104
tests/bus/test_heartbeat.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Shared ``run_health_heartbeat`` helper (DEBT-031 workers 7–9).
|
||||
|
||||
Three workers (agent, forwarder, updater) publish identical
|
||||
``system.<worker>.health`` heartbeats. Rather than copy the loop three
|
||||
times, ``decnet.bus.publish.run_health_heartbeat`` carries it. These
|
||||
tests pin:
|
||||
|
||||
* topic is ``system.<worker>.health`` via the builder;
|
||||
* payload carries worker name and monotonic-ish timestamp;
|
||||
* ``extra()`` hook merges per-worker fields;
|
||||
* ``None`` bus yields a benign no-op loop (still cancellable);
|
||||
* ``extra()`` failure doesn't break the tick.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.bus.publish import run_health_heartbeat
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def bus() -> FakeBus:
|
||||
b = FakeBus()
|
||||
await b.connect()
|
||||
yield b
|
||||
await b.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_publishes_under_system_worker_health(bus: FakeBus) -> None:
|
||||
task = asyncio.create_task(
|
||||
run_health_heartbeat(bus, "agent", interval=0.05),
|
||||
)
|
||||
try:
|
||||
sub = bus.subscribe("system.*.health")
|
||||
async with sub:
|
||||
event = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert event.topic == "system.agent.health"
|
||||
assert event.type == "health"
|
||||
assert event.payload["worker"] == "agent"
|
||||
assert isinstance(event.payload["ts"], float)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_merges_extra_payload(bus: FakeBus) -> None:
|
||||
task = asyncio.create_task(
|
||||
run_health_heartbeat(
|
||||
bus, "forwarder", interval=0.05,
|
||||
extra=lambda: {"offset": 4096, "connected": True},
|
||||
),
|
||||
)
|
||||
try:
|
||||
sub = bus.subscribe("system.forwarder.health")
|
||||
async with sub:
|
||||
event = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert event.payload["offset"] == 4096
|
||||
assert event.payload["connected"] is True
|
||||
assert event.payload["worker"] == "forwarder"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_survives_extra_failure(bus: FakeBus) -> None:
|
||||
# An extra() that blows up must not abort the heartbeat loop.
|
||||
def _boom():
|
||||
raise RuntimeError("extras exploded")
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_health_heartbeat(bus, "updater", interval=0.05, extra=_boom),
|
||||
)
|
||||
try:
|
||||
sub = bus.subscribe("system.updater.health")
|
||||
async with sub:
|
||||
event = await asyncio.wait_for(sub.__anext__(), timeout=2.0)
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
# Base payload still present despite extra() blowing up.
|
||||
assert event.payload["worker"] == "updater"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_is_cancellable_with_none_bus() -> None:
|
||||
# Bus-disabled path: loop runs but publishes nothing. Must still
|
||||
# cancel cleanly so lifespan teardown doesn't hang.
|
||||
task = asyncio.create_task(
|
||||
run_health_heartbeat(None, "agent", interval=0.01),
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
assert task.done()
|
||||
87
tests/bus/test_protocol.py
Normal file
87
tests/bus/test_protocol.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Tests for the wire protocol framing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus import protocol
|
||||
|
||||
|
||||
def _reader_from(data: bytes) -> asyncio.StreamReader:
|
||||
reader = asyncio.StreamReader()
|
||||
reader.feed_data(data)
|
||||
reader.feed_eof()
|
||||
return reader
|
||||
|
||||
|
||||
async def _read_one(data: bytes) -> protocol.Frame | None:
|
||||
return await protocol.read_frame(_reader_from(data))
|
||||
|
||||
|
||||
class TestEncodeDecode:
|
||||
async def test_pub_round_trip(self) -> None:
|
||||
data = protocol.encode(protocol.PUB, args="topology.abc.status", body={"payload": {"x": 1}})
|
||||
frame = await _read_one(data)
|
||||
assert frame is not None
|
||||
assert frame.verb == protocol.PUB
|
||||
assert frame.args == "topology.abc.status"
|
||||
assert protocol.decode_body(frame.body) == {"payload": {"x": 1}}
|
||||
|
||||
async def test_sub_empty_body(self) -> None:
|
||||
data = protocol.encode(protocol.SUB, args="topology.*.mutation.*")
|
||||
frame = await _read_one(data)
|
||||
assert frame is not None
|
||||
assert frame.verb == protocol.SUB
|
||||
assert frame.args == "topology.*.mutation.*"
|
||||
assert frame.body == b""
|
||||
|
||||
async def test_bye_no_args(self) -> None:
|
||||
data = protocol.encode(protocol.BYE)
|
||||
frame = await _read_one(data)
|
||||
assert frame is not None
|
||||
assert frame.verb == protocol.BYE
|
||||
assert frame.args == ""
|
||||
assert frame.body == b""
|
||||
|
||||
async def test_clean_eof_returns_none(self) -> None:
|
||||
assert await _read_one(b"") is None
|
||||
|
||||
|
||||
class TestProtocolErrors:
|
||||
def test_encode_rejects_unknown_verb(self) -> None:
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
protocol.encode("NOPE", args="x")
|
||||
|
||||
def test_encode_rejects_newline_in_args(self) -> None:
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
protocol.encode(protocol.PUB, args="bad\ntopic")
|
||||
|
||||
def test_encode_rejects_oversized_body(self) -> None:
|
||||
big = {"payload": {"x": "a" * (protocol.MAX_BODY_BYTES + 1)}}
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
protocol.encode(protocol.PUB, args="t", body=big)
|
||||
|
||||
async def test_decode_rejects_unknown_verb(self) -> None:
|
||||
bad = b"NOPE x\n" + struct.pack(">I", 0)
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
await _read_one(bad)
|
||||
|
||||
async def test_decode_rejects_oversized_body_length(self) -> None:
|
||||
bad = b"PUB x\n" + struct.pack(">I", protocol.MAX_BODY_BYTES + 1)
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
await _read_one(bad)
|
||||
|
||||
async def test_decode_rejects_truncated_body(self) -> None:
|
||||
bad = b"PUB x\n" + struct.pack(">I", 10) + b"short"
|
||||
with pytest.raises(Exception): # IncompleteReadError bubbles up
|
||||
await _read_one(bad)
|
||||
|
||||
def test_decode_body_rejects_non_object(self) -> None:
|
||||
import orjson
|
||||
with pytest.raises(protocol.ProtocolError):
|
||||
protocol.decode_body(orjson.dumps([1, 2, 3]))
|
||||
|
||||
def test_decode_body_empty_returns_empty_dict(self) -> None:
|
||||
assert protocol.decode_body(b"") == {}
|
||||
64
tests/bus/test_publish.py
Normal file
64
tests/bus/test_publish.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests for :mod:`decnet.bus.publish`.
|
||||
|
||||
The whole point of ``publish_safely`` is that it never raises back at the
|
||||
caller. These tests pin that contract: ``None`` bus is a no-op, a real
|
||||
bus publishes, and a raising bus is swallowed + logged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.base import BaseBus, Event, Subscription
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.bus.publish import publish_safely
|
||||
|
||||
|
||||
class _ExplodingBus(BaseBus):
|
||||
"""Minimal bus whose ``publish`` always raises."""
|
||||
|
||||
async def connect(self) -> None: # pragma: no cover - trivial
|
||||
return None
|
||||
|
||||
async def publish(self, topic, payload, *, event_type=""):
|
||||
raise RuntimeError("transport exploded")
|
||||
|
||||
def subscribe(self, pattern: str) -> Subscription: # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
async def close(self) -> None: # pragma: no cover - trivial
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_safely_none_bus_is_noop() -> None:
|
||||
# Must not raise. A worker that couldn't connect at startup passes
|
||||
# bus=None and expects every call to silently no-op.
|
||||
await publish_safely(None, "system.log", {"msg": "hi"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_safely_delivers_on_live_bus() -> None:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
try:
|
||||
sub = bus.subscribe("system.log")
|
||||
async with sub:
|
||||
await publish_safely(bus, "system.log", {"msg": "hi"}, event_type="log")
|
||||
event = await sub.__anext__()
|
||||
assert isinstance(event, Event)
|
||||
assert event.topic == "system.log"
|
||||
assert event.type == "log"
|
||||
assert event.payload == {"msg": "hi"}
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_safely_swallows_transport_errors(caplog: pytest.LogCaptureFixture) -> None:
|
||||
caplog.set_level(logging.WARNING, logger="bus.publish")
|
||||
# The exploding bus would crash the caller without publish_safely.
|
||||
# After wrapping, the caller sees nothing but a log line.
|
||||
await publish_safely(_ExplodingBus(), "system.log", {"msg": "hi"})
|
||||
assert any("bus publish failed" in rec.message for rec in caplog.records)
|
||||
89
tests/bus/test_topics.py
Normal file
89
tests/bus/test_topics.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Tests for the topic hierarchy builders."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus import topics
|
||||
|
||||
|
||||
def test_topology_mutation_builder() -> None:
|
||||
topic = topics.topology_mutation("abc123", topics.MUTATION_APPLIED)
|
||||
assert topic == "topology.abc123.mutation.applied"
|
||||
|
||||
|
||||
def test_topology_status_builder() -> None:
|
||||
assert topics.topology_status("t-1") == "topology.t-1.status"
|
||||
|
||||
|
||||
def test_decky_builder() -> None:
|
||||
assert topics.decky("d-42", topics.DECKY_STATE) == "decky.d-42.state"
|
||||
assert topics.decky("d-42", topics.DECKY_TRAFFIC) == "decky.d-42.traffic"
|
||||
|
||||
|
||||
def test_system_builder_allows_dotted_leaf() -> None:
|
||||
# system.bus.health has a dot in the leaf — that's intentional and a
|
||||
# legitimate hierarchy refinement, not a segment violation.
|
||||
assert topics.system(topics.SYSTEM_BUS_HEALTH) == "system.bus.health"
|
||||
assert topics.system(topics.SYSTEM_LOG) == "system.log"
|
||||
|
||||
|
||||
def test_system_builder_rejects_empty() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.system("")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "has.dot", "has*wildcard", "has>wild", "with space", "with\ttab"])
|
||||
def test_segment_validation(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.topology_mutation(bad, topics.MUTATION_APPLIED)
|
||||
with pytest.raises(ValueError):
|
||||
topics.topology_status(bad)
|
||||
with pytest.raises(ValueError):
|
||||
topics.decky(bad, topics.DECKY_STATE)
|
||||
with pytest.raises(ValueError):
|
||||
topics.system_health(bad)
|
||||
|
||||
|
||||
def test_attacker_builder() -> None:
|
||||
assert topics.attacker(topics.ATTACKER_OBSERVED) == "attacker.observed"
|
||||
assert topics.attacker(topics.ATTACKER_SCORED) == "attacker.scored"
|
||||
assert topics.attacker(topics.ATTACKER_FINGERPRINTED) == "attacker.fingerprinted"
|
||||
# Dotted leaf is intentional — same as system.bus.health.
|
||||
assert topics.attacker(topics.ATTACKER_SESSION_STARTED) == "attacker.session.started"
|
||||
assert topics.attacker(topics.ATTACKER_SESSION_ENDED) == "attacker.session.ended"
|
||||
|
||||
|
||||
def test_attacker_builder_rejects_empty() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.attacker("")
|
||||
|
||||
|
||||
def test_system_health_builder() -> None:
|
||||
assert topics.system_health("sniffer") == "system.sniffer.health"
|
||||
assert topics.system_health("mutator") == "system.mutator.health"
|
||||
|
||||
|
||||
def test_system_control_builder() -> None:
|
||||
assert topics.system_control("mutator") == "system.mutator.control"
|
||||
assert topics.system_control("collector") == "system.collector.control"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "has.dot", "has*wildcard", "has>wild", "with space", "with\ttab"])
|
||||
def test_system_control_rejects_bad_segments(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.system_control(bad)
|
||||
|
||||
|
||||
# ─── Identity resolution topics (commit 4 of IDENTITY_RESOLUTION.md) ─────────
|
||||
|
||||
|
||||
def test_identity_builder() -> None:
|
||||
assert topics.identity(topics.IDENTITY_FORMED) == "identity.formed"
|
||||
assert topics.identity(topics.IDENTITY_OBSERVATION_LINKED) == "identity.observation.linked"
|
||||
assert topics.identity(topics.IDENTITY_MERGED) == "identity.merged"
|
||||
assert topics.identity(topics.IDENTITY_UNMERGED) == "identity.unmerged"
|
||||
|
||||
|
||||
def test_identity_builder_rejects_empty() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
topics.identity("")
|
||||
131
tests/bus/test_unix_socket_bus.py
Normal file
131
tests/bus/test_unix_socket_bus.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""End-to-end tests for :class:`UnixSocketBus` against a real :class:`BusServer`.
|
||||
|
||||
These tests run in the dev loop (no pytest marker) because they only need
|
||||
the tmp filesystem — no Docker, no external broker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus.unix_client import UnixSocketBus
|
||||
from decnet.bus.unix_server import BusServer
|
||||
|
||||
|
||||
async def _drain(sub, n: int, timeout: float = 1.5) -> list:
|
||||
out = []
|
||||
try:
|
||||
async with asyncio.timeout(timeout):
|
||||
async for event in sub:
|
||||
out.append(event)
|
||||
if len(out) >= n:
|
||||
break
|
||||
except TimeoutError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
class TestEndToEnd:
|
||||
async def test_pub_sub_exact(self, unix_bus) -> None:
|
||||
server, client = unix_bus
|
||||
sub = client.subscribe("topology.abc.status")
|
||||
# Give the SUB frame a tick to register on the server.
|
||||
await asyncio.sleep(0.05)
|
||||
async with sub:
|
||||
await client.publish("topology.abc.status", {"status": "active"})
|
||||
events = await _drain(sub, 1)
|
||||
# A publisher doesn't see its own events — use a second client.
|
||||
assert events == []
|
||||
|
||||
async def test_pub_sub_across_two_clients(
|
||||
self, tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
sock = tmp_path / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
await server.start()
|
||||
serve_task = asyncio.create_task(server.serve_forever())
|
||||
|
||||
publisher = UnixSocketBus(sock, client_name="publisher")
|
||||
subscriber = UnixSocketBus(sock, client_name="subscriber")
|
||||
await publisher.connect()
|
||||
await subscriber.connect()
|
||||
|
||||
try:
|
||||
sub = subscriber.subscribe("topology.*.mutation.*")
|
||||
await asyncio.sleep(0.05) # let SUB register
|
||||
|
||||
async with sub:
|
||||
await publisher.publish(
|
||||
"topology.t1.mutation.applied", {"id": 1}, event_type="applied",
|
||||
)
|
||||
await publisher.publish(
|
||||
"decky.xyz.state", {"state": "running"}, # should not match
|
||||
)
|
||||
await publisher.publish(
|
||||
"topology.t2.mutation.failed", {"id": 2}, event_type="failed",
|
||||
)
|
||||
events = await _drain(sub, 2)
|
||||
ids = {e.payload["id"] for e in events}
|
||||
assert ids == {1, 2}
|
||||
finally:
|
||||
await publisher.close()
|
||||
await subscriber.close()
|
||||
serve_task.cancel()
|
||||
try:
|
||||
await serve_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await server.close()
|
||||
|
||||
async def test_socket_file_mode(self, tmp_path: pathlib.Path) -> None:
|
||||
sock = tmp_path / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
await server.start()
|
||||
try:
|
||||
mode = stat.S_IMODE(sock.stat().st_mode)
|
||||
assert mode == 0o660
|
||||
finally:
|
||||
await server.close()
|
||||
|
||||
async def test_server_close_wakes_subscribers(
|
||||
self, tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
sock = tmp_path / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
await server.start()
|
||||
serve_task = asyncio.create_task(server.serve_forever())
|
||||
|
||||
client = UnixSocketBus(sock, client_name="watcher")
|
||||
await client.connect()
|
||||
sub = client.subscribe("system.>")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
async def consume() -> list:
|
||||
out = []
|
||||
async for event in sub:
|
||||
out.append(event)
|
||||
return out
|
||||
|
||||
consumer = asyncio.create_task(consume())
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
serve_task.cancel()
|
||||
try:
|
||||
await serve_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await server.close()
|
||||
|
||||
# The consumer must unblock within a reasonable time.
|
||||
events = await asyncio.wait_for(consumer, timeout=1.0)
|
||||
assert events == []
|
||||
await client.close()
|
||||
|
||||
async def test_start_rejects_missing_parent(self, tmp_path: pathlib.Path) -> None:
|
||||
sock = tmp_path / "nonexistent-dir" / "bus.sock"
|
||||
server = BusServer(sock, group=None)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await server.start()
|
||||
68
tests/bus/test_worker.py
Normal file
68
tests/bus/test_worker.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Tests for :func:`decnet.bus.worker.bus_worker` lifecycle + heartbeat."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.bus import topics
|
||||
from decnet.bus.unix_client import UnixSocketBus
|
||||
from decnet.bus.worker import bus_worker
|
||||
|
||||
|
||||
class TestBusWorker:
|
||||
async def test_worker_serves_and_heartbeats(
|
||||
self, tmp_path: pathlib.Path,
|
||||
) -> None:
|
||||
sock = tmp_path / "bus.sock"
|
||||
task = asyncio.create_task(
|
||||
bus_worker(sock, group=None, heartbeat_interval=1),
|
||||
)
|
||||
# Wait for the socket to exist.
|
||||
for _ in range(40):
|
||||
if sock.exists():
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert sock.exists(), "bus worker did not create socket"
|
||||
|
||||
client = UnixSocketBus(sock, client_name="hb-watcher")
|
||||
await client.connect()
|
||||
sub = client.subscribe(topics.system(topics.SYSTEM_BUS_HEALTH))
|
||||
try:
|
||||
async with sub:
|
||||
async with asyncio.timeout(3.0):
|
||||
async for event in sub:
|
||||
assert event.topic == "system.bus.health"
|
||||
assert "pid" in event.payload
|
||||
break
|
||||
finally:
|
||||
await client.close()
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def test_worker_creates_home_fallback_parent(
|
||||
self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Point Path.home() at tmp_path so the "auto-mkdir ~/.decnet" branch
|
||||
# activates without touching the real home directory.
|
||||
monkeypatch.setattr(pathlib.Path, "home", classmethod(lambda cls: tmp_path))
|
||||
sock = tmp_path / ".decnet" / "bus.sock"
|
||||
task = asyncio.create_task(
|
||||
bus_worker(sock, group=None, heartbeat_interval=60),
|
||||
)
|
||||
try:
|
||||
for _ in range(40):
|
||||
if sock.exists():
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
assert sock.exists()
|
||||
finally:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
0
tests/canary/__init__.py
Normal file
0
tests/canary/__init__.py
Normal file
88
tests/canary/conftest.py
Normal file
88
tests/canary/conftest.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Shared fixtures for canary tests — minimal DOCX/XLSX/HTML/PDF fixtures.
|
||||
|
||||
We synthesise the OOXML zips inline rather than checking real binary
|
||||
fixtures into the repo. Keeps the test surface portable and the diff
|
||||
reviewable; the smallest valid DOCX is ~12 files but Word/LibreOffice
|
||||
both accept a stripped-down skeleton with just ``[Content_Types].xml``,
|
||||
``_rels/.rels``, ``word/document.xml``, and ``word/_rels/document.xml.rels``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_DOCX_CONTENT_TYPES = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Override PartName="/word/document.xml" '
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
||||
'</Types>'
|
||||
)
|
||||
|
||||
_DOCX_PACKAGE_RELS = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
|
||||
'Target="word/document.xml"/>'
|
||||
'</Relationships>'
|
||||
)
|
||||
|
||||
_DOCX_DOCUMENT = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
'<w:body><w:p><w:r><w:t>Existing content.</w:t></w:r></w:p></w:body>'
|
||||
'</w:document>'
|
||||
)
|
||||
|
||||
_DOCX_DOCUMENT_RELS = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'</Relationships>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_docx() -> bytes:
|
||||
"""Return a tiny but structurally valid DOCX as bytes."""
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("[Content_Types].xml", _DOCX_CONTENT_TYPES)
|
||||
zf.writestr("_rels/.rels", _DOCX_PACKAGE_RELS)
|
||||
zf.writestr("word/document.xml", _DOCX_DOCUMENT)
|
||||
zf.writestr("word/_rels/document.xml.rels", _DOCX_DOCUMENT_RELS)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
_XLSX_CONTENT_TYPES = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Override PartName="/xl/workbook.xml" '
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||
'</Types>'
|
||||
)
|
||||
|
||||
_XLSX_WORKBOOK_RELS = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'</Relationships>'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minimal_xlsx() -> bytes:
|
||||
"""Return a tiny but structurally valid XLSX as bytes."""
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("[Content_Types].xml", _XLSX_CONTENT_TYPES)
|
||||
zf.writestr("_rels/.rels", _DOCX_PACKAGE_RELS.replace("word/document.xml", "xl/workbook.xml"))
|
||||
zf.writestr("xl/workbook.xml", '<workbook/>')
|
||||
zf.writestr("xl/_rels/workbook.xml.rels", _XLSX_WORKBOOK_RELS)
|
||||
return out.getvalue()
|
||||
24
tests/canary/test_cli.py
Normal file
24
tests/canary/test_cli.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Smoke coverage for the ``decnet canary`` CLI subcommand.
|
||||
|
||||
We don't run the worker (it would block on HTTP/DNS sockets) — we
|
||||
just confirm the command is registered and not master-gated, so an
|
||||
agent host can run ``decnet canary`` without the gate hiding it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from decnet.cli import app
|
||||
from decnet.cli.gating import MASTER_ONLY_COMMANDS
|
||||
|
||||
|
||||
def test_canary_command_registered() -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["canary", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "Run the canary HTTP + DNS callback receiver" in result.output
|
||||
|
||||
|
||||
def test_canary_is_not_master_only() -> None:
|
||||
# Agents must be able to run their own canary worker.
|
||||
assert "canary" not in MASTER_ONLY_COMMANDS
|
||||
145
tests/canary/test_cultivator.py
Normal file
145
tests/canary/test_cultivator.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Realism-driven canary cultivation.
|
||||
|
||||
Stage 7 of the realism migration: the orchestrator's planner picks a
|
||||
canary content_class ~3% of file ticks; the cultivator turns that into
|
||||
a CanaryArtifact + persisted CanaryToken row.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.canary.cultivator import cultivate
|
||||
from decnet.realism.taxonomy import ContentClass, Plan
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path):
|
||||
r = SQLiteRepository(db_path=str(tmp_path / "decnet.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
await r.engine.dispose()
|
||||
|
||||
|
||||
def _plan(cls: ContentClass, persona: str = "admin") -> Plan:
|
||||
return Plan(
|
||||
decky_uuid="d1",
|
||||
decky_name="alpha",
|
||||
persona=persona,
|
||||
content_class=cls,
|
||||
action="create",
|
||||
target_path="",
|
||||
mtime=datetime(2026, 4, 27, 11, 30, tzinfo=timezone.utc),
|
||||
body_hint=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cultivate_records_canary_token_row(repo, monkeypatch):
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test")
|
||||
monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test")
|
||||
|
||||
artifact = await cultivate(
|
||||
_plan(ContentClass.CANARY_GIT_CONFIG), repo,
|
||||
)
|
||||
assert artifact.path == "/home/admin/.git/config"
|
||||
assert artifact.content
|
||||
# Token row landed and the slug round-trips through the slug index.
|
||||
rows = await repo.list_canary_tokens(decky_name="alpha")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["generator"] == "git_config"
|
||||
assert rows[0]["placement_path"] == "/home/admin/.git/config"
|
||||
assert rows[0]["callback_token"] in artifact.content.decode("utf-8")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cultivate_persists_path_for_each_class(repo, monkeypatch):
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test")
|
||||
monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test")
|
||||
|
||||
classes_and_paths = {
|
||||
ContentClass.CANARY_AWS_CREDS: "/home/admin/.aws/credentials",
|
||||
ContentClass.CANARY_ENV_FILE: "/home/admin/app/.env",
|
||||
ContentClass.CANARY_GIT_CONFIG: "/home/admin/.git/config",
|
||||
ContentClass.CANARY_SSH_KEY: "/home/admin/.ssh/id_rsa",
|
||||
ContentClass.CANARY_HONEYDOC: "/home/admin/Documents/notes.html",
|
||||
ContentClass.CANARY_MYSQL_DUMP: "/var/backups/db_backup.sql",
|
||||
}
|
||||
for cls, expected in classes_and_paths.items():
|
||||
artifact = await cultivate(_plan(cls), repo)
|
||||
assert artifact.path == expected, (
|
||||
f"{cls.value!r} planted at {artifact.path!r}, want {expected!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cultivate_rejects_non_canary_class(repo):
|
||||
with pytest.raises(ValueError, match="non-canary"):
|
||||
await cultivate(_plan(ContentClass.NOTE), repo)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cultivate_persona_login_normalisation(repo, monkeypatch):
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test")
|
||||
monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test")
|
||||
artifact = await cultivate(
|
||||
_plan(ContentClass.CANARY_AWS_CREDS, persona="John Smith"), repo,
|
||||
)
|
||||
# Spaces collapsed to lowercase login, same convention as the
|
||||
# realism namer's _home() function.
|
||||
assert artifact.path == "/home/johnsmith/.aws/credentials"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cultivate_artifact_does_not_leak_decnet_string(repo, monkeypatch):
|
||||
"""Stealth contract (per feedback_stealth.md): a planted canary's
|
||||
bytes must never carry the DECNET literal — that would tell an
|
||||
attacker the file is a honeypot trap."""
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test")
|
||||
monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test")
|
||||
for cls in (
|
||||
ContentClass.CANARY_AWS_CREDS,
|
||||
ContentClass.CANARY_GIT_CONFIG,
|
||||
ContentClass.CANARY_ENV_FILE,
|
||||
ContentClass.CANARY_SSH_KEY,
|
||||
):
|
||||
artifact = await cultivate(_plan(cls), repo)
|
||||
body = artifact.content.decode("utf-8", errors="replace")
|
||||
assert "decnet" not in body.lower(), (
|
||||
f"{cls.value!r} body leaked 'decnet': "
|
||||
f"{body[:120]!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cultivate_records_kind_per_generator(repo, monkeypatch):
|
||||
"""The token row's ``kind`` reflects the trip surface of the
|
||||
underlying generator: HTTP slug callback, DNS resolution, or
|
||||
passive bait. The canary worker uses ``kind`` to route incoming
|
||||
callbacks; a wrong kind means the trip won't attribute correctly."""
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.example.test")
|
||||
monkeypatch.setenv("DECNET_CANARY_DNS_ZONE", "canary.example.test")
|
||||
cases = [
|
||||
(ContentClass.CANARY_AWS_CREDS, "aws_passive"),
|
||||
(ContentClass.CANARY_ENV_FILE, "http"),
|
||||
(ContentClass.CANARY_GIT_CONFIG, "http"),
|
||||
(ContentClass.CANARY_HONEYDOC, "http"),
|
||||
(ContentClass.CANARY_HONEYDOC_DOCX, "http"),
|
||||
(ContentClass.CANARY_HONEYDOC_PDF, "http"),
|
||||
(ContentClass.CANARY_SSH_KEY, "dns"),
|
||||
(ContentClass.CANARY_MYSQL_DUMP, "dns"),
|
||||
]
|
||||
for cls, expected_kind in cases:
|
||||
await cultivate(_plan(cls, persona=f"p-{cls.value}"), repo)
|
||||
rows = await repo.list_canary_tokens(decky_name="alpha")
|
||||
by_gen = {r["generator"]: r["kind"] for r in rows}
|
||||
for cls, expected_kind in cases:
|
||||
from decnet.canary.cultivator import _CLASS_TO_GENERATOR
|
||||
gen = _CLASS_TO_GENERATOR[cls]
|
||||
assert by_gen[gen] == expected_kind, (
|
||||
f"{cls.value!r} → generator {gen!r} got kind={by_gen[gen]!r}, "
|
||||
f"want {expected_kind!r}"
|
||||
)
|
||||
80
tests/canary/test_deploy_hook.py
Normal file
80
tests/canary/test_deploy_hook.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Smoke coverage for the deploy-time canary baseline seed.
|
||||
|
||||
The deployer hook calls ``decnet.canary.planter.seed_baseline`` for
|
||||
every running decky. Two properties matter:
|
||||
|
||||
* a baseline seed runs, producing one token row per configured
|
||||
generator; and
|
||||
* failures in seed_baseline must never abort the surrounding
|
||||
deploy flow (resilience principle).
|
||||
|
||||
We don't drive the full ``deploy()`` here — that pulls in docker,
|
||||
network helpers, etc. Instead we exercise ``seed_baseline``
|
||||
directly with the planter's docker-exec patched, then assert the
|
||||
hook's wiring via static inspection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import AsyncIterator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.canary import planter
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
import decnet.web.db.models # noqa: F401
|
||||
|
||||
|
||||
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 _patch(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)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
|
||||
r = SQLiteRepository(str(tmp_path / "h.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_baseline_creates_tokens_per_decky(
|
||||
repo: SQLiteRepository, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BASELINE", "git_config,env_file,aws_creds")
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
||||
with _patch(rc=0):
|
||||
await planter.seed_baseline("web1", repo)
|
||||
await planter.seed_baseline("web2", repo)
|
||||
web1 = await repo.list_canary_tokens(decky_name="web1")
|
||||
web2 = await repo.list_canary_tokens(decky_name="web2")
|
||||
assert len(web1) == 3 and len(web2) == 3
|
||||
assert {t["generator"] for t in web1} == {"git_config", "env_file", "aws_creds"}
|
||||
|
||||
|
||||
def test_deploy_hook_is_wired_into_deployer() -> None:
|
||||
"""Static check: deployer's _mirror_fleet_to_db calls seed_baseline.
|
||||
|
||||
We grep the source rather than driving the full deploy() because
|
||||
that pulls in docker + networking helpers and we don't want a
|
||||
second test environment for this one assertion.
|
||||
"""
|
||||
import inspect
|
||||
from decnet.engine import deployer
|
||||
source = inspect.getsource(deployer)
|
||||
assert "seed_baseline" in source, "deployer must call canary.planter.seed_baseline"
|
||||
# And the call must be wrapped in try/except so a failure doesn't
|
||||
# abort the deploy.
|
||||
assert "canary baseline seed failed (best-effort)" in source
|
||||
88
tests/canary/test_factory.py
Normal file
88
tests/canary/test_factory.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Coverage for the generator/instrumenter factory + MIME dispatch.
|
||||
|
||||
The concrete generators and instrumenters land in subsequent commits;
|
||||
this file only tests the dispatch surface — it must reject unknown
|
||||
names with ``ValueError`` and pick the right instrumenter for known
|
||||
MIME types (with passthrough as the fallback for binary blobs we
|
||||
can't safely mutate).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.canary.factory import (
|
||||
KNOWN_GENERATORS,
|
||||
KNOWN_INSTRUMENTERS,
|
||||
pick_instrumenter_for_mime,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mime, expected",
|
||||
[
|
||||
("application/pdf", "pdf"),
|
||||
("application/PDF", "pdf"), # case-insensitive
|
||||
("application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"),
|
||||
("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"),
|
||||
("text/html", "html"),
|
||||
("application/xhtml+xml", "html"),
|
||||
("text/plain", "plain"),
|
||||
("text/x-yaml", "plain"),
|
||||
("application/json", "plain"),
|
||||
("application/yaml", "plain"),
|
||||
("application/toml", "plain"),
|
||||
("image/png", "image"),
|
||||
("image/jpeg", "image"),
|
||||
("image/gif", "image"),
|
||||
],
|
||||
)
|
||||
def test_mime_dispatch_known(mime: str, expected: str) -> None:
|
||||
assert pick_instrumenter_for_mime(mime) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mime",
|
||||
[
|
||||
"",
|
||||
"application/octet-stream",
|
||||
"application/x-tar",
|
||||
"application/zip", # bare zip — DOCX/XLSX dispatch by alias, not raw zip
|
||||
"video/mp4",
|
||||
"audio/mpeg",
|
||||
],
|
||||
)
|
||||
def test_mime_dispatch_falls_back_to_passthrough(mime: str) -> None:
|
||||
assert pick_instrumenter_for_mime(mime) == "passthrough"
|
||||
|
||||
|
||||
def test_known_lists_are_stable() -> None:
|
||||
# If anyone adds/removes from the dispatch tables, the test
|
||||
# surfaces it. Keeps the schema-of-record in one place.
|
||||
assert KNOWN_GENERATORS == (
|
||||
"git_config", "env_file", "ssh_key", "aws_creds",
|
||||
"honeydoc", "honeydoc_docx", "honeydoc_pdf", "mysql_dump",
|
||||
)
|
||||
assert KNOWN_INSTRUMENTERS == (
|
||||
"docx", "xlsx", "pdf", "html", "image", "plain", "passthrough",
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_generator_raises() -> None:
|
||||
from decnet.canary.factory import get_generator
|
||||
with pytest.raises(ValueError, match="Unknown canary generator"):
|
||||
get_generator("bogus")
|
||||
|
||||
|
||||
def test_unknown_instrumenter_raises() -> None:
|
||||
from decnet.canary.factory import get_instrumenter
|
||||
with pytest.raises(ValueError, match="Unknown canary instrumenter"):
|
||||
get_instrumenter("bogus")
|
||||
|
||||
|
||||
def test_base_artifact_dataclass_defaults() -> None:
|
||||
from decnet.canary import CanaryArtifact
|
||||
a = CanaryArtifact(path="/x", content=b"y")
|
||||
assert a.mode == 0o600
|
||||
assert a.mtime_offset == 0
|
||||
assert a.notes == []
|
||||
assert a.generator is None and a.instrumenter is None
|
||||
188
tests/canary/test_generators.py
Normal file
188
tests/canary/test_generators.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Coverage for the synthesised-artifact generators.
|
||||
|
||||
Each generator MUST be deterministic for a given ``CanaryContext`` —
|
||||
the planter relies on that idempotency to re-seed without storing
|
||||
the rendered bytes. We assert byte-for-byte stability across two
|
||||
calls with the same inputs as well as the obvious "slug appears in
|
||||
the artifact" property.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.canary import CanaryContext, get_generator
|
||||
from decnet.canary.factory import KNOWN_GENERATORS
|
||||
|
||||
|
||||
def _ctx(**kw) -> CanaryContext:
|
||||
defaults = dict(
|
||||
callback_token="abcDEF123-test",
|
||||
http_base="https://canary.example.test",
|
||||
dns_zone="canary.example.test",
|
||||
persona="linux",
|
||||
)
|
||||
defaults.update(kw)
|
||||
return CanaryContext(**defaults)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", KNOWN_GENERATORS)
|
||||
def test_generator_is_deterministic(name: str) -> None:
|
||||
g = get_generator(name)
|
||||
a = g.generate(_ctx())
|
||||
b = g.generate(_ctx())
|
||||
assert a.content == b.content, f"{name} not deterministic"
|
||||
assert a.generator == name
|
||||
assert a.instrumenter is None
|
||||
assert a.mode in (0o600, 0o644)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["git_config", "env_file", "honeydoc"])
|
||||
def test_callback_url_embedded(name: str) -> None:
|
||||
g = get_generator(name)
|
||||
art = g.generate(_ctx(callback_token="slug-XYZ"))
|
||||
body = art.content.decode("utf-8")
|
||||
assert "slug-XYZ" in body, f"{name} did not embed slug"
|
||||
assert "https://canary.example.test" in body
|
||||
|
||||
|
||||
def test_aws_creds_passive_does_not_embed_url() -> None:
|
||||
# AWS creds are passive — there's no realistic field to hide a URL
|
||||
# in. Asserting the absence prevents a regression where a future
|
||||
# change tries to slip the slug into a comment and breaks realism.
|
||||
g = get_generator("aws_creds")
|
||||
art = g.generate(_ctx(callback_token="slug-XYZ"))
|
||||
body = art.content.decode("utf-8")
|
||||
assert "https://" not in body
|
||||
assert "slug-XYZ" not in body
|
||||
# Access key matches the AKIA[A-Z0-9]{16} shape.
|
||||
assert re.search(r"AKIA[A-Z0-9]{16}", body)
|
||||
|
||||
|
||||
def test_aws_creds_changes_with_slug() -> None:
|
||||
g = get_generator("aws_creds")
|
||||
a = g.generate(_ctx(callback_token="slug-A"))
|
||||
b = g.generate(_ctx(callback_token="slug-B"))
|
||||
assert a.content != b.content
|
||||
|
||||
|
||||
def test_ssh_key_uses_dns_zone_when_available() -> None:
|
||||
g = get_generator("ssh_key")
|
||||
art = g.generate(_ctx(callback_token="slugZ", dns_zone="canary.test"))
|
||||
assert b"slugZ.canary.test" in art.content
|
||||
|
||||
|
||||
def test_ssh_key_falls_back_to_http_host_without_dns() -> None:
|
||||
g = get_generator("ssh_key")
|
||||
art = g.generate(_ctx(
|
||||
http_base="https://example.test", dns_zone="",
|
||||
))
|
||||
assert b"example.test" in art.content
|
||||
|
||||
|
||||
def test_honeydoc_html_is_valid_ish_html() -> None:
|
||||
g = get_generator("honeydoc")
|
||||
art = g.generate(_ctx())
|
||||
body = art.content.decode("utf-8")
|
||||
assert "<!DOCTYPE html>" in body
|
||||
assert "<img" in body
|
||||
assert "width=\"1\" height=\"1\"" in body
|
||||
|
||||
|
||||
def test_honeydoc_docx_produces_valid_zip_with_callback() -> None:
|
||||
import io
|
||||
import zipfile
|
||||
g = get_generator("honeydoc_docx")
|
||||
art = g.generate(_ctx(callback_token="slugDX"))
|
||||
assert art.content[:4] == b"PK\x03\x04" # zip magic
|
||||
with zipfile.ZipFile(io.BytesIO(art.content), "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
assert {"[Content_Types].xml", "_rels/.rels", "word/document.xml",
|
||||
"word/_rels/document.xml.rels"} <= names
|
||||
rels = zf.read("word/_rels/document.xml.rels").decode()
|
||||
assert "https://canary.example.test/c/slugDX" in rels
|
||||
assert "TargetMode=\"External\"" in rels
|
||||
doc = zf.read("word/document.xml").decode()
|
||||
assert "Q3 Operations Review" in doc
|
||||
assert "<w:drawing>" in doc
|
||||
|
||||
|
||||
def test_honeydoc_pdf_produces_valid_pdf_with_openaction() -> None:
|
||||
pikepdf = pytest.importorskip("pikepdf")
|
||||
g = get_generator("honeydoc_pdf")
|
||||
art = g.generate(_ctx(callback_token="slugPDF"))
|
||||
assert art.content[:5] == b"%PDF-"
|
||||
# Re-open and confirm OpenAction URI round-trips.
|
||||
import io
|
||||
with pikepdf.open(io.BytesIO(art.content)) as pdf:
|
||||
action = pdf.Root["/OpenAction"]
|
||||
assert str(action["/S"]) == "/URI"
|
||||
assert str(action["/URI"]) == "https://canary.example.test/c/slugPDF"
|
||||
|
||||
|
||||
def test_git_config_remote_url_shape() -> None:
|
||||
g = get_generator("git_config")
|
||||
art = g.generate(_ctx(callback_token="slug42"))
|
||||
body = art.content.decode("utf-8")
|
||||
assert "[remote \"origin\"]" in body
|
||||
assert "https://canary.example.test/c/slug42/repo.git" in body
|
||||
|
||||
|
||||
def test_env_file_carries_two_callback_fields() -> None:
|
||||
g = get_generator("env_file")
|
||||
art = g.generate(_ctx(callback_token="slugEnv"))
|
||||
body = art.content.decode("utf-8")
|
||||
assert "API_BASE_URL=https://canary.example.test/c/slugEnv" in body
|
||||
assert "WEBHOOK_NOTIFY_URL=https://canary.example.test/c/slugEnv/webhook" in body
|
||||
|
||||
|
||||
def test_mysql_dump_requires_dns_zone() -> None:
|
||||
g = get_generator("mysql_dump")
|
||||
with pytest.raises(ValueError, match="dns_zone"):
|
||||
g.generate(_ctx(dns_zone=""))
|
||||
|
||||
|
||||
def test_mysql_dump_payload_round_trips_through_base64() -> None:
|
||||
import base64 as _b64
|
||||
g = get_generator("mysql_dump")
|
||||
art = g.generate(_ctx(callback_token="slugSQL", dns_zone="canary.test"))
|
||||
body = art.content.decode("utf-8")
|
||||
# Slug must NOT appear in plaintext — the camouflage is base64.
|
||||
assert "slugSQL" not in body.replace("\n", " ").split("SET @b = '")[0]
|
||||
# Locate the base64 blob and decode it; the inner SQL must reference
|
||||
# the slug-bearing replica host, smuggle @@hostname/@@lc_time_names
|
||||
# into SOURCE_USER, and target port 3306.
|
||||
m = re.search(r"SET @b = '([A-Za-z0-9+/=]+)';", body)
|
||||
assert m, "expected base64 payload assignment"
|
||||
inner = _b64.b64decode(m.group(1)).decode("utf-8")
|
||||
assert "slugSQL.canary.test" in inner
|
||||
assert "SOURCE_PORT=3306" in inner
|
||||
assert "@@hostname" in inner
|
||||
assert "@@lc_time_names" in inner
|
||||
assert "CHANGE REPLICATION SOURCE TO" in inner
|
||||
|
||||
|
||||
def test_mysql_dump_executes_and_starts_replica() -> None:
|
||||
g = get_generator("mysql_dump")
|
||||
art = g.generate(_ctx(callback_token="slugSQL2", dns_zone="canary.test"))
|
||||
body = art.content.decode("utf-8")
|
||||
# The PREPARE/EXECUTE/START REPLICA chain is what makes the import
|
||||
# actually phone home; missing any of these silently breaks the trip.
|
||||
assert "PREPARE stmt1 FROM @s2;" in body
|
||||
assert "EXECUTE stmt1;" in body
|
||||
assert "PREPARE stmt2 FROM @bb;" in body
|
||||
assert "EXECUTE stmt2;" in body
|
||||
assert "START REPLICA;" in body
|
||||
# Realism: header + trailer markers that mysqldump emits.
|
||||
assert body.startswith("-- MySQL dump")
|
||||
assert "-- Dump completed" in body
|
||||
|
||||
|
||||
def test_artifacts_carry_notes() -> None:
|
||||
# Notes drive the API ``preview`` endpoint so operators can sanity-
|
||||
# check what we did before the file lands. Empty notes would mean
|
||||
# the operator is staring at opaque bytes.
|
||||
for name in KNOWN_GENERATORS:
|
||||
art = get_generator(name).generate(_ctx())
|
||||
assert art.notes, f"{name} produced no notes"
|
||||
173
tests/canary/test_instrumenters.py
Normal file
173
tests/canary/test_instrumenters.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Coverage for the operator-upload instrumenters.
|
||||
|
||||
Each instrumenter is round-tripped against a small, real-shaped
|
||||
fixture. We assert:
|
||||
|
||||
* the callback URL ends up somewhere in the mutated bytes;
|
||||
* the output still parses (zip stays a valid zip; HTML stays
|
||||
reasonable);
|
||||
* the rejection paths surface :class:`InstrumenterRejectedError`
|
||||
with a useful message.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.canary import CanaryContext, get_instrumenter
|
||||
from decnet.canary.base import InstrumenterRejectedError
|
||||
|
||||
|
||||
def _ctx(slug: str = "slug-abc") -> CanaryContext:
|
||||
return CanaryContext(
|
||||
callback_token=slug,
|
||||
http_base="https://canary.example.test",
|
||||
dns_zone="canary.example.test",
|
||||
persona="linux",
|
||||
)
|
||||
|
||||
|
||||
# ----------------------- passthrough ------------------------------------
|
||||
|
||||
def test_passthrough_preserves_bytes() -> None:
|
||||
ins = get_instrumenter("passthrough")
|
||||
out = ins.instrument(b"\x00\x01\x02bin", _ctx(), target_path="/tmp/x.bin")
|
||||
assert out.content == b"\x00\x01\x02bin"
|
||||
assert out.path == "/tmp/x.bin"
|
||||
assert out.instrumenter == "passthrough"
|
||||
|
||||
|
||||
# ----------------------- plain ------------------------------------------
|
||||
|
||||
def test_plain_substitutes_url_placeholder() -> None:
|
||||
ins = get_instrumenter("plain")
|
||||
blob = b"api: {{CANARY_URL}}\nhost: {{CANARY_HOST}}\n"
|
||||
out = ins.instrument(blob, _ctx("slugXYZ"), target_path="/etc/x.yaml")
|
||||
assert b"https://canary.example.test/c/slugXYZ" in out.content
|
||||
assert b"slugXYZ.canary.example.test" in out.content
|
||||
assert b"{{CANARY_URL}}" not in out.content
|
||||
|
||||
|
||||
def test_plain_appends_when_no_placeholder() -> None:
|
||||
ins = get_instrumenter("plain")
|
||||
out = ins.instrument(b"key=value\n", _ctx("s1"), target_path="/etc/x.env")
|
||||
assert b"https://canary.example.test/c/s1" in out.content
|
||||
# Original content survives.
|
||||
assert out.content.startswith(b"key=value\n")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"head, expect_prefix",
|
||||
[
|
||||
(b"[default]\nfoo=1\n", b"; "),
|
||||
(b"// js code\nconst x = 1;\n", b"// "),
|
||||
(b"#!/bin/bash\necho hi\n", b"# "),
|
||||
],
|
||||
)
|
||||
def test_plain_picks_comment_prefix(head: bytes, expect_prefix: bytes) -> None:
|
||||
ins = get_instrumenter("plain")
|
||||
out = ins.instrument(head, _ctx(), target_path="/etc/x")
|
||||
# The appended comment line uses the matching prefix.
|
||||
appended = out.content[len(head):]
|
||||
assert appended.lstrip(b"\n").startswith(expect_prefix)
|
||||
|
||||
|
||||
# ----------------------- html -------------------------------------------
|
||||
|
||||
def test_html_injects_pixel_before_body_close() -> None:
|
||||
ins = get_instrumenter("html")
|
||||
blob = b"<html><body><h1>hi</h1></body></html>"
|
||||
out = ins.instrument(blob, _ctx("slugH"), target_path="/srv/x.html")
|
||||
assert b"https://canary.example.test/c/slugH" in out.content
|
||||
# Pixel sits before </body>, not after.
|
||||
body_close = out.content.index(b"</body>")
|
||||
pixel_pos = out.content.index(b"<img ")
|
||||
assert pixel_pos < body_close
|
||||
# Original markup survives intact.
|
||||
assert b"<h1>hi</h1>" in out.content
|
||||
|
||||
|
||||
def test_html_appends_pixel_when_body_missing() -> None:
|
||||
ins = get_instrumenter("html")
|
||||
out = ins.instrument(b"<p>no body</p>", _ctx(), target_path="/srv/x.html")
|
||||
assert out.content.endswith(b">\n") or out.content.endswith(b'>\n')
|
||||
assert b"<img" in out.content
|
||||
|
||||
|
||||
# ----------------------- docx -------------------------------------------
|
||||
|
||||
def test_docx_injects_external_image_relationship(minimal_docx: bytes) -> None:
|
||||
ins = get_instrumenter("docx")
|
||||
out = ins.instrument(minimal_docx, _ctx("slugD"), target_path="/x/r.docx")
|
||||
# Output is still a valid zip we can re-open.
|
||||
with zipfile.ZipFile(io.BytesIO(out.content), "r") as zf:
|
||||
rels = zf.read("word/_rels/document.xml.rels").decode()
|
||||
doc = zf.read("word/document.xml").decode()
|
||||
assert "https://canary.example.test/c/slugD" in rels
|
||||
assert "TargetMode=\"External\"" in rels
|
||||
assert "image" in rels
|
||||
# Drawing is embedded in the document body, before </w:body>.
|
||||
assert "<w:drawing>" in doc
|
||||
assert doc.index("<w:drawing>") < doc.index("</w:body>")
|
||||
|
||||
|
||||
def test_docx_rejects_non_zip() -> None:
|
||||
ins = get_instrumenter("docx")
|
||||
with pytest.raises(InstrumenterRejectedError, match="not a valid DOCX"):
|
||||
ins.instrument(b"not a docx at all", _ctx(), target_path="/x")
|
||||
|
||||
|
||||
def test_docx_rejects_zip_missing_members() -> None:
|
||||
ins = get_instrumenter("docx")
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w") as zf:
|
||||
zf.writestr("readme.txt", "hello")
|
||||
with pytest.raises(InstrumenterRejectedError, match="missing expected member"):
|
||||
ins.instrument(out.getvalue(), _ctx(), target_path="/x")
|
||||
|
||||
|
||||
# ----------------------- xlsx -------------------------------------------
|
||||
|
||||
def test_xlsx_injects_relationship(minimal_xlsx: bytes) -> None:
|
||||
ins = get_instrumenter("xlsx")
|
||||
out = ins.instrument(minimal_xlsx, _ctx("slugX"), target_path="/x/r.xlsx")
|
||||
with zipfile.ZipFile(io.BytesIO(out.content), "r") as zf:
|
||||
rels = zf.read("xl/_rels/workbook.xml.rels").decode()
|
||||
assert "https://canary.example.test/c/slugX" in rels
|
||||
assert "TargetMode=\"External\"" in rels
|
||||
|
||||
|
||||
def test_xlsx_rejects_zip_without_workbook_rels() -> None:
|
||||
ins = get_instrumenter("xlsx")
|
||||
out = io.BytesIO()
|
||||
with zipfile.ZipFile(out, "w") as zf:
|
||||
zf.writestr("readme.txt", "hello")
|
||||
with pytest.raises(InstrumenterRejectedError, match="no workbook relationships"):
|
||||
ins.instrument(out.getvalue(), _ctx(), target_path="/x")
|
||||
|
||||
|
||||
# ----------------------- pdf / image (optional dep) ---------------------
|
||||
|
||||
def test_pdf_rejects_when_pikepdf_missing() -> None:
|
||||
pytest.importorskip # noqa: B018 — fence below
|
||||
try:
|
||||
import pikepdf # noqa: F401
|
||||
except ImportError:
|
||||
ins = get_instrumenter("pdf")
|
||||
with pytest.raises(InstrumenterRejectedError, match="pikepdf"):
|
||||
ins.instrument(b"%PDF-1.4\n", _ctx(), target_path="/x.pdf")
|
||||
else:
|
||||
pytest.skip("pikepdf is installed; skipping the missing-dep guard")
|
||||
|
||||
|
||||
def test_image_rejects_when_pillow_missing() -> None:
|
||||
try:
|
||||
import PIL # noqa: F401
|
||||
except ImportError:
|
||||
ins = get_instrumenter("image")
|
||||
with pytest.raises(InstrumenterRejectedError, match="Pillow"):
|
||||
ins.instrument(b"\x89PNG\r\n", _ctx(), target_path="/x.png")
|
||||
else:
|
||||
pytest.skip("Pillow is installed; skipping the missing-dep guard")
|
||||
85
tests/canary/test_models.py
Normal file
85
tests/canary/test_models.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Smoke coverage for the Pydantic request/response shapes + helpers.
|
||||
|
||||
The tables themselves are exercised end-to-end in
|
||||
:mod:`tests.canary.test_repository`; this module only covers the
|
||||
helpers and request validation that don't go through the DB —
|
||||
``CanaryTrigger.headers()`` JSON decoding, the
|
||||
``CanaryTokenCreateRequest`` body shape, and the dump-roundtrip on
|
||||
the response models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.web.db.models import (
|
||||
CanaryBlobResponse,
|
||||
CanaryTokenCreateRequest,
|
||||
CanaryTokenResponse,
|
||||
CanaryTrigger,
|
||||
CanaryTriggerResponse,
|
||||
)
|
||||
|
||||
|
||||
def test_create_request_minimal() -> None:
|
||||
r = CanaryTokenCreateRequest(
|
||||
decky_name="web1",
|
||||
kind="http",
|
||||
placement_path="/home/admin/.env",
|
||||
generator="env_file",
|
||||
)
|
||||
assert r.blob_uuid is None
|
||||
assert r.persona_path_hint is None
|
||||
|
||||
|
||||
def test_create_request_kind_is_constrained() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
CanaryTokenCreateRequest(
|
||||
decky_name="web1", kind="bogus", # type: ignore[arg-type]
|
||||
placement_path="/x", generator="aws_creds",
|
||||
)
|
||||
|
||||
|
||||
def test_trigger_headers_decode_valid_json() -> None:
|
||||
t = CanaryTrigger(
|
||||
token_uuid="t",
|
||||
src_ip="1.2.3.4",
|
||||
raw_headers='{"user-agent":"curl"}',
|
||||
)
|
||||
assert t.headers() == {"user-agent": "curl"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", "not json", "[1,2,3]", "null"])
|
||||
def test_trigger_headers_falls_back_to_empty(raw: str) -> None:
|
||||
t = CanaryTrigger(token_uuid="t", src_ip="1.2.3.4", raw_headers=raw)
|
||||
assert t.headers() == {}
|
||||
|
||||
|
||||
def test_response_models_round_trip() -> None:
|
||||
# Canonical shapes — proves the field set + types match what the
|
||||
# router will hand back. Strings everywhere because the DB layer
|
||||
# uses str UUIDs (project convention).
|
||||
blob = CanaryBlobResponse(
|
||||
uuid="b1", sha256="0" * 64, filename="x.docx",
|
||||
content_type="application/octet-stream", size_bytes=1,
|
||||
uploaded_by="u1", uploaded_at="2026-04-27T00:00:00Z", # type: ignore[arg-type]
|
||||
token_count=2,
|
||||
)
|
||||
assert blob.token_count == 2
|
||||
|
||||
tok = CanaryTokenResponse(
|
||||
uuid="t1", kind="http", decky_name="web1",
|
||||
blob_uuid=None, instrumenter=None, generator="aws_creds",
|
||||
placement_path="/a", callback_token="s",
|
||||
placed_at="2026-04-27T00:00:00Z", # type: ignore[arg-type]
|
||||
last_triggered_at=None, trigger_count=0,
|
||||
created_by="u1", state="planted", last_error=None,
|
||||
)
|
||||
assert tok.kind == "http"
|
||||
|
||||
trig = CanaryTriggerResponse(
|
||||
uuid="x", token_uuid="t1",
|
||||
occurred_at="2026-04-27T00:00:00Z", # type: ignore[arg-type]
|
||||
src_ip="1.2.3.4", user_agent=None, request_path=None,
|
||||
dns_qname=None, headers={}, attacker_id=None,
|
||||
)
|
||||
assert trig.src_ip == "1.2.3.4"
|
||||
68
tests/canary/test_paths.py
Normal file
68
tests/canary/test_paths.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Coverage for the persona-aware path resolver + placement validator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.canary.paths import (
|
||||
DEFAULT_LINUX_USER,
|
||||
DEFAULT_WINDOWS_USER,
|
||||
default_path_for,
|
||||
default_user,
|
||||
normalize_placement,
|
||||
)
|
||||
|
||||
|
||||
def test_default_user_dispatch() -> None:
|
||||
assert default_user("linux") == DEFAULT_LINUX_USER
|
||||
assert default_user("windows") == DEFAULT_WINDOWS_USER
|
||||
# Unknown personas fall through to Linux — better to plant than fail.
|
||||
assert default_user("aix") == DEFAULT_LINUX_USER
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"generator, persona, expected_substr",
|
||||
[
|
||||
("aws_creds", "linux", "/home/admin/.aws/credentials"),
|
||||
("aws_creds", "windows", "/home/Administrator/.aws/credentials"),
|
||||
("env_file", "linux", "/home/admin/.env"),
|
||||
("env_file", "windows", "/home/Administrator/Desktop/prod.env"),
|
||||
("git_config", "linux", "/home/admin/.git/config"),
|
||||
("ssh_key", "linux", "/home/admin/.ssh/id_rsa"),
|
||||
("honeydoc", "linux", "/home/admin/Documents/quarterly_report.html"),
|
||||
("honeydoc_docx", "linux", "/home/admin/Documents/quarterly_report.docx"),
|
||||
("honeydoc_pdf", "linux", "/home/admin/Documents/quarterly_report.pdf"),
|
||||
],
|
||||
)
|
||||
def test_default_path_for_known_generators(
|
||||
generator: str, persona: str, expected_substr: str,
|
||||
) -> None:
|
||||
assert default_path_for(generator, persona) == expected_substr
|
||||
|
||||
|
||||
def test_default_path_for_unknown_generator_falls_through() -> None:
|
||||
# Unknown generator — defensive /tmp drop. The API rejects unknowns
|
||||
# upstream, but the resolver shouldn't crash if one slips through.
|
||||
assert default_path_for("bogus") == "/tmp/bogus.canary"
|
||||
|
||||
|
||||
def test_normalize_placement_accepts_clean_paths() -> None:
|
||||
assert normalize_placement("/home/admin/.env") == "/home/admin/.env"
|
||||
assert normalize_placement("/var/lib/x") == "/var/lib/x"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"",
|
||||
"relative/path",
|
||||
"./still-relative",
|
||||
"/path/with\x00nul",
|
||||
"/path/with\nnewline",
|
||||
"/path/with\rcr",
|
||||
"/path/../escape",
|
||||
"/trailing/..",
|
||||
],
|
||||
)
|
||||
def test_normalize_placement_rejects_bad(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
normalize_placement(bad)
|
||||
247
tests/canary/test_planter.py
Normal file
247
tests/canary/test_planter.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Coverage for the canary planter (docker exec wrapper).
|
||||
|
||||
We don't actually invoke docker — :func:`asyncio.create_subprocess_exec`
|
||||
is patched to record argv and return canned ``(rc, stdout, stderr)``
|
||||
triples. That lets us assert:
|
||||
|
||||
* the docker argv has the right shape (container = ``<decky>-ssh``,
|
||||
``sh -c <script>``);
|
||||
* the script base64-decodes the artifact bytes losslessly;
|
||||
* mtime is backdated by the right offset;
|
||||
* state transitions hit the repo on success/failure;
|
||||
* the bus event publishes on success.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
from typing import AsyncIterator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.bus import topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.canary import CanaryArtifact
|
||||
from decnet.canary import planter
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
import decnet.web.db.models # noqa: F401
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, rc: int, stdout: bytes = b"", stderr: bytes = b"") -> None:
|
||||
self.returncode = rc
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
return self._stdout, self._stderr
|
||||
|
||||
def kill(self) -> None: # pragma: no cover — never reached in non-timeout tests
|
||||
pass
|
||||
|
||||
|
||||
def _patch_subprocess(rc: int = 0, stderr: bytes = b""):
|
||||
captured: list[list[str]] = []
|
||||
stdin_seen: list[bytes | None] = []
|
||||
|
||||
async def _fake(*argv, **kw):
|
||||
captured.append(list(argv))
|
||||
# Capture whatever bytes the planter would stream over stdin —
|
||||
# the new contract pipes the base64 payload here instead of
|
||||
# interpolating it into the sh script.
|
||||
proc = _FakeProc(rc, b"", stderr)
|
||||
orig = proc.communicate
|
||||
|
||||
async def communicate(input=None):
|
||||
stdin_seen.append(input)
|
||||
return await orig()
|
||||
proc.communicate = communicate # type: ignore[assignment]
|
||||
return proc
|
||||
|
||||
return patch.object(asyncio, "create_subprocess_exec", _fake), captured, stdin_seen
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
|
||||
r = SQLiteRepository(str(tmp_path / "p.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def fake_bus() -> AsyncIterator[FakeBus]:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
yield bus
|
||||
await bus.close()
|
||||
|
||||
|
||||
# ---------------- argv shape + base64 round-trip --------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plant_argv_and_base64_round_trip(repo: SQLiteRepository, fake_bus: FakeBus, tmp_path) -> None:
|
||||
art = CanaryArtifact(
|
||||
path="/home/admin/.aws/credentials",
|
||||
content=b"\x00binary\xffpayload",
|
||||
mode=0o600,
|
||||
mtime_offset=-86400,
|
||||
generator="aws_creds",
|
||||
)
|
||||
# Persist a token row so the state-update path has something to flip.
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-1", "kind": "http", "decky_name": "web1",
|
||||
"generator": "aws_creds", "placement_path": art.path,
|
||||
"callback_token": "slug", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
patcher, captured, stdin_seen = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
ok, err = await planter.plant(
|
||||
"web1", art, token_uuid="tok-1", repo=repo, bus=fake_bus,
|
||||
)
|
||||
assert ok is True and err is None
|
||||
assert len(captured) == 1
|
||||
argv = captured[0]
|
||||
# docker exec -i <container> sh -c <script>
|
||||
assert argv[:4] == ["docker", "exec", "-i", "web1-ssh"]
|
||||
assert argv[4:6] == ["sh", "-c"]
|
||||
script = argv[6]
|
||||
# The base64 payload is streamed via stdin, NOT interpolated into
|
||||
# the script (would blow past ARG_MAX for any non-trivial blob).
|
||||
assert stdin_seen[0] == base64.b64encode(art.content)
|
||||
assert "base64 -d > /home/admin/.aws/credentials" in script
|
||||
assert base64.b64encode(art.content).decode() not in script
|
||||
# touch -d @<mtime> with negative offset → an int strictly less than now.
|
||||
m = re.search(r"touch -d @(\d+) ", script)
|
||||
assert m and int(m.group(1)) > 0
|
||||
# State transitioned to planted.
|
||||
row = await repo.get_canary_token("tok-1")
|
||||
assert row["state"] == "planted" and row["last_error"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plant_records_failure_when_docker_returns_nonzero(repo: SQLiteRepository, fake_bus: FakeBus) -> None:
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-2", "kind": "http", "decky_name": "web1",
|
||||
"generator": "env_file", "placement_path": "/x",
|
||||
"callback_token": "slug2", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
art = CanaryArtifact(path="/x", content=b"y", generator="env_file")
|
||||
patcher, _argvs, _stdin = _patch_subprocess(rc=125, stderr=b"container not running")
|
||||
with patcher:
|
||||
ok, err = await planter.plant(
|
||||
"web1", art, token_uuid="tok-2", repo=repo, bus=fake_bus,
|
||||
)
|
||||
assert ok is False
|
||||
assert err and "not running" in err
|
||||
row = await repo.get_canary_token("tok-2")
|
||||
assert row["state"] == "failed" and row["last_error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plant_rejects_empty_path(repo: SQLiteRepository) -> None:
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-3", "kind": "http", "decky_name": "web1",
|
||||
"generator": "env_file", "placement_path": "/x",
|
||||
"callback_token": "slug3", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
art = CanaryArtifact(path="", content=b"y")
|
||||
ok, err = await planter.plant("web1", art, token_uuid="tok-3", repo=repo)
|
||||
assert ok is False and err is not None
|
||||
row = await repo.get_canary_token("tok-3")
|
||||
assert row["state"] == "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plant_publishes_placed_event(repo: SQLiteRepository, fake_bus: FakeBus) -> None:
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-4", "kind": "http", "decky_name": "web1",
|
||||
"generator": "env_file", "placement_path": "/x",
|
||||
"callback_token": "slug4", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
sub = fake_bus.subscribe("canary.>")
|
||||
art = CanaryArtifact(path="/x", content=b"y", generator="env_file")
|
||||
patcher, _argvs, _stdin = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
await planter.plant(
|
||||
"web1", art, token_uuid="tok-4", repo=repo, bus=fake_bus,
|
||||
)
|
||||
event = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
|
||||
assert event.topic == topics.canary("tok-4", topics.CANARY_PLACED)
|
||||
assert event.payload["decky_name"] == "web1"
|
||||
assert event.payload["generator"] == "env_file"
|
||||
|
||||
|
||||
# ---------------- revoke --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_unlinks_and_publishes(repo: SQLiteRepository, fake_bus: FakeBus) -> None:
|
||||
await repo.create_canary_token({
|
||||
"uuid": "tok-r", "kind": "http", "decky_name": "web1",
|
||||
"generator": "env_file", "placement_path": "/etc/x.env",
|
||||
"callback_token": "slugR", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
sub = fake_bus.subscribe("canary.>")
|
||||
patcher, captured, _stdin = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
ok, err = await planter.revoke(
|
||||
"web1", "/etc/x.env",
|
||||
token_uuid="tok-r", repo=repo, bus=fake_bus,
|
||||
)
|
||||
assert ok and not err
|
||||
assert "rm -f /etc/x.env" in captured[0][5]
|
||||
row = await repo.get_canary_token("tok-r")
|
||||
assert row["state"] == "revoked"
|
||||
event = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
|
||||
assert event.topic == topics.canary("tok-r", topics.CANARY_REVOKED)
|
||||
|
||||
|
||||
# ---------------- seed_baseline ------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_baseline_creates_one_token_per_generator(
|
||||
repo: SQLiteRepository, fake_bus: FakeBus, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BASELINE", "git_config,env_file,aws_creds")
|
||||
monkeypatch.setenv("DECNET_CANARY_HTTP_BASE", "https://canary.test")
|
||||
patcher, captured, _stdin = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
rows = await planter.seed_baseline("web1", repo, bus=fake_bus)
|
||||
assert {r["generator"] for r in rows} == {"git_config", "env_file", "aws_creds"}
|
||||
# One docker exec per generator.
|
||||
assert len(captured) == 3
|
||||
# aws_creds ends up as kind=aws_passive; the other two are http.
|
||||
by_gen = {r["generator"]: r for r in rows}
|
||||
assert by_gen["aws_creds"]["kind"] == "aws_passive"
|
||||
assert by_gen["env_file"]["kind"] == "http"
|
||||
persisted = await repo.list_canary_tokens(decky_name="web1")
|
||||
assert len(persisted) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_baseline_skips_unknown_generator(repo: SQLiteRepository, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BASELINE", "env_file,bogus")
|
||||
patcher, _argvs, _stdin = _patch_subprocess(rc=0)
|
||||
with patcher:
|
||||
rows = await planter.seed_baseline("web1", repo)
|
||||
assert {r["generator"] for r in rows} == {"env_file"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_baseline_marks_failed_when_docker_errors(
|
||||
repo: SQLiteRepository, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BASELINE", "env_file")
|
||||
patcher, _argvs, _stdin = _patch_subprocess(rc=125, stderr=b"container down")
|
||||
with patcher:
|
||||
rows = await planter.seed_baseline("web1", repo)
|
||||
assert len(rows) == 1
|
||||
persisted = await repo.list_canary_tokens(decky_name="web1")
|
||||
assert persisted[0]["state"] == "failed"
|
||||
assert persisted[0]["last_error"]
|
||||
179
tests/canary/test_repository.py
Normal file
179
tests/canary/test_repository.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Repository CRUD coverage for canary blobs / tokens / triggers.
|
||||
|
||||
Same harness as the rest of :mod:`tests.db` — spin up a SQLite-backed
|
||||
:class:`SQLiteRepository` against a tempfile, exercise the public
|
||||
methods, assert observable state.
|
||||
|
||||
We deliberately don't go through the API; that gets its own test
|
||||
module once the router lands. This file proves the repository layer
|
||||
in isolation: dedup, refcount-aware delete, slug lookup, atomic
|
||||
trigger record + counter bump, attribution.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import AsyncIterator
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
import decnet.web.db.models # noqa: F401 — registers tables on import
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path) -> AsyncIterator[SQLiteRepository]:
|
||||
r = SQLiteRepository(str(tmp_path / "canary.db"))
|
||||
await r.initialize()
|
||||
yield r
|
||||
|
||||
|
||||
async def _make_blob(repo: SQLiteRepository, content: bytes, *, by: str = "u1") -> dict:
|
||||
return await repo.upsert_canary_blob({
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
"filename": "report.docx",
|
||||
"content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"size_bytes": len(content),
|
||||
"uploaded_by": by,
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_blob_dedupes_by_sha256(repo: SQLiteRepository) -> None:
|
||||
a = await _make_blob(repo, b"same bytes", by="u1")
|
||||
b = await _make_blob(repo, b"same bytes", by="u2")
|
||||
assert a["uuid"] == b["uuid"], "second upload must return the canonical row"
|
||||
# Different bytes → different blob.
|
||||
c = await _make_blob(repo, b"different bytes", by="u1")
|
||||
assert c["uuid"] != a["uuid"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_blob_requires_sha256(repo: SQLiteRepository) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
await repo.upsert_canary_blob({"filename": "x", "content_type": "x", "size_bytes": 0, "uploaded_by": "u"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_blob_by_sha256(repo: SQLiteRepository) -> None:
|
||||
blob = await _make_blob(repo, b"x")
|
||||
found = await repo.get_canary_blob_by_sha256(blob["sha256"])
|
||||
assert found is not None and found["uuid"] == blob["uuid"]
|
||||
assert await repo.get_canary_blob_by_sha256("0" * 64) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_blobs_carries_token_count(repo: SQLiteRepository) -> None:
|
||||
blob = await _make_blob(repo, b"x")
|
||||
listed = await repo.list_canary_blobs()
|
||||
assert len(listed) == 1 and listed[0]["token_count"] == 0
|
||||
await repo.create_canary_token({
|
||||
"kind": "http", "decky_name": "web1", "blob_uuid": blob["uuid"],
|
||||
"instrumenter": "docx", "placement_path": "/tmp/x.docx",
|
||||
"callback_token": "slug-1", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
listed = await repo.list_canary_blobs()
|
||||
assert listed[0]["token_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_blob_refuses_while_referenced(repo: SQLiteRepository) -> None:
|
||||
blob = await _make_blob(repo, b"x")
|
||||
await repo.create_canary_token({
|
||||
"kind": "http", "decky_name": "web1", "blob_uuid": blob["uuid"],
|
||||
"instrumenter": "docx", "placement_path": "/tmp/x.docx",
|
||||
"callback_token": "slug-r", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
assert await repo.delete_canary_blob(blob["uuid"]) is False
|
||||
# Even after revoke, the row still references the blob — operator
|
||||
# must explicitly clean tokens before they can prune the blob.
|
||||
tok = await repo.get_canary_token_by_slug("slug-r")
|
||||
await repo.update_canary_token_state(tok["uuid"], "revoked")
|
||||
assert await repo.delete_canary_blob(blob["uuid"]) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_blob_returns_false_for_missing(repo: SQLiteRepository) -> None:
|
||||
assert await repo.delete_canary_blob("00000000-0000-0000-0000-000000000000") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_slug_lookup(repo: SQLiteRepository) -> None:
|
||||
await repo.create_canary_token({
|
||||
"kind": "http", "decky_name": "web1", "generator": "aws_creds",
|
||||
"placement_path": "/home/admin/.aws/credentials",
|
||||
"callback_token": "slug-aws", "secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
found = await repo.get_canary_token_by_slug("slug-aws")
|
||||
assert found is not None and found["decky_name"] == "web1"
|
||||
assert await repo.get_canary_token_by_slug("nonexistent") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tokens_filters(repo: SQLiteRepository) -> None:
|
||||
await repo.create_canary_token({
|
||||
"kind": "http", "decky_name": "web1", "generator": "aws_creds",
|
||||
"placement_path": "/a", "callback_token": "s1",
|
||||
"secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
await repo.create_canary_token({
|
||||
"kind": "dns", "decky_name": "web2", "generator": "aws_creds",
|
||||
"placement_path": "/b", "callback_token": "s2",
|
||||
"secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
assert len(await repo.list_canary_tokens()) == 2
|
||||
assert len(await repo.list_canary_tokens(decky_name="web1")) == 1
|
||||
assert len(await repo.list_canary_tokens(kind="dns")) == 1
|
||||
assert len(await repo.list_canary_tokens(state="revoked")) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_trigger_bumps_counters_atomically(repo: SQLiteRepository) -> None:
|
||||
await repo.create_canary_token({
|
||||
"kind": "http", "decky_name": "web1", "generator": "aws_creds",
|
||||
"placement_path": "/a", "callback_token": "slug-c",
|
||||
"secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
tok = await repo.get_canary_token_by_slug("slug-c")
|
||||
assert tok["trigger_count"] == 0 and tok["last_triggered_at"] is None
|
||||
trig_id = await repo.record_canary_trigger({
|
||||
"token_uuid": tok["uuid"], "src_ip": "1.2.3.4",
|
||||
"request_path": "/c/slug-c", "user_agent": "curl/8.0",
|
||||
"raw_headers": {"user-agent": "curl/8.0"},
|
||||
})
|
||||
assert trig_id
|
||||
tok2 = await repo.get_canary_token_by_slug("slug-c")
|
||||
assert tok2["trigger_count"] == 1
|
||||
assert tok2["last_triggered_at"] is not None
|
||||
# raw_headers stored as JSON text and decodes via the model helper.
|
||||
triggers = await repo.list_canary_triggers(tok["uuid"])
|
||||
assert len(triggers) == 1
|
||||
assert triggers[0]["src_ip"] == "1.2.3.4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attribute_trigger_sets_attacker(repo: SQLiteRepository) -> None:
|
||||
await repo.create_canary_token({
|
||||
"kind": "http", "decky_name": "web1", "generator": "aws_creds",
|
||||
"placement_path": "/a", "callback_token": "slug-at",
|
||||
"secret_seed": "s", "created_by": "u1",
|
||||
})
|
||||
tok = await repo.get_canary_token_by_slug("slug-at")
|
||||
trig_id = await repo.record_canary_trigger({
|
||||
"token_uuid": tok["uuid"], "src_ip": "9.9.9.9",
|
||||
})
|
||||
assert await repo.attribute_canary_trigger(trig_id, "attacker-uuid-123") is True
|
||||
assert await repo.attribute_canary_trigger("missing-trig", "x") is False
|
||||
triggers = await repo.list_canary_triggers(tok["uuid"])
|
||||
assert triggers[0]["attacker_id"] == "attacker-uuid-123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_token_returns_none_for_missing(repo: SQLiteRepository) -> None:
|
||||
assert await repo.get_canary_token("00000000-0000-0000-0000-000000000000") is None
|
||||
assert await repo.get_canary_blob("00000000-0000-0000-0000-000000000000") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_state_returns_false_for_missing(repo: SQLiteRepository) -> None:
|
||||
assert await repo.update_canary_token_state("missing", "revoked") is False
|
||||
52
tests/canary/test_storage.py
Normal file
52
tests/canary/test_storage.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Coverage for the on-disk blob store."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from decnet.canary import storage
|
||||
|
||||
|
||||
def test_write_blob_is_idempotent(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
||||
sha1, p1, sz1 = storage.write_blob(b"hello canary")
|
||||
sha2, p2, sz2 = storage.write_blob(b"hello canary")
|
||||
assert sha1 == sha2 == hashlib.sha256(b"hello canary").hexdigest()
|
||||
assert p1 == p2
|
||||
assert sz1 == sz2 == len(b"hello canary")
|
||||
# Two-level fan-out: ab/cd/abcd...
|
||||
assert p1.parent.parent.parent == tmp_path
|
||||
assert p1.parent.name == sha1[2:4]
|
||||
assert p1.parent.parent.name == sha1[:2]
|
||||
|
||||
|
||||
def test_read_blob_returns_bytes(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
||||
sha, _, _ = storage.write_blob(b"some payload")
|
||||
assert storage.read_blob(sha) == b"some payload"
|
||||
|
||||
|
||||
def test_unlink_blob_returns_false_for_missing(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
||||
sha = "0" * 64
|
||||
assert storage.unlink_blob(sha) is False
|
||||
|
||||
|
||||
def test_unlink_blob_removes_file(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path))
|
||||
sha, path, _ = storage.write_blob(b"to be removed")
|
||||
assert path.exists()
|
||||
assert storage.unlink_blob(sha) is True
|
||||
assert not path.exists()
|
||||
# Second unlink is a no-op rather than a crash.
|
||||
assert storage.unlink_blob(sha) is False
|
||||
|
||||
|
||||
def test_blob_dir_honors_env(monkeypatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("DECNET_CANARY_BLOB_DIR", str(tmp_path / "alt"))
|
||||
assert storage.blob_dir() == tmp_path / "alt"
|
||||
|
||||
|
||||
def test_short_sha_rejected() -> None:
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
storage._path_for("abc")
|
||||
44
tests/canary/test_systemd_unit.py
Normal file
44
tests/canary/test_systemd_unit.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Sanity check on the decnet-canary.service unit + decnet.target.
|
||||
|
||||
Tests are deliberately static (no rendering, no systemd) — they just
|
||||
confirm the unit file exists, references the canary CLI command, is
|
||||
included in the master target, and follows the same security
|
||||
hardening posture as decnet-webhook.service.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
DEPLOY = Path(__file__).resolve().parents[2] / "deploy"
|
||||
|
||||
|
||||
def test_canary_unit_exists() -> None:
|
||||
assert (DEPLOY / "decnet-canary.service.j2").exists()
|
||||
|
||||
|
||||
def test_canary_unit_runs_decnet_canary() -> None:
|
||||
body = (DEPLOY / "decnet-canary.service.j2").read_text()
|
||||
assert "{{ venv_dir }}/bin/decnet canary" in body
|
||||
assert "After=" in body and "decnet-bus.service" in body
|
||||
|
||||
|
||||
def test_canary_unit_has_security_hardening() -> None:
|
||||
"""Canary handles attacker traffic — must mirror webhook's hardening."""
|
||||
body = (DEPLOY / "decnet-canary.service.j2").read_text()
|
||||
for required in (
|
||||
"NoNewPrivileges=yes",
|
||||
"ProtectSystem=full",
|
||||
"ProtectHome=read-only",
|
||||
"PrivateTmp=yes",
|
||||
"ProtectKernelTunables=yes",
|
||||
"ProtectKernelModules=yes",
|
||||
"ProtectControlGroups=yes",
|
||||
"RestrictSUIDSGID=yes",
|
||||
"LockPersonality=yes",
|
||||
):
|
||||
assert required in body, f"missing hardening directive: {required}"
|
||||
|
||||
|
||||
def test_canary_listed_in_master_target() -> None:
|
||||
body = (DEPLOY / "decnet.target").read_text()
|
||||
assert "decnet-canary.service" in body
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user