The dashboard's update button ran 'hermes update' immediately with no preview. Now the System page shows whether an update is available and asks the user to confirm before applying it. - New GET /api/hermes/update/check: reports install method, current version, and commits-behind (via banner.check_for_updates, 6h-cached; ?force=1 busts the cache). Soft-fails to behind=null on network error; marks docker/nix/homebrew as can_apply=false with the out-of-band cmd. - System page: update-status badge on the Hermes version row (latest / N behind), a Check-for-updates button, and an Update-now button that opens a ConfirmDialog showing the commit count before POST /api/hermes/ update fires. Cached status loads with the rest of the page. - Docs + 5 endpoint tests (git/up-to-date/docker/soft-failure + auth gate).
501 lines
18 KiB
Python
501 lines
18 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",
|
|
"/api/hermes/update/check",
|
|
],
|
|
)
|
|
def test_gated(self, path):
|
|
resp = self.client.get(path)
|
|
assert resp.status_code in (401, 403)
|
|
|
|
|
|
class TestUpdateCheckEndpoint:
|
|
"""``GET /api/hermes/update/check`` reports availability without applying.
|
|
|
|
Powers the dashboard's check-before-you-update flow: the System page
|
|
shows the commit-behind count and asks the user to confirm before
|
|
``POST /api/hermes/update`` runs ``hermes update``.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(self, _isolate_hermes_home):
|
|
self.client, _ = _client()
|
|
|
|
def test_git_install_reports_behind_count(self, monkeypatch):
|
|
import hermes_cli.web_server as ws
|
|
|
|
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
|
# Stub the shared checker so the contract is deterministic (no network).
|
|
import hermes_cli.banner as banner
|
|
|
|
monkeypatch.setattr(banner, "check_for_updates", lambda: 5)
|
|
|
|
r = self.client.get("/api/hermes/update/check")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert {
|
|
"install_method",
|
|
"current_version",
|
|
"behind",
|
|
"update_available",
|
|
"can_apply",
|
|
"update_command",
|
|
"message",
|
|
} <= set(body)
|
|
assert body["install_method"] == "git"
|
|
assert body["behind"] == 5
|
|
assert body["update_available"] is True
|
|
# git/pip installs can apply the update in place from the dashboard.
|
|
assert body["can_apply"] is True
|
|
|
|
def test_up_to_date(self, monkeypatch):
|
|
import hermes_cli.web_server as ws
|
|
import hermes_cli.banner as banner
|
|
|
|
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
|
monkeypatch.setattr(banner, "check_for_updates", lambda: 0)
|
|
|
|
body = self.client.get("/api/hermes/update/check").json()
|
|
assert body["behind"] == 0
|
|
assert body["update_available"] is False
|
|
|
|
def test_docker_is_not_applyable(self, monkeypatch):
|
|
import hermes_cli.web_server as ws
|
|
|
|
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "docker")
|
|
body = self.client.get("/api/hermes/update/check").json()
|
|
# Docker images are immutable — the dashboard can't apply an update.
|
|
assert body["can_apply"] is False
|
|
assert body["message"]
|
|
assert body["behind"] is None
|
|
|
|
def test_check_failure_is_soft(self, monkeypatch):
|
|
import hermes_cli.web_server as ws
|
|
import hermes_cli.banner as banner
|
|
|
|
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
|
|
|
def _boom():
|
|
raise RuntimeError("offline")
|
|
|
|
monkeypatch.setattr(banner, "check_for_updates", _boom)
|
|
# A failed check must not 500 — it returns behind=null with guidance.
|
|
r = self.client.get("/api/hermes/update/check")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["behind"] is None
|
|
assert body["update_available"] is False
|
|
assert body["message"]
|
|
|