"""Unit tests for boot-time relay self-provisioning. Covers gateway.relay.self_provision_relay() + the relay_endpoint() / relay_route_keys() config readers. The connector HTTP POST is monkeypatched (the cross-repo E2E exercises the real /relay/provision); these prove the TRIGGER logic, in-process env wiring, and fail-soft boot behaviour. The trigger is deliberately NOT is_managed() (that means NixOS/package-manager- managed, which is False on a NAS-hosted Fly agent). The real gate is "relay_url set + no pinned secret + a resolvable NAS token". """ from __future__ import annotations import os import pytest import gateway.relay as relay @pytest.fixture(autouse=True) def _clean_env(monkeypatch): for k in ( "GATEWAY_RELAY_URL", "GATEWAY_RELAY_ID", "GATEWAY_RELAY_SECRET", "GATEWAY_RELAY_DELIVERY_KEY", "GATEWAY_RELAY_ENDPOINT", "GATEWAY_RELAY_ROUTE_KEYS", "GATEWAY_RELAY_PLATFORM", "GATEWAY_RELAY_BOT_ID", "GATEWAY_RELAY_INSTANCE_ID", "GATEWAY_RELAY_WAKE_URL", ): monkeypatch.delenv(k, raising=False) # Never read config.yaml off disk in these tests. monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}, raising=False) def _stub_post(captured: dict): """A fake _post_provision that records its kwargs and returns creds.""" def _fake(**kwargs): captured.update(kwargs) return { "secret": "a" * 64, "deliveryKey": "b" * 64, "tenant": "org-tenant-x", "gatewayId": kwargs["gateway_id"], "routeKeys": kwargs["route_keys"], } return _fake def _arm(monkeypatch, *, url="wss://connector.example/relay", token="nas-token"): """Arm the real trigger: a relay URL + a resolvable NAS token. Note there is intentionally no `managed` knob — self-provision no longer consults is_managed(). A test that wants the "no NAS identity" branch monkeypatches resolve_nous_access_token to raise instead. """ monkeypatch.setattr(relay, "relay_url", lambda: url) monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: token) # ─────────────────────────── config readers ─────────────────────────── def test_relay_endpoint_from_env(monkeypatch): monkeypatch.setenv("GATEWAY_RELAY_ENDPOINT", "https://gw.example.com/inbound/") assert relay.relay_endpoint() == "https://gw.example.com/inbound" def test_relay_endpoint_absent_is_none(): assert relay.relay_endpoint() is None def test_relay_route_keys_csv(monkeypatch): monkeypatch.setenv("GATEWAY_RELAY_ROUTE_KEYS", "guild-1, guild-2 ,, guild-3") assert relay.relay_route_keys() == ["guild-1", "guild-2", "guild-3"] def test_relay_route_keys_empty(): assert relay.relay_route_keys() == [] def test_relay_instance_id_from_env(monkeypatch): monkeypatch.setenv("GATEWAY_RELAY_INSTANCE_ID", " inst-abc ") assert relay.relay_instance_id() == "inst-abc" def test_relay_instance_id_absent_is_none(): assert relay.relay_instance_id() is None def test_relay_instance_id_from_config(monkeypatch): monkeypatch.setattr( "gateway.run._load_gateway_config", lambda: {"gateway": {"relay_instance_id": "inst-from-config"}}, raising=False, ) assert relay.relay_instance_id() == "inst-from-config" def test_provision_url_maps_ws_to_http(): assert relay._provision_url("wss://c.example/relay") == "https://c.example/relay/provision" assert relay._provision_url("ws://c.example/relay") == "http://c.example/relay/provision" assert relay._provision_url("https://c.example") == "https://c.example/relay/provision" # ─────────────────────────── trigger logic ─────────────────────────── def test_provisions_on_nas_host_that_is_NOT_is_managed(monkeypatch): """Regression: a NAS-hosted Fly agent sets neither HERMES_MANAGED nor a .managed marker, so is_managed() is False. Self-provision must STILL fire — the old is_managed() gate silently no-oped exactly this case in staging. """ # Force is_managed() False to model a real hosted agent; it must be irrelevant. monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False) _arm(monkeypatch) captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) assert relay.self_provision_relay() is True assert relay.relay_connection_auth()[1] == "a" * 64 def test_skips_when_relay_not_configured(monkeypatch): _arm(monkeypatch, url=None) called = {"n": 0} monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {}) assert relay.self_provision_relay() is False assert called["n"] == 0 def test_skips_when_secret_already_pinned(monkeypatch): """A self-hosted, enrolled gateway has a pinned secret -> never self-provisions.""" _arm(monkeypatch) monkeypatch.setenv("GATEWAY_RELAY_ID", "gw-pinned") monkeypatch.setenv("GATEWAY_RELAY_SECRET", "deadbeef") called = {"n": 0} monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {}) assert relay.self_provision_relay() is False assert called["n"] == 0 # The pinned secret is untouched. assert relay.relay_connection_auth() == ("gw-pinned", "deadbeef") # ─────────────────────────── happy path ─────────────────────────── def test_provisions_and_sets_env_in_process(monkeypatch): _arm(monkeypatch) monkeypatch.setenv("GATEWAY_RELAY_ENDPOINT", "https://gw.example.com/inbound") monkeypatch.setenv("GATEWAY_RELAY_ROUTE_KEYS", "guild-1,guild-2") captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) assert relay.self_provision_relay() is True # The connector POST carried the gateway-asserted endpoint + route keys. assert captured["provision_url"] == "https://connector.example/relay/provision" assert captured["access_token"] == "nas-token" assert captured["gateway_endpoint"] == "https://gw.example.com/inbound" assert captured["route_keys"] == ["guild-1", "guild-2"] # Creds landed in os.environ (in-process), so register_relay_adapter() reads them. gid, secret = relay.relay_connection_auth() assert gid and secret == "a" * 64 # The delivery key is persisted in-process too (issued by the connector, # kept for forward-compat; inbound rides the WS so it isn't consumed). assert os.environ["GATEWAY_RELAY_DELIVERY_KEY"] == "b" * 64 def test_outbound_only_when_no_endpoint(monkeypatch): _arm(monkeypatch) captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) assert relay.self_provision_relay() is True assert captured["gateway_endpoint"] is None assert captured["route_keys"] == [] assert relay.relay_connection_auth()[1] == "a" * 64 # ─────────────────── instance-id forwarding (Phase 6 Unit α) ─────────────────── def test_forwards_instance_id_to_provision(monkeypatch): """A managed agent stamped with GATEWAY_RELAY_INSTANCE_ID forwards it to the connector so it can bind gatewayId -> instanceId (per-instance routing).""" _arm(monkeypatch) monkeypatch.setenv("GATEWAY_RELAY_INSTANCE_ID", "inst-abc") captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) assert relay.self_provision_relay() is True assert captured["instance_id"] == "inst-abc" def test_instance_id_absent_forwards_none(monkeypatch): """No stamp (self-hosted / pre-Phase-6) -> instance_id None; the connector stores null and per-instance routing simply has no binding yet.""" _arm(monkeypatch) captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) assert relay.self_provision_relay() is True assert captured["instance_id"] is None def test_post_provision_body_includes_instanceId_only_when_set(monkeypatch): """The real _post_provision adds `instanceId` to the JSON body ONLY when a value is supplied — omitting it lets the connector store null (back-compat), rather than binding an empty string.""" import json sent: dict = {} class _Resp: def __enter__(self): return self def __exit__(self, *a): return False def read(self): return json.dumps({"secret": "a" * 64, "deliveryKey": "b" * 64, "tenant": "t", "gatewayId": "gw-1"}).encode() def _fake_urlopen(req, timeout=None): # noqa: ANN001 sent["body"] = json.loads(req.data.decode()) return _Resp() monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) # With an instance id -> present in the body. relay._post_provision( provision_url="https://c.example/relay/provision", access_token="tok", gateway_id="gw-1", platform="discord", bot_id="app", gateway_endpoint=None, route_keys=[], instance_id="inst-abc", ) assert sent["body"]["instanceId"] == "inst-abc" # Without one -> the key is absent entirely (not "" ). relay._post_provision( provision_url="https://c.example/relay/provision", access_token="tok", gateway_id="gw-1", platform="discord", bot_id="app", gateway_endpoint=None, route_keys=[], ) assert "instanceId" not in sent["body"] # ─────────────────── wake-url forwarding (Phase 5 Unit C) ─────────────────── def test_relay_wake_url_from_env(monkeypatch): monkeypatch.setenv("GATEWAY_RELAY_WAKE_URL", " https://wake.example/poke ") assert relay.relay_wake_url() == "https://wake.example/poke" def test_relay_wake_url_absent_is_none(): assert relay.relay_wake_url() is None def test_relay_wake_url_from_config(monkeypatch): monkeypatch.setattr( "gateway.run._load_gateway_config", lambda: {"gateway": {"relay_wake_url": "https://wake.from-config/poke"}}, raising=False, ) assert relay.relay_wake_url() == "https://wake.from-config/poke" def test_forwards_wake_url_to_provision(monkeypatch): """A suspendable agent stamped with GATEWAY_RELAY_WAKE_URL forwards it to the connector so the connector can poke it awake when the first buffered event lands on a flipped destination (Unit C wake primitive).""" _arm(monkeypatch) monkeypatch.setenv("GATEWAY_RELAY_WAKE_URL", "https://wake.example/poke") captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) assert relay.self_provision_relay() is True assert captured["wake_url"] == "https://wake.example/poke" def test_wake_url_absent_forwards_none(monkeypatch): """No stamp (self-hosted / non-suspendable) -> wake_url None; the connector stores null and simply never pokes (it can't wake what it can't reach).""" _arm(monkeypatch) captured: dict = {} monkeypatch.setattr(relay, "_post_provision", _stub_post(captured)) assert relay.self_provision_relay() is True assert captured["wake_url"] is None def test_post_provision_body_includes_wakeUrl_only_when_set(monkeypatch): """The real _post_provision adds `wakeUrl` to the JSON body ONLY when a value is supplied — omitting it lets the connector store null (back-compat).""" import json sent: dict = {} class _Resp: def __enter__(self): return self def __exit__(self, *a): return False def read(self): return json.dumps({"secret": "a" * 64, "deliveryKey": "b" * 64, "tenant": "t", "gatewayId": "gw-1"}).encode() def _fake_urlopen(req, timeout=None): # noqa: ANN001 sent["body"] = json.loads(req.data.decode()) return _Resp() monkeypatch.setattr("urllib.request.urlopen", _fake_urlopen) # With a wake url -> present in the body. relay._post_provision( provision_url="https://c.example/relay/provision", access_token="tok", gateway_id="gw-1", platform="discord", bot_id="app", gateway_endpoint=None, route_keys=[], wake_url="https://wake.example/poke", ) assert sent["body"]["wakeUrl"] == "https://wake.example/poke" # Without one -> the key is absent entirely (not ""). relay._post_provision( provision_url="https://c.example/relay/provision", access_token="tok", gateway_id="gw-1", platform="discord", bot_id="app", gateway_endpoint=None, route_keys=[], ) assert "wakeUrl" not in sent["body"] # ─────────────────────────── fail-soft ─────────────────────────── def test_no_nas_token_is_non_fatal(monkeypatch): """A self-hosted box with a relay URL but no resolvable NAS identity skips quietly (this is the branch that replaces the old is_managed() gate for the non-NAS case).""" monkeypatch.setattr(relay, "relay_url", lambda: "wss://connector.example/relay") def _boom(): raise RuntimeError("no token") monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", _boom) # Must not raise; returns False; no creds set. assert relay.self_provision_relay() is False assert relay.relay_connection_auth() == (None, None) def test_connector_failure_is_non_fatal(monkeypatch): _arm(monkeypatch) def _boom(**kwargs): raise RuntimeError("connector returned HTTP 503") monkeypatch.setattr(relay, "_post_provision", _boom) assert relay.self_provision_relay() is False assert relay.relay_connection_auth() == (None, None)