hermes-agent/tests/gateway/test_config_driven_access_policy.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

262 lines
10 KiB
Python

"""Tests for config-driven platform access policies at the gateway layer.
Background (#34515): WeCom, Weixin, Yuanbao, QQBot, and WhatsApp expose a
documented config-driven access surface (``dm_policy`` / ``group_policy`` /
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra``) and enforce
it at intake —
a message is dropped inside the adapter and never reaches the gateway unless it
already passed that policy.
The gateway's env-based allowlist check (``_is_user_authorized``) runs *after*
the adapter. Adapters that own their access policy declare
``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property, default
``False``) so the gateway can honor a config-only ``dm_policy: allowlist`` /
``allow_from`` (which the adapter already enforced) instead of double-denying it
when no ``PLATFORM_ALLOWED_USERS`` env var is set.
Crucially, the flag is NOT a blanket "already authorized" pass. These adapters
default ``dm_policy`` / ``group_policy`` to ``"open"``, which forwards *every*
sender, so the gateway trusts the adapter only when its effective policy for the
chat type is an actual ``"allowlist"`` restriction. Trusting ``"open"`` here
admitted the whole external network with no operator-configured allowlist — the
fail-open SECURITY.md §2.6 forbids for network-exposed adapters ("an allowlist
is required for every enabled network-exposed adapter ... code paths that fail
open when no allowlist is configured are code bugs"). Open access requires an
explicit ``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` opt-in.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.session import SessionSource
# Platforms whose adapters own their access policy at intake.
_OWN_POLICY_PLATFORMS = [
Platform.WECOM,
Platform.WEIXIN,
Platform.YUANBAO,
Platform.QQBOT,
Platform.WHATSAPP,
]
def _clear_auth_env(monkeypatch) -> None:
for key in (
"WECOM_ALLOWED_USERS",
"WEIXIN_ALLOWED_USERS",
"YUANBAO_ALLOWED_USERS",
"QQ_ALLOWED_USERS",
"QQ_GROUP_ALLOWED_USERS",
"WHATSAPP_ALLOWED_USERS",
"TELEGRAM_ALLOWED_USERS",
"GATEWAY_ALLOWED_USERS",
"GATEWAY_ALLOW_ALL_USERS",
"WECOM_ALLOW_ALL_USERS",
"WEIXIN_ALLOW_ALL_USERS",
"YUANBAO_ALLOW_ALL_USERS",
"QQ_ALLOW_ALL_USERS",
"WHATSAPP_ALLOW_ALL_USERS",
):
monkeypatch.delenv(key, raising=False)
def _make_runner(platform: Platform, config: GatewayConfig, *, enforces: bool):
"""Build a bare GatewayRunner with one adapter for *platform*.
``enforces`` controls whether the adapter declares
``enforces_own_access_policy`` — i.e. whether it owns its access gate.
"""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = config
adapter = SimpleNamespace(send=AsyncMock(), enforces_own_access_policy=enforces)
runner.adapters = {platform: adapter}
runner.pairing_store = MagicMock()
runner.pairing_store.is_approved.return_value = False
runner.pairing_store._is_rate_limited.return_value = False
return runner, adapter
def _source(platform: Platform, *, chat_type: str = "dm") -> SessionSource:
return SessionSource(
platform=platform,
user_id="some-user",
chat_id="some-chat",
user_name="tester",
chat_type=chat_type,
)
# ---------------------------------------------------------------------------
# Layer 1: the base-class contract and per-adapter overrides
# ---------------------------------------------------------------------------
def test_base_adapter_defaults_to_not_owning_access_policy():
"""Adapters that don't override the property delegate to the gateway."""
from gateway.platforms.base import BasePlatformAdapter
# The default lives on the base property descriptor.
assert BasePlatformAdapter.enforces_own_access_policy.fget(object()) is False
@pytest.mark.parametrize(
"module_path, class_name",
[
("plugins.platforms.wecom.adapter", "WeComAdapter"),
("gateway.platforms.weixin", "WeixinAdapter"),
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
("gateway.platforms.qqbot.adapter", "QQAdapter"),
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"),
],
)
def test_own_policy_adapters_declare_the_flag(module_path, class_name):
"""The config-policy adapters override the flag to True."""
import importlib
module = importlib.import_module(module_path)
adapter_cls = getattr(module, class_name)
# Property is overridden on the subclass and returns True regardless of
# instance state (it reflects a static capability, not runtime config).
value = adapter_cls.enforces_own_access_policy.fget(object.__new__(adapter_cls))
assert value is True
# ---------------------------------------------------------------------------
# Layer 2: gateway trusts the adapter-enforced flag
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
def test_own_policy_allowlist_authorized_without_env_allowlist(monkeypatch, platform):
"""A config-only ``dm_policy: allowlist`` is trusted without an env allowlist.
The adapter only forwards an allowlisted sender under ``allowlist`` policy,
so a message reaching the gateway *was* authorized for this specific sender.
The gateway must honor that instead of double-denying (the #34515 case).
"""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "allowlist"})}
)
runner, _adapter = _make_runner(platform, config, enforces=True)
assert runner._is_user_authorized(_source(platform)) is True
@pytest.mark.parametrize(
"module_path, class_name, dm_helper",
[
("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter", "_is_dm_allowed"),
("plugins.platforms.wecom.adapter", "WeComAdapter", "_is_dm_allowed"),
("gateway.platforms.weixin", "WeixinAdapter", "_is_dm_allowed"),
("gateway.platforms.qqbot.adapter", "QQAdapter", "_is_dm_allowed"),
],
)
def test_pairing_dm_policy_strict_intake_auth_denies_unknown(
monkeypatch, module_path, class_name, dm_helper,
):
"""Default ``dm_policy: pairing`` must not admit senders via strict auth."""
_clear_auth_env(monkeypatch)
import importlib
from gateway.config import PlatformConfig
module = importlib.import_module(module_path)
adapter_cls = getattr(module, class_name)
adapter = adapter_cls(PlatformConfig(enabled=True, extra={"dm_policy": "pairing"}))
assert getattr(adapter, dm_helper)("unknown-user") is False
@pytest.mark.parametrize("blank_sender", ["", " ", None])
def test_yuanbao_pairing_dm_intake_denies_blank_principal(monkeypatch, blank_sender):
"""Yuanbao pairing intake must not forward senderless C2C callbacks."""
_clear_auth_env(monkeypatch)
from gateway.platforms.yuanbao import AccessPolicy
policy = AccessPolicy(
dm_policy="pairing",
dm_allow_from=[],
group_policy="pairing",
group_allow_from=[],
)
assert policy.is_dm_intake_allowed(blank_sender) is False
assert policy.is_dm_intake_allowed("user-1") is True
def test_wecom_open_group_with_per_group_sender_allowlist_is_authorized(monkeypatch):
"""WeCom ``groups.<id>.allow_from`` is an adapter-enforced restriction.
The top-level group policy is still ``open`` for the chat ID, but the
adapter has already checked the sender allowlist before dispatching to the
gateway. That is not the fail-open case and must not be double-denied.
"""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={
Platform.WECOM: PlatformConfig(
enabled=True,
extra={
"group_policy": "open",
"groups": {"some-chat": {"allow_from": ["some-user"]}},
},
)
}
)
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
assert runner._is_user_authorized(_source(Platform.WECOM, chat_type="group")) is True
# ---------------------------------------------------------------------------
# Layer 2b: `dm_policy: pairing` is NOT blanket-trusted
# ---------------------------------------------------------------------------
#
# Regression: WeCom/Weixin document ``dm_policy: pairing`` and declare
# ``enforces_own_access_policy=True``, but their intake helper only special-cases
# ``disabled`` / ``allowlist`` — ``pairing`` falls through and forwards the DM so
# the gateway can run its pairing handshake. With no env allowlist, the
# adapter-trust shortcut above then authorized *every* unpaired sender, silently
# degrading pairing mode to open access. The shortcut must skip pairing-mode DMs
# so an unpaired sender falls through to default-deny (and gets a pairing code).
# ---------------------------------------------------------------------------
# Layer 3: unauthorized-DM behavior reads config dm_policy
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"dm_policy, expected",
[
("allowlist", "ignore"),
("disabled", "ignore"),
("pairing", "pair"),
],
)
def test_unauthorized_dm_behavior_follows_config_dm_policy(monkeypatch, dm_policy, expected):
"""A restrictive dm_policy drops unauthorized DMs; pairing opts back in."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": dm_policy})}
)
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == expected
def test_unauthorized_dm_behavior_open_policy_keeps_default(monkeypatch):
"""``dm_policy: open`` is not restrictive → falls through to the default."""
_clear_auth_env(monkeypatch)
config = GatewayConfig(
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
)
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
# No allowlist + no restrictive policy → open-gateway pairing default.
assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == "pair"