hermes-agent/tests/hermes_cli/test_dashboard_admin_endpoints.py
Teknium bd8e2ec1a6
feat(dashboard): complete admin panel — MCP catalog, enable/disable toggles, hook creation, system stats (#36736)
* feat(dashboard): MCP catalog + enable/disable, webhook toggle, hook create/delete, system stats

Backend for the comprehensive admin pass:
- MCP: GET /api/mcp/catalog (browse Nous-approved optional-mcps), POST
  /api/mcp/catalog/install, PUT /api/mcp/servers/{name}/enabled
- Webhooks: PUT /api/webhooks/{name}/enabled; gateway rejects disabled routes
  with 403 (hot-reloaded, no restart)
- Hooks: POST/DELETE /api/ops/hooks — create (with consent approval) + remove;
  list now reports accurate allowlist status + valid events
- System: GET /api/system/stats — OS/arch/python/cpu + psutil memory/disk/
  uptime/process, stdlib fallback

All gated by dashboard auth; secrets never returned.

* feat(dashboard): MCP catalog UI, enable/disable toggles, hook create, system stats

- McpPage: catalog section (browse Nous-approved MCPs, one-click install with
  env prompts) + per-server enable/disable toggle with gateway-restart note
- WebhooksPage: per-subscription enable/disable toggle (muted + badge when off)
- SystemPage: new Host stats section (OS/arch/python/cpu/mem/disk/uptime/load),
  shell-hook create modal + delete, 'Create backup' label
- api.ts: client methods + types for catalog, toggles, hook CRUD, system stats

* test(dashboard): cover catalog, toggles, hook CRUD, system stats, webhook toggle

Adds tests for the comprehensive pass: MCP enable/disable + catalog list +
catalog-install-unknown, hook create/delete with consent, system stats shape,
and webhook enable/disable. 26 tests total, all green.

* docs(dashboard): document the comprehensive admin pass + fresh screenshots

Updates the MCP/Webhooks/Pairing/System sections for catalog browse+install,
enable/disable toggles, hook creation, and host system stats; adds the new
endpoints to the API table; replaces the screenshots with live captures of
the rebuilt pages (real data, no dummies) including the hook-create modal.

* feat(dashboard): curator, portal status, and prompt-size/dump/migrate ops

Closes the last in-scope CLI gaps from the coverage audit:
- Curator: GET /api/curator (status), PUT /api/curator/paused, POST
  /api/curator/run (background)
- Portal: GET /api/portal (Nous auth + Tool Gateway routing, read-only)
- Diagnostics: POST /api/ops/prompt-size, /api/ops/dump, /api/ops/config-migrate
  (backgrounded, tailed via action status)

Host-bound commands (secrets/proxy/lsp/acp/computer-use/desktop/completion/
postinstall/uninstall/claw) remain CLI-only by design.

* feat(dashboard): curator + portal + diagnostics UI, tests

- SystemPage: Nous Portal status section (auth + Tool Gateway routing),
  Skill curator card (status + pause/resume + run now), and three new
  Operations buttons (prompt size, support dump, migrate config)
- api.ts: client methods + CuratorStatus/PortalStatus types
- tests: curator pause/resume, portal shape, system-stats shape, + auth-gate
  coverage for the new GET endpoints (31 tests total)

* docs(dashboard): document curator, portal, and diagnostics + refresh System screenshots

Updates the System section for the Nous Portal status, Skill curator
controls, and the new prompt-size/dump/migrate operations; adds them to the
API table; refreshes the System screenshots (now showing Portal + Curator)
and adds a dedicated curator/gateway/memory capture.

* feat(dashboard): session stats/export/prune + skills hub search endpoints

Completes the existing tabs' backend depth (audit vs CLI):
- Sessions: GET /api/sessions/stats (store stats), GET /api/sessions/{id}/export,
  POST /api/sessions/prune. /stats is registered before /{session_id} so the
  literal path isn't captured by the parameterized route.
- Skills: GET /api/skills/hub/search — parallel multi-source hub search (threaded),
  returns installable identifiers
- (rename via PATCH and cron-edit via PUT already existed; now surfaced in UI)

* feat(dashboard): complete existing tabs — sessions mgmt, skills hub browse, cron edit

Audited every existing tab against its CLI command and filled the gaps:
- Sessions: store stats bar, per-row rename + export (JSON download), and a
  prune-old-sessions control (mirrors hermes sessions rename/export/prune/stats)
- Skills: new 'Browse hub' view — search the skill hub across all sources,
  install by identifier with a live install log, and 'Update all' (mirrors
  hermes skills search/install/update)
- Cron: per-job Edit modal (pre-filled) calling updateCronJob (hermes cron edit)
- api.ts: renameSession/getSessionStats/exportSessionUrl/pruneSessions,
  updateCronJob, searchSkillsHub + types

Models tab was already comprehensive (provider+model picker, dynamic per-provider
lists, main + all 11 aux-task assignments, reset) — verified, no change needed.

* test(dashboard): cover session stats/rename/export/prune + skills hub search

Adds the route-shadowing guard for /api/sessions/stats (must not be captured
by /api/sessions/{session_id}), rename/export/prune, and the empty-query
short-circuit for hub search. 36 tests total, all green.

* docs(dashboard): document enhanced Sessions, Skills hub, and Cron edit

Sessions: stats bar, rename, export, prune (+ screenshot). Skills: new Browse
hub view for search/install/update (+ screenshot). Cron: edit action. API
table updated with the new endpoints.
2026-06-02 00:16:11 -04:00

419 lines
15 KiB
Python

"""Tests for the dashboard admin API endpoints (MCP, pairing, webhooks,
credential pool, memory, gateway lifecycle, ops, skills hub).
These endpoints turn the web dashboard into an administration panel for
operators without CLI access to the host. The tests assert the request
contract and the CLI-config parity (servers/keys written via the API are
visible to the CLI data layer), not specific catalog values.
"""
import pytest
def _client():
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
client = TestClient(app)
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Keep the state DB under the isolated HERMES_HOME for any handler that
# touches it.
hermes_state.DEFAULT_DB_PATH = get_hermes_home() / "state.db"
return client, _SESSION_HEADER_NAME
class TestMcpEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, self.header = _client()
def test_list_add_remove_roundtrip(self):
assert self.client.get("/api/mcp/servers").json()["servers"] == []
r = self.client.post(
"/api/mcp/servers", json={"name": "srv1", "url": "https://x/mcp"}
)
assert r.status_code == 200
assert r.json()["transport"] == "http"
servers = self.client.get("/api/mcp/servers").json()["servers"]
assert [s["name"] for s in servers] == ["srv1"]
# CLI parity: the server is in config.yaml under mcp_servers.
from hermes_cli.mcp_config import _get_mcp_servers
assert "srv1" in _get_mcp_servers()
assert self.client.delete("/api/mcp/servers/srv1").status_code == 200
assert self.client.get("/api/mcp/servers").json()["servers"] == []
def test_stdio_env_is_redacted_on_read(self):
self.client.post(
"/api/mcp/servers",
json={
"name": "srv2",
"command": "npx",
"args": ["-y", "pkg"],
"env": {"API_KEY": "sk-secret-1234567890"},
},
)
srv = self.client.get("/api/mcp/servers").json()["servers"][0]
assert srv["env"]["API_KEY"] != "sk-secret-1234567890"
def test_duplicate_rejected(self):
self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"})
r = self.client.post("/api/mcp/servers", json={"name": "dup", "url": "u"})
assert r.status_code == 409
def test_missing_transport_rejected(self):
r = self.client.post("/api/mcp/servers", json={"name": "bad"})
assert r.status_code == 400
def test_enable_disable_toggle(self):
self.client.post("/api/mcp/servers", json={"name": "tog", "url": "u"})
r = self.client.put("/api/mcp/servers/tog/enabled", json={"enabled": False})
assert r.status_code == 200 and r.json()["enabled"] is False
srv = [
s for s in self.client.get("/api/mcp/servers").json()["servers"]
if s["name"] == "tog"
][0]
assert srv["enabled"] is False
# Toggling a missing server is a 404.
assert self.client.put(
"/api/mcp/servers/nope/enabled", json={"enabled": True}
).status_code == 404
def test_catalog_lists_entries(self):
r = self.client.get("/api/mcp/catalog")
assert r.status_code == 200
body = r.json()
assert "entries" in body and "diagnostics" in body
# The shipped optional-mcps/ catalog has at least one entry; each must
# carry the install/enabled status fields the UI relies on.
for e in body["entries"]:
assert {"name", "transport", "installed", "enabled", "needs_install"} <= set(e)
def test_catalog_install_unknown_404(self):
r = self.client.post("/api/mcp/catalog/install", json={"name": "no-such-mcp-xyz"})
assert r.status_code == 404
class TestCredentialPoolEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_add_list_remove_and_cli_parity(self):
assert self.client.get("/api/credentials/pool").json()["providers"] == []
r = self.client.post(
"/api/credentials/pool",
json={"provider": "openrouter", "api_key": "sk-or-abcdef1234", "label": "p"},
)
assert r.status_code == 200 and r.json()["count"] == 1
providers = self.client.get("/api/credentials/pool").json()["providers"]
entry = providers[0]["entries"][0]
# API redacts the key but exposes a preview + 1-based index.
assert entry["index"] == 1
assert entry["token_preview"] != "sk-or-abcdef1234"
# CLI parity: the raw, usable key is retrievable via the pool API.
from agent.credential_pool import load_pool
raw = load_pool("openrouter").entries()
assert raw[0].access_token == "sk-or-abcdef1234"
assert self.client.delete("/api/credentials/pool/openrouter/1").status_code == 200
assert self.client.delete("/api/credentials/pool/openrouter/99").status_code == 404
def test_empty_body_rejected(self):
r = self.client.post(
"/api/credentials/pool", json={"provider": "", "api_key": ""}
)
assert r.status_code == 400
class TestMemoryEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
from hermes_constants import get_hermes_home
(get_hermes_home() / "memories").mkdir(parents=True, exist_ok=True)
def test_status_and_select(self):
data = self.client.get("/api/memory").json()
assert "active" in data and "providers" in data and "builtin_files" in data
r = self.client.put("/api/memory/provider", json={"provider": "built-in"})
assert r.status_code == 200 and r.json()["active"] == ""
r = self.client.put(
"/api/memory/provider", json={"provider": "no-such-provider-xyz"}
)
assert r.status_code == 400
def test_reset_targets(self):
from hermes_constants import get_hermes_home
mem = get_hermes_home() / "memories"
(mem / "MEMORY.md").write_text("notes")
(mem / "USER.md").write_text("user")
r = self.client.post("/api/memory/reset", json={"target": "user"})
assert r.status_code == 200 and "USER.md" in r.json()["deleted"]
assert (mem / "MEMORY.md").exists()
assert self.client.post(
"/api/memory/reset", json={"target": "bogus"}
).status_code == 400
class TestPairingEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_list_and_bad_approve(self):
data = self.client.get("/api/pairing").json()
assert data == {"pending": [], "approved": []}
r = self.client.post(
"/api/pairing/approve", json={"platform": "telegram", "code": "NOPE99"}
)
assert r.status_code == 404
class TestWebhookEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_list_disabled_and_create_blocked(self):
data = self.client.get("/api/webhooks").json()
assert data["enabled"] is False
r = self.client.post("/api/webhooks", json={"name": "gh", "deliver": "log"})
assert r.status_code == 400
class TestOpsEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_hooks_list_reads_config(self):
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg["hooks"] = {
"pre_tool_call": [
{"matcher": "terminal", "command": "/bin/echo hi", "timeout": 5}
]
}
save_config(cfg)
data = self.client.get("/api/ops/hooks").json()
assert data["hooks"][0]["command"] == "/bin/echo hi"
assert "valid_events" in data and len(data["valid_events"]) >= 1
def test_hook_create_and_delete(self):
# Create with consent approval.
r = self.client.post(
"/api/ops/hooks",
json={
"event": "pre_tool_call",
"command": "/bin/echo created",
"matcher": "terminal",
"timeout": 7,
"approve": True,
},
)
assert r.status_code == 200 and r.json()["approved"] is True
hooks = self.client.get("/api/ops/hooks").json()["hooks"]
created = [h for h in hooks if h["command"] == "/bin/echo created"]
assert created and created[0]["allowed"] is True
# Unknown event rejected.
assert self.client.post(
"/api/ops/hooks", json={"event": "no_such_event", "command": "/x"}
).status_code == 400
# Delete it.
r = self.client.request(
"DELETE",
"/api/ops/hooks",
json={"event": "pre_tool_call", "command": "/bin/echo created"},
)
assert r.status_code == 200
hooks2 = self.client.get("/api/ops/hooks").json()["hooks"]
assert not [h for h in hooks2 if h["command"] == "/bin/echo created"]
def test_checkpoints_list_empty(self):
data = self.client.get("/api/ops/checkpoints").json()
assert data == {"sessions": [], "total_bytes": 0}
def test_import_missing_archive_404(self):
r = self.client.post("/api/ops/import", json={"archive": "/no/such.zip"})
assert r.status_code == 404
class TestSystemStatsEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_stats_shape(self):
r = self.client.get("/api/system/stats")
assert r.status_code == 200
s = r.json()
# Identity fields always present (stdlib-sourced).
for key in ("os", "arch", "hostname", "python_version", "hermes_version"):
assert key in s and s[key]
# psutil flag tells the UI whether the richer metrics are populated.
assert "psutil" in s
class TestCuratorEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_status_and_pause_toggle(self):
r = self.client.get("/api/curator")
assert r.status_code == 200
body = r.json()
assert {"enabled", "paused", "interval_hours"} <= set(body)
# Pause then resume; the read reflects the write.
r = self.client.put("/api/curator/paused", json={"paused": True})
assert r.status_code == 200 and r.json()["paused"] is True
assert self.client.get("/api/curator").json()["paused"] is True
r = self.client.put("/api/curator/paused", json={"paused": False})
assert r.status_code == 200 and r.json()["paused"] is False
class TestPortalEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_status_shape(self):
r = self.client.get("/api/portal")
assert r.status_code == 200
body = r.json()
assert {"logged_in", "features", "subscription_url", "provider"} <= set(body)
assert isinstance(body["features"], list)
class TestSessionManagementEndpoints:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
from hermes_state import SessionDB
db = SessionDB()
db.create_session(session_id="sess-x", source="cli")
db.close()
def test_stats_not_shadowed_by_session_id_route(self):
# /api/sessions/stats must resolve to the stats handler, not be captured
# as {session_id}="stats" by the parameterized route registered after it.
r = self.client.get("/api/sessions/stats")
assert r.status_code == 200
body = r.json()
assert {"total", "active_store", "archived", "messages", "by_source"} <= set(body)
assert body["total"] >= 1
def test_rename(self):
r = self.client.patch("/api/sessions/sess-x", json={"title": "Renamed"})
assert r.status_code == 200 and r.json()["title"] == "Renamed"
def test_export(self):
r = self.client.get("/api/sessions/sess-x/export")
assert r.status_code == 200 and "messages" in r.json()
assert self.client.get("/api/sessions/nope/export").status_code == 404
def test_prune_validation(self):
r = self.client.post("/api/sessions/prune", json={"older_than_days": 9999})
assert r.status_code == 200 and "removed" in r.json()
assert self.client.post(
"/api/sessions/prune", json={"older_than_days": 0}
).status_code == 400
class TestSkillsHubSearchEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_empty_query_returns_empty(self):
# Empty query short-circuits (no network) and returns no results.
r = self.client.get("/api/skills/hub/search?q=")
assert r.status_code == 200 and r.json() == {"results": []}
class TestWebhookToggleEndpoint:
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
# Enable the webhook platform so a subscription can be created.
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("platforms", {})["webhook"] = {
"enabled": True,
"extra": {"host": "0.0.0.0", "port": 8644},
}
save_config(cfg)
def test_create_toggle_disable(self):
r = self.client.post(
"/api/webhooks", json={"name": "hook1", "deliver": "log", "events": ["push"]}
)
assert r.status_code == 200 and r.json()["enabled"] is True
r = self.client.put("/api/webhooks/hook1/enabled", json={"enabled": False})
assert r.status_code == 200 and r.json()["enabled"] is False
subs = self.client.get("/api/webhooks").json()["subscriptions"]
assert subs[0]["enabled"] is False
assert self.client.put(
"/api/webhooks/nope/enabled", json={"enabled": True}
).status_code == 404
class TestAdminEndpointsAuthGate:
"""Every admin endpoint must sit behind the dashboard session-token gate."""
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
from starlette.testclient import TestClient
from hermes_cli.web_server import app
# No session header → must be rejected.
self.client = TestClient(app)
@pytest.mark.parametrize(
"path",
[
"/api/mcp/servers",
"/api/pairing",
"/api/webhooks",
"/api/credentials/pool",
"/api/memory",
"/api/ops/hooks",
"/api/ops/checkpoints",
"/api/curator",
"/api/portal",
"/api/system/stats",
],
)
def test_gated(self, path):
resp = self.client.get(path)
assert resp.status_code in (401, 403)