Files
hermes-agent/tests/gateway/relay/test_explicit_disable.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

144 lines
6.4 KiB
Python

"""``platforms.relay.enabled: false`` beats a deployment-injected relay URL.
Real profile files under a temp HERMES_HOME, the production startup hook and the
standalone predicate. Only the connector HTTP calls are replaced.
"""
from unittest.mock import Mock
import json
import os
import pytest
import hermes_yaml as yaml
import gateway.relay as relay
from gateway.config import Platform, load_gateway_config
from gateway.platform_registry import platform_registry
from gateway.run_startup import GatewayStartupMixin
URL = "wss://connector.example/relay"
DISABLE = {"relay": {"enabled": False}}
# Every place the loader accepts a relay block, plus the managed overlay. The verdict must be
# the same whichever one the operator used.
SPELLINGS = {
"top": {"platforms": DISABLE},
"nested": {"gateway": {"platforms": DISABLE}},
"string-false": {"platforms": {"relay": {"enabled": "false"}}},
"top-beats-nested": {"platforms": DISABLE, "gateway": {"platforms": {"relay": {"enabled": True}}}},
"managed": None, # user YAML absent; disable comes from HERMES_MANAGED_DIR
}
@pytest.fixture
def profile(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
for key in list(os.environ):
if key.startswith("GATEWAY_RELAY_"):
monkeypatch.delenv(key)
# A deployment stamp: URL + identity provider, no pinned secret, so an
# un-vetoed boot would resolve a token and provision credentials.
monkeypatch.setenv("GATEWAY_RELAY_URL", URL)
monkeypatch.setenv("GATEWAY_RELAY_PLATFORMS", "slack")
monkeypatch.setenv("GATEWAY_RELAY_IDP_TOKEN_URL", "https://identity.example/token")
monkeypatch.setenv("SLACK_BOT_TOKEN", "native-test-token")
monkeypatch.setattr("hermes_cli.plugins.discover_plugins", lambda: None)
monkeypatch.setattr(GatewayStartupMixin, "_register_config_hooks", lambda *a, **k: None)
platform_registry.unregister("relay")
yield tmp_path
platform_registry.unregister("relay")
def write_disable(home, spelling, monkeypatch):
doc = SPELLINGS[spelling]
if doc is None:
managed = home / "managed"
managed.mkdir()
(managed / "config.yaml").write_text(yaml.safe_dump({"platforms": DISABLE}))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
return
(home / "config.yaml").write_text(yaml.safe_dump(doc))
@pytest.mark.parametrize("spelling", list(SPELLINGS))
def test_disabled_relay_startup_hook_has_no_side_effects(profile, monkeypatch, spelling):
write_disable(profile, spelling, monkeypatch)
http = Mock(side_effect=AssertionError("disabled relay reached the connector"))
monkeypatch.setattr("urllib.request.urlopen", http)
monkeypatch.setattr(relay, "_resolve_relay_identity_token", http)
before = {k: v for k, v in os.environ.items() if k.startswith("GATEWAY_RELAY_")}
GatewayStartupMixin._start_register_plugins_relay_hooks()
http.assert_not_called()
assert not platform_registry.is_registered("relay")
assert {k: v for k, v in os.environ.items() if k.startswith("GATEWAY_RELAY_")} == before
# The test seam and an explicit URL do not override the operator either.
assert relay.register_relay_adapter(force=True, url=URL) is False
# Standalone routing (cron preflight) agrees: nothing is relay-fronted.
assert relay.relay_fronted_platforms() == set()
def test_url_only_activation_is_unchanged(profile, monkeypatch):
"""Control: with no ``enabled`` key the injected URL still activates relay."""
(profile / "config.yaml").write_text(yaml.safe_dump({"platforms": {"relay": {"extra": {"note": "kept"}}}}))
provision = Mock(return_value={"gatewayId": "g", "secret": "b" * 64, "deliveryKey": "d"})
monkeypatch.setattr(relay, "_resolve_relay_identity_token", Mock(return_value="identity-token"))
monkeypatch.setattr(relay, "_post_provision", provision)
monkeypatch.setattr(relay, "_post_policy", Mock(return_value=200))
GatewayStartupMixin._start_register_plugins_relay_hooks()
provision.assert_called_once()
assert platform_registry.is_registered("relay")
assert relay.relay_fronted_platforms() == {"slack"}
@pytest.mark.parametrize("spelling", list(SPELLINGS))
def test_predicate_agrees_with_loader_and_keeps_native_adapters(profile, monkeypatch, spelling):
"""The standalone predicate and ``load_gateway_config()`` are two readers of one rule, and an
opted-out relay does not own the profile's native connections."""
write_disable(profile, spelling, monkeypatch)
config = load_gateway_config()
assert relay.relay_explicitly_disabled() is True
assert config.platforms[Platform.RELAY].enabled is False
# Without the veto GATEWAY_RELAY_URL disables every directly-connected platform.
assert config.platforms[Platform.SLACK].enabled is True
from cron.scheduler_delivery import _resolve_target_transport
resolved, error = _resolve_target_transport(
{"id": "job"}, Platform.SLACK, "slack", {"chat_id": "channel"}, {}, config,
)
assert error is None
assert resolved[1] is config.platforms[Platform.SLACK]
def test_legacy_json_disable_is_advisory_like_other_platforms(profile, monkeypatch):
(profile / "gateway.json").write_text(json.dumps({"platforms": {
"relay": {"enabled": False},
"telegram": {"enabled": False},
}}))
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "native-telegram-test-token")
monkeypatch.setenv("GATEWAY_RELAY_ALLOW_DIRECT_PLATFORMS", "true")
config = load_gateway_config()
# Only a YAML ``enabled`` key sets ``_enabled_explicit``; env presence (the URL, the token)
# re-enables a gateway.json disable for relay exactly as it does for telegram.
assert relay.relay_explicitly_disabled() is False
assert config.platforms[Platform.RELAY].enabled is True
assert config.platforms[Platform.TELEGRAM].enabled is True
def test_malformed_user_yaml_drops_the_yaml_layer_for_both_readers(profile, monkeypatch):
"""The loader falls back to env + gateway.json WITHOUT the managed layer on a malformed user
file; the predicate must fall back the same way or startup suppresses native adapters for a
relay it then refuses to register."""
(profile / "config.yaml").write_text("platforms: [\n")
write_disable(profile, "managed", monkeypatch)
config = load_gateway_config()
assert relay.relay_explicitly_disabled() is False
assert config.platforms[Platform.RELAY].enabled is True
assert relay.relay_url() == URL