merge: testing → main (reconcile 2-week divergence)
This commit is contained in:
0
tests/api/workers/__init__.py
Normal file
0
tests/api/workers/__init__.py
Normal file
179
tests/api/workers/test_start_workers.py
Normal file
179
tests/api/workers/test_start_workers.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Tests for ``POST /api/v1/workers/{name}/start`` + ``start-all``.
|
||||
|
||||
Uses the shared ``client`` / ``auth_token`` / ``viewer_token`` fixtures
|
||||
from ``tests/api/conftest.py``. Stubs out ``systemd_control`` so tests
|
||||
never touch real systemctl.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Set
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.web.router.workers import api_list_workers as _list
|
||||
from decnet.web.router.workers import api_start_all_workers as _start_all
|
||||
from decnet.web.router.workers import api_start_worker as _start
|
||||
from decnet.web.services import systemd_control as _sc
|
||||
|
||||
|
||||
def _patch_installed(monkeypatch: Any, names: Set[str]) -> None:
|
||||
async def _stub() -> Set[str]:
|
||||
return set(names)
|
||||
|
||||
# Each module imported `systemd_control` directly; patch on the
|
||||
# module-level attribute so all three endpoints see the stub.
|
||||
for mod in (_start, _start_all, _list):
|
||||
monkeypatch.setattr(mod.systemd_control, "list_installed", _stub)
|
||||
|
||||
|
||||
def _patch_start(monkeypatch: Any, *, raises: _sc.SystemctlError | None = None) -> list[str]:
|
||||
calls: list[str] = []
|
||||
|
||||
async def _stub(name: str) -> None:
|
||||
calls.append(name)
|
||||
if raises is not None:
|
||||
raise raises
|
||||
|
||||
monkeypatch.setattr(_sc, "start", _stub)
|
||||
return calls
|
||||
|
||||
|
||||
def _patch_is_active(monkeypatch: Any, active: Set[str]) -> None:
|
||||
async def _stub(name: str) -> bool:
|
||||
return name in active
|
||||
|
||||
monkeypatch.setattr(_sc, "is_active", _stub)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_admin_happy_path(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"mutator", "bus"})
|
||||
calls = _patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/start",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body == {"accepted": True, "worker": "mutator", "action": "start"}
|
||||
assert calls == ["mutator"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_viewer_forbidden(
|
||||
client: httpx.AsyncClient, viewer_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"mutator"})
|
||||
_patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/start",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_unknown_name_404(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"mutator"})
|
||||
_patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/nosuch/start",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_not_installed_503(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, set()) # nothing installed
|
||||
_patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/start",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_worker_systemctl_failure_502(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"mutator"})
|
||||
err = _sc.SystemctlError(
|
||||
unit="decnet-mutator.service",
|
||||
returncode=1,
|
||||
stderr="Failed to start decnet-mutator.service: unit not found",
|
||||
)
|
||||
_patch_start(monkeypatch, raises=err)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/start",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 502
|
||||
body = resp.json()
|
||||
assert "not found" in body["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_all_aggregates_success_running_and_failure(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"bus", "api", "mutator"})
|
||||
_patch_is_active(monkeypatch, {"bus"}) # bus is already running
|
||||
|
||||
async def _stub_start(name: str) -> None:
|
||||
if name == "mutator":
|
||||
raise _sc.SystemctlError(
|
||||
unit="decnet-mutator.service",
|
||||
returncode=1,
|
||||
stderr="Unit decnet-mutator.service is masked.",
|
||||
)
|
||||
# api starts cleanly
|
||||
|
||||
monkeypatch.setattr(_sc, "start", _stub_start)
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/start-all",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["already_running"] == ["bus"]
|
||||
assert body["started"] == ["api"]
|
||||
assert len(body["failed"]) == 1
|
||||
assert body["failed"][0]["name"] == "mutator"
|
||||
assert "masked" in body["failed"][0]["reason"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_all_viewer_forbidden(
|
||||
client: httpx.AsyncClient, viewer_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, {"bus"})
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/start-all",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_all_skips_uninstalled(
|
||||
client: httpx.AsyncClient, auth_token: str, monkeypatch,
|
||||
) -> None:
|
||||
_patch_installed(monkeypatch, set()) # no units installed
|
||||
_patch_is_active(monkeypatch, set())
|
||||
_patch_start(monkeypatch)
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/start-all",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"started": [], "already_running": [], "failed": []}
|
||||
171
tests/api/workers/test_workers_api.py
Normal file
171
tests/api/workers/test_workers_api.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Tests for the Workers panel API endpoints.
|
||||
|
||||
Covers ``GET /api/v1/workers`` (viewer-readable, always surfaces every
|
||||
known worker) and ``POST /api/v1/workers/{name}/stop`` (admin-only,
|
||||
publishes a stop intent on the bus).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from decnet.bus import topics as _topics
|
||||
from decnet.bus.fake import FakeBus
|
||||
from decnet.web import worker_registry as _wr
|
||||
from decnet.web.router.workers import api_control_worker as _ctl
|
||||
from decnet.web.router.workers import api_list_workers as _list
|
||||
from decnet.web.worker_registry import KNOWN_WORKERS
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry() -> None:
|
||||
_wr.reset_registry_for_tests()
|
||||
yield
|
||||
_wr.reset_registry_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fake_bus(monkeypatch) -> FakeBus:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
|
||||
async def _stub_get_app_bus() -> FakeBus:
|
||||
return bus
|
||||
|
||||
# Patch the symbol the control endpoint imported into its namespace.
|
||||
monkeypatch.setattr(_ctl, "get_app_bus", _stub_get_app_bus)
|
||||
yield bus
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workers_viewer_sees_all_unknown(
|
||||
client: httpx.AsyncClient, viewer_token: str,
|
||||
) -> None:
|
||||
resp = await client.get(
|
||||
"/api/v1/workers",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
names = {w["name"] for w in body["workers"]}
|
||||
assert names == set(KNOWN_WORKERS)
|
||||
# No heartbeats have arrived in the test harness, so every row is unknown.
|
||||
for w in body["workers"]:
|
||||
assert w["status"] == "unknown"
|
||||
assert w["last_heartbeat_ts"] is None
|
||||
assert w["seconds_since"] is None
|
||||
assert "bus_connected" in body
|
||||
assert isinstance(body["bus_connected"], bool)
|
||||
# `installed` flag is always present + boolean.
|
||||
for w in body["workers"]:
|
||||
assert "installed" in w
|
||||
assert isinstance(w["installed"], bool)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workers_requires_auth(client: httpx.AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/workers")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workers_reports_bus_connected_false_when_no_bus(
|
||||
client: httpx.AsyncClient, viewer_token: str, monkeypatch,
|
||||
) -> None:
|
||||
async def _no_bus() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(_list, "get_app_bus", _no_bus)
|
||||
resp = await client.get(
|
||||
"/api/v1/workers",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["bus_connected"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_workers_reports_bus_connected_true_with_fake_bus(
|
||||
client: httpx.AsyncClient, viewer_token: str, monkeypatch,
|
||||
) -> None:
|
||||
bus = FakeBus()
|
||||
await bus.connect()
|
||||
|
||||
async def _fake_bus() -> FakeBus:
|
||||
return bus
|
||||
|
||||
monkeypatch.setattr(_list, "get_app_bus", _fake_bus)
|
||||
try:
|
||||
resp = await client.get(
|
||||
"/api/v1/workers",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["bus_connected"] is True
|
||||
finally:
|
||||
await bus.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_worker_admin_publishes_on_bus(
|
||||
client: httpx.AsyncClient, auth_token: str, fake_bus: FakeBus,
|
||||
) -> None:
|
||||
topic = _topics.system_control("mutator")
|
||||
received: list[Any] = []
|
||||
|
||||
sub = fake_bus.subscribe(topic)
|
||||
await sub.__aenter__()
|
||||
|
||||
async def _drain() -> None:
|
||||
async for event in sub:
|
||||
received.append(event)
|
||||
return
|
||||
|
||||
import asyncio
|
||||
reader = asyncio.create_task(_drain())
|
||||
# Give the subscribe a tick so the publish lands on a live reader.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
try:
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/stop",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert body == {"accepted": True, "worker": "mutator", "action": "stop"}
|
||||
|
||||
await asyncio.wait_for(reader, timeout=1.0)
|
||||
assert len(received) == 1
|
||||
ev = received[0]
|
||||
assert ev.topic == topic
|
||||
assert ev.payload["action"] == _topics.WORKER_CONTROL_STOP
|
||||
assert "requested_by" in ev.payload
|
||||
assert "ts" in ev.payload
|
||||
finally:
|
||||
await sub.__aexit__(None, None, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_worker_viewer_forbidden(
|
||||
client: httpx.AsyncClient, viewer_token: str, fake_bus: FakeBus,
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/mutator/stop",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_worker_unknown_name_404(
|
||||
client: httpx.AsyncClient, auth_token: str, fake_bus: FakeBus,
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/workers/nonsense/stop",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user