First migration of an existing built-in platform adapter to the plugin system established by IRC / Teams / LINE / Google Chat. Closes #24325; advances the umbrella refactor in #3823. Matches Teams' shape exactly — adapter under ``plugins/platforms/discord/`` with the standard ``__init__.py`` / ``adapter.py`` / ``plugin.yaml`` shell, ``register(ctx)`` entry point, **no back-compat shim** at the old import path, and full parity for the four hooks Teams uses plus the ``apply_yaml_config_fn`` hook that landed in #25443 (the Discord plugin is the first consumer of that hook): * ``standalone_sender_fn`` — out-of-process cron delivery via REST API * ``setup_fn`` — interactive ``hermes setup gateway`` wizard * ``apply_yaml_config_fn`` — translate ``config.yaml`` ``discord:`` keys into ``DISCORD_*`` env vars (replaces the hardcoded block in ``gateway/config.py``) * ``is_connected`` — declares connection state from ``DISCORD_BOT_TOKEN`` * ``check_fn`` — lazy-installs ``discord.py`` on demand * plus ``allowed_users_env``, ``allow_all_env``, ``cron_deliver_env_var``, ``max_message_length``, ``emoji``, ``required_env``, ``install_hint`` * ``gateway/platforms/discord.py`` (5,101 LOC) → ``plugins/platforms/discord/adapter.py`` (git rename, R090). * New ``plugins/platforms/discord/{__init__.py, plugin.yaml}`` with ``requires_env`` / ``optional_env`` declarations. * Append ``register(ctx)`` block + new hook implementations (``_standalone_send``, ``interactive_setup``, ``_apply_yaml_config``, ``_clean_discord_user_ids``, ``_is_connected``, ``_build_adapter``, plus helpers ``_DISCORD_CHANNEL_TYPE_PROBE_CACHE`` etc.) to the adapter. * Replace the ``Platform.DISCORD elif`` branch in ``GatewayRunner._create_adapter()`` (−9 LOC) with a generic post-creation hook (+6 LOC) in the registry path: any plugin adapter that declares a ``gateway_runner`` attribute now gets it auto-injected. Webhook's built-in branch is unchanged (it doesn't go through the registry path). * Move ``_send_discord`` (190 LOC) and helpers (``_DISCORD_CHANNEL_TYPE_PROBE_CACHE``, ``_remember_channel_is_forum``, ``_probe_is_forum_cached``, ``_derive_forum_thread_name``) from ``tools/send_message_tool.py`` into the plugin as ``_standalone_send``. * Wire via ``standalone_sender_fn=_standalone_send`` (Teams pattern; same gap fixed in #21804 for other plugin platforms). * Replace the Discord ``elif`` in ``tools/send_message_tool.py`` ``_send_to_platform`` with a 10-line registry-hook dispatch. * Drop the ``DiscordAdapter`` import and the ``Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH`` ``_MAX_LENGTHS`` entry — the registry's ``max_message_length=2000`` covers it. * Move ``_setup_discord`` and ``_clean_discord_user_ids`` (68 LOC) from ``hermes_cli/setup.py`` into the plugin as ``interactive_setup``. * Wire via ``setup_fn=interactive_setup``. CLI helpers (``prompt``, ``print_info``, etc.) are lazy-imported so the plugin's module-load surface stays minimal. * Remove ``"discord": _s._setup_discord`` from ``hermes_cli/gateway.py::_builtin_setup_fn``. * Remove the entire 32-line ``_PLATFORMS["discord"]`` static dict entry — Discord's setup metadata is now discovered dynamically via ``_all_platforms()`` from the registry entry. * Move the 59-line ``discord_cfg`` YAML→env bridge from ``gateway/config.py::load_gateway_config()`` into the plugin as ``_apply_yaml_config``. Covers ``require_mention``, ``thread_require_mention``, ``free_response_channels``, ``auto_thread``, ``reactions``, ``ignored_channels``, ``allowed_channels``, ``no_thread_channels``, ``allow_mentions.{everyone,roles,users, replied_user}``, and ``reply_to_mode`` (including the YAML 1.1 ``off``-as-False coercion and the ``extra.reply_to_mode`` fallback). * Wire via ``apply_yaml_config_fn=_apply_yaml_config``. * The hook runs BEFORE ``_apply_env_overrides`` and after the generic shared-key loop, exactly as documented in ``website/docs/developer-guide/adding-platform-adapters.md``. * Behavior is preserved exactly — every assignment still uses ``not os.getenv(...)`` guards so env vars take precedence over YAML. All 78 references to the old import path are rewritten — no back-compat shim: * 51 ``from gateway.platforms.discord import X`` → ``from plugins.platforms.discord.adapter import X`` * 5 ``import gateway.platforms.discord as discord_platform`` → ``import plugins.platforms.discord.adapter as discord_platform`` * 1 ``from gateway.platforms import discord as discord_mod`` → ``from plugins.platforms.discord import adapter as discord_mod`` * 21 ``mock.patch("gateway.platforms.discord.X")`` strings → ``mock.patch("plugins.platforms.discord.adapter.X")`` * 1 docstring reference in ``hermes_cli/commands.py`` * 1 import in ``tools/send_message_tool.py`` (now removed entirely) The import-safety test in ``tests/gateway/test_discord_imports.py`` is updated to purge the new canonical module name from ``sys.modules``. **38 files changed, +621 / −473** — net positive due to the YAML hook implementation (89 new LOC in the plugin trading for 59 deleted in core), but every line moved has a clear plugin home now. The git rename is detected at R090 because the adapter gained ~340 LOC of moved-in hook implementations (``_standalone_send`` + ``interactive_setup`` + ``_apply_yaml_config`` + helpers). * All 568 Discord-specific tests pass across 25 ``test_discord_*.py`` files plus voice/send/text-batching/reload-skills/stream-consumer/ integration tests. * All 147 tests in the YAML-touching subset (``test_discord_reply_mode``, ``test_discord_free_response``, ``test_discord_allowed_channels``, ``test_discord_allowed_mentions``, ``test_discord_channel_controls``, ``test_discord_reactions``, ``test_discord_thread_persistence``, ``test_runtime_footer``) pass — this is the strongest signal that the YAML→env hook behaves identically to the legacy block. * Broader gateway/cron/integration sweep (1297 tests) introduces zero new failures vs ``main``. Pre-existing failures in ``tests/gateway/test_tts_media_routing.py`` and ``tests/e2e/test_platform_commands.py`` reproduce identically on the unchanged ``main`` revision. * Plugin discovery sanity check confirms Discord registers alongside the other four platform plugins: Registered platforms: ['discord', 'google_chat', 'irc', 'line', 'teams'] These Discord-shaped tendrils in core were **deliberately not moved** — they are generic platform-registry concerns affecting every platform, not Discord-specific: * ``gateway/config.py:1205`` ``DISCORD_BOT_TOKEN → config.token`` env enablement — same shape Telegram has. The existing ``env_enablement_fn`` registry hook only seeds ``extra``, not ``.token``, so it can't replace this without an adapter refactor to read from ``extra["bot_token"]``. * ``gateway/run.py`` voice-mode hooks (``self.adapters.get(Platform.DISCORD)`` for ``start_voice_mode``/``stop_voice_mode``), role-based auth, ``DISCORD_ALLOW_BOTS`` branch in ``_is_user_authorized``, ``_UPDATE_ALLOWED_PLATFORMS`` frozenset, and the per-platform allowlist maps — generic platform-registry concerns. * ``Platform.DISCORD`` enum literal — stable identifier used as dict keys throughout the codebase; removing it is a separate refactor with no real benefit. * ``tools/discord_tool.py`` and ``tools/environments/local.py`` — first-class agent tools and env-passthrough config, neither is the gateway adapter. Each of these is worth its own scoping issue when the time comes.
742 lines
28 KiB
Python
742 lines
28 KiB
Python
"""Security regression tests: slash commands honor on_message authorization gates.
|
|
|
|
Slash invocations (``_run_simple_slash``, ``_handle_thread_create_slash``)
|
|
historically bypassed every gate ``on_message`` enforces — DISCORD_ALLOWED_USERS,
|
|
DISCORD_ALLOWED_ROLES, DISCORD_ALLOWED_CHANNELS, DISCORD_IGNORED_CHANNELS.
|
|
Any guild member could invoke ``/background``, ``/restart``, etc. as the
|
|
operator. ``_check_slash_authorization`` mirrors all four gates one-for-one.
|
|
|
|
These tests pin the security-correct behavior so the bypass cannot regress.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import sys
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Discord module mock — borrowed from test_discord_slash_commands.py so this
|
|
# file runs on machines without discord.py installed.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ensure_discord_mock():
|
|
if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"):
|
|
return # real discord installed
|
|
|
|
if sys.modules.get("discord") is None:
|
|
discord_mod = MagicMock()
|
|
discord_mod.Intents.default.return_value = MagicMock()
|
|
discord_mod.DMChannel = type("DMChannel", (), {})
|
|
discord_mod.Thread = type("Thread", (), {})
|
|
discord_mod.ForumChannel = type("ForumChannel", (), {})
|
|
discord_mod.Interaction = object
|
|
|
|
class _FakePermissions:
|
|
def __init__(self, value=0, **_):
|
|
self.value = value
|
|
|
|
discord_mod.Permissions = _FakePermissions
|
|
|
|
class _FakeGroup:
|
|
def __init__(self, *, name, description, parent=None):
|
|
self.name = name
|
|
self.description = description
|
|
self.parent = parent
|
|
self._children: dict[str, object] = {}
|
|
if parent is not None:
|
|
parent.add_command(self)
|
|
|
|
def add_command(self, cmd):
|
|
self._children[cmd.name] = cmd
|
|
|
|
class _FakeCommand:
|
|
def __init__(self, *, name, description, callback, parent=None):
|
|
self.name = name
|
|
self.description = description
|
|
self.callback = callback
|
|
self.parent = parent
|
|
self.default_permissions = None
|
|
|
|
discord_mod.app_commands = SimpleNamespace(
|
|
describe=lambda **kwargs: (lambda fn: fn),
|
|
choices=lambda **kwargs: (lambda fn: fn),
|
|
autocomplete=lambda **kwargs: (lambda fn: fn),
|
|
Choice=lambda **kwargs: SimpleNamespace(**kwargs),
|
|
Group=_FakeGroup,
|
|
Command=_FakeCommand,
|
|
)
|
|
|
|
ext_mod = MagicMock()
|
|
commands_mod = MagicMock()
|
|
commands_mod.Bot = MagicMock
|
|
ext_mod.commands = commands_mod
|
|
|
|
sys.modules["discord"] = discord_mod
|
|
sys.modules.setdefault("discord.ext", ext_mod)
|
|
sys.modules.setdefault("discord.ext.commands", commands_mod)
|
|
|
|
|
|
_ensure_discord_mock()
|
|
|
|
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_discord_env(monkeypatch):
|
|
for var in (
|
|
"DISCORD_ALLOWED_USERS",
|
|
"DISCORD_ALLOWED_ROLES",
|
|
"DISCORD_ALLOWED_CHANNELS",
|
|
"DISCORD_IGNORED_CHANNELS",
|
|
"DISCORD_HIDE_SLASH_COMMANDS",
|
|
"DISCORD_ALLOW_BOTS",
|
|
):
|
|
monkeypatch.delenv(var, raising=False)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stub_discord_permissions(monkeypatch):
|
|
"""Pin discord.Permissions to a plain stand-in so tests can assert the
|
|
bitfield value regardless of whether real discord.py or a sibling test
|
|
module's MagicMock is loaded."""
|
|
import discord
|
|
|
|
class _Perm:
|
|
def __init__(self, value=0, **_):
|
|
self.value = value
|
|
|
|
monkeypatch.setattr(discord, "Permissions", _Perm)
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter():
|
|
config = PlatformConfig(enabled=True, token="***")
|
|
a = DiscordAdapter(config)
|
|
a._client = SimpleNamespace(user=SimpleNamespace(id=99999, name="HermesBot"), guilds=[])
|
|
return a
|
|
|
|
|
|
_SENTINEL = object()
|
|
|
|
|
|
def _make_interaction(
|
|
user_id, *, channel_id=12345, guild_id=42, in_dm=False, in_thread=False,
|
|
parent_channel_id=None, user=_SENTINEL,
|
|
):
|
|
"""Build a mock Discord Interaction with a still-unresponded response.
|
|
|
|
``channel_id`` may be set to ``None`` to simulate a guild interaction
|
|
payload missing a resolvable channel id (fail-closed exercise).
|
|
Pass ``user=None`` to simulate a payload missing the user object.
|
|
"""
|
|
import discord
|
|
|
|
response = SimpleNamespace(send_message=AsyncMock(), defer=AsyncMock())
|
|
|
|
if in_dm:
|
|
channel = discord.DMChannel()
|
|
elif in_thread:
|
|
channel = discord.Thread()
|
|
channel.id = channel_id
|
|
channel.parent_id = parent_channel_id
|
|
elif channel_id is None:
|
|
channel = None
|
|
else:
|
|
channel = SimpleNamespace(id=channel_id)
|
|
|
|
if user is _SENTINEL:
|
|
user_obj = SimpleNamespace(id=int(user_id), name=f"user_{user_id}")
|
|
else:
|
|
user_obj = user
|
|
|
|
return SimpleNamespace(
|
|
user=user_obj,
|
|
# `get_member` needed for the guild-scoped role fallback path in
|
|
# _is_allowed_user after the #12136 cross-guild fix. Fixture guild
|
|
# has no members by default — tests exercising positive role paths
|
|
# assign their own Member via user.roles + matching allowed_role_ids.
|
|
guild=SimpleNamespace(owner_id=999, id=guild_id, get_member=lambda uid: None),
|
|
guild_id=guild_id,
|
|
channel_id=channel_id,
|
|
channel=channel,
|
|
response=response,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backwards-compat: empty allowlist → everything passes (matches on_message)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_allowlist_allows_everyone(adapter):
|
|
"""SECURITY-CRITICAL backwards-compat: deployments without any allowlist
|
|
env vars set must see ZERO behavior change. on_message lets everyone
|
|
through in this case (returns True at line 1890); slash must do the same.
|
|
"""
|
|
interaction = _make_interaction("999999999")
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is True
|
|
interaction.response.send_message.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_allowlist_dm_also_allowed(adapter):
|
|
"""Same for DMs — no allowlist means no restriction, matching on_message."""
|
|
interaction = _make_interaction("999999999", in_dm=True)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# User allowlist (DISCORD_ALLOWED_USERS) parity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allowed_user_passes(adapter):
|
|
adapter._allowed_user_ids = {"100200300"}
|
|
interaction = _make_interaction("100200300")
|
|
assert await adapter._check_slash_authorization(interaction, "/background hi") is True
|
|
interaction.response.send_message.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disallowed_user_rejected_with_ephemeral(adapter, caplog):
|
|
adapter._allowed_user_ids = {"100200300"}
|
|
interaction = _make_interaction("999999999")
|
|
with caplog.at_level(logging.WARNING):
|
|
assert await adapter._check_slash_authorization(interaction, "/background hi") is False
|
|
interaction.response.send_message.assert_awaited_once()
|
|
args, kwargs = interaction.response.send_message.call_args
|
|
assert kwargs.get("ephemeral") is True
|
|
assert "not authorized" in (args[0] if args else kwargs.get("content", "")).lower()
|
|
assert any("Unauthorized slash attempt" in r.message for r in caplog.records)
|
|
assert any("DISCORD_ALLOWED_USERS" in r.message for r in caplog.records)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Role allowlist (DISCORD_ALLOWED_ROLES) parity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_role_member_passes(adapter):
|
|
"""A user whose Member.roles includes an allowed role passes the gate."""
|
|
adapter._allowed_role_ids = {1234}
|
|
interaction = _make_interaction("999999999")
|
|
interaction.user.roles = [SimpleNamespace(id=1234)]
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_role_non_member_rejected(adapter):
|
|
"""A user without any matching role is rejected even if no user allowlist."""
|
|
adapter._allowed_role_ids = {1234}
|
|
interaction = _make_interaction("999999999")
|
|
interaction.user.roles = [SimpleNamespace(id=9999)] # different role
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Channel allowlist (DISCORD_ALLOWED_CHANNELS) parity — the gate prajer used
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_not_in_allowlist_rejected(adapter, monkeypatch, caplog):
|
|
"""on_message blocks messages in channels not in DISCORD_ALLOWED_CHANNELS;
|
|
slash must do the same. This is the EXACT bypass prajer exploited.
|
|
"""
|
|
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "1111,2222")
|
|
interaction = _make_interaction("100200300", channel_id=9999)
|
|
with caplog.at_level(logging.WARNING):
|
|
assert await adapter._check_slash_authorization(interaction, "/background hi") is False
|
|
assert any("DISCORD_ALLOWED_CHANNELS" in r.message for r in caplog.records)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_in_allowlist_passes(adapter, monkeypatch):
|
|
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "1111,2222")
|
|
interaction = _make_interaction("100200300", channel_id=1111)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_allowlist_wildcard_passes(adapter, monkeypatch):
|
|
"""``*`` in DISCORD_ALLOWED_CHANNELS = allow any channel, matching on_message."""
|
|
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "*")
|
|
interaction = _make_interaction("100200300", channel_id=9999)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_channel_allowlist_does_not_apply_to_dms(adapter, monkeypatch):
|
|
"""DMs aren't channel-gated — they go through on_message's DM lockdown."""
|
|
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "1111")
|
|
interaction = _make_interaction("100200300", in_dm=True)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Channel blocklist (DISCORD_IGNORED_CHANNELS) parity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ignored_channel_rejected(adapter, monkeypatch, caplog):
|
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "9999")
|
|
interaction = _make_interaction("100200300", channel_id=9999)
|
|
with caplog.at_level(logging.WARNING):
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is False
|
|
assert any("DISCORD_IGNORED_CHANNELS" in r.message for r in caplog.records)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ignored_channel_wildcard_blocks_all(adapter, monkeypatch):
|
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "*")
|
|
interaction = _make_interaction("100200300", channel_id=9999)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cross-platform admin notification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthorized_attempt_notifies_telegram(adapter):
|
|
from gateway.session import Platform
|
|
|
|
telegram_adapter = SimpleNamespace(send=AsyncMock())
|
|
home = SimpleNamespace(chat_id="987654321")
|
|
runner = SimpleNamespace(
|
|
adapters={Platform.TELEGRAM: telegram_adapter},
|
|
config=SimpleNamespace(get_home_channel=lambda p: home if p is Platform.TELEGRAM else None),
|
|
)
|
|
adapter.gateway_runner = runner
|
|
adapter._allowed_user_ids = {"100200300"}
|
|
|
|
interaction = _make_interaction("999999999")
|
|
await adapter._check_slash_authorization(interaction, "/background hi")
|
|
|
|
# Notify is fire-and-forget — let the scheduled task run.
|
|
await asyncio.sleep(0)
|
|
await asyncio.sleep(0)
|
|
|
|
telegram_adapter.send.assert_awaited_once()
|
|
chat_id, msg = telegram_adapter.send.call_args.args
|
|
assert chat_id == "987654321"
|
|
assert "Unauthorized" in msg
|
|
assert "999999999" in msg
|
|
assert "/background hi" in msg
|
|
assert "DISCORD_ALLOWED_USERS" in msg
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_silently_no_ops_without_runner(adapter):
|
|
adapter.gateway_runner = None
|
|
await adapter._notify_unauthorized_slash("u", "1", 2, 3, "/x", "reason") # must not raise
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_falls_back_to_slack_if_no_telegram(adapter):
|
|
from gateway.session import Platform
|
|
|
|
slack_adapter = SimpleNamespace(send=AsyncMock())
|
|
home_slack = SimpleNamespace(chat_id="C12345")
|
|
runner = SimpleNamespace(
|
|
adapters={Platform.SLACK: slack_adapter},
|
|
config=SimpleNamespace(
|
|
get_home_channel=lambda p: home_slack if p is Platform.SLACK else None,
|
|
),
|
|
)
|
|
adapter.gateway_runner = runner
|
|
await adapter._notify_unauthorized_slash("u", "1", 2, 3, "/x", "reason")
|
|
slack_adapter.send.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Opt-in visibility hide
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_visibility_hide_off_by_default_is_noop(adapter, monkeypatch):
|
|
"""DISCORD_HIDE_SLASH_COMMANDS unset → don't touch any command's permissions."""
|
|
cmd = SimpleNamespace(name="x", default_permissions="UNCHANGED")
|
|
tree = SimpleNamespace(get_commands=lambda: [cmd])
|
|
|
|
# Re-run the registration tail logic by calling the bit that decides:
|
|
# we don't have a clean way to simulate the env-gated branch from
|
|
# _register_slash_commands, so we just confirm the helper itself works
|
|
# AND assert the env-gating logic is correct.
|
|
assert os.environ.get("DISCORD_HIDE_SLASH_COMMANDS") is None
|
|
# Helper should still work when called directly:
|
|
adapter._apply_owner_only_visibility(tree)
|
|
# When called directly the helper applies — env gating is at the call site,
|
|
# which we exercise in an integration-style test below.
|
|
|
|
|
|
def test_visibility_hide_helper_zeroes_perms(adapter):
|
|
cmd_a = SimpleNamespace(name="a", default_permissions=None)
|
|
cmd_b = SimpleNamespace(name="b", default_permissions=None)
|
|
tree = SimpleNamespace(get_commands=lambda: [cmd_a, cmd_b])
|
|
adapter._apply_owner_only_visibility(tree)
|
|
assert cmd_a.default_permissions is not None
|
|
assert cmd_b.default_permissions is not None
|
|
assert cmd_a.default_permissions.value == 0
|
|
assert cmd_b.default_permissions.value == 0
|
|
|
|
|
|
def test_visibility_hide_tolerates_unsetable_command(adapter, caplog):
|
|
class _Frozen:
|
|
__slots__ = ("name",)
|
|
def __init__(self, name):
|
|
self.name = name
|
|
|
|
cmd_ok = SimpleNamespace(name="ok", default_permissions=None)
|
|
cmd_bad = _Frozen("bad")
|
|
tree = SimpleNamespace(get_commands=lambda: [cmd_bad, cmd_ok])
|
|
|
|
with caplog.at_level(logging.DEBUG):
|
|
adapter._apply_owner_only_visibility(tree)
|
|
|
|
assert cmd_ok.default_permissions.value == 0
|
|
|
|
|
|
# os import for test_visibility_hide_off_by_default_is_noop
|
|
import os # noqa: E402
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-closed parity on malformed slash auth context
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_channel_id_rejected_when_channel_policy_configured(
|
|
adapter, monkeypatch,
|
|
):
|
|
"""A guild interaction without a resolvable channel id must fail
|
|
closed when DISCORD_ALLOWED_CHANNELS is configured. Without this
|
|
guard the entire channel-policy block silently fell through."""
|
|
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "1111,2222")
|
|
interaction = _make_interaction("100200300", channel_id=None)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is False
|
|
interaction.response.send_message.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_channel_id_allowed_when_no_channel_policy(adapter):
|
|
"""No DISCORD_ALLOWED_CHANNELS configured + missing channel id: still
|
|
pass through the channel block (matches no-allowlist default)."""
|
|
interaction = _make_interaction("100200300", channel_id=None)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_user_rejected_when_allowlist_configured(adapter):
|
|
"""interaction.user is None with a user/role allowlist active:
|
|
fail closed without raising AttributeError."""
|
|
adapter._allowed_user_ids = {"100200300"}
|
|
interaction = _make_interaction("100200300", user=None)
|
|
# Must not raise — must return False with an ephemeral rejection
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is False
|
|
interaction.response.send_message.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_user_allowed_when_no_allowlist_configured(adapter):
|
|
"""interaction.user is None but no allowlist configured: allow
|
|
(preserves no-allowlist back-compat -- anyone is allowed when no
|
|
policy is in effect)."""
|
|
interaction = _make_interaction("100200300", user=None)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thread parent channel allowlist parity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_thread_parent_in_allowlist_passes(adapter, monkeypatch):
|
|
"""Thread whose parent channel is on DISCORD_ALLOWED_CHANNELS passes
|
|
even though the thread id itself isn't on the list."""
|
|
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "5555")
|
|
interaction = _make_interaction(
|
|
"100200300", channel_id=9999, in_thread=True, parent_channel_id=5555,
|
|
)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_thread_parent_in_ignorelist_rejects(adapter, monkeypatch):
|
|
"""Thread whose parent channel is on DISCORD_IGNORED_CHANNELS rejects
|
|
even when the thread id itself isn't ignored."""
|
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "5555")
|
|
interaction = _make_interaction(
|
|
"100200300", channel_id=9999, in_thread=True, parent_channel_id=5555,
|
|
)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ignored_beats_allowed(adapter, monkeypatch):
|
|
"""Channel listed in BOTH allowed and ignored: the ignored entry wins.
|
|
Anything else would be a foot-gun where adding to ignored does nothing
|
|
if the channel is also explicitly allowed."""
|
|
monkeypatch.setenv("DISCORD_ALLOWED_CHANNELS", "1111")
|
|
monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "1111")
|
|
interaction = _make_interaction("100200300", channel_id=1111)
|
|
assert await adapter._check_slash_authorization(interaction, "/help") is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Admin notify soft-fail fallback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_falls_back_to_slack_on_telegram_soft_fail(adapter):
|
|
"""adapter.send returning SendResult(success=False) must NOT short-
|
|
circuit the fallback chain. Treating a soft failure as delivered
|
|
means a Telegram outage swallows alerts silently."""
|
|
from gateway.session import Platform
|
|
|
|
soft_fail = SimpleNamespace(success=False, error="rate limited")
|
|
telegram_adapter = SimpleNamespace(send=AsyncMock(return_value=soft_fail))
|
|
slack_adapter = SimpleNamespace(send=AsyncMock())
|
|
home_tg = SimpleNamespace(chat_id="987654321")
|
|
home_sl = SimpleNamespace(chat_id="C12345")
|
|
homes = {Platform.TELEGRAM: home_tg, Platform.SLACK: home_sl}
|
|
runner = SimpleNamespace(
|
|
adapters={
|
|
Platform.TELEGRAM: telegram_adapter,
|
|
Platform.SLACK: slack_adapter,
|
|
},
|
|
config=SimpleNamespace(get_home_channel=lambda p: homes.get(p)),
|
|
)
|
|
adapter.gateway_runner = runner
|
|
|
|
await adapter._notify_unauthorized_slash("u", "1", 2, 3, "/x", "reason")
|
|
|
|
telegram_adapter.send.assert_awaited_once()
|
|
slack_adapter.send.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_returns_on_telegram_truthy_success(adapter):
|
|
"""adapter.send returning SendResult(success=True) -- or any object
|
|
without a falsy success attribute -- should still short-circuit at
|
|
Telegram. (This guards against the soft-fail patch over-correcting.)"""
|
|
from gateway.session import Platform
|
|
|
|
ok = SimpleNamespace(success=True, message_id="m1")
|
|
telegram_adapter = SimpleNamespace(send=AsyncMock(return_value=ok))
|
|
slack_adapter = SimpleNamespace(send=AsyncMock())
|
|
home_tg = SimpleNamespace(chat_id="987654321")
|
|
home_sl = SimpleNamespace(chat_id="C12345")
|
|
homes = {Platform.TELEGRAM: home_tg, Platform.SLACK: home_sl}
|
|
runner = SimpleNamespace(
|
|
adapters={
|
|
Platform.TELEGRAM: telegram_adapter,
|
|
Platform.SLACK: slack_adapter,
|
|
},
|
|
config=SimpleNamespace(get_home_channel=lambda p: homes.get(p)),
|
|
)
|
|
adapter.gateway_runner = runner
|
|
|
|
await adapter._notify_unauthorized_slash("u", "1", 2, 3, "/x", "reason")
|
|
|
|
telegram_adapter.send.assert_awaited_once()
|
|
slack_adapter.send.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /skill autocomplete + callback gating
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _capture_skill_registration(adapter, monkeypatch, entries):
|
|
"""Run ``_register_skill_group`` against a stubbed skill catalog and
|
|
return ``(handler_callback, autocomplete_callback)``.
|
|
|
|
The autocomplete callback is captured by monkeypatching
|
|
``discord.app_commands.autocomplete`` -- the production decorator is
|
|
a no-op stub in this test file's discord mock, so capturing the
|
|
callback through it is the direct route in tests.
|
|
"""
|
|
import discord
|
|
|
|
captured: dict = {}
|
|
|
|
def fake_categories(reserved_names):
|
|
# Match discord_skill_commands_by_category's tuple shape:
|
|
# (categories_dict, uncategorized_list, hidden_count)
|
|
return ({}, list(entries), 0)
|
|
|
|
import hermes_cli.commands as _hc
|
|
monkeypatch.setattr(
|
|
_hc, "discord_skill_commands_by_category", fake_categories,
|
|
)
|
|
|
|
def capture_autocomplete(**kwargs):
|
|
# Only one autocomplete in /skill registration: name=...
|
|
captured["autocomplete"] = kwargs.get("name")
|
|
|
|
def _passthrough(fn):
|
|
return fn
|
|
|
|
return _passthrough
|
|
|
|
monkeypatch.setattr(
|
|
discord.app_commands, "autocomplete", capture_autocomplete,
|
|
raising=False,
|
|
)
|
|
|
|
registered: list = []
|
|
|
|
class _Tree:
|
|
def get_commands(self):
|
|
return []
|
|
|
|
def add_command(self, cmd):
|
|
registered.append(cmd)
|
|
|
|
adapter._register_skill_group(_Tree())
|
|
assert registered, "_register_skill_group did not register a command"
|
|
return registered[0].callback, captured["autocomplete"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_autocomplete_returns_empty_for_unauthorized(
|
|
adapter, monkeypatch,
|
|
):
|
|
"""Autocomplete must not leak the installed skill catalog to users
|
|
who can't run /skill. With DISCORD_ALLOWED_USERS configured and the
|
|
interaction user outside it, the autocomplete callback returns []."""
|
|
adapter._allowed_user_ids = {"100200300"}
|
|
entries = [
|
|
("alpha", "First skill", "/alpha"),
|
|
("beta", "Second skill", "/beta"),
|
|
]
|
|
_handler, autocomplete = _capture_skill_registration(
|
|
adapter, monkeypatch, entries,
|
|
)
|
|
|
|
interaction = _make_interaction("999999999")
|
|
result = await autocomplete(interaction, "")
|
|
assert result == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_autocomplete_returns_choices_for_authorized(
|
|
adapter, monkeypatch,
|
|
):
|
|
"""Sanity: an authorized user still gets the autocomplete suggestions."""
|
|
adapter._allowed_user_ids = {"100200300"}
|
|
entries = [
|
|
("alpha", "First skill", "/alpha"),
|
|
("beta", "Second skill", "/beta"),
|
|
]
|
|
_handler, autocomplete = _capture_skill_registration(
|
|
adapter, monkeypatch, entries,
|
|
)
|
|
|
|
interaction = _make_interaction("100200300")
|
|
result = await autocomplete(interaction, "")
|
|
assert len(result) == 2
|
|
assert {choice.value for choice in result} == {"alpha", "beta"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_handler_rejects_before_dispatch_for_unauthorized(
|
|
adapter, monkeypatch,
|
|
):
|
|
"""The /skill handler must call _check_slash_authorization BEFORE
|
|
skill_lookup. Otherwise unknown vs known names produce divergent
|
|
responses ("Unknown skill: foo" vs auth rejection) which is a
|
|
catalog-probing oracle."""
|
|
adapter._allowed_user_ids = {"100200300"}
|
|
entries = [("alpha", "First skill", "/alpha")]
|
|
handler, _autocomplete = _capture_skill_registration(
|
|
adapter, monkeypatch, entries,
|
|
)
|
|
|
|
# Patch _run_simple_slash so we can detect any leak through it.
|
|
dispatched: list = []
|
|
|
|
async def fake_dispatch(_interaction, text):
|
|
dispatched.append(text)
|
|
|
|
adapter._run_simple_slash = fake_dispatch # type: ignore[assignment]
|
|
|
|
interaction = _make_interaction("999999999")
|
|
await handler(interaction, "alpha", "")
|
|
|
|
interaction.response.send_message.assert_awaited_once()
|
|
args, kwargs = interaction.response.send_message.call_args
|
|
assert kwargs.get("ephemeral") is True
|
|
assert "not authorized" in (
|
|
args[0] if args else kwargs.get("content", "")
|
|
).lower()
|
|
# Critically: nothing was dispatched, and the auth message did NOT
|
|
# mention the skill name "alpha" (no catalog leak).
|
|
assert dispatched == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_handler_known_and_unknown_produce_same_rejection(
|
|
adapter, monkeypatch,
|
|
):
|
|
"""An unauthorized user probing for valid skill names must see the
|
|
same rejection text regardless of whether the name they tried is
|
|
on the registered catalog."""
|
|
adapter._allowed_user_ids = {"100200300"}
|
|
entries = [("alpha", "First skill", "/alpha")]
|
|
handler, _ = _capture_skill_registration(adapter, monkeypatch, entries)
|
|
|
|
adapter._run_simple_slash = AsyncMock() # type: ignore[assignment]
|
|
|
|
known_interaction = _make_interaction("999999999")
|
|
unknown_interaction = _make_interaction("999999999")
|
|
await handler(known_interaction, "alpha", "")
|
|
await handler(unknown_interaction, "definitely-not-a-skill", "")
|
|
|
|
known_interaction.response.send_message.assert_awaited_once()
|
|
unknown_interaction.response.send_message.assert_awaited_once()
|
|
known_args, known_kwargs = known_interaction.response.send_message.call_args
|
|
unknown_args, unknown_kwargs = (
|
|
unknown_interaction.response.send_message.call_args
|
|
)
|
|
assert known_args == unknown_args
|
|
assert known_kwargs == unknown_kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_handler_dispatches_for_authorized(
|
|
adapter, monkeypatch,
|
|
):
|
|
"""Sanity: an authorized user reaches _run_simple_slash with the
|
|
resolved cmd_key and arguments."""
|
|
adapter._allowed_user_ids = {"100200300"}
|
|
entries = [("alpha", "First skill", "/alpha")]
|
|
handler, _ = _capture_skill_registration(adapter, monkeypatch, entries)
|
|
|
|
dispatched: list = []
|
|
|
|
async def fake_dispatch(_interaction, text):
|
|
dispatched.append(text)
|
|
|
|
adapter._run_simple_slash = fake_dispatch # type: ignore[assignment]
|
|
|
|
interaction = _make_interaction("100200300")
|
|
await handler(interaction, "alpha", "extra args")
|
|
assert dispatched == ["/alpha extra args"]
|