"""Public chat/triage hostnames stay served on every layer during a rename. PR #34 renamed the chat and triage public hosts in place: the legacy names left the certificate SANs, the Ingress rules, the CoreDNS overrides and the Keycloak ensure script in one change. The legacy hosts began answering 404 with Traefik's default certificate while the renamed hosts could not finish a login, because Keycloak matches ``redirect_uri`` exactly and still held the old callback. Chat and triage were unreachable on every hostname at once. These tests pin the contract that makes that outage impossible to reintroduce silently: a public host is either served by all four layers or by none of them. """ from __future__ import annotations import re from pathlib import Path import pytest import yaml REPO = Path(__file__).parents[2] HERMES = REPO / "services/hermes" KEYCLOAK = REPO / "services/keycloak" COREDNS = REPO / "infrastructure/core/coredns-custom.yaml" ENSURE_SCRIPT = KEYCLOAK / "scripts/hermes_access_oidc_ensure.sh" # Every hostname the chat/triage surfaces must answer on, with the oauth2-proxy # backend that serves it. Both the renamed and the legacy names belong here # until a deliberate retirement change removes a row from this table. PUBLIC_HOSTS = { "chat.bstein.dev": "oauth2-proxy-hermes-chat", "chat.hermes.bstein.dev": "oauth2-proxy-hermes-chat", "triage.bstein.dev": "oauth2-proxy-hermes-triage", "triage.hermes.bstein.dev": "oauth2-proxy-hermes-triage", } # The agent surface is mid-rename too: worker.bstein.dev is canonical and the # legacy name stays served until a deliberate retirement change removes it. AGENT_HOST = "agent.hermes.bstein.dev" AGENT_CANONICAL_HOST = "worker.bstein.dev" def _docs(path: Path) -> list[dict]: return [doc for doc in yaml.safe_load_all(path.read_text()) if doc] def _named(path: Path, kind: str, name: str) -> dict: for doc in _docs(path): if doc.get("kind") == kind and doc["metadata"]["name"] == name: return doc raise AssertionError(f"{kind}/{name} missing from {path}") @pytest.fixture(scope="module") def certificate() -> dict: return _named(HERMES / "agent-certificate.yaml", "Certificate", "hermes-sites-tls") @pytest.fixture(scope="module") def sites_ingress() -> dict: return _named(HERMES / "agent-ingress.yaml", "Ingress", "hermes-sites") @pytest.fixture(scope="module") def coredns_hosts() -> set[str]: block = yaml.safe_load(COREDNS.read_text())["data"]["bstein-dev.server"] return { line.split()[1] for line in block.splitlines() if len(line.split()) == 2 and re.fullmatch(r"[\d.]+", line.split()[0]) } @pytest.fixture(scope="module") def ensure_script() -> str: return ENSURE_SCRIPT.read_text() @pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS)) def test_host_is_on_the_shared_certificate(host: str, certificate: dict): """A host without a SAN serves Traefik's default self-signed certificate.""" assert host in certificate["spec"]["dnsNames"] @pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS)) def test_host_resolves_inside_the_cluster(host: str, coredns_hosts: set[str]): assert host in coredns_hosts @pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS)) def test_host_has_an_ingress_rule_and_tls_entry(host: str, sites_ingress: dict): """A host without a rule answers 404 even though DNS and TLS look healthy.""" spec = sites_ingress["spec"] assert host in {name for entry in spec["tls"] for name in entry["hosts"]} rule = next((item for item in spec["rules"] if item["host"] == host), None) assert rule is not None, f"no hermes-sites rule serves {host}" backends = { path["backend"]["service"]["name"] for path in rule["http"]["paths"] } assert backends == {PUBLIC_HOSTS[host]} @pytest.mark.parametrize("host", sorted(PUBLIC_HOSTS)) def test_host_is_registered_with_keycloak(host: str, ensure_script: str): """Keycloak matches redirect_uri exactly, so every served host needs one.""" assert f"https://{host}" in ensure_script @pytest.mark.parametrize("host", [AGENT_HOST, AGENT_CANONICAL_HOST]) def test_agent_surface_serves_both_hosts_during_its_rename( host: str, certificate: dict, coredns_hosts: set[str], ensure_script: str ): """Retiring the legacy owner host is a separate, deliberate change. The agent hosts are served by hermes-agent-dashboard/-terminal rather than hermes-sites, so their Ingress rules are asserted in ``test_hermes_agent_runtime_patches.py`` instead of the table above. """ assert host in certificate["spec"]["dnsNames"] assert host in coredns_hosts assert f"https://{host}" in ensure_script def test_ensure_script_registers_legacy_and_renamed_origins_together( ensure_script: str, ): """Every renamed proxy must accept its old and new origin at the same time.""" for client, canonical, legacy in ( ("hermes-chat-proxy", "chat.bstein.dev", "chat.hermes.bstein.dev"), ("hermes-triage-proxy", "triage.bstein.dev", "triage.hermes.bstein.dev"), ("hermes-agent-proxy", "worker.bstein.dev", "agent.hermes.bstein.dev"), ): call = re.search( rf'ensure_proxy_client "{client}".*?(?=\nensure_)', ensure_script, re.DOTALL, ) assert call, f"{client} is never ensured" assert f"https://{canonical}" in call.group(0) assert f"https://{legacy}" in call.group(0) def test_ensure_job_is_rerun_whenever_the_script_changes(): """The Job is immutable, so a stale name silently skips the rerun.""" job = _named( KEYCLOAK / "bootstrap-jobs/hermes-access-oidc-client-job.yaml", "Job", # The suffix moves with every rerun; resolve it from the manifest. _job_name(), ) assert job["spec"]["template"]["spec"]["containers"][0]["command"] == [ "/scripts/hermes_access_oidc_ensure.sh" ] def _job_name() -> str: path = KEYCLOAK / "bootstrap-jobs/hermes-access-oidc-client-job.yaml" name = yaml.safe_load(path.read_text())["metadata"]["name"] assert re.fullmatch(r"hermes-access-oidc-client-ensure-\d+", name), name return name