Some checks failed
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Contributor Attribution Check / check-attribution (pull_request) Failing after 23s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 27s
Tests / e2e (pull_request) Successful in 1m51s
Tests / test (pull_request) Failing after 37m0s
Refs #892 - Gateway config debt: missing keys and broken fallbacks Changes: - Add `_is_network_accessible()` helper to gateway/config.py (avoids circular import with gateway.platforms.base which imports from gateway.config) - Add API_SERVER_KEY warning in `_validate_gateway_config`: when the API server is enabled on a network-accessible address (0.0.0.0, public IP, hostname) but no key is configured, log a warning at config-load time so operators see the issue before any adapter initialisation runs - Add `TestValidateGatewayConfig` in tests/gateway/test_config.py covering: - idle_minutes <= 0 and None are corrected to 1440 (default) - at_hour outside 0-23 is corrected to 4 (default) - Boundary hours 0 and 23 are accepted unchanged - Empty platform token triggers a warning log - Disabled platform with empty token produces no warning - API server on 0.0.0.0 without key logs a warning - API server on 127.0.0.1 without key is silent (loopback is allowed) - API server with a key set logs no warning regardless of bind address Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
446 lines
16 KiB
Python
446 lines
16 KiB
Python
"""Tests for gateway configuration management."""
|
|
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
from gateway.config import (
|
|
GatewayConfig,
|
|
HomeChannel,
|
|
Platform,
|
|
PlatformConfig,
|
|
SessionResetPolicy,
|
|
_apply_env_overrides,
|
|
_validate_gateway_config,
|
|
load_gateway_config,
|
|
)
|
|
|
|
|
|
class TestHomeChannelRoundtrip:
|
|
def test_to_dict_from_dict(self):
|
|
hc = HomeChannel(platform=Platform.DISCORD, chat_id="999", name="general")
|
|
d = hc.to_dict()
|
|
restored = HomeChannel.from_dict(d)
|
|
|
|
assert restored.platform == Platform.DISCORD
|
|
assert restored.chat_id == "999"
|
|
assert restored.name == "general"
|
|
|
|
|
|
class TestPlatformConfigRoundtrip:
|
|
def test_to_dict_from_dict(self):
|
|
pc = PlatformConfig(
|
|
enabled=True,
|
|
token="tok_123",
|
|
home_channel=HomeChannel(
|
|
platform=Platform.TELEGRAM,
|
|
chat_id="555",
|
|
name="Home",
|
|
),
|
|
extra={"foo": "bar"},
|
|
)
|
|
d = pc.to_dict()
|
|
restored = PlatformConfig.from_dict(d)
|
|
|
|
assert restored.enabled is True
|
|
assert restored.token == "tok_123"
|
|
assert restored.home_channel.chat_id == "555"
|
|
assert restored.extra == {"foo": "bar"}
|
|
|
|
def test_disabled_no_token(self):
|
|
pc = PlatformConfig()
|
|
d = pc.to_dict()
|
|
restored = PlatformConfig.from_dict(d)
|
|
assert restored.enabled is False
|
|
assert restored.token is None
|
|
|
|
|
|
class TestGetConnectedPlatforms:
|
|
def test_returns_enabled_with_token(self):
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.TELEGRAM: PlatformConfig(enabled=True, token="t"),
|
|
Platform.DISCORD: PlatformConfig(enabled=False, token="d"),
|
|
Platform.SLACK: PlatformConfig(enabled=True), # no token
|
|
},
|
|
)
|
|
connected = config.get_connected_platforms()
|
|
assert Platform.TELEGRAM in connected
|
|
assert Platform.DISCORD not in connected
|
|
assert Platform.SLACK not in connected
|
|
|
|
def test_empty_platforms(self):
|
|
config = GatewayConfig()
|
|
assert config.get_connected_platforms() == []
|
|
|
|
|
|
class TestSessionResetPolicy:
|
|
def test_roundtrip(self):
|
|
policy = SessionResetPolicy(mode="idle", at_hour=6, idle_minutes=120)
|
|
d = policy.to_dict()
|
|
restored = SessionResetPolicy.from_dict(d)
|
|
assert restored.mode == "idle"
|
|
assert restored.at_hour == 6
|
|
assert restored.idle_minutes == 120
|
|
|
|
def test_defaults(self):
|
|
policy = SessionResetPolicy()
|
|
assert policy.mode == "both"
|
|
assert policy.at_hour == 4
|
|
assert policy.idle_minutes == 1440
|
|
|
|
def test_from_dict_treats_null_values_as_defaults(self):
|
|
restored = SessionResetPolicy.from_dict(
|
|
{"mode": None, "at_hour": None, "idle_minutes": None}
|
|
)
|
|
assert restored.mode == "both"
|
|
assert restored.at_hour == 4
|
|
assert restored.idle_minutes == 1440
|
|
|
|
|
|
class TestGatewayConfigRoundtrip:
|
|
def test_full_roundtrip(self):
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.TELEGRAM: PlatformConfig(
|
|
enabled=True,
|
|
token="tok_123",
|
|
home_channel=HomeChannel(Platform.TELEGRAM, "123", "Home"),
|
|
),
|
|
},
|
|
reset_triggers=["/new"],
|
|
quick_commands={"limits": {"type": "exec", "command": "echo ok"}},
|
|
group_sessions_per_user=False,
|
|
thread_sessions_per_user=True,
|
|
)
|
|
d = config.to_dict()
|
|
restored = GatewayConfig.from_dict(d)
|
|
|
|
assert Platform.TELEGRAM in restored.platforms
|
|
assert restored.platforms[Platform.TELEGRAM].token == "tok_123"
|
|
assert restored.reset_triggers == ["/new"]
|
|
assert restored.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
|
|
assert restored.group_sessions_per_user is False
|
|
assert restored.thread_sessions_per_user is True
|
|
|
|
def test_roundtrip_preserves_unauthorized_dm_behavior(self):
|
|
config = GatewayConfig(
|
|
unauthorized_dm_behavior="ignore",
|
|
platforms={
|
|
Platform.WHATSAPP: PlatformConfig(
|
|
enabled=True,
|
|
extra={"unauthorized_dm_behavior": "pair"},
|
|
),
|
|
},
|
|
)
|
|
|
|
restored = GatewayConfig.from_dict(config.to_dict())
|
|
|
|
assert restored.unauthorized_dm_behavior == "ignore"
|
|
assert restored.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair"
|
|
|
|
|
|
class TestLoadGatewayConfig:
|
|
def test_bridges_quick_commands_from_config_yaml(self, tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
config_path = hermes_home / "config.yaml"
|
|
config_path.write_text(
|
|
"quick_commands:\n"
|
|
" limits:\n"
|
|
" type: exec\n"
|
|
" command: echo ok\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
config = load_gateway_config()
|
|
|
|
assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
|
|
|
|
def test_bridges_group_sessions_per_user_from_config_yaml(self, tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
config_path = hermes_home / "config.yaml"
|
|
config_path.write_text("group_sessions_per_user: false\n", encoding="utf-8")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
config = load_gateway_config()
|
|
|
|
assert config.group_sessions_per_user is False
|
|
|
|
def test_bridges_thread_sessions_per_user_from_config_yaml(self, tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
config_path = hermes_home / "config.yaml"
|
|
config_path.write_text("thread_sessions_per_user: true\n", encoding="utf-8")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
config = load_gateway_config()
|
|
|
|
assert config.thread_sessions_per_user is True
|
|
|
|
def test_thread_sessions_per_user_defaults_to_false(self, tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
config_path = hermes_home / "config.yaml"
|
|
config_path.write_text("{}\n", encoding="utf-8")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
config = load_gateway_config()
|
|
|
|
assert config.thread_sessions_per_user is False
|
|
|
|
def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
config_path = hermes_home / "config.yaml"
|
|
config_path.write_text("quick_commands: not-a-mapping\n", encoding="utf-8")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
config = load_gateway_config()
|
|
|
|
assert config.quick_commands == {}
|
|
|
|
def test_bridges_unauthorized_dm_behavior_from_config_yaml(self, tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
config_path = hermes_home / "config.yaml"
|
|
config_path.write_text(
|
|
"unauthorized_dm_behavior: ignore\n"
|
|
"whatsapp:\n"
|
|
" unauthorized_dm_behavior: pair\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
|
|
config = load_gateway_config()
|
|
|
|
assert config.unauthorized_dm_behavior == "ignore"
|
|
assert config.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair"
|
|
|
|
|
|
class TestHomeChannelEnvOverrides:
|
|
"""Home channel env vars should apply even when the platform was already
|
|
configured via config.yaml (not just when credential env vars create it)."""
|
|
|
|
def test_existing_platform_configs_accept_home_channel_env_overrides(self):
|
|
cases = [
|
|
(
|
|
Platform.SLACK,
|
|
PlatformConfig(enabled=True, token="xoxb-from-config"),
|
|
{"SLACK_HOME_CHANNEL": "C123", "SLACK_HOME_CHANNEL_NAME": "Ops"},
|
|
("C123", "Ops"),
|
|
),
|
|
(
|
|
Platform.SIGNAL,
|
|
PlatformConfig(
|
|
enabled=True,
|
|
extra={"http_url": "http://localhost:9090", "account": "+15551234567"},
|
|
),
|
|
{"SIGNAL_HOME_CHANNEL": "+1555000", "SIGNAL_HOME_CHANNEL_NAME": "Phone"},
|
|
("+1555000", "Phone"),
|
|
),
|
|
(
|
|
Platform.MATTERMOST,
|
|
PlatformConfig(
|
|
enabled=True,
|
|
token="mm-token",
|
|
extra={"url": "https://mm.example.com"},
|
|
),
|
|
{"MATTERMOST_HOME_CHANNEL": "ch_abc123", "MATTERMOST_HOME_CHANNEL_NAME": "General"},
|
|
("ch_abc123", "General"),
|
|
),
|
|
(
|
|
Platform.MATRIX,
|
|
PlatformConfig(
|
|
enabled=True,
|
|
token="syt_abc123",
|
|
extra={"homeserver": "https://matrix.example.org"},
|
|
),
|
|
{"MATRIX_HOME_ROOM": "!room123:example.org", "MATRIX_HOME_ROOM_NAME": "Bot Room"},
|
|
("!room123:example.org", "Bot Room"),
|
|
),
|
|
(
|
|
Platform.EMAIL,
|
|
PlatformConfig(
|
|
enabled=True,
|
|
extra={
|
|
"address": "hermes@test.com",
|
|
"imap_host": "imap.test.com",
|
|
"smtp_host": "smtp.test.com",
|
|
},
|
|
),
|
|
{"EMAIL_HOME_ADDRESS": "user@test.com", "EMAIL_HOME_ADDRESS_NAME": "Inbox"},
|
|
("user@test.com", "Inbox"),
|
|
),
|
|
(
|
|
Platform.SMS,
|
|
PlatformConfig(enabled=True, api_key="token_abc"),
|
|
{"SMS_HOME_CHANNEL": "+15559876543", "SMS_HOME_CHANNEL_NAME": "My Phone"},
|
|
("+15559876543", "My Phone"),
|
|
),
|
|
]
|
|
|
|
for platform, platform_config, env, expected in cases:
|
|
config = GatewayConfig(platforms={platform: platform_config})
|
|
with patch.dict(os.environ, env, clear=True):
|
|
_apply_env_overrides(config)
|
|
|
|
home = config.platforms[platform].home_channel
|
|
assert home is not None, f"{platform.value}: home_channel should not be None"
|
|
assert (home.chat_id, home.name) == expected, platform.value
|
|
|
|
|
|
class TestValidateGatewayConfig:
|
|
"""Tests for _validate_gateway_config — in-place sanitisation of loaded config."""
|
|
|
|
# -- idle_minutes validation --
|
|
|
|
def test_idle_minutes_zero_is_corrected_to_default(self):
|
|
config = GatewayConfig()
|
|
config.default_reset_policy.idle_minutes = 0
|
|
_validate_gateway_config(config)
|
|
assert config.default_reset_policy.idle_minutes == 1440
|
|
|
|
def test_idle_minutes_negative_is_corrected_to_default(self):
|
|
config = GatewayConfig()
|
|
config.default_reset_policy.idle_minutes = -60
|
|
_validate_gateway_config(config)
|
|
assert config.default_reset_policy.idle_minutes == 1440
|
|
|
|
def test_idle_minutes_none_is_corrected_to_default(self):
|
|
config = GatewayConfig()
|
|
config.default_reset_policy.idle_minutes = None # type: ignore[assignment]
|
|
_validate_gateway_config(config)
|
|
assert config.default_reset_policy.idle_minutes == 1440
|
|
|
|
def test_valid_idle_minutes_is_unchanged(self):
|
|
config = GatewayConfig()
|
|
config.default_reset_policy.idle_minutes = 90
|
|
_validate_gateway_config(config)
|
|
assert config.default_reset_policy.idle_minutes == 90
|
|
|
|
# -- at_hour validation --
|
|
|
|
def test_at_hour_too_high_is_corrected_to_default(self):
|
|
config = GatewayConfig()
|
|
config.default_reset_policy.at_hour = 24
|
|
_validate_gateway_config(config)
|
|
assert config.default_reset_policy.at_hour == 4
|
|
|
|
def test_at_hour_negative_is_corrected_to_default(self):
|
|
config = GatewayConfig()
|
|
config.default_reset_policy.at_hour = -1
|
|
_validate_gateway_config(config)
|
|
assert config.default_reset_policy.at_hour == 4
|
|
|
|
def test_valid_at_hour_is_unchanged(self):
|
|
config = GatewayConfig()
|
|
config.default_reset_policy.at_hour = 3
|
|
_validate_gateway_config(config)
|
|
assert config.default_reset_policy.at_hour == 3
|
|
|
|
def test_at_hour_boundary_values_are_valid(self):
|
|
for valid_hour in (0, 23):
|
|
config = GatewayConfig()
|
|
config.default_reset_policy.at_hour = valid_hour
|
|
_validate_gateway_config(config)
|
|
assert config.default_reset_policy.at_hour == valid_hour
|
|
|
|
# -- empty-token warning (enabled platforms) --
|
|
|
|
def test_empty_string_token_logs_warning(self, caplog):
|
|
import logging
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.TELEGRAM: PlatformConfig(enabled=True, token=""),
|
|
}
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="gateway.config"):
|
|
_validate_gateway_config(config)
|
|
assert any(
|
|
"TELEGRAM_BOT_TOKEN" in r.message and "empty" in r.message
|
|
for r in caplog.records
|
|
)
|
|
|
|
def test_disabled_platform_with_empty_token_no_warning(self, caplog):
|
|
import logging
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.TELEGRAM: PlatformConfig(enabled=False, token=""),
|
|
}
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="gateway.config"):
|
|
_validate_gateway_config(config)
|
|
assert not any("TELEGRAM_BOT_TOKEN" in r.message for r in caplog.records)
|
|
|
|
# -- API Server key / binding warnings --
|
|
|
|
def test_api_server_network_binding_without_key_logs_warning(self, caplog):
|
|
import logging
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.API_SERVER: PlatformConfig(
|
|
enabled=True,
|
|
extra={"host": "0.0.0.0"},
|
|
),
|
|
}
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="gateway.config"):
|
|
_validate_gateway_config(config)
|
|
assert any(
|
|
"API_SERVER_KEY" in r.message for r in caplog.records
|
|
)
|
|
|
|
def test_api_server_loopback_without_key_no_warning(self, caplog):
|
|
import logging
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.API_SERVER: PlatformConfig(
|
|
enabled=True,
|
|
extra={"host": "127.0.0.1"},
|
|
),
|
|
}
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="gateway.config"):
|
|
_validate_gateway_config(config)
|
|
assert not any(
|
|
"API_SERVER_KEY" in r.message for r in caplog.records
|
|
)
|
|
|
|
def test_api_server_network_binding_with_key_no_warning(self, caplog):
|
|
import logging
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.API_SERVER: PlatformConfig(
|
|
enabled=True,
|
|
extra={"host": "0.0.0.0", "key": "sk-real-key-here"},
|
|
),
|
|
}
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="gateway.config"):
|
|
_validate_gateway_config(config)
|
|
assert not any(
|
|
"API_SERVER_KEY" in r.message for r in caplog.records
|
|
)
|
|
|
|
def test_api_server_default_loopback_without_key_no_warning(self, caplog):
|
|
"""API server with no explicit host defaults to 127.0.0.1 — no warning."""
|
|
import logging
|
|
config = GatewayConfig(
|
|
platforms={
|
|
Platform.API_SERVER: PlatformConfig(enabled=True),
|
|
}
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="gateway.config"):
|
|
_validate_gateway_config(config)
|
|
assert not any(
|
|
"API_SERVER_KEY" in r.message for r in caplog.records
|
|
)
|