Files
hermes-agent/tests/gateway/test_discord_component_auth.py
Teknium 6b81590c55 test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

281 lines
10 KiB
Python

"""Security regression tests: Discord component views honor allowlists.
The interactive component views (ExecApprovalView, SlashConfirmView,
UpdatePromptView, ModelPickerView, ClarifyChoiceView) historically accepted only
``allowed_user_ids``. Deployments that configure DISCORD_ALLOWED_ROLES
without DISCORD_ALLOWED_USERS therefore had a wide-open component
surface: any guild member who could see the prompt could approve exec
commands, cancel slash confirmations, or switch the model -- even when
the same user would be rejected at the slash and on_message gates.
These tests pin user/role/global allowlist semantics, explicit allow-all
handling, and fail-closed behavior so the parity cannot regress.
"""
from types import SimpleNamespace
import pytest
# Trigger the shared discord mock from tests/gateway/conftest.py before
# importing the production module.
from plugins.platforms.discord.adapter import ( # noqa: E402
ClarifyChoiceView,
ExecApprovalView,
ModelPickerView,
SlashConfirmView,
UpdatePromptView,
_component_check_auth,
_resolve_exec_approval_admin_gate,
)
@pytest.fixture(autouse=True)
def _clear_component_auth_env(monkeypatch):
from unittest.mock import MagicMock, patch
for name in (
"DISCORD_ALLOW_ALL_USERS",
"GATEWAY_ALLOW_ALL_USERS",
"GATEWAY_ALLOWED_USERS",
):
monkeypatch.delenv(name, raising=False)
# Default-mock PairingStore so tests don't hit the filesystem.
# Pairing-specific tests override this with explicit mock values.
mock_store = MagicMock()
mock_store.is_approved.return_value = False
with patch("gateway.pairing.PairingStore", return_value=mock_store):
yield
# ---------------------------------------------------------------------------
# Direct helper coverage -- the views all delegate to this helper, so
# pinning the helper's contract pins all call sites.
# ---------------------------------------------------------------------------
def _interaction(user_id, role_ids=None, *, drop_user=False, drop_roles=False):
"""Build a mock interaction with the requested user/role shape.
drop_user simulates a payload whose .user attribute is None.
drop_roles simulates a payload where .user has no .roles attribute
at all (DM-context Member, raw User payload).
"""
if drop_user:
return SimpleNamespace(user=None)
user_kwargs = {"id": user_id}
if not drop_roles:
user_kwargs["roles"] = [SimpleNamespace(id=r) for r in (role_ids or [])]
return SimpleNamespace(user=SimpleNamespace(**user_kwargs))
# ── no policy configured -> deny unless allow-all is explicit ──────────────
@pytest.mark.parametrize(
("env_name", "env_value"),
[
("DISCORD_ALLOW_ALL_USERS", "true"),
("GATEWAY_ALLOW_ALL_USERS", "yes"),
],
)
def test_component_check_explicit_allow_all_passes(monkeypatch, env_name, env_value):
monkeypatch.setenv(env_name, env_value)
interaction = _interaction(11111)
assert _component_check_auth(interaction, set(), set()) is True
# ── user allowlist ─────────────────────────────────────────────────────────
# ── role allowlist OR semantics ────────────────────────────────────────────
# ── fail-closed on missing role data ───────────────────────────────────────
# ---------------------------------------------------------------------------
# View construction: every view must accept allowed_role_ids and route
# through the shared helper. Default value preserves prior call-sites.
# ---------------------------------------------------------------------------
def test_exec_approval_view_accepts_role_allowlist():
view = ExecApprovalView(
session_key="sess-1",
allowed_user_ids={"11111"},
allowed_role_ids={42},
)
# Role-only user passes
assert view._check_auth(_interaction(99999, role_ids=[42])) is True
# Neither user nor role match: reject
assert view._check_auth(_interaction(99999, role_ids=[7])) is False
def test_slash_confirm_view_accepts_role_allowlist():
view = SlashConfirmView(
session_key="sess-1",
confirm_id="c1",
allowed_user_ids=set(),
allowed_role_ids={42},
)
assert view._check_auth(_interaction(99999, role_ids=[42])) is True
assert view._check_auth(_interaction(99999, role_ids=[7])) is False
def test_update_prompt_view_accepts_role_allowlist():
view = UpdatePromptView(
session_key="sess-1",
allowed_user_ids=set(),
allowed_role_ids={42},
)
assert view._check_auth(_interaction(99999, role_ids=[42])) is True
assert view._check_auth(_interaction(99999, role_ids=[7])) is False
def test_clarify_choice_view_accepts_role_allowlist():
view = ClarifyChoiceView(
choices=["one", "two"],
clarify_id="clarify-1",
allowed_user_ids=set(),
allowed_role_ids={42},
)
assert view._check_auth(_interaction(99999, role_ids=[42])) is True
assert view._check_auth(_interaction(99999, role_ids=[7])) is False
# ---------------------------------------------------------------------------
# Empty allowlists across views: fail closed unless allow-all is explicit.
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"view_factory",
[
lambda: ExecApprovalView(session_key="s", allowed_user_ids=set()),
lambda: SlashConfirmView(session_key="s", confirm_id="c", allowed_user_ids=set()),
lambda: UpdatePromptView(session_key="s", allowed_user_ids=set()),
lambda: ClarifyChoiceView(
choices=["one"],
clarify_id="c",
allowed_user_ids=set(),
),
],
)
def test_views_empty_allowlists_reject_by_default(view_factory, monkeypatch):
monkeypatch.delenv("DISCORD_ALLOW_ALL_USERS", raising=False)
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
monkeypatch.delenv("GATEWAY_ALLOWED_USERS", raising=False)
view = view_factory()
assert view._check_auth(_interaction(99999)) is False
def test_model_picker_view_empty_allowlists_reject_by_default(monkeypatch):
monkeypatch.delenv("DISCORD_ALLOW_ALL_USERS", raising=False)
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
monkeypatch.delenv("GATEWAY_ALLOWED_USERS", raising=False)
async def _noop(*_a, **_k):
return ""
view = ModelPickerView(
providers=[],
current_model="m",
current_provider="p",
session_key="s",
on_model_selected=_noop,
allowed_user_ids=set(),
)
assert view.allowed_role_ids == set()
assert view._check_auth(_interaction(99999)) is False
def test_view_empty_allowlists_allow_with_explicit_allow_all(monkeypatch):
monkeypatch.setenv("DISCORD_ALLOW_ALL_USERS", "true")
view = ExecApprovalView(session_key="s", allowed_user_ids=set())
assert view._check_auth(_interaction(99999)) is True
# ---------------------------------------------------------------------------
# Pairing store: users approved via ``hermes pairing approve`` must be
# authorized even without DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES.
# ---------------------------------------------------------------------------
def test_component_check_pairing_approved_user_passes(monkeypatch):
"""User approved in pairing store passes even without allowlists."""
from unittest.mock import MagicMock, patch
mock_store = MagicMock()
mock_store.is_approved.return_value = True
# Override the autouse fixture's mock with approved=True
with patch("gateway.pairing.PairingStore", return_value=mock_store):
interaction = _interaction(11111)
assert _component_check_auth(interaction, set(), set()) is True
mock_store.is_approved.assert_called_once_with("discord", "11111")
# ---------------------------------------------------------------------------
# Opt-in admin gate for exec-approval buttons (feat/discord-admin-exec-approval).
# Default OFF: any admitted user can approve (the v0.16-restored behavior).
# When `require_admin_for_exec_approval` is true, the clicker must ALSO be in
# `allow_admin_from`. Fails closed (logged) when the toggle is on but no
# admins are configured. Only ExecApprovalView is gated — other views stay
# user-scope.
# ---------------------------------------------------------------------------
def test_admin_gate_resolver_on_parses_admins():
"""Toggle true -> gate enabled, admins coerced from allow_admin_from."""
require_admin, admins = _resolve_exec_approval_admin_gate(
{"require_admin_for_exec_approval": True, "allow_admin_from": "111, 222"}
)
assert require_admin is True
assert admins == {"111", "222"}
# list form normalizes identically
_, admins_list = _resolve_exec_approval_admin_gate(
{"require_admin_for_exec_approval": "true", "allow_admin_from": [111, 222]}
)
assert admins_list == {"111", "222"}
def test_exec_view_gate_on_non_admin_rejected():
"""Gate on: admitted user who is NOT an admin is rejected at the button."""
view = ExecApprovalView(
session_key="s",
allowed_user_ids={"11111", "22222"},
require_admin=True,
admin_user_ids={"11111"},
)
# 22222 is admitted (in allowlist) but not an admin -> rejected.
assert view._check_auth(_interaction(22222)) is False
def test_exec_view_gate_on_no_admins_fails_closed(caplog):
"""Gate on but no admins configured -> nobody approves, logged once."""
import logging
view = ExecApprovalView(
session_key="s",
allowed_user_ids={"11111"},
require_admin=True,
admin_user_ids=set(),
)
with caplog.at_level(logging.WARNING):
assert view._check_auth(_interaction(11111)) is False
assert any(
"require_admin_for_exec_approval" in r.message for r in caplog.records
)
def test_other_views_not_admin_gated():
"""Lower-stakes views never take the admin gate — they stay user-scope."""
# SlashConfirmView/ModelPickerView/etc. construct without require_admin and
# delegate straight to _component_check_auth.
sc = SlashConfirmView(
session_key="s", confirm_id="c", allowed_user_ids={"11111"}
)
assert sc._check_auth(_interaction(11111)) is True