Files
hermes-webui/tests/test_agent_health_remote.py
nesquena-hermes ba987040c7
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.260 — Release IB (stage-r8) (#3614)
## Release v0.51.260 — Release IB (stage-r8)

Un-held safety fixes (author resolved my earlier hold findings; re-reviewed fresh) + a clean fix batch. 6 PRs.

### Fixed
| Issue/PR | Author | Fix |
|----------|--------|-----|
| #3535 (#3538) | @rodboev | **Self-update recovers from a stash-pop conflict without data loss.** Was a BRICK bug (`git reset --merge` + `git stash drop` discarded local mods while reporting success). Now keeps the stash, returns `ok:false` + "preserved in `stash@{0}`", no restart on conflict. *(was held — fix verified)* |
| #1909 s3 (#3562) | @rodboev | **Auth `Secure` cookie no longer locks out plain-HTTP LAN/Tailscale users.** Secure now keys only on real TLS evidence (env / TLS socket / opt-in `TRUST_FORWARDED_PROTO`); non-loopback plain-HTTP is no longer force-Secure. SameSite back to `Lax`. *(was held — fix verified)* |
| #2785 (#3559) | @franksong2702 | Clearer cron/gateway diagnostics for single-container Docker (gateway configured, no daemon → jobs silently don't fire). |
| #3555 | @lambyangzhao | Long TTS responses chunked at sentence boundaries (works around the browser's ~32K silent-truncation). |
| #3340 (#3342) | @rly09 | Persistent-state toast when a turn has saved memory / created-updated a skill. |
| #3533 | @franksong2702 | `/reload-mcp` marked `cli_only` so the WebUI doesn't dispatch it as an LLM prompt. |

### Gate
- Full pytest suite: **7681 passed, 0 failed**
- ESLint: CLEAN · ruff: CLEAN · browser-smoke: CLEAN
- Codex (regression): **SAFE TO SHIP** — confirmed the stash-conflict path never drops the stash / never restarts on conflict, auth Secure handles LAN-HTTP correctly with no header-forgery hole, `/reload-mcp` allowlisted, state-toast has a real backend writer + active-session guard, diagnostics leak no paths, TTS chunking preserves order.

Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Co-authored-by: lambyangzhao <lambyangzhao@users.noreply.github.com>
Co-authored-by: rly09 <rly09@users.noreply.github.com>
2026-06-04 15:21:41 -07:00

244 lines
9.1 KiB
Python

"""Tests for HERMES_API_URL remote gateway probe (#3281, #3355)."""
from __future__ import annotations
import json
from unittest import mock
import pytest
from api import agent_health
@pytest.fixture(autouse=True)
def _clear_cache():
agent_health._reset_remote_probe_cache_for_tests()
yield
agent_health._reset_remote_probe_cache_for_tests()
class _FakeResp:
def __init__(self, status: int = 200, body: bytes = b""):
self.status = status
self._body = body
def getcode(self) -> int:
return self.status
def read(self, amt: int | None = None) -> bytes:
if amt is None:
return self._body
return self._body[:amt]
def __enter__(self):
return self
def __exit__(self, *_a):
return False
def test_remote_gateway_healthy_when_200(monkeypatch):
monkeypatch.setenv("HERMES_API_URL", "http://gateway:8080")
calls: list[str] = []
def fake_urlopen(req, timeout=None):
calls.append(req.full_url)
return _FakeResp(200)
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is True
assert payload["details"]["reason"] == "remote_gateway"
assert payload["details"]["status_code"] == 200
assert calls and calls[0].startswith("http://gateway:8080/")
def test_remote_gateway_unreachable_when_network_error(monkeypatch):
monkeypatch.setenv("HERMES_API_URL", "http://gateway:8080/")
def fake_urlopen(req, timeout=None):
raise OSError("connection refused")
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is False
assert payload["details"]["reason"] == "remote_gateway_unreachable"
assert payload["details"]["endpoint"] == "http://gateway:8080"
assert "error" in payload["details"]
def test_falls_back_to_local_when_no_env(monkeypatch):
monkeypatch.delenv("HERMES_API_URL", raising=False)
# Force the local importlib path to fail so we hit the well-known
# "gateway_not_configured" terminal state — proving the remote probe was
# NOT invoked and the legacy local path ran.
def boom(name):
raise ModuleNotFoundError(name)
with mock.patch.object(agent_health.importlib, "import_module", boom):
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is None
assert payload["details"]["reason"] == "gateway_status_unavailable"
def test_remote_probe_result_cached_for_5s(monkeypatch):
monkeypatch.setenv("HERMES_API_URL", "http://gateway:8080")
call_count = {"n": 0}
def fake_urlopen(req, timeout=None):
call_count["n"] += 1
return _FakeResp(200)
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
first = agent_health.build_agent_health_payload()
second = agent_health.build_agent_health_payload()
assert first["alive"] is True
assert second["alive"] is True
assert second["details"]["reason"] == "remote_gateway"
# Second call must NOT have hit the network.
assert call_count["n"] == 1
# checked_at is refreshed even on cache hit so the UI shows a current time.
assert "checked_at" in second
# ── #3355: gateway_state extraction and probe-order tests ─────────────────
def test_gateway_state_populated_from_health_detailed(monkeypatch):
"""Remote probe should extract gateway_state from /health/detailed JSON body."""
monkeypatch.setenv("HERMES_API_URL", "http://fake-gateway:8642")
body = json.dumps({"gateway_state": "running", "uptime": 3600}).encode()
def fake_urlopen(req, timeout=None):
return _FakeResp(200, body=body)
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is True
assert payload["details"]["gateway_state"] == "running"
assert payload["details"]["reason"] == "remote_gateway"
def test_probe_order_prefers_health_detailed(monkeypatch):
"""First probe path should be /health/detailed so gateway_state is available."""
monkeypatch.setenv("HERMES_API_URL", "http://fake-gateway:8642")
probed_urls: list[str] = []
def fake_urlopen(req, timeout=None):
probed_urls.append(req.full_url)
# Return 200 on first hit so the loop stops immediately
return _FakeResp(200, body=b'{"gateway_state": "running"}')
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
agent_health.build_agent_health_payload()
assert len(probed_urls) == 1
assert probed_urls[0] == "http://fake-gateway:8642/health/detailed"
def test_gateway_health_url_env_used(monkeypatch):
"""GATEWAY_HEALTH_URL should be respected by the remote probe."""
monkeypatch.delenv("HERMES_API_URL", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_HEALTH_URL", raising=False)
monkeypatch.setenv("GATEWAY_HEALTH_URL", "http://custom:9999")
probed_urls: list[str] = []
def fake_urlopen(req, timeout=None):
probed_urls.append(req.full_url)
return _FakeResp(200, body=b'{}')
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is True
assert probed_urls[0].startswith("http://custom:9999/")
def test_default_url_when_no_env(monkeypatch):
"""Without any gateway env vars, should fall back to local detection (not remote)."""
monkeypatch.delenv("HERMES_API_URL", raising=False)
monkeypatch.delenv("GATEWAY_HEALTH_URL", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_HEALTH_URL", raising=False)
# Force the local importlib path to fail so we hit "gateway_status_unavailable",
# proving the remote probe was NOT invoked.
def boom(name):
raise ModuleNotFoundError(name)
with mock.patch.object(agent_health.importlib, "import_module", boom):
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is None
assert payload["details"]["reason"] == "gateway_status_unavailable"
def test_gateway_health_url_with_health_suffix_is_normalized(monkeypatch):
"""A gateway env var that already points AT a health path must not produce
a doubled path like /health/health/detailed (#3355 Codex follow-up)."""
monkeypatch.delenv("HERMES_API_URL", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_HEALTH_URL", raising=False)
monkeypatch.setenv("GATEWAY_HEALTH_URL", "http://gw:8642/health")
probed: list[str] = []
def fake_urlopen(req, timeout=None):
probed.append(req.full_url)
if req.full_url == "http://gw:8642/health/detailed":
return _FakeResp(200, body=json.dumps({"gateway_state": "running"}).encode())
return _FakeResp(404)
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
payload = agent_health.build_agent_health_payload()
assert all("/health/health" not in u for u in probed), probed
assert "http://gw:8642/health/detailed" in probed
assert payload["alive"] is True
assert payload["details"].get("gateway_state") == "running"
def test_gateway_webui_base_url_env_is_used_for_remote_probe(monkeypatch):
"""`HERMES_WEBUI_GATEWAY_BASE_URL` should be treated like a gateway health base URL."""
monkeypatch.delenv("HERMES_API_URL", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_HEALTH_URL", raising=False)
monkeypatch.delenv("GATEWAY_HEALTH_URL", raising=False)
monkeypatch.setenv("HERMES_WEBUI_GATEWAY_BASE_URL", "http://container-gw:8642")
seen = []
def fake_urlopen(req, timeout=None):
seen.append(req.full_url)
return _FakeResp(200, body=b'{"gateway_state":"running"}')
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is True
assert payload["details"]["reason"] == "remote_gateway"
assert seen[0].startswith("http://container-gw:8642/")
def test_oversized_remote_body_does_not_hang_and_skips_parse(monkeypatch):
"""A 2xx response with a huge body must be capped (not read unbounded) and
still report the gateway alive, just without a parsed gateway_state."""
monkeypatch.setenv("HERMES_API_URL", "http://gateway:8080")
huge = b"x" * (agent_health._REMOTE_PROBE_BODY_LIMIT_BYTES * 4)
captured_amt: list[int | None] = []
class _HugeResp(_FakeResp):
def read(self, amt: int | None = None):
captured_amt.append(amt)
return huge[:amt] if amt is not None else huge
def fake_urlopen(req, timeout=None):
return _HugeResp(200, body=huge)
with mock.patch.object(agent_health.urllib_request, "urlopen", fake_urlopen):
payload = agent_health.build_agent_health_payload()
assert captured_amt and all(a is not None for a in captured_amt)
assert payload["alive"] is True
assert "gateway_state" not in payload["details"]