mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
Phase 0, Task 0.3. start_server now computes should_require_auth(host, allow_public) and records it on app.state.auth_required BEFORE the existing legacy SystemExit guard fires. This gives middleware, the SPA token-injection path, and WS endpoints a consistent read source for 'is the gate active'. The flag is set but no one reads it yet — Phase 3 registers the gate middleware. Note: 4 pre-existing test failures in tests/hermes_cli/test_web_server.py (PtyWebSocket) + test_update_hangup_protection.py reproduce on pristine HEAD and are unrelated to this change (starlette TestClient WS regression).
154 lines
5.8 KiB
Python
154 lines
5.8 KiB
Python
"""Regression harness for the dashboard auth gate.
|
|
|
|
Phase 0 — establish a baseline pin on the current (pre-OAuth) behavior so
|
|
later phases can prove they didn't break loopback mode.
|
|
"""
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from hermes_cli import web_server
|
|
|
|
|
|
@pytest.fixture
|
|
def client_loopback():
|
|
# Pin the bound-host state for host_header_middleware so requests with
|
|
# default Host: testclient pass the DNS-rebinding check. TestClient
|
|
# sends Host: testserver by default, but our middleware accepts the
|
|
# loopback aliases when bound_host is loopback.
|
|
prev_host = getattr(web_server.app.state, "bound_host", None)
|
|
prev_port = getattr(web_server.app.state, "bound_port", None)
|
|
web_server.app.state.bound_host = "127.0.0.1"
|
|
web_server.app.state.bound_port = 9119
|
|
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
|
|
yield client
|
|
web_server.app.state.bound_host = prev_host
|
|
web_server.app.state.bound_port = prev_port
|
|
|
|
|
|
def test_loopback_status_is_public(client_loopback):
|
|
"""`/api/status` must remain reachable without a token in loopback mode."""
|
|
r = client_loopback.get("/api/status")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert "version" in body
|
|
|
|
|
|
def test_loopback_protected_route_requires_token(client_loopback):
|
|
"""Any non-public /api/ route must require the session token."""
|
|
# /api/sessions exists and is auth-gated by auth_middleware.
|
|
r = client_loopback.get("/api/sessions")
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_loopback_protected_route_accepts_session_token(client_loopback):
|
|
"""The injected SPA token unlocks protected /api/ routes."""
|
|
r = client_loopback.get(
|
|
"/api/sessions",
|
|
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
|
|
)
|
|
# 200 or 404 (no sessions yet) both prove the auth layer let it through.
|
|
# 500 is also acceptable if there's a downstream issue unrelated to auth.
|
|
assert r.status_code != 401, (
|
|
f"Expected auth to succeed but got 401; body: {r.text}"
|
|
)
|
|
|
|
|
|
def test_loopback_index_injects_session_token(client_loopback):
|
|
"""Loopback mode keeps injecting the SPA token into index.html.
|
|
|
|
This is the property that the new auth gate MUST disable once a gated
|
|
bind is detected. Phase 3 will add an inverse test for the gated path.
|
|
"""
|
|
r = client_loopback.get("/")
|
|
if r.status_code == 404:
|
|
pytest.skip("WEB_DIST not built in this env")
|
|
assert "__HERMES_SESSION_TOKEN__" in r.text
|
|
|
|
|
|
def test_loopback_host_header_validation_still_enforced(client_loopback):
|
|
"""DNS-rebinding protection: a foreign Host header is rejected."""
|
|
r = client_loopback.get("/api/status", headers={"Host": "evil.test"})
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# should_require_auth predicate (Task 0.2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize("host,allow_public,expected", [
|
|
("127.0.0.1", False, False),
|
|
("127.0.0.1", True, False),
|
|
("localhost", False, False),
|
|
("::1", False, False),
|
|
("0.0.0.0", True, False), # --insecure escape hatch
|
|
("0.0.0.0", False, True),
|
|
("192.168.1.5", False, True),
|
|
("10.0.0.1", True, False),
|
|
("100.64.0.1", False, True), # Tailscale CGNAT — treated as public
|
|
("hermes-agent-prod-abc.fly.dev", False, True),
|
|
])
|
|
def test_should_require_auth_truth_table(host, allow_public, expected):
|
|
from hermes_cli.web_server import should_require_auth
|
|
assert should_require_auth(host, allow_public) is expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start_server stashes auth_required on app.state (Task 0.3)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _stub_uvicorn_run(monkeypatch):
|
|
"""Replace uvicorn.run with a no-op recorder so start_server returns
|
|
immediately (rather than blocking on the event loop). Returns the dict
|
|
that will capture the keyword args."""
|
|
import uvicorn
|
|
captured: dict = {}
|
|
|
|
def _fake_run(*args, **kwargs):
|
|
captured["args"] = args
|
|
captured["kwargs"] = kwargs
|
|
|
|
monkeypatch.setattr(uvicorn, "run", _fake_run)
|
|
return captured
|
|
|
|
|
|
def test_start_server_loopback_sets_auth_required_false(monkeypatch):
|
|
"""Loopback bind: app.state.auth_required is False after start_server."""
|
|
_stub_uvicorn_run(monkeypatch)
|
|
# Force a fresh state to detect that start_server actually set it.
|
|
web_server.app.state.auth_required = None
|
|
web_server.start_server(
|
|
host="127.0.0.1", port=9119,
|
|
open_browser=False, allow_public=False,
|
|
)
|
|
assert web_server.app.state.auth_required is False
|
|
|
|
|
|
def test_start_server_insecure_public_sets_auth_required_false(monkeypatch):
|
|
"""``--insecure`` (allow_public=True) on a public host: gate stays OFF."""
|
|
_stub_uvicorn_run(monkeypatch)
|
|
web_server.app.state.auth_required = None
|
|
web_server.start_server(
|
|
host="0.0.0.0", port=9119,
|
|
open_browser=False, allow_public=True,
|
|
)
|
|
assert web_server.app.state.auth_required is False
|
|
|
|
|
|
def test_start_server_public_without_insecure_records_auth_required(monkeypatch):
|
|
"""Public bind without --insecure: the gate is meant to engage.
|
|
|
|
Until Phase 3 lands, start_server still raises SystemExit on this path
|
|
(the legacy "refusing to bind" guard). We must still observe the
|
|
auth_required flag being set on app.state BEFORE the exit happens, so
|
|
the rest of the system can branch on it consistently.
|
|
"""
|
|
_stub_uvicorn_run(monkeypatch)
|
|
web_server.app.state.auth_required = None
|
|
with pytest.raises(SystemExit):
|
|
web_server.start_server(
|
|
host="0.0.0.0", port=9119,
|
|
open_browser=False, allow_public=False,
|
|
)
|
|
assert web_server.app.state.auth_required is True
|