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
278 lines
11 KiB
Python
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
|