merge: testing → main (reconcile 2-week divergence)

This commit is contained in:
2026-04-28 18:36:00 -04:00
parent 499836c9e4
commit 862e4dbb31
1235 changed files with 160255 additions and 7996 deletions

0
tests/agent/__init__.py Normal file
View File

View 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}"
)

View File

View 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

View File

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

View File

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

View File

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

View File

View File

@@ -0,0 +1 @@
# viewer_token fixture is now in tests/api/conftest.py (shared across all API tests)

View 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

View 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

View 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()

View 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

View 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

View File

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

View File

View 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

View File

View 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

View 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

View File

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

View File

View 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()

View File

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

View File

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

View File

View 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

View 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",
}

View 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

View File

@@ -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"},

View File

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

View 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

View File

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

View 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

View 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

View 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

View 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

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

View 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}")

View File

@@ -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({

View File

@@ -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)

View 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)

View 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)

View 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

View File

View 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

View 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

View 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 == []

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

View 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()

View 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

View 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

View File

View 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

View File

View 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

View File

View 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": []}

View 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
View File

22
tests/asn/conftest.py Normal file
View 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
View 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
View 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 == ""

View 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

View 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
View File

59
tests/bus/conftest.py Normal file
View 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)

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

View 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

View 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
View 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
View 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
View File

@@ -0,0 +1,104 @@
"""Shared ``run_health_heartbeat`` helper (DEBT-031 workers 79).
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()

View 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
View 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
View 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("")

View 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
View 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
View File

88
tests/canary/conftest.py Normal file
View 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
View 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

View 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}"
)

View 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

View 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

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

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

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

View 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)

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

View 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

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

View 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