Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
"""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
|
||||
|
||||
Reference in New Issue
Block a user