test(gateway): invariant tests for per-profile setting precedence and its consumers
Real loader + real adapter constructors under _profile_runtime_scope: a secondary reads its own YAML lists/flags and never the launch env on a miss; explicit env beats YAML for the owning profile; the central allow_bots gate agrees with the adapter; Matrix YAML lists gate intake and approval; Yuanbao home channel is live and reloadable; the WhatsApp bridge env carries the secondary's policy. All eight cases red on origin/main.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""Per-profile adapter settings resolve explicit scoped env → the profile's own YAML → default.
|
||||
|
||||
Invariants from the #108440 post-merge review: a multiplexed secondary constructed under the real
|
||||
``_profile_runtime_scope`` reads its own YAML lists/flags (Matrix authz lists, Telegram proxy,
|
||||
Discord mentions), never inherits the launch process's env on a scoped miss, and the central
|
||||
``allow_bots`` gate agrees with the adapter; an explicit env value still beats YAML for the owning
|
||||
profile (single-profile contract). Real loader, real constructors; only transport is substituted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import types
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.secret_scope import set_multiplex_active
|
||||
from gateway.config import Platform, load_gateway_config
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _plugins():
|
||||
discover_plugins()
|
||||
|
||||
|
||||
class _Mentions:
|
||||
"""``discord.AllowedMentions`` stand-in exposing the four flags (the suite stubs ``discord``)."""
|
||||
|
||||
def __init__(self, *, everyone, roles, users, replied_user):
|
||||
self.everyone, self.roles, self.users, self.replied_user = everyone, roles, users, replied_user
|
||||
|
||||
|
||||
def _allowed_mentions(extra):
|
||||
from plugins.platforms.discord import adapter as discord_adapter
|
||||
with patch.object(discord_adapter, "DISCORD_AVAILABLE", True), \
|
||||
patch.object(discord_adapter, "discord", types.SimpleNamespace(AllowedMentions=_Mentions), create=True):
|
||||
return discord_adapter._build_allowed_mentions(extra)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def homes(tmp_path, monkeypatch):
|
||||
"""(launch_home, secondary_home); HERMES_HOME points at the launch home, multiplex off on exit."""
|
||||
launch = tmp_path / "launch"
|
||||
secondary = tmp_path / "launch" / "profiles" / "b2"
|
||||
secondary.mkdir(parents=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(launch))
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
yield launch, secondary
|
||||
set_multiplex_active(False)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _secondary_scope(home):
|
||||
from gateway.run import _profile_runtime_scope
|
||||
set_multiplex_active(True)
|
||||
try:
|
||||
with _profile_runtime_scope(home, prepared_secret_scope={}):
|
||||
yield
|
||||
finally:
|
||||
set_multiplex_active(False)
|
||||
|
||||
|
||||
def test_secondary_reads_own_yaml_and_never_the_launch_env(homes, monkeypatch):
|
||||
launch, secondary = homes
|
||||
(launch / "config.yaml").write_text(
|
||||
"matrix:\n process_notices: true\n session_scope: room\n"
|
||||
"discord:\n reactions: false\n allow_mentions:\n everyone: true\n"
|
||||
"slack:\n reactions: false\n ignored_channels: [C_LAUNCH]\n"
|
||||
"telegram:\n reactions: true\n")
|
||||
load_gateway_config() # launch profile bridges its YAML into os.environ (single-profile contract)
|
||||
(secondary / "config.yaml").write_text(
|
||||
"matrix:\n enabled: true\n user_id: '@bot:example.org'\n"
|
||||
" allowed_users: ['@owner:example.org']\n ignore_user_patterns: ['^@ignored:']\n"
|
||||
"discord:\n enabled: true\nslack:\n enabled: true\n"
|
||||
"telegram:\n enabled: true\n proxy_url: http://127.0.0.1:18080\n")
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
from plugins.platforms.slack.adapter import SlackAdapter
|
||||
from plugins.platforms.telegram import adapter as tg
|
||||
with _secondary_scope(secondary):
|
||||
cfg = load_gateway_config()
|
||||
m = MatrixAdapter(cfg.platforms[Platform.MATRIX])
|
||||
d = DiscordAdapter(cfg.platforms[Platform.DISCORD])
|
||||
s = SlackAdapter(cfg.platforms[Platform.SLACK])
|
||||
t = tg.TelegramAdapter(cfg.platforms[Platform.TELEGRAM])
|
||||
# Omitted keys resolve to the DEFAULT, not the launch profile's bridged env.
|
||||
assert (m._process_notices, m._matrix_session_scope) == (False, "auto")
|
||||
assert d._reactions_enabled() and s._reactions_enabled() and s._slack_ignored_channels() == set()
|
||||
assert not t._reactions_enabled()
|
||||
assert _allowed_mentions(d.config.extra).everyone is False
|
||||
# The seeded YAML lists reach the Matrix authz consumers.
|
||||
assert m._allowed_user_ids == {"@owner:example.org"} and len(m._ignored_user_patterns) == 1
|
||||
# The secondary's YAML proxy reaches request construction without an env bridge.
|
||||
monkeypatch.setenv("HERMES_TELEGRAM_DISABLE_FALLBACK_IPS", "true")
|
||||
built: list = []
|
||||
with patch.object(tg, "HTTPXRequest", lambda **kw: built.append(kw) or types.SimpleNamespace()), \
|
||||
patch.object(t, "_instrument_polling_request", side_effect=lambda r: r):
|
||||
asyncio.run(t._build_ptb_requests())
|
||||
assert [kw.get("proxy") for kw in built] == ["http://127.0.0.1:18080"] * 2
|
||||
|
||||
|
||||
def test_explicit_env_beats_yaml_for_the_owning_profile(homes, monkeypatch):
|
||||
"""Single-profile / owning-profile contract: DISCORD_ALLOW_MENTION_EVERYONE=false beats
|
||||
``everyone: true`` and TELEGRAM_REACTIONS=true beats the stock ``reactions: false`` (#109032)."""
|
||||
launch, _ = homes
|
||||
(launch / "config.yaml").write_text(
|
||||
"discord:\n allow_mentions:\n everyone: true\ntelegram:\n reactions: false\n")
|
||||
monkeypatch.setenv("DISCORD_ALLOW_MENTION_EVERYONE", "false")
|
||||
monkeypatch.setenv("TELEGRAM_REACTIONS", "true")
|
||||
from plugins.platforms.telegram.adapter import TelegramAdapter
|
||||
cfg = load_gateway_config()
|
||||
assert _allowed_mentions(cfg.platforms[Platform.DISCORD].extra).everyone is False
|
||||
assert TelegramAdapter(cfg.platforms[Platform.TELEGRAM])._reactions_enabled() is True
|
||||
|
||||
|
||||
def test_central_allow_bots_gate_honours_a_secondary_yaml_policy(homes):
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
from plugins.platforms.slack.adapter import SlackAdapter
|
||||
_, secondary = homes
|
||||
(secondary / "config.yaml").write_text(
|
||||
"discord:\n enabled: true\n allow_bots: all\nslack:\n enabled: true\n allow_bots: all\n")
|
||||
with _secondary_scope(secondary):
|
||||
cfg = load_gateway_config()
|
||||
d, s = DiscordAdapter(cfg.platforms[Platform.DISCORD]), SlackAdapter(cfg.platforms[Platform.SLACK])
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config, runner._primary_profile_name, runner.adapters = cfg, "default", {}
|
||||
runner._profile_adapters = {"b2": {Platform.DISCORD: d, Platform.SLACK: s}}
|
||||
for platform, user_id in ((Platform.DISCORD, "123"), (Platform.SLACK, None)):
|
||||
src = SessionSource(platform=platform, chat_id="C_TEST", chat_type="group", user_id=user_id,
|
||||
is_bot=True, profile="b2")
|
||||
assert runner._is_user_authorized(src), platform
|
||||
|
||||
|
||||
def test_matrix_yaml_lists_gate_intake_and_approval(homes):
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
_, secondary = homes
|
||||
(secondary / "config.yaml").write_text(
|
||||
"matrix:\n enabled: true\n user_id: '@bot:example.org'\n"
|
||||
" allowed_users: ['@owner:example.org']\n ignore_user_patterns: ['^@ignored:']\n")
|
||||
with _secondary_scope(secondary):
|
||||
a = MatrixAdapter(load_gateway_config().platforms[Platform.MATRIX])
|
||||
a._user_id = "@bot:example.org"
|
||||
a._is_allowed_matrix_room_event = AsyncMock(return_value=True)
|
||||
a._handle_text_message = AsyncMock()
|
||||
a.send = AsyncMock()
|
||||
event = types.SimpleNamespace(room_id="!r:example.org", sender="@ignored:example.org", event_id="$1",
|
||||
content={"msgtype": "m.text", "body": "hello"})
|
||||
asyncio.run(a._on_room_message(event))
|
||||
prompt = types.SimpleNamespace(requester_user_id="@owner:example.org")
|
||||
owner_ok = asyncio.run(a._validate_matrix_prompt_reactor("!r:example.org", "$a", "@owner:example.org", prompt, "approval"))
|
||||
other_ok = asyncio.run(a._validate_matrix_prompt_reactor("!r:example.org", "$a", "@other:example.org", prompt, "approval"))
|
||||
assert a._handle_text_message.await_count == 0 and owner_ok and not other_ok
|
||||
|
||||
|
||||
def test_yuanbao_secondary_home_channel_is_live_and_reloadable(homes):
|
||||
"""Auto-sethome from a secondary lands in ``platforms.yuanbao.home_channel`` of ITS config (read back
|
||||
by ``load_gateway_config``) and on the live PlatformConfig; the process env stays untouched."""
|
||||
import os
|
||||
from gateway.platforms.yuanbao import AutoSetHomeMiddleware
|
||||
_, secondary = homes
|
||||
(secondary / "config.yaml").write_text(
|
||||
"platforms:\n yuanbao:\n enabled: true\n extra:\n app_id: a\n app_secret: b\n")
|
||||
adapter = types.SimpleNamespace(name="yuanbao-b2")
|
||||
ctx = types.SimpleNamespace(chat_id="dm:tenant-b2", chat_name="b2")
|
||||
with _secondary_scope(secondary):
|
||||
adapter.config = load_gateway_config().platforms[Platform.YUANBAO]
|
||||
AutoSetHomeMiddleware._persist_home(adapter, ctx)
|
||||
reloaded = load_gateway_config().get_home_channel(Platform.YUANBAO)
|
||||
assert adapter.config.home_channel.chat_id == "dm:tenant-b2"
|
||||
assert reloaded is not None and reloaded.chat_id == "dm:tenant-b2"
|
||||
assert "YUANBAO_HOME_CHANNEL" not in os.environ
|
||||
|
||||
|
||||
def test_whatsapp_bridge_env_carries_the_secondary_effective_policy(homes, monkeypatch):
|
||||
"""bridge.js gates DMs before Python: it must receive the adapter's resolved dm_policy/allow_from, not the
|
||||
launch process's WHATSAPP_* values."""
|
||||
from plugins.platforms.whatsapp.adapter import WhatsAppAdapter
|
||||
_, secondary = homes
|
||||
monkeypatch.setenv("WHATSAPP_DM_POLICY", "allowlist")
|
||||
monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550001111")
|
||||
(secondary / "config.yaml").write_text("whatsapp:\n enabled: true\n dm_policy: pairing\n")
|
||||
with _secondary_scope(secondary):
|
||||
a = WhatsAppAdapter(load_gateway_config().platforms[Platform.WHATSAPP])
|
||||
env = a._bridge_env()
|
||||
assert a._dm_policy == "pairing" == env["WHATSAPP_DM_POLICY"]
|
||||
assert "WHATSAPP_ALLOWED_USERS" not in env
|
||||
Reference in New Issue
Block a user