"""Tests for Matrix adapter fail-closed approval reaction auth. When MATRIX_ALLOWED_USERS is not configured, _on_reaction must deny approval reactions by default unless GATEWAY_ALLOW_ALL_USERS=true. Mirrors the Telegram _is_callback_user_authorized fix (commit 89d32052e, PR #28494). """ import asyncio import sys import types from collections import deque from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest # --------------------------------------------------------------------------- # Stub mautrix so plugins.platforms.matrix.adapter can be imported without the SDK. # --------------------------------------------------------------------------- def _stub_mautrix(): stub = types.ModuleType("mautrix") for sub in ("mautrix.types", "mautrix.client", "mautrix.client.api", "mautrix.errors", "mautrix.crypto", "mautrix.util", "mautrix.util.config"): sys.modules.setdefault(sub, types.ModuleType(sub)) sys.modules.setdefault("mautrix", stub) m = sys.modules["mautrix.types"] class EventType: ROOM_MESSAGE = "m.room.message" REACTION = "m.reaction" ROOM_ENCRYPTED = "m.room.encrypted" ROOM_NAME = "m.room.name" class PaginationDirection: BACKWARD = "b" FORWARD = "f" class PresenceState: ONLINE = "online" OFFLINE = "offline" UNAVAILABLE = "unavailable" class RoomCreatePreset: PRIVATE = "private_chat" PUBLIC = "public_chat" TRUSTED_PRIVATE = "trusted_private_chat" class TrustState: UNVERIFIED = 0 VERIFIED = 1 for attr in ("ContentURI", "EventID", "RoomID", "SyncToken", "UserID"): setattr(m, attr, str) m.EventType = EventType m.PaginationDirection = PaginationDirection m.PresenceState = PresenceState m.RoomCreatePreset = RoomCreatePreset m.TrustState = TrustState _stub_mautrix() from plugins.platforms.matrix.adapter import MatrixAdapter, _MatrixApprovalPrompt # noqa: E402 # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_adapter(allowed_user_ids=None): """Construct a MatrixAdapter with only the state needed by _on_reaction.""" adapter = object.__new__(MatrixAdapter) adapter._user_id = "@bot:matrix.org" adapter._allowed_user_ids = set(allowed_user_ids) if allowed_user_ids else set() adapter._approval_reaction_map = {"✅": "once", "❎": "deny"} adapter._approval_prompts_by_event = {} adapter._approval_prompt_by_session = {} adapter._processed_events = deque(maxlen=512) adapter._processed_events_set = set() return adapter def _make_event(sender, reacts_to, key="✅"): """Minimal Matrix reaction event.""" return SimpleNamespace( sender=sender, event_id=f"$reaction-{sender.split(':')[0]}", room_id="!testroom:matrix.org", content={"m.relates_to": {"event_id": reacts_to, "key": key}}, ) def _make_prompt(chat_id="!testroom:matrix.org"): return _MatrixApprovalPrompt( session_key="session-abc", chat_id=chat_id, message_id="$prompt-event-1", ) def _run(adapter, event): """Run _on_reaction and return whether the prompt was resolved.""" prompt_event_id = "$prompt-event-1" prompt = _make_prompt() adapter._approval_prompts_by_event[prompt_event_id] = prompt adapter._redact_bot_approval_reactions = AsyncMock() fake_approval = types.ModuleType("tools.approval") fake_approval.resolve_gateway_approval = lambda session_key, choice: 1 with patch.dict(sys.modules, {"tools.approval": fake_approval}): asyncio.run(adapter._on_reaction(event)) return prompt.resolved # --------------------------------------------------------------------------- # Test class # --------------------------------------------------------------------------- class TestApprovalReactionFailClosed: """_on_reaction approval auth must be fail-closed (parity with Telegram).""" def test_no_allowlist_no_allow_all_denies(self, monkeypatch): """No MATRIX_ALLOWED_USERS + no GATEWAY_ALLOW_ALL_USERS → deny.""" monkeypatch.delenv("MATRIX_ALLOWED_USERS", raising=False) monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) adapter = _make_adapter(allowed_user_ids=None) event = _make_event("@stranger:matrix.org", "$prompt-event-1") assert _run(adapter, event) is False def test_no_allowlist_allow_all_permits(self, monkeypatch): """No MATRIX_ALLOWED_USERS + GATEWAY_ALLOW_ALL_USERS=true → allow.""" monkeypatch.delenv("MATRIX_ALLOWED_USERS", raising=False) monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true") adapter = _make_adapter(allowed_user_ids=None) event = _make_event("@anyone:matrix.org", "$prompt-event-1") assert _run(adapter, event) is True def test_listed_sender_permits(self, monkeypatch): """Sender in MATRIX_ALLOWED_USERS → allow.""" monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) adapter = _make_adapter(allowed_user_ids=["@alice:matrix.org"]) event = _make_event("@alice:matrix.org", "$prompt-event-1") assert _run(adapter, event) is True def test_unlisted_sender_denies(self, monkeypatch): """Sender not in MATRIX_ALLOWED_USERS → deny.""" monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) adapter = _make_adapter(allowed_user_ids=["@alice:matrix.org"]) event = _make_event("@mallory:matrix.org", "$prompt-event-1") assert _run(adapter, event) is False