mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Systematic prune per AGENTS.md test policy, one pass over every major test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli, cron, tui_gateway, honcho/openviking, root-level): - DELETE: source-reading tests (read_text/getsource on prod files), change-detector tests (exact catalog counts, model-name snapshots, config version literals), mock-echo tests (assert a mock returns what it was told), assertion-free/trivial tests, near-duplicate parametrizations (boundaries + one representative kept), async/sync twin duplicates, cosmetic within-file variations. - KEEP (mandatory): security/redaction/approval guards, message-role alternation invariants, prompt-caching/deterministic-call-id invariants, issue-number regression tests (deduped), E2E tests. - 6 test files deleted outright (script-style/no-assert or fully redundant); conftest.py, fakes/, fixtures/ untouched. - tests/acp/conftest.py added: autouse fixture stubs the live models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server tests performed on every session create — test_server.py 147s → 3.4s, and the tests are now genuinely hermetic. - Sleep-based slowness shrunk where safe (codex_ttfb_watchdog, compression_concurrent_fork, etc.); no wall-clock assertion tightened. Verification: full hermetic suite via scripts/run_tests.sh — 2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall (baseline: 583s wall, 13,564s subprocess CPU).
246 lines
9 KiB
Python
246 lines
9 KiB
Python
"""Regression tests for runtime plugin disable gating.
|
|
|
|
Covers two residual bypasses addressed in the PR:
|
|
|
|
1. Plugin API routes mounted at startup remain callable even after the
|
|
plugin is added to ``plugins.disabled`` at runtime. The new
|
|
``_plugin_api_runtime_gate`` middleware blocks these requests.
|
|
|
|
2. Bundled plugin assets were served from the unauthenticated
|
|
``/dashboard-plugins/{name}/{path}`` route even when the bundled
|
|
plugin was in ``plugins.disabled``. The updated ``serve_plugin_asset``
|
|
now applies the disabled check to bundled plugins too.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch, AsyncMock
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import web_server
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_plugin_cache():
|
|
"""Bust the plugin cache before and after each test."""
|
|
web_server._dashboard_plugins_cache = None
|
|
yield
|
|
web_server._dashboard_plugins_cache = None
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client(monkeypatch, tmp_path):
|
|
"""Set up a Starlette TestClient with auth bypassed."""
|
|
try:
|
|
from starlette.testclient import TestClient
|
|
except ImportError:
|
|
pytest.skip("fastapi/starlette not installed")
|
|
|
|
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
|
|
|
# Isolate HERMES_HOME so config reads go to our tmp.
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
|
|
(tmp_path / "home").mkdir(parents=True)
|
|
|
|
client = TestClient(app)
|
|
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
|
return client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_user_plugin(tmp_path, name="hot"):
|
|
"""Create a minimal user plugin with a JS asset."""
|
|
dashboard_dir = tmp_path / "plugins" / name / "dashboard"
|
|
dashboard_dir.mkdir(parents=True)
|
|
dist_dir = dashboard_dir / "dist"
|
|
dist_dir.mkdir()
|
|
(dist_dir / "index.js").write_text("console.log('hello');")
|
|
(dashboard_dir / "manifest.json").write_text(json.dumps({
|
|
"name": name,
|
|
"label": name.title(),
|
|
"entry": "dist/index.js",
|
|
}))
|
|
return dashboard_dir
|
|
|
|
|
|
def _make_bundled_plugin(tmp_path, name="bundledx"):
|
|
"""Create a minimal bundled plugin with a JS asset."""
|
|
dashboard_dir = tmp_path / "bundled" / name / "dashboard"
|
|
dashboard_dir.mkdir(parents=True)
|
|
dist_dir = dashboard_dir / "dist"
|
|
dist_dir.mkdir()
|
|
(dist_dir / "index.js").write_text("console.log('bundled');")
|
|
(dashboard_dir / "manifest.json").write_text(json.dumps({
|
|
"name": name,
|
|
"label": name.title(),
|
|
"entry": "dist/index.js",
|
|
}))
|
|
return dashboard_dir
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 1: Runtime-disabled user plugin API routes return 404
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPluginApiRuntimeGate:
|
|
"""After a user plugin is disabled at runtime, its mounted API routes
|
|
must return 404 — not 200 — even though the router was already
|
|
included at startup. The _plugin_api_runtime_gate middleware enforces
|
|
this at request time."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_middleware_blocks_disabled_user_plugin(self):
|
|
"""Middleware returns 404 for a user plugin added to disabled set."""
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
|
|
fake_plugin = {
|
|
"name": "hot",
|
|
"source": "user",
|
|
}
|
|
|
|
# Simulate a request to /api/plugins/hot/probe
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/api/plugins/hot/probe",
|
|
"query_string": b"",
|
|
"headers": [],
|
|
"state": {"token_authenticated": True},
|
|
}
|
|
request = Request(scope)
|
|
|
|
call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
|
|
|
|
with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
|
|
patch("hermes_cli.plugins_cmd._get_enabled_set", return_value={"hot"}), \
|
|
patch("hermes_cli.plugins_cmd._get_disabled_set", return_value={"hot"}):
|
|
response = await web_server._plugin_api_runtime_gate(request, call_next)
|
|
|
|
assert response.status_code == 404
|
|
call_next.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_middleware_passes_non_plugin_api_routes(self):
|
|
"""Middleware ignores non-plugin API routes."""
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/api/status",
|
|
"query_string": b"",
|
|
"headers": [],
|
|
"state": {"token_authenticated": True},
|
|
}
|
|
request = Request(scope)
|
|
|
|
expected_resp = JSONResponse({"ok": True})
|
|
call_next = AsyncMock(return_value=expected_resp)
|
|
|
|
response = await web_server._plugin_api_runtime_gate(request, call_next)
|
|
|
|
assert response is expected_resp
|
|
call_next.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_middleware_unknown_plugin_defaults_to_user_blocks(self):
|
|
"""Unknown plugin name (not in discovery cache) is treated as user
|
|
plugin and blocked when not enabled."""
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse
|
|
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/api/plugins/unknown/action",
|
|
"query_string": b"",
|
|
"headers": [],
|
|
"state": {"token_authenticated": True},
|
|
}
|
|
request = Request(scope)
|
|
|
|
call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
|
|
|
|
with patch.object(web_server, "_get_dashboard_plugins", return_value=[]), \
|
|
patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
|
|
patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()):
|
|
response = await web_server._plugin_api_runtime_gate(request, call_next)
|
|
|
|
assert response.status_code == 404
|
|
call_next.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test 2: Disabled bundled plugin assets return 404
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBundledPluginAssetGate:
|
|
"""Bundled plugins in ``plugins.disabled`` must have their static
|
|
assets blocked — not just hidden from the listing endpoint."""
|
|
|
|
def test_bundled_asset_returns_404_when_disabled(self, test_client, tmp_path, monkeypatch):
|
|
"""A disabled bundled plugin's JS asset must return 404."""
|
|
plugin_dir = _make_bundled_plugin(tmp_path, "bundledx")
|
|
|
|
fake_plugin = {
|
|
"name": "bundledx",
|
|
"label": "Bundledx",
|
|
"source": "bundled",
|
|
"entry": "dist/index.js",
|
|
"_dir": str(plugin_dir),
|
|
}
|
|
|
|
with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]):
|
|
# Sanity: asset is served when not disabled.
|
|
with patch(
|
|
"hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
|
|
), patch(
|
|
"hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
|
|
):
|
|
resp = test_client.get("/dashboard-plugins/bundledx/dist/index.js")
|
|
assert resp.status_code == 200, (
|
|
"Sanity: bundled plugin asset should be served when not disabled"
|
|
)
|
|
|
|
# Disable it.
|
|
with patch(
|
|
"hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
|
|
), patch(
|
|
"hermes_cli.plugins_cmd._get_disabled_set", return_value={"bundledx"}
|
|
):
|
|
resp = test_client.get("/dashboard-plugins/bundledx/dist/index.js")
|
|
assert resp.status_code == 404, (
|
|
"Disabled bundled plugin asset must return 404"
|
|
)
|
|
|
|
def test_bundled_asset_served_when_not_disabled(self, test_client, tmp_path, monkeypatch):
|
|
"""Bundled plugin assets are served normally when not in disabled set."""
|
|
plugin_dir = _make_bundled_plugin(tmp_path, "goodbundled")
|
|
|
|
fake_plugin = {
|
|
"name": "goodbundled",
|
|
"label": "Good Bundled",
|
|
"source": "bundled",
|
|
"entry": "dist/index.js",
|
|
"_dir": str(plugin_dir),
|
|
}
|
|
|
|
with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]):
|
|
with patch(
|
|
"hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
|
|
), patch(
|
|
"hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
|
|
):
|
|
resp = test_client.get("/dashboard-plugins/goodbundled/dist/index.js")
|
|
assert resp.status_code == 200
|
|
|