mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
feat(serve): add --quiet mode and developer dashboard API
Headless serve with plain stderr logging; /api/dashboard/* (status, sessions, jobs, logs, SSE/poll events) behind a separate serve.dashboard.key.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"""Dashboard auth split, stats middleware, log ring buffer and session events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from unshackle.core.api.events import bus
|
||||
from unshackle.core.api.handlers import dashboard_authentication
|
||||
from unshackle.core.api.routes import cors_middleware, setup_routes
|
||||
from unshackle.core.api.session_store import SessionStore
|
||||
from unshackle.core.api.stats import RingLogHandler, stats, stats_middleware
|
||||
from unshackle.core.config import config
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_cfg(monkeypatch):
|
||||
monkeypatch.setitem(config.serve, "dashboard", {"key": "dash-secret"})
|
||||
monkeypatch.setitem(config.serve, "users", {"tier-key": {"username": "tier1"}})
|
||||
stats.requests_total = 0
|
||||
stats.requests_by_key.clear()
|
||||
|
||||
|
||||
def make_app(dashboard: bool = True) -> web.Application:
|
||||
app = web.Application(middlewares=[cors_middleware, stats_middleware, dashboard_authentication])
|
||||
app["config"] = {"users": {}}
|
||||
app["debug_api"] = False
|
||||
setup_routes(app, remote_only=True, dashboard=dashboard)
|
||||
return app
|
||||
|
||||
|
||||
async def test_dashboard_key_split(aiohttp_client, dashboard_cfg) -> None:
|
||||
client = await aiohttp_client(make_app())
|
||||
assert (await client.get("/api/dashboard/status", headers={"X-Secret-Key": "tier-key"})).status == 401
|
||||
assert (await client.get("/api/dashboard/status")).status == 401
|
||||
resp = await client.get("/api/dashboard/status", headers={"X-Secret-Key": "dash-secret"})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["mode"] and "uptime_seconds" in body and body["sessions"] == 0
|
||||
# rejected requests count only as rejected; accepted ones are keyed by masked label
|
||||
assert stats.requests_total == 3 and stats.requests_rejected == 2 and stats.requests_by_key == {"dash…": 1}
|
||||
await client.get("/api/health", headers={"X-Secret-Key": "tier-key"})
|
||||
assert stats.requests_by_key["tier1"] == 1
|
||||
|
||||
|
||||
async def test_dashboard_routes_absent_without_key(aiohttp_client, dashboard_cfg) -> None:
|
||||
client = await aiohttp_client(make_app(dashboard=False))
|
||||
assert (await client.get("/api/dashboard/status", headers={"X-Secret-Key": "dash-secret"})).status == 404
|
||||
|
||||
|
||||
def test_ring_since_and_level() -> None:
|
||||
ring = RingLogHandler(maxlen=3)
|
||||
ring.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger = logging.getLogger("test.ring")
|
||||
logger.propagate = False
|
||||
logger.addHandler(ring)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
for i in range(5):
|
||||
(logger.warning if i % 2 else logger.info)(f"m{i}")
|
||||
assert [r["msg"] for r in ring.records] == ["m2", "m3", "m4"]
|
||||
assert ring.seq == 5
|
||||
assert [r["msg"] for r in ring.since(3)] == ["m3", "m4"]
|
||||
assert [r["msg"] for r in ring.since(0, "warning")] == ["m3"]
|
||||
assert ring.since(0, logger="test") and not ring.since(0, logger="other")
|
||||
|
||||
|
||||
async def test_session_events() -> None:
|
||||
queue = bus.subscribe()
|
||||
try:
|
||||
store = SessionStore()
|
||||
entry = await store.create("EXAMPLE", object())
|
||||
assert [e.session_id for e in store.list()] == [entry.session_id]
|
||||
await store.delete(entry.session_id)
|
||||
create, delete = queue.get_nowait(), queue.get_nowait()
|
||||
assert create["event"] == "session" and create["data"]["action"] == "create"
|
||||
assert delete["data"]["action"] == "delete" and delete["data"]["id"] == entry.session_id
|
||||
assert "owner_key" not in create["data"]
|
||||
finally:
|
||||
bus.unsubscribe(queue)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
async def test_events_burst_mode(aiohttp_client, dashboard_cfg) -> None:
|
||||
client = await aiohttp_client(make_app())
|
||||
mark = bus.seq
|
||||
bus.publish("session", {"action": "create", "id": "s1"})
|
||||
bus.publish("session", {"action": "delete", "id": "s1"})
|
||||
resp = await client.get(f"/api/dashboard/events?since={mark}", headers={"X-Secret-Key": "dash-secret"})
|
||||
assert resp.status == 200
|
||||
body = await resp.json()
|
||||
assert body["seq"] == bus.seq and "stats" in body
|
||||
assert [e["data"]["action"] for e in body["events"] if e["event"] == "session"] == ["create", "delete"]
|
||||
|
||||
|
||||
async def test_session_action_log() -> None:
|
||||
store = SessionStore()
|
||||
entry = await store.create("EXAMPLE", object(), owner_key="k")
|
||||
queue = bus.subscribe()
|
||||
try:
|
||||
store.record_action(entry.session_id, {"action": "titles", "status": 200})
|
||||
store.record_action("missing", {"action": "titles", "status": 200})
|
||||
assert entry.summary()["actions"] == [{"action": "titles", "status": 200}]
|
||||
assert queue.get_nowait()["data"]["action"] == "update" and queue.empty()
|
||||
finally:
|
||||
bus.unsubscribe(queue)
|
||||
@@ -32,6 +32,7 @@ def test_serve_help_lists_documented_flags(runner: CliRunner) -> None:
|
||||
"--debug-api",
|
||||
"--debug",
|
||||
"--remote-only",
|
||||
"--quiet",
|
||||
):
|
||||
assert flag in out, f"missing flag in --help: {flag}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user