Files
hermes-webui/tests/test_tls_aware_probe.py
T
nesquena-hermesandtomas-forgac a308fdd5e8 release: v0.51.494 — TLS-aware launcher health probes (#4412)
Absorbs #4412 (tomas-forgac) rebased onto fresh master, plus two
Codex-regression-gate fixes: (1) use the scheme that actually answered the
probe for the ready/already-up URL + browser-open, (2) honor an explicit
HERMES_WEBUI_HEALTH_URL override in the WSL autostart probe.

Co-authored-by: tomas-forgac <tomas-forgac@users.noreply.github.com>

Closes #4412
2026-06-18 18:13:46 +00:00

278 lines
11 KiB
Python

"""Tests for the TLS-aware /health probe shared by all launchers.
Covers the shared shell helper (``scripts/lib/health_probe.sh``), the Python
probe in ``bootstrap.py`` (``wait_for_health``), and two regressions raised in
review of the original TLS-probe PR:
1. ``ctl.sh status`` must not crash when ``.env`` contains shell-readonly
variable assignments such as ``UID=1000`` (it sources ``.env`` to learn the
scheme).
2. Probes must fall back to plain HTTP when TLS cert/key are configured but the
server fell back to serving HTTP (server.py's tested contract), instead of
polling HTTPS forever.
"""
from __future__ import annotations
import http.server
import importlib.util
import os
import socket
import ssl
import subprocess
import threading
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
HEALTH_PROBE = REPO_ROOT / "scripts" / "lib" / "health_probe.sh"
CTL = REPO_ROOT / "ctl.sh"
BOOTSTRAP = REPO_ROOT / "bootstrap.py"
# ── helpers ─────────────────────────────────────────────────────────────────
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class _HealthHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self): # noqa: N802
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"status": "ok"}')
def log_message(self, *args): # suppress server log noise during tests
pass
class _Server:
"""Minimal /health server, optionally TLS-wrapped, on a background thread."""
def __init__(self, cert: str | None = None, key: str | None = None):
self.port = _free_port()
self.httpd = http.server.HTTPServer(("127.0.0.1", self.port), _HealthHandler)
if cert and key:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert, key)
self.httpd.socket = ctx.wrap_socket(self.httpd.socket, server_side=True)
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.httpd.shutdown()
self.httpd.server_close()
self.thread.join(timeout=5)
@pytest.fixture
def self_signed_cert(tmp_path: Path) -> tuple[str, str]:
cert = str(tmp_path / "cert.pem")
key = str(tmp_path / "key.pem")
subprocess.run(
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-keyout", key,
"-out", cert, "-days", "1", "-nodes", "-subj", "/CN=localhost"],
check=True, capture_output=True,
)
return cert, key
def _run_helper(host: str, port: int, env: dict[str, str]) -> subprocess.CompletedProcess:
merged = os.environ.copy()
merged.update(env)
return subprocess.run(
["bash", str(HEALTH_PROBE), host, str(port), "/health", "3"],
capture_output=True, text=True, env=merged, timeout=30,
)
def _load_bootstrap():
spec = importlib.util.spec_from_file_location("hermes_bootstrap_probe", BOOTSTRAP)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ── shared shell helper ─────────────────────────────────────────────────────
def test_helper_scheme_http_without_tls():
out = subprocess.run(
["bash", "-c", f". {HEALTH_PROBE}; hermes_webui_probe_scheme"],
capture_output=True, text=True, env={**os.environ,
"HERMES_WEBUI_TLS_CERT": "",
"HERMES_WEBUI_TLS_KEY": ""},
)
assert out.stdout == "http"
def test_helper_scheme_https_with_tls():
out = subprocess.run(
["bash", "-c", f". {HEALTH_PROBE}; hermes_webui_probe_scheme"],
capture_output=True, text=True,
env={**os.environ, "HERMES_WEBUI_TLS_CERT": "/c", "HERMES_WEBUI_TLS_KEY": "/k"},
)
assert out.stdout == "https"
def test_helper_plain_http_probe():
with _Server() as srv:
res = _run_helper("127.0.0.1", srv.port,
{"HERMES_WEBUI_TLS_CERT": "", "HERMES_WEBUI_TLS_KEY": ""})
assert res.returncode == 0
assert '"status": "ok"' in res.stdout
def test_helper_self_signed_warns_and_succeeds(self_signed_cert):
cert, key = self_signed_cert
with _Server(cert, key) as srv:
res = _run_helper("127.0.0.1", srv.port,
{"HERMES_WEBUI_TLS_CERT": cert, "HERMES_WEBUI_TLS_KEY": key})
assert res.returncode == 0, res.stderr
assert '"status": "ok"' in res.stdout
assert "self-signed" in res.stderr.lower()
def test_helper_insecure_optin_is_silent(self_signed_cert):
cert, key = self_signed_cert
with _Server(cert, key) as srv:
res = _run_helper("127.0.0.1", srv.port, {
"HERMES_WEBUI_TLS_CERT": cert,
"HERMES_WEBUI_TLS_KEY": key,
"HERMES_WEBUI_TLS_INSECURE_PROBE": "1",
})
assert res.returncode == 0, res.stderr
assert '"status": "ok"' in res.stdout
# Explicit opt-in is silent by contract.
assert res.stderr.strip() == ""
def test_helper_https_falls_back_to_http_when_server_is_plain_http(self_signed_cert):
"""TLS configured in env, but the server serves plain HTTP (server.py's
fallback-on-bad-cert contract). The probe must still succeed via HTTP."""
cert, key = self_signed_cert
with _Server() as srv: # plain HTTP server
res = _run_helper("127.0.0.1", srv.port,
{"HERMES_WEBUI_TLS_CERT": cert, "HERMES_WEBUI_TLS_KEY": key})
assert res.returncode == 0, res.stderr
assert '"status": "ok"' in res.stdout
def test_helper_insecure_optin_falls_back_to_http(self_signed_cert):
"""Shell helper: insecure opt-in + server serving plain HTTP must still
succeed via the HTTP fallback (and stay silent per the opt-in contract)."""
cert, key = self_signed_cert
with _Server() as srv: # plain HTTP
res = _run_helper("127.0.0.1", srv.port, {
"HERMES_WEBUI_TLS_CERT": cert,
"HERMES_WEBUI_TLS_KEY": key,
"HERMES_WEBUI_TLS_INSECURE_PROBE": "1",
})
assert res.returncode == 0, res.stderr
assert '"status": "ok"' in res.stdout
assert res.stderr.strip() == ""
# ── bootstrap.py wait_for_health ────────────────────────────────────────────
def test_bootstrap_probe_self_signed(monkeypatch, self_signed_cert):
cert, key = self_signed_cert
monkeypatch.setenv("HERMES_WEBUI_TLS_CERT", cert)
monkeypatch.setenv("HERMES_WEBUI_TLS_KEY", key)
monkeypatch.delenv("HERMES_WEBUI_TLS_INSECURE_PROBE", raising=False)
bs = _load_bootstrap()
with _Server(cert, key) as srv:
assert bs.wait_for_health(f"https://127.0.0.1:{srv.port}/health", timeout=8)
def test_bootstrap_probe_http_fallback(monkeypatch, self_signed_cert):
"""TLS env set but server serves HTTP -> must succeed via HTTP fallback,
not poll HTTPS forever (regression for server.py's fallback contract)."""
cert, key = self_signed_cert
monkeypatch.setenv("HERMES_WEBUI_TLS_CERT", cert)
monkeypatch.setenv("HERMES_WEBUI_TLS_KEY", key)
bs = _load_bootstrap()
with _Server() as srv: # plain HTTP
# The returned scheme must reflect the ACTUAL successful scheme (http),
# not the configured https — so the ready URL / browser-open uses the
# scheme the server is reachable on, not a dead https:// (Codex gate).
assert bs.wait_for_health(f"https://127.0.0.1:{srv.port}/health", timeout=8) == "http"
def test_bootstrap_probe_returns_scheme_actually_used(monkeypatch, self_signed_cert):
"""wait_for_health returns the scheme that answered, truthy on success."""
cert, key = self_signed_cert
# HTTPS server with self-signed cert -> answered over https.
monkeypatch.setenv("HERMES_WEBUI_TLS_CERT", cert)
monkeypatch.setenv("HERMES_WEBUI_TLS_KEY", key)
monkeypatch.delenv("HERMES_WEBUI_TLS_INSECURE_PROBE", raising=False)
bs = _load_bootstrap()
with _Server(cert, key) as srv:
assert bs.wait_for_health(f"https://127.0.0.1:{srv.port}/health", timeout=8) == "https"
# Plain-HTTP, no TLS env -> answered over http.
monkeypatch.delenv("HERMES_WEBUI_TLS_CERT", raising=False)
monkeypatch.delenv("HERMES_WEBUI_TLS_KEY", raising=False)
bs = _load_bootstrap()
with _Server() as srv:
assert bs.wait_for_health(f"http://127.0.0.1:{srv.port}/health", timeout=8) == "http"
# Timeout -> empty string (falsy, so `if not wait_for_health(...)` still works).
assert bs.wait_for_health("http://127.0.0.1:1/health", timeout=0.5) == ""
def test_bootstrap_probe_insecure_optin_http_fallback(monkeypatch, self_signed_cert):
"""Insecure opt-in is set AND the server fell back to plain HTTP.
The probe must still succeed via the HTTP fallback rather than spinning on
unverified HTTPS until timeout. Locks the reachability of the HTTP fallback
from inside the insecure-opt-in branch (guards against a future refactor
that moves the fallback under the non-opt-in ``else``).
"""
cert, key = self_signed_cert
monkeypatch.setenv("HERMES_WEBUI_TLS_CERT", cert)
monkeypatch.setenv("HERMES_WEBUI_TLS_KEY", key)
monkeypatch.setenv("HERMES_WEBUI_TLS_INSECURE_PROBE", "1")
bs = _load_bootstrap()
with _Server() as srv: # plain HTTP server, no TLS
assert bs.wait_for_health(f"https://127.0.0.1:{srv.port}/health", timeout=8)
def test_bootstrap_probe_plain_http(monkeypatch):
monkeypatch.delenv("HERMES_WEBUI_TLS_CERT", raising=False)
monkeypatch.delenv("HERMES_WEBUI_TLS_KEY", raising=False)
bs = _load_bootstrap()
with _Server() as srv:
assert bs.wait_for_health(f"http://127.0.0.1:{srv.port}/health", timeout=8)
def test_bootstrap_probe_rejects_bad_scheme(monkeypatch):
bs = _load_bootstrap()
with pytest.raises(ValueError):
bs.wait_for_health("file:///etc/passwd", timeout=1)
# ── ctl.sh status regression: readonly .env vars ────────────────────────────
def test_ctl_status_survives_readonly_env_vars(tmp_path: Path):
"""`.env` with UID=/GID= must not crash `ctl.sh status` (it sources .env)."""
repo = tmp_path / "repo"
(repo / "scripts" / "lib").mkdir(parents=True)
(repo / "ctl.sh").write_bytes(CTL.read_bytes())
(repo / "scripts" / "lib" / "health_probe.sh").write_bytes(HEALTH_PROBE.read_bytes())
(repo / ".env").write_text("UID=1000\nGID=1000\nHERMES_WEBUI_PORT=8787\n",
encoding="utf-8")
env = os.environ.copy()
env["HERMES_HOME"] = str(tmp_path / ".hermes")
res = subprocess.run(
["bash", str(repo / "ctl.sh"), "status"],
capture_output=True, text=True, env=env, timeout=30,
)
assert res.returncode == 0, res.stderr
assert "hermes-webui" in res.stdout
assert "readonly variable" not in res.stderr