mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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).
2864 lines
110 KiB
Python
2864 lines
110 KiB
Python
"""Tests for Matrix platform adapter (mautrix-python backend)."""
|
|
import asyncio
|
|
import re
|
|
import stat
|
|
import sys
|
|
import time
|
|
import types
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch, AsyncMock
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import MessageType
|
|
|
|
|
|
def _make_fake_mautrix():
|
|
"""Create a lightweight set of fake ``mautrix`` modules.
|
|
|
|
The adapter does ``from mautrix.api import HTTPAPI``,
|
|
``from mautrix.client import Client``, ``from mautrix.types import ...``
|
|
at import time and inside methods. We provide just enough stubs for
|
|
tests that need to mock the mautrix import chain.
|
|
|
|
Use via ``patch.dict("sys.modules", _make_fake_mautrix())``.
|
|
"""
|
|
# --- mautrix (root) ---
|
|
mautrix = types.ModuleType("mautrix")
|
|
|
|
# --- mautrix.api ---
|
|
mautrix_api = types.ModuleType("mautrix.api")
|
|
|
|
class HTTPAPI:
|
|
def __init__(self, base_url="", token="", **kwargs):
|
|
self.base_url = base_url
|
|
self.token = token
|
|
self.session = MagicMock()
|
|
self.session.close = AsyncMock()
|
|
|
|
mautrix_api.HTTPAPI = HTTPAPI
|
|
mautrix.api = mautrix_api
|
|
|
|
# --- mautrix.types ---
|
|
mautrix_types = types.ModuleType("mautrix.types")
|
|
|
|
class EventType:
|
|
ROOM_MESSAGE = "m.room.message"
|
|
REACTION = "m.reaction"
|
|
ROOM_ENCRYPTED = "m.room.encrypted"
|
|
ROOM_NAME = "m.room.name"
|
|
|
|
class UserID(str):
|
|
pass
|
|
|
|
class RoomID(str):
|
|
pass
|
|
|
|
class EventID(str):
|
|
pass
|
|
|
|
class ContentURI(str):
|
|
pass
|
|
|
|
class SyncToken(str):
|
|
pass
|
|
|
|
class RoomCreatePreset:
|
|
PRIVATE = "private_chat"
|
|
PUBLIC = "public_chat"
|
|
TRUSTED_PRIVATE = "trusted_private_chat"
|
|
|
|
class PresenceState:
|
|
ONLINE = "online"
|
|
OFFLINE = "offline"
|
|
UNAVAILABLE = "unavailable"
|
|
|
|
class TrustState:
|
|
UNVERIFIED = 0
|
|
VERIFIED = 1
|
|
|
|
class PaginationDirection:
|
|
BACKWARD = "b"
|
|
FORWARD = "f"
|
|
|
|
mautrix_types.EventType = EventType
|
|
mautrix_types.UserID = UserID
|
|
mautrix_types.RoomID = RoomID
|
|
mautrix_types.EventID = EventID
|
|
mautrix_types.ContentURI = ContentURI
|
|
mautrix_types.SyncToken = SyncToken
|
|
mautrix_types.RoomCreatePreset = RoomCreatePreset
|
|
mautrix_types.PresenceState = PresenceState
|
|
mautrix_types.TrustState = TrustState
|
|
mautrix_types.PaginationDirection = PaginationDirection
|
|
mautrix.types = mautrix_types
|
|
|
|
# --- mautrix.client ---
|
|
mautrix_client = types.ModuleType("mautrix.client")
|
|
|
|
class Client:
|
|
def __init__(self, mxid=None, device_id=None, api=None,
|
|
state_store=None, sync_store=None, **kwargs):
|
|
self.mxid = mxid
|
|
self.device_id = device_id
|
|
self.api = api
|
|
self.state_store = state_store
|
|
self.sync_store = sync_store
|
|
self.crypto = None
|
|
self._event_handlers = {}
|
|
|
|
def add_event_handler(self, event_type, handler, **kwargs):
|
|
self._event_handlers.setdefault(event_type, []).append(handler)
|
|
|
|
def add_dispatcher(self, dispatcher_type):
|
|
pass
|
|
|
|
class InternalEventType:
|
|
INVITE = "internal.invite"
|
|
|
|
mautrix_client.Client = Client
|
|
mautrix_client.InternalEventType = InternalEventType
|
|
mautrix.client = mautrix_client
|
|
|
|
# --- mautrix.client.dispatcher ---
|
|
mautrix_client_dispatcher = types.ModuleType("mautrix.client.dispatcher")
|
|
|
|
class MembershipEventDispatcher:
|
|
pass
|
|
|
|
mautrix_client_dispatcher.MembershipEventDispatcher = MembershipEventDispatcher
|
|
|
|
# --- mautrix.client.state_store ---
|
|
mautrix_client_state_store = types.ModuleType("mautrix.client.state_store")
|
|
|
|
class MemoryStateStore:
|
|
async def get_member(self, room_id, user_id):
|
|
return None
|
|
|
|
async def get_members(self, room_id):
|
|
return []
|
|
|
|
async def get_member_profiles(self, room_id):
|
|
return {}
|
|
|
|
class MemorySyncStore:
|
|
def __init__(self):
|
|
self.next_batch = None
|
|
|
|
async def get_next_batch(self):
|
|
return self.next_batch
|
|
|
|
async def put_next_batch(self, token):
|
|
self.next_batch = token
|
|
|
|
mautrix_client_state_store.MemoryStateStore = MemoryStateStore
|
|
mautrix_client_state_store.MemorySyncStore = MemorySyncStore
|
|
|
|
# --- mautrix.crypto ---
|
|
mautrix_crypto = types.ModuleType("mautrix.crypto")
|
|
|
|
class OlmMachine:
|
|
def __init__(self, client=None, crypto_store=None, state_store=None):
|
|
self.share_keys_min_trust = None
|
|
self.send_keys_min_trust = None
|
|
|
|
async def load(self):
|
|
pass
|
|
|
|
async def share_keys(self):
|
|
pass
|
|
|
|
async def decrypt_megolm_event(self, event):
|
|
return event
|
|
|
|
mautrix_crypto.OlmMachine = OlmMachine
|
|
|
|
# --- mautrix.crypto.store ---
|
|
mautrix_crypto_store = types.ModuleType("mautrix.crypto.store")
|
|
|
|
class MemoryCryptoStore:
|
|
def __init__(self, account_id="", pickle_key=""): # noqa: S301
|
|
self.account_id = account_id
|
|
self.pickle_key = pickle_key
|
|
|
|
mautrix_crypto_store.MemoryCryptoStore = MemoryCryptoStore
|
|
|
|
# --- mautrix.crypto.attachments ---
|
|
mautrix_crypto_attachments = types.ModuleType("mautrix.crypto.attachments")
|
|
|
|
def encrypt_attachment(data):
|
|
encrypted_file = MagicMock()
|
|
encrypted_file.serialize.return_value = {
|
|
"key": {"k": "testkey"}, "iv": "testiv",
|
|
"hashes": {"sha256": "testhash"}, "v": "v2",
|
|
}
|
|
return (b"ciphertext_" + data, encrypted_file)
|
|
|
|
mautrix_crypto_attachments.encrypt_attachment = encrypt_attachment
|
|
|
|
# --- mautrix.crypto.store.asyncpg ---
|
|
mautrix_crypto_store_asyncpg = types.ModuleType("mautrix.crypto.store.asyncpg")
|
|
|
|
class PgCryptoStore:
|
|
upgrade_table = MagicMock()
|
|
|
|
def __init__(self, account_id="", pickle_key="", db=None): # noqa: S301
|
|
self.account_id = account_id
|
|
self.pickle_key = pickle_key
|
|
self.db = db
|
|
self._device_id = ""
|
|
|
|
async def open(self):
|
|
pass
|
|
|
|
async def put_device_id(self, device_id):
|
|
self._device_id = device_id
|
|
|
|
mautrix_crypto_store_asyncpg.PgCryptoStore = PgCryptoStore
|
|
|
|
# --- mautrix.util ---
|
|
mautrix_util = types.ModuleType("mautrix.util")
|
|
|
|
# --- mautrix.util.async_db ---
|
|
mautrix_util_async_db = types.ModuleType("mautrix.util.async_db")
|
|
|
|
class Database:
|
|
@classmethod
|
|
def create(cls, url, upgrade_table=None):
|
|
db = MagicMock()
|
|
db.start = AsyncMock()
|
|
db.stop = AsyncMock()
|
|
return db
|
|
|
|
mautrix_util_async_db.Database = Database
|
|
|
|
return {
|
|
"mautrix": mautrix,
|
|
"mautrix.api": mautrix_api,
|
|
"mautrix.types": mautrix_types,
|
|
"mautrix.client": mautrix_client,
|
|
"mautrix.client.dispatcher": mautrix_client_dispatcher,
|
|
"mautrix.client.state_store": mautrix_client_state_store,
|
|
"mautrix.crypto": mautrix_crypto,
|
|
"mautrix.crypto.attachments": mautrix_crypto_attachments,
|
|
"mautrix.crypto.store": mautrix_crypto_store,
|
|
"mautrix.crypto.store.asyncpg": mautrix_crypto_store_asyncpg,
|
|
"mautrix.util": mautrix_util,
|
|
"mautrix.util.async_db": mautrix_util_async_db,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Platform & Config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixConfigLoading:
|
|
|
|
def test_apply_env_overrides_with_password(self, monkeypatch):
|
|
monkeypatch.delenv("MATRIX_ACCESS_TOKEN", raising=False)
|
|
monkeypatch.setenv("MATRIX_PASSWORD", "secret123")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
monkeypatch.setenv("MATRIX_USER_ID", "@bot:example.org")
|
|
|
|
from gateway.config import GatewayConfig, _apply_env_overrides
|
|
config = GatewayConfig()
|
|
_apply_env_overrides(config)
|
|
|
|
assert Platform.MATRIX in config.platforms
|
|
mc = config.platforms[Platform.MATRIX]
|
|
assert mc.enabled is True
|
|
assert mc.extra.get("password") == "secret123"
|
|
assert mc.extra.get("user_id") == "@bot:example.org"
|
|
|
|
|
|
def test_matrix_e2ee_mode_optional_sets_config(self, monkeypatch):
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
monkeypatch.setenv("MATRIX_E2EE_MODE", "optional")
|
|
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
|
|
|
|
from gateway.config import GatewayConfig, _apply_env_overrides
|
|
config = GatewayConfig()
|
|
_apply_env_overrides(config)
|
|
|
|
mc = config.platforms[Platform.MATRIX]
|
|
assert mc.extra.get("encryption") is True
|
|
assert mc.extra.get("e2ee_mode") == "optional"
|
|
|
|
|
|
def test_matrix_home_room(self, monkeypatch):
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
monkeypatch.setenv("MATRIX_HOME_ROOM", "!room123:example.org")
|
|
monkeypatch.setenv("MATRIX_HOME_ROOM_NAME", "Bot Room")
|
|
|
|
from gateway.config import GatewayConfig, _apply_env_overrides
|
|
config = GatewayConfig()
|
|
_apply_env_overrides(config)
|
|
|
|
home = config.get_home_channel(Platform.MATRIX)
|
|
assert home is not None
|
|
assert home.chat_id == "!room123:example.org"
|
|
assert home.name == "Bot Room"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_adapter():
|
|
"""Create a MatrixAdapter with mocked config."""
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
return adapter
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Typing indicator
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixTypingIndicator:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._client = MagicMock()
|
|
self.adapter._client.set_typing = AsyncMock()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_typing_clears_matrix_typing_state(self):
|
|
"""stop_typing() should send typing=false instead of waiting for timeout expiry."""
|
|
from plugins.platforms.matrix.adapter import RoomID
|
|
|
|
await self.adapter.stop_typing("!room:example.org")
|
|
|
|
self.adapter._client.set_typing.assert_awaited_once_with(
|
|
RoomID("!room:example.org"),
|
|
timeout=0,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# mxc:// URL conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixMxcToHttp:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
|
|
def test_mxc_with_different_server(self):
|
|
"""mxc:// from a different server should still use our homeserver."""
|
|
mxc = "mxc://other.server/media456"
|
|
result = self.adapter._mxc_to_http(mxc)
|
|
assert result.startswith("https://matrix.example.org/")
|
|
assert "other.server/media456" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DM detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixDmDetection:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
def test_room_in_m_direct_is_dm(self):
|
|
"""A room listed in m.direct should be detected as DM."""
|
|
self.adapter._joined_rooms = {"!dm_room:ex.org", "!group_room:ex.org"}
|
|
self.adapter._dm_rooms = {
|
|
"!dm_room:ex.org": True,
|
|
"!group_room:ex.org": False,
|
|
}
|
|
|
|
assert self.adapter._dm_rooms.get("!dm_room:ex.org") is True
|
|
assert self.adapter._dm_rooms.get("!group_room:ex.org") is False
|
|
|
|
def test_unknown_room_not_in_cache(self):
|
|
"""Unknown rooms should not be in the DM cache."""
|
|
self.adapter._dm_rooms = {}
|
|
assert self.adapter._dm_rooms.get("!unknown:ex.org") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_named_two_member_dm_is_dm(self):
|
|
"""A named two-member room in m.direct is a DM (not a room).
|
|
|
|
Most Matrix clients auto-name DM rooms (e.g. "Alice & Bot"), so the
|
|
old `not has_explicit_name` override misclassified them as rooms.
|
|
"""
|
|
self.adapter._joined_rooms = {"!named_dm:ex.org"}
|
|
self.adapter._dm_rooms = {"!named_dm:ex.org": True}
|
|
self.adapter._client = MagicMock()
|
|
self.adapter._client.get_state_event = AsyncMock(
|
|
side_effect=lambda room_id, event_type: {"name": "Alice & Bot"}
|
|
if event_type == "m.room.name"
|
|
else (_ for _ in ()).throw(Exception("no alias"))
|
|
)
|
|
self.adapter._client.state_store = MagicMock()
|
|
self.adapter._client.state_store.get_members = AsyncMock(
|
|
return_value=["@bot:ex.org", "@alice:ex.org"]
|
|
)
|
|
|
|
identity = await self.adapter._resolve_room_identity("!named_dm:ex.org")
|
|
|
|
assert identity.chat_type == "dm"
|
|
assert identity.conflict is False
|
|
assert identity.joined_member_count == 2
|
|
assert await self.adapter._is_dm_room("!named_dm:ex.org") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reply fallback stripping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixReplyFallbackStripping:
|
|
"""Test that Matrix reply fallback lines ('> ' prefix) are stripped."""
|
|
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._user_id = "@bot:example.org"
|
|
self.adapter._startup_ts = 0.0
|
|
self.adapter._dm_rooms = {}
|
|
self.adapter._message_handler = AsyncMock()
|
|
|
|
def _strip_fallback(self, body: str, has_reply: bool = True) -> str:
|
|
"""Simulate the reply fallback stripping logic from _on_room_message."""
|
|
reply_to = "some_event_id" if has_reply else None
|
|
if reply_to and body.startswith("> "):
|
|
lines = body.split("\n")
|
|
stripped = []
|
|
past_fallback = False
|
|
for line in lines:
|
|
if not past_fallback:
|
|
if line.startswith("> ") or line == ">":
|
|
continue
|
|
if line == "":
|
|
past_fallback = True
|
|
continue
|
|
past_fallback = True
|
|
stripped.append(line)
|
|
body = "\n".join(stripped) if stripped else body
|
|
return body
|
|
|
|
def test_simple_reply_fallback(self):
|
|
body = "> <@alice:ex.org> Original message\n\nActual reply"
|
|
result = self._strip_fallback(body)
|
|
assert result == "Actual reply"
|
|
|
|
def test_multiline_reply_fallback(self):
|
|
body = "> <@alice:ex.org> Line 1\n> Line 2\n\nMy response"
|
|
result = self._strip_fallback(body)
|
|
assert result == "My response"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Matrix-friendly command aliases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixBangCommandAlias:
|
|
"""Matrix clients may reserve /commands, so Hermes supports !commands."""
|
|
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._is_dm_room = AsyncMock(return_value=True)
|
|
self.adapter._get_display_name = AsyncMock(return_value="Alice")
|
|
self.adapter._background_read_receipt = MagicMock()
|
|
self.adapter._text_batch_delay_seconds = 0
|
|
|
|
async def _dispatch_text(self, body: str, *, is_dm: bool = True):
|
|
captured_event = None
|
|
self.adapter._is_dm_room = AsyncMock(return_value=is_dm)
|
|
self.adapter._require_mention = True
|
|
self.adapter._free_rooms = set()
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter.handle_message = capture
|
|
await self.adapter._handle_text_message(
|
|
room_id="!room:example.org",
|
|
sender="@alice:example.org",
|
|
event_id="$matrix-command-test",
|
|
event_ts=0.0,
|
|
source_content={"msgtype": "m.text", "body": body},
|
|
relates_to={},
|
|
)
|
|
return captured_event
|
|
|
|
async def _dispatch_text_reply(self, body: str, *, is_dm: bool = True):
|
|
"""Dispatch a message that is a Matrix reply (m.in_reply_to set), so
|
|
the reply-fallback quote stripping path runs before command detection.
|
|
"""
|
|
captured_event = None
|
|
self.adapter._is_dm_room = AsyncMock(return_value=is_dm)
|
|
self.adapter._require_mention = True
|
|
self.adapter._free_rooms = set()
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter.handle_message = capture
|
|
await self.adapter._handle_text_message(
|
|
room_id="!room:example.org",
|
|
sender="@alice:example.org",
|
|
event_id="$matrix-reply-command-test",
|
|
event_ts=0.0,
|
|
source_content={"msgtype": "m.text", "body": body},
|
|
relates_to={"m.in_reply_to": {"event_id": "$parent-event"}},
|
|
)
|
|
return captured_event
|
|
|
|
def test_known_bang_command_normalizes_to_slash_command(self):
|
|
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
|
|
|
|
assert _normalize_matrix_bang_command("!model") == "/model"
|
|
assert (
|
|
_normalize_matrix_bang_command("!queue continue the plan")
|
|
== "/queue continue the plan"
|
|
)
|
|
assert (
|
|
_normalize_matrix_bang_command("!btw research this")
|
|
== "/btw research this"
|
|
)
|
|
assert _normalize_matrix_bang_command("!tasks") == "/tasks"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_bang_text_stays_normal_text(self):
|
|
captured_event = await self._dispatch_text("!important note")
|
|
|
|
assert captured_event is not None
|
|
assert captured_event.text == "!important note"
|
|
assert captured_event.message_type == MessageType.TEXT
|
|
assert captured_event.get_command() is None
|
|
|
|
|
|
def test_bang_skill_command_normalizes(self):
|
|
"""The get_skill_commands() branch normalizes installed skill
|
|
commands, not just built-in gateway commands. Skill keys are stored
|
|
slash-prefixed (e.g. "/arxiv"), which the resolver must account for."""
|
|
import agent.skill_commands as skill_commands_mod
|
|
|
|
fake_skills = {"/arxiv": {}, "/obsidian": {}}
|
|
with patch.object(
|
|
skill_commands_mod, "get_skill_commands", return_value=fake_skills
|
|
):
|
|
from plugins.platforms.matrix.adapter import _normalize_matrix_bang_command
|
|
|
|
# is_gateway_known_command won't know these; the skill branch must.
|
|
assert _normalize_matrix_bang_command("!arxiv") == "/arxiv"
|
|
assert (
|
|
_normalize_matrix_bang_command("!obsidian search foo")
|
|
== "/obsidian search foo"
|
|
)
|
|
# A name in neither registry stays plain text.
|
|
assert (
|
|
_normalize_matrix_bang_command("!definitelynotacommand")
|
|
== "!definitelynotacommand"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_slash_command_in_quoted_reply_normalizes(self):
|
|
"""Sanity: the slash equivalent already works post-strip — the bang
|
|
form above must reach parity with this."""
|
|
captured_event = await self._dispatch_text_reply(
|
|
"> <@bob:example.org> earlier message\n\n/model"
|
|
)
|
|
|
|
assert captured_event is not None
|
|
assert captured_event.text == "/model"
|
|
assert captured_event.message_type == MessageType.COMMAND
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Thread detection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixThreadDetection:
|
|
|
|
|
|
def test_no_thread_for_edit(self):
|
|
"""m.replace relation should not set thread_id."""
|
|
relates_to = {
|
|
"rel_type": "m.replace",
|
|
"event_id": "$edited_event",
|
|
}
|
|
thread_id = None
|
|
if relates_to.get("rel_type") == "m.thread":
|
|
thread_id = relates_to.get("event_id")
|
|
assert thread_id is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Format message
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixFormatMessage:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
def test_image_markdown_stripped(self):
|
|
""" should be converted to just the URL."""
|
|
result = self.adapter.format_message("")
|
|
assert result == "https://img.example.com/cat.png"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rendering payloads
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixRenderingPayloads:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.mock_client = MagicMock()
|
|
self.mock_client.send_message_event = AsyncMock(return_value="$evt")
|
|
self.adapter._client = self.mock_client
|
|
|
|
def _sent_contents(self):
|
|
return [
|
|
call.args[2] if len(call.args) > 2 else call.kwargs["content"]
|
|
for call in self.mock_client.send_message_event.await_args_list
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_thread_payload_uses_m_thread_with_reply_fallback(self):
|
|
result = await self.adapter.send(
|
|
"!room:example.org",
|
|
"threaded",
|
|
metadata={"thread_id": "$root"},
|
|
)
|
|
|
|
assert result.success is True
|
|
relates_to = self._sent_contents()[0]["m.relates_to"]
|
|
assert relates_to == {
|
|
"rel_type": "m.thread",
|
|
"event_id": "$root",
|
|
"is_falling_back": True,
|
|
"m.in_reply_to": {"event_id": "$root"},
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_long_response_split_preserves_thread_context(self):
|
|
# Build a payload guaranteed to exceed the adapter's outbound chunk
|
|
# size (configurable since #53026) so send() must split it.
|
|
repeats = (self.adapter.max_message_length // 15) + 200
|
|
long_text = "Intro\n```python\n" + ("print('hello')\n" * repeats) + "```\nDone"
|
|
|
|
result = await self.adapter.send(
|
|
"!room:example.org",
|
|
long_text,
|
|
metadata={"thread_id": "$root"},
|
|
)
|
|
|
|
assert result.success is True
|
|
contents = self._sent_contents()
|
|
assert len(contents) > 1
|
|
for content in contents:
|
|
assert content["m.relates_to"]["rel_type"] == "m.thread"
|
|
assert content["m.relates_to"]["event_id"] == "$root"
|
|
assert content["m.relates_to"]["m.in_reply_to"] == {"event_id": "$root"}
|
|
assert content["body"].count("```") % 2 == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Markdown to HTML conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixMarkdownToHtml:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
def test_bold_conversion(self):
|
|
"""**bold** should produce <strong> tags."""
|
|
result = self.adapter._markdown_to_html("**bold**")
|
|
assert "<strong>" in result or "<b>" in result
|
|
assert "bold" in result
|
|
|
|
def test_italic_conversion(self):
|
|
"""*italic* should produce <em> tags."""
|
|
result = self.adapter._markdown_to_html("*italic*")
|
|
assert "<em>" in result or "<i>" in result
|
|
|
|
def test_inline_code(self):
|
|
"""`code` should produce <code> tags."""
|
|
result = self.adapter._markdown_to_html("`code`")
|
|
assert "<code>" in result
|
|
|
|
def test_plain_text_returns_html(self):
|
|
"""Plain text should still be returned (possibly with <br> or <p>)."""
|
|
result = self.adapter._markdown_to_html("Hello world")
|
|
assert "Hello world" in result
|
|
|
|
|
|
def test_matrix_markdown_preserves_table_structure(self):
|
|
table = "\n".join(
|
|
[
|
|
"| Item | Quantity |",
|
|
"| --- | --- |",
|
|
"| Apples | 4 |",
|
|
"| Bread | 1 |",
|
|
]
|
|
)
|
|
|
|
result = self.adapter._markdown_to_html(table)
|
|
|
|
assert "<table>" in result
|
|
assert "<thead>" in result
|
|
assert "<tbody>" in result
|
|
assert "<th>Item</th>" in result
|
|
assert "<td>Apples</td>" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: display name extraction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixDisplayName:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_display_name_from_state_store(self):
|
|
"""Should get display name from state_store.get_member()."""
|
|
mock_member = MagicMock()
|
|
mock_member.displayname = "Alice"
|
|
|
|
mock_state_store = MagicMock()
|
|
mock_state_store.get_member = AsyncMock(return_value=mock_member)
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.state_store = mock_state_store
|
|
self.adapter._client = mock_client
|
|
|
|
name = await self.adapter._get_display_name("!room:ex.org", "@alice:ex.org")
|
|
assert name == "Alice"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Requirements check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixModuleImport:
|
|
def test_module_importable_without_mautrix(self):
|
|
"""plugins.platforms.matrix.adapter must be importable even when mautrix is
|
|
not installed — otherwise the gateway crashes for ALL platforms.
|
|
|
|
This test uses a subprocess to avoid polluting the current process's
|
|
sys.modules (reimporting a module creates a second module object whose
|
|
classes don't share globals with the original — breaking patch.object
|
|
in subsequent tests).
|
|
"""
|
|
import subprocess
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", (
|
|
"import sys\n"
|
|
"# Block mautrix completely\n"
|
|
"class _Blocker:\n"
|
|
" def find_module(self, name, path=None):\n"
|
|
" if name.startswith('mautrix'): return self\n"
|
|
" def load_module(self, name):\n"
|
|
" raise ImportError(f'blocked: {name}')\n"
|
|
"sys.meta_path.insert(0, _Blocker())\n"
|
|
"for k in list(sys.modules):\n"
|
|
" if k.startswith('mautrix'): del sys.modules[k]\n"
|
|
"from unittest.mock import patch\n"
|
|
"from plugins.platforms.matrix.adapter import check_matrix_requirements\n"
|
|
"with patch('tools.lazy_deps.ensure', side_effect=ImportError('blocked')):\n"
|
|
" assert not check_matrix_requirements()\n"
|
|
"print('OK')\n"
|
|
)],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"Subprocess failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
|
)
|
|
|
|
|
|
class TestMatrixRequirements:
|
|
|
|
|
|
def test_check_requirements_encryption_true_no_e2ee_deps(self, monkeypatch):
|
|
"""MATRIX_ENCRYPTION=true should fail if python-olm is not installed."""
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
monkeypatch.setenv("MATRIX_ENCRYPTION", "true")
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \
|
|
patch("tools.lazy_deps.feature_missing", return_value=()):
|
|
assert matrix_mod.check_matrix_requirements() is False
|
|
|
|
def test_check_requirements_e2ee_optional_no_deps_ok(self, monkeypatch):
|
|
"""MATRIX_E2EE_MODE=optional should not block startup without python-olm."""
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
monkeypatch.setenv("MATRIX_E2EE_MODE", "optional")
|
|
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \
|
|
patch("tools.lazy_deps.feature_missing", return_value=()), \
|
|
patch("tools.lazy_deps.ensure_and_bind", return_value=True):
|
|
assert matrix_mod.check_matrix_requirements() is True
|
|
|
|
def test_check_requirements_encryption_false_no_e2ee_deps_ok(self, monkeypatch):
|
|
"""Without encryption, missing E2EE deps should not block startup."""
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False), \
|
|
patch("tools.lazy_deps.feature_missing", return_value=()):
|
|
assert matrix_mod.check_matrix_requirements() is True
|
|
|
|
def test_check_requirements_encryption_true_with_e2ee_deps(self, monkeypatch):
|
|
"""MATRIX_ENCRYPTION=true should pass if E2EE deps are available."""
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
monkeypatch.setenv("MATRIX_ENCRYPTION", "true")
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True), \
|
|
patch("tools.lazy_deps.feature_missing", return_value=()):
|
|
assert matrix_mod.check_matrix_requirements() is True
|
|
|
|
def test_check_e2ee_deps_requires_asyncpg(self, monkeypatch):
|
|
"""E2EE deps check must reject when asyncpg is missing — even if olm is present.
|
|
|
|
Regression for #31116: ``mautrix[encryption]`` extra installs python-olm
|
|
but NOT asyncpg/aiosqlite, which are required by mautrix's crypto store
|
|
at connect time. ``_check_e2ee_deps`` previously only tested
|
|
``OlmMachine`` import and returned True, so the failure manifested as
|
|
a confusing ``No module named 'asyncpg'`` deep in
|
|
``MatrixAdapter.connect()``.
|
|
"""
|
|
from plugins.platforms.matrix.adapter import _check_e2ee_deps
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def _blocking_import(name, *args, **kwargs):
|
|
if name == "asyncpg" or name.startswith("asyncpg."):
|
|
raise ImportError("blocked for test")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
with patch.object(builtins, "__import__", _blocking_import):
|
|
assert _check_e2ee_deps() is False
|
|
|
|
def test_check_e2ee_deps_requires_aiosqlite(self):
|
|
"""E2EE deps check must reject when aiosqlite is missing.
|
|
|
|
Mautrix's ``Database.create("sqlite:///...")`` driver lookup imports
|
|
aiosqlite lazily — without it, connect fails at ``crypto_db.start()``.
|
|
"""
|
|
from plugins.platforms.matrix.adapter import _check_e2ee_deps
|
|
import builtins
|
|
real_import = builtins.__import__
|
|
|
|
def _blocking_import(name, *args, **kwargs):
|
|
if name == "aiosqlite" or name.startswith("aiosqlite."):
|
|
raise ImportError("blocked for test")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
with patch.object(builtins, "__import__", _blocking_import):
|
|
assert _check_e2ee_deps() is False
|
|
|
|
def test_check_requirements_runs_lazy_install_when_partial(self, monkeypatch):
|
|
"""When mautrix is installed but asyncpg/aiosqlite are missing,
|
|
check_matrix_requirements must still run the lazy installer.
|
|
|
|
Regression for #31116: the previous ``try: import mautrix`` gate
|
|
short-circuited the install of the OTHER 4 platform.matrix packages,
|
|
so a partial install (mautrix only) was treated as fully installed.
|
|
"""
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
monkeypatch.delenv("MATRIX_ENCRYPTION", raising=False)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
|
|
# Simulate "mautrix installed, asyncpg missing" → feature_missing
|
|
# returns a non-empty tuple → ensure_and_bind MUST be called.
|
|
called = {"ensure_and_bind": False}
|
|
|
|
def _fake_ensure_and_bind(feature, importer, target_globals, **kwargs):
|
|
called["ensure_and_bind"] = True
|
|
assert feature == "platform.matrix"
|
|
return True # Pretend install succeeded.
|
|
|
|
with patch("tools.lazy_deps.feature_missing", return_value=("asyncpg==0.31.0",)), \
|
|
patch("tools.lazy_deps.ensure_and_bind", side_effect=_fake_ensure_and_bind):
|
|
matrix_mod.check_matrix_requirements()
|
|
|
|
assert called["ensure_and_bind"], (
|
|
"check_matrix_requirements must call ensure_and_bind whenever ANY "
|
|
"platform.matrix dep is missing, not just when mautrix itself is "
|
|
"missing (#31116)"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Access-token auth / E2EE bootstrap
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixAccessTokenAuth:
|
|
@pytest.mark.asyncio
|
|
async def test_connect_with_access_token_and_encryption(self):
|
|
"""connect() should call whoami, set user_id/device_id, set up crypto."""
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_access_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"encryption": True,
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
|
|
class FakeWhoamiResponse:
|
|
def __init__(self, user_id, device_id):
|
|
self.user_id = user_id
|
|
self.device_id = device_id
|
|
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
# Create a mock client that returns from the mautrix.client.Client constructor
|
|
mock_client = MagicMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = None
|
|
mock_client.state_store = MagicMock()
|
|
mock_client.sync_store = MagicMock()
|
|
mock_client.crypto = None
|
|
mock_client.whoami = AsyncMock(return_value=FakeWhoamiResponse("@bot:example.org", "DEV123"))
|
|
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
|
|
mock_client.add_event_handler = MagicMock()
|
|
mock_client.handle_sync = MagicMock(return_value=[])
|
|
mock_client.query_keys = AsyncMock(return_value={
|
|
"device_keys": {"@bot:example.org": {"DEV123": {
|
|
"keys": {"ed25519:DEV123": "fake_ed25519_key"},
|
|
}}},
|
|
})
|
|
mock_client.api = MagicMock()
|
|
mock_client.api.token = "syt_test_access_token"
|
|
mock_client.api.session = MagicMock()
|
|
mock_client.api.session.close = AsyncMock()
|
|
|
|
# Mock the crypto setup
|
|
mock_olm = MagicMock()
|
|
mock_olm.load = AsyncMock()
|
|
mock_olm.share_keys = AsyncMock()
|
|
mock_olm.share_keys_min_trust = None
|
|
mock_olm.send_keys_min_trust = None
|
|
mock_olm.account = MagicMock()
|
|
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
|
|
|
|
# Patch Client constructor to return our mock
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
|
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
assert await adapter.connect() is True
|
|
|
|
mock_client.whoami.assert_awaited_once()
|
|
assert adapter._user_id == "@bot:example.org"
|
|
|
|
await adapter.disconnect()
|
|
|
|
|
|
class TestDeviceKeyReVerification:
|
|
@pytest.mark.asyncio
|
|
async def test_verify_fails_when_server_keys_mismatch_after_upload(self):
|
|
"""share_keys() succeeds but server still has old keys -> should return False."""
|
|
adapter = _make_adapter()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = "TESTDEVICE"
|
|
|
|
# First query: keys missing -> triggers share_keys
|
|
# Second query: keys still don't match -> should fail
|
|
mock_keys_missing = MagicMock()
|
|
mock_keys_missing.device_keys = {"@bot:example.org": {}}
|
|
|
|
mock_keys_mismatch = MagicMock()
|
|
mock_device = MagicMock()
|
|
mock_device.keys = {"ed25519:TESTDEVICE": "server_old_key"}
|
|
mock_keys_mismatch.device_keys = {"@bot:example.org": {"TESTDEVICE": mock_device}}
|
|
|
|
mock_client.query_keys = AsyncMock(side_effect=[mock_keys_missing, mock_keys_mismatch])
|
|
|
|
mock_olm = MagicMock()
|
|
mock_olm.account = MagicMock()
|
|
mock_olm.account.shared = False
|
|
mock_olm.account.identity_keys = {"ed25519": "local_new_key"}
|
|
mock_olm.share_keys = AsyncMock()
|
|
|
|
result = await adapter._verify_device_keys_on_server(mock_client, mock_olm)
|
|
|
|
assert result is False
|
|
mock_olm.share_keys.assert_awaited_once()
|
|
|
|
|
|
class TestMatrixE2EEHardFail:
|
|
"""connect() must refuse to start when E2EE is requested but deps are missing."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_fails_when_encryption_true_but_no_e2ee_deps(self):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_access_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"encryption": True,
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
|
|
mock_client.api = MagicMock()
|
|
mock_client.api.token = "syt_test_access_token"
|
|
mock_client.api.session = MagicMock()
|
|
mock_client.api.session.close = AsyncMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = None
|
|
mock_client.crypto = None
|
|
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False):
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
result = await adapter.connect()
|
|
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_continues_when_e2ee_optional_but_no_deps(self):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_access_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"e2ee_mode": "optional",
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
mock_sync_store = MagicMock()
|
|
mock_sync_store.get_next_batch = AsyncMock(return_value=None)
|
|
mock_sync_store.put_next_batch = AsyncMock()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
|
|
mock_client.api = MagicMock()
|
|
mock_client.api.token = "syt_test_access_token"
|
|
mock_client.api.session = MagicMock()
|
|
mock_client.api.session.close = AsyncMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = None
|
|
mock_client.crypto = None
|
|
mock_client.sync_store = mock_sync_store
|
|
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {}}, "next_batch": "s1"})
|
|
mock_client.get_account_data = AsyncMock(return_value=MagicMock(content={}))
|
|
mock_client.add_dispatcher = MagicMock()
|
|
mock_client.add_event_handler = MagicMock()
|
|
mock_client.handle_sync = MagicMock(return_value=[])
|
|
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False):
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(matrix_mod, "_create_matrix_session", return_value=MagicMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
result = await adapter.connect()
|
|
|
|
assert result is True
|
|
assert adapter._encryption is False
|
|
await adapter.disconnect()
|
|
|
|
|
|
class TestMatrixDeviceId:
|
|
"""MATRIX_DEVICE_ID should be used for stable device identity."""
|
|
|
|
|
|
def test_device_id_config_takes_precedence_over_env(self, monkeypatch):
|
|
monkeypatch.setenv("MATRIX_DEVICE_ID", "FROM_ENV")
|
|
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"device_id": "FROM_CONFIG",
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
assert adapter._device_id == "FROM_CONFIG"
|
|
|
|
|
|
class TestMatrixPasswordLoginDeviceId:
|
|
"""MATRIX_DEVICE_ID should be passed to mautrix Client even with password login."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_password_login_uses_device_id(self):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"password": "secret",
|
|
"device_id": "STABLE_PW_DEVICE",
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = None
|
|
mock_client.state_store = MagicMock()
|
|
mock_client.sync_store = MagicMock()
|
|
mock_client.crypto = None
|
|
mock_client.login = AsyncMock(return_value=MagicMock(device_id="STABLE_PW_DEVICE", access_token="tok"))
|
|
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {}}})
|
|
mock_client.add_event_handler = MagicMock()
|
|
mock_client.api = MagicMock()
|
|
mock_client.api.token = ""
|
|
mock_client.api.session = MagicMock()
|
|
mock_client.api.session.close = AsyncMock()
|
|
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
|
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
assert await adapter.connect() is True
|
|
|
|
mock_client.login.assert_awaited_once()
|
|
assert adapter._device_id == "STABLE_PW_DEVICE"
|
|
|
|
await adapter.disconnect()
|
|
|
|
|
|
class TestMatrixDeviceIdConfig:
|
|
"""MATRIX_DEVICE_ID should be plumbed through gateway config."""
|
|
|
|
def test_device_id_in_config_extra(self, monkeypatch):
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_abc123")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
monkeypatch.setenv("MATRIX_DEVICE_ID", "HERMES_BOT")
|
|
|
|
from gateway.config import GatewayConfig, _apply_env_overrides
|
|
config = GatewayConfig()
|
|
_apply_env_overrides(config)
|
|
|
|
mc = config.platforms[Platform.MATRIX]
|
|
assert mc.extra.get("device_id") == "HERMES_BOT"
|
|
|
|
|
|
class TestMatrixSyncLoop:
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_sync_accepts_async_handle_sync(self):
|
|
"""Some fake clients expose handle_sync as an async dispatcher."""
|
|
adapter = _make_adapter()
|
|
called = False
|
|
|
|
async def handle_sync(sync_data):
|
|
nonlocal called
|
|
called = sync_data["next_batch"] == "s1"
|
|
return []
|
|
|
|
adapter._client = types.SimpleNamespace(handle_sync=handle_sync)
|
|
|
|
await adapter._dispatch_sync({"next_batch": "s1"})
|
|
|
|
assert called is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_loop_dispatches_registered_room_message_handler(self):
|
|
"""Inbound sync data should flow through handle_sync into message handling."""
|
|
adapter = _make_adapter()
|
|
adapter._closing = False
|
|
adapter._user_id = "@bot:example.org"
|
|
adapter._startup_ts = time.time() - 10
|
|
adapter._dm_rooms = {"!dm:example.org": True}
|
|
adapter._text_batch_delay_seconds = 0
|
|
adapter._background_read_receipt = MagicMock()
|
|
|
|
captured = []
|
|
|
|
async def capture(event):
|
|
captured.append(event)
|
|
|
|
adapter.handle_message = capture
|
|
|
|
event = types.SimpleNamespace(
|
|
sender="@alice:example.org",
|
|
event_id="$dm1",
|
|
room_id="!dm:example.org",
|
|
timestamp=int(time.time() * 1000),
|
|
content={"msgtype": "m.text", "body": "hello"},
|
|
)
|
|
|
|
async def _sync_once(**kwargs):
|
|
adapter._closing = True
|
|
return {"rooms": {"join": {"!dm:example.org": {}}}, "next_batch": "s1234"}
|
|
|
|
mock_sync_store = MagicMock()
|
|
mock_sync_store.get_next_batch = AsyncMock(return_value=None)
|
|
mock_sync_store.put_next_batch = AsyncMock()
|
|
|
|
fake_client = MagicMock()
|
|
fake_client.sync = AsyncMock(side_effect=_sync_once)
|
|
fake_client.sync_store = mock_sync_store
|
|
fake_client.get_state_event = AsyncMock(side_effect=Exception("no state"))
|
|
fake_client.state_store = MagicMock()
|
|
fake_client.state_store.get_members = AsyncMock(return_value=["@bot:example.org", "@alice:example.org"])
|
|
fake_client.state_store.get_member = AsyncMock(return_value=None)
|
|
|
|
def handle_sync(sync_data):
|
|
return [asyncio.create_task(adapter._on_room_message(event))]
|
|
|
|
fake_client.handle_sync = MagicMock(side_effect=handle_sync)
|
|
adapter._client = fake_client
|
|
|
|
await adapter._sync_loop()
|
|
|
|
assert len(captured) == 1
|
|
assert captured[0].text == "hello"
|
|
assert captured[0].source.chat_type == "dm"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_receives_dm_from_initial_sync_dispatch(self):
|
|
"""A DM delivered by initial sync should reach the message handler after connect."""
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
adapter = MatrixAdapter(
|
|
PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_access_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"encryption": False,
|
|
},
|
|
)
|
|
)
|
|
adapter._text_batch_delay_seconds = 0
|
|
adapter._background_read_receipt = MagicMock()
|
|
|
|
captured = []
|
|
|
|
async def capture(event):
|
|
captured.append(event)
|
|
|
|
adapter.handle_message = capture
|
|
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
mock_sync_store = MagicMock()
|
|
mock_sync_store.get_next_batch = AsyncMock(return_value=None)
|
|
mock_sync_store.put_next_batch = AsyncMock()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = None
|
|
mock_client.crypto = None
|
|
mock_client.sync_store = mock_sync_store
|
|
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
|
|
mock_client.sync = AsyncMock(return_value={
|
|
"rooms": {"join": {"!dm:example.org": {}}},
|
|
"next_batch": "s1",
|
|
})
|
|
mock_client.get_account_data = AsyncMock(
|
|
return_value=MagicMock(content={"@alice:example.org": ["!dm:example.org"]})
|
|
)
|
|
mock_client.get_state_event = AsyncMock(side_effect=Exception("no state"))
|
|
mock_client.state_store = MagicMock()
|
|
mock_client.state_store.get_members = AsyncMock(return_value=["@bot:example.org", "@alice:example.org"])
|
|
mock_client.state_store.get_member = AsyncMock(return_value=None)
|
|
mock_client.add_event_handler = MagicMock()
|
|
mock_client.add_dispatcher = MagicMock()
|
|
mock_client.api = MagicMock()
|
|
mock_client.api.token = "syt_test_access_token"
|
|
mock_client.api.session = MagicMock()
|
|
mock_client.api.session.close = AsyncMock()
|
|
|
|
event = types.SimpleNamespace(
|
|
sender="@alice:example.org",
|
|
event_id="$initial-dm",
|
|
room_id="!dm:example.org",
|
|
timestamp=int(time.time() * 1000),
|
|
content={"msgtype": "m.text", "body": "hello after connect"},
|
|
)
|
|
|
|
def handle_sync(sync_data):
|
|
return [asyncio.create_task(adapter._on_room_message(event))]
|
|
|
|
mock_client.handle_sync = MagicMock(side_effect=handle_sync)
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(matrix_mod, "_create_matrix_session", return_value=MagicMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
assert await adapter.connect() is True
|
|
|
|
assert len(captured) == 1
|
|
assert captured[0].text == "hello after connect"
|
|
assert captured[0].source.chat_type == "dm"
|
|
|
|
await adapter.disconnect()
|
|
|
|
|
|
class TestMatrixUploadAndSend:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_encrypted_room_uses_file_payload(self):
|
|
"""Encrypted rooms should use 'file' key with crypto metadata."""
|
|
adapter = _make_adapter()
|
|
adapter._encryption = True
|
|
mock_client = MagicMock()
|
|
mock_client.crypto = object()
|
|
mock_client.state_store = MagicMock()
|
|
mock_client.state_store.is_encrypted = AsyncMock(return_value=True)
|
|
mock_client.upload_media = AsyncMock(return_value="mxc://example.org/enc")
|
|
mock_client.send_message_event = AsyncMock(return_value="$event")
|
|
adapter._client = mock_client
|
|
|
|
with patch.dict("sys.modules", _make_fake_mautrix()):
|
|
result = await adapter._upload_and_send(
|
|
"!room:example.org", b"secret", "secret.txt", "text/plain", "m.file",
|
|
)
|
|
|
|
assert result.success is True
|
|
# Should have uploaded ciphertext, not plaintext
|
|
uploaded_data = mock_client.upload_media.await_args.args[0]
|
|
assert uploaded_data != b"secret"
|
|
sent = mock_client.send_message_event.await_args.args[2]
|
|
assert "url" not in sent
|
|
assert "file" in sent
|
|
assert sent["file"]["url"] == "mxc://example.org/enc"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_preserves_caption_and_thread(self):
|
|
adapter = _make_adapter()
|
|
mock_client = MagicMock()
|
|
mock_client.upload_media = AsyncMock(return_value="mxc://example.org/plain")
|
|
mock_client.send_message_event = AsyncMock(return_value="$event")
|
|
adapter._client = mock_client
|
|
|
|
result = await adapter._upload_and_send(
|
|
"!room:example.org",
|
|
b"image",
|
|
"chart.png",
|
|
"image/png",
|
|
"m.image",
|
|
caption="Chart caption",
|
|
metadata={"thread_id": "$root"},
|
|
)
|
|
|
|
assert result.success is True
|
|
sent = mock_client.send_message_event.await_args.args[2]
|
|
assert sent["body"] == "Chart caption"
|
|
assert sent["m.relates_to"]["rel_type"] == "m.thread"
|
|
assert sent["m.relates_to"]["event_id"] == "$root"
|
|
assert sent["m.relates_to"]["m.in_reply_to"] == {"event_id": "$root"}
|
|
|
|
|
|
class TestMatrixDiagnostics:
|
|
def test_diagnostics_redacts_credentials_and_reports_status(self, monkeypatch):
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
|
|
monkeypatch.setenv("MATRIX_RECOVERY_KEY", "secret recovery key")
|
|
adapter = _make_adapter()
|
|
adapter._access_token = "syt_super_secret"
|
|
adapter._password = "password"
|
|
adapter._user_id = "@bot:example.org"
|
|
adapter._device_id = "DEV123"
|
|
adapter._joined_rooms = {"!one:example.org", "!two:example.org"}
|
|
adapter._last_sync_ts = time.time() - 7
|
|
adapter._max_media_bytes = 123
|
|
adapter._client = MagicMock()
|
|
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
|
diagnostics = adapter.get_diagnostics()
|
|
|
|
assert diagnostics["auth"]["token_preview"] == "***"
|
|
assert "syt_super_secret" not in str(diagnostics)
|
|
assert "DEV123" not in str(diagnostics)
|
|
assert diagnostics["auth"]["device_id_present"] is True
|
|
assert diagnostics["auth"]["device_id_preview"] == "***"
|
|
assert diagnostics["sync"]["connected"] is True
|
|
assert diagnostics["sync"]["joined_room_count"] == 2
|
|
assert diagnostics["sync"]["last_sync_age_seconds"] >= 0
|
|
assert diagnostics["e2ee"]["recovery_key_configured"] is True
|
|
assert diagnostics["media"]["max_media_bytes"] == 123
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_matrix_recovery_key_bootstrap_skips_existing_output_file(
|
|
self,
|
|
tmp_path,
|
|
monkeypatch,
|
|
caplog,
|
|
):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
output_path = tmp_path / "matrix-recovery-key.txt"
|
|
output_path.write_text("existing\n")
|
|
monkeypatch.delenv("MATRIX_RECOVERY_KEY", raising=False)
|
|
monkeypatch.setenv("MATRIX_RECOVERY_KEY_OUTPUT_FILE", str(output_path))
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"encryption": True,
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = None
|
|
mock_client.state_store = MagicMock()
|
|
mock_client.sync_store = MagicMock()
|
|
mock_client.crypto = None
|
|
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
|
|
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {}}})
|
|
mock_client.add_event_handler = MagicMock()
|
|
mock_client.add_dispatcher = MagicMock()
|
|
mock_client.handle_sync = MagicMock(return_value=[])
|
|
mock_client.query_keys = AsyncMock(return_value={
|
|
"device_keys": {"@bot:example.org": {"DEV123": {
|
|
"keys": {"ed25519:DEV123": "fake_ed25519_key"},
|
|
}}},
|
|
})
|
|
mock_client.api = MagicMock()
|
|
mock_client.api.token = "syt_test_token"
|
|
mock_client.api.session = MagicMock()
|
|
mock_client.api.session.close = AsyncMock()
|
|
|
|
mock_olm = MagicMock()
|
|
mock_olm.load = AsyncMock()
|
|
mock_olm.share_keys = AsyncMock()
|
|
mock_olm.get_own_cross_signing_public_keys = AsyncMock(return_value=None)
|
|
mock_olm.generate_recovery_key = AsyncMock(return_value="super-secret-key")
|
|
mock_olm.share_keys_min_trust = None
|
|
mock_olm.send_keys_min_trust = None
|
|
mock_olm.account = MagicMock()
|
|
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
|
|
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
|
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
assert await adapter.connect() is True
|
|
|
|
mock_olm.generate_recovery_key.assert_not_called()
|
|
assert "already exists" in caplog.text
|
|
assert "super-secret-key" not in caplog.text
|
|
assert output_path.read_text() == "existing\n"
|
|
await adapter.disconnect()
|
|
|
|
def test_matrix_diagnostics_redacts_recovery_key(self, monkeypatch):
|
|
monkeypatch.setenv("MATRIX_RECOVERY_KEY", "diagnostic-secret-recovery-key")
|
|
adapter = _make_adapter()
|
|
|
|
diagnostics = adapter.get_diagnostics()
|
|
|
|
assert diagnostics["e2ee"]["recovery_key_configured"] is True
|
|
assert "diagnostic-secret-recovery-key" not in str(diagnostics)
|
|
|
|
|
|
class TestMatrixEncryptedSendFallback:
|
|
@pytest.mark.asyncio
|
|
async def test_send_retries_after_e2ee_error(self):
|
|
"""send() should retry with crypto.share_keys() on E2EE errors."""
|
|
adapter = _make_adapter()
|
|
adapter._encryption = True
|
|
|
|
fake_client = MagicMock()
|
|
fake_client.send_message_event = AsyncMock(side_effect=[
|
|
Exception("encryption error"),
|
|
"$event123", # mautrix returns EventID string directly
|
|
])
|
|
mock_crypto = MagicMock()
|
|
mock_crypto.share_keys = AsyncMock()
|
|
fake_client.crypto = mock_crypto
|
|
adapter._client = fake_client
|
|
|
|
result = await adapter.send("!room:example.org", "hello")
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "$event123"
|
|
mock_crypto.share_keys.assert_awaited_once()
|
|
assert fake_client.send_message_event.await_count == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# E2EE: _joined_rooms reference preservation for CryptoStateStore
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestJoinedRoomsReference:
|
|
def test_joined_rooms_reference_preserved_after_reassignment(self):
|
|
"""_CryptoStateStore must see updates after initial sync populates rooms."""
|
|
from plugins.platforms.matrix.adapter import _CryptoStateStore
|
|
|
|
joined = set()
|
|
store = _CryptoStateStore(MagicMock(), joined)
|
|
|
|
# Simulate what connect() should do: mutate in place, not reassign.
|
|
joined.clear()
|
|
joined.update(["!room1:example.org", "!room2:example.org"])
|
|
|
|
import asyncio
|
|
rooms = asyncio.get_event_loop().run_until_complete(store.find_shared_rooms("@user:ex"))
|
|
assert set(rooms) == {"!room1:example.org", "!room2:example.org"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# E2EE: connect registers encrypted event handler
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixEncryptedEventHandler:
|
|
@pytest.mark.asyncio
|
|
async def test_connect_registers_encrypted_event_handler_when_encryption_on(self):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"encryption": True,
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = None
|
|
mock_client.state_store = MagicMock()
|
|
mock_client.sync_store = MagicMock()
|
|
mock_client.crypto = None # Will be set during connect
|
|
mock_client.whoami = AsyncMock(return_value=MagicMock(user_id="@bot:example.org", device_id="DEV123"))
|
|
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
|
|
mock_client.add_event_handler = MagicMock()
|
|
mock_client.handle_sync = MagicMock(return_value=[])
|
|
mock_client.query_keys = AsyncMock(return_value={
|
|
"device_keys": {"@bot:example.org": {"DEV123": {
|
|
"keys": {"ed25519:DEV123": "fake_ed25519_key"},
|
|
}}},
|
|
})
|
|
mock_client.api = MagicMock()
|
|
mock_client.api.token = "syt_test_token"
|
|
mock_client.api.session = MagicMock()
|
|
mock_client.api.session.close = AsyncMock()
|
|
|
|
mock_olm = MagicMock()
|
|
mock_olm.load = AsyncMock()
|
|
mock_olm.share_keys = AsyncMock()
|
|
mock_olm.share_keys_min_trust = None
|
|
mock_olm.send_keys_min_trust = None
|
|
mock_olm.account = MagicMock()
|
|
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
|
|
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
|
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
assert await adapter.connect() is True
|
|
|
|
# Verify inbound event handlers were registered as sync-awaited
|
|
# callbacks. mautrix only returns waited handler tasks from
|
|
# handle_sync(), so background-only handlers leave _dispatch_sync()
|
|
# without a completion point for Hermes' Matrix intake.
|
|
handler_calls = mock_client.add_event_handler.call_args_list
|
|
waited_types = {
|
|
str(call.args[0])
|
|
for call in handler_calls
|
|
if call.kwargs.get("wait_sync") is True
|
|
}
|
|
|
|
assert "m.room.message" in waited_types
|
|
assert "m.reaction" in waited_types
|
|
assert "internal.invite" in waited_types
|
|
|
|
await adapter.disconnect()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Disconnect
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixDisconnect:
|
|
@pytest.mark.asyncio
|
|
async def test_disconnect_closes_api_session(self):
|
|
"""disconnect() should close client.api.session."""
|
|
adapter = _make_adapter()
|
|
adapter._sync_task = None
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.close = AsyncMock()
|
|
|
|
mock_api = MagicMock()
|
|
mock_api.session = mock_session
|
|
|
|
fake_client = MagicMock()
|
|
fake_client.api = mock_api
|
|
adapter._client = fake_client
|
|
|
|
await adapter.disconnect()
|
|
|
|
mock_session.close.assert_awaited_once()
|
|
assert adapter._client is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Markdown to HTML: security tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixMarkdownHtmlSecurity:
|
|
"""Tests for HTML injection prevention in _markdown_to_html_fallback."""
|
|
|
|
def setup_method(self):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
self.convert = MatrixAdapter._markdown_to_html_fallback
|
|
|
|
def test_script_injection_in_header(self):
|
|
result = self.convert("# <script>alert(1)</script>")
|
|
assert "<script>" not in result
|
|
assert "<script>" in result
|
|
|
|
def test_script_injection_in_plain_text(self):
|
|
result = self.convert("Hello <script>alert(1)</script>")
|
|
assert "<script>" not in result
|
|
|
|
|
|
def test_link_text_html_injection(self):
|
|
result = self.convert('[<img onerror="x">](http://safe.com)')
|
|
assert "<img" not in result or "<img" in result
|
|
|
|
|
|
def test_html_injection_in_bold(self):
|
|
result = self.convert("**<img onerror=alert(1)>**")
|
|
assert "<img" not in result or "<img" in result
|
|
|
|
def test_html_injection_in_italic(self):
|
|
result = self.convert("*<script>alert(1)</script>*")
|
|
assert "<script>" not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Markdown to HTML: extended formatting tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixMarkdownHtmlFormatting:
|
|
"""Tests for new formatting capabilities in _markdown_to_html_fallback."""
|
|
|
|
def setup_method(self):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
self.convert = MatrixAdapter._markdown_to_html_fallback
|
|
|
|
def test_fenced_code_block(self):
|
|
result = self.convert('```python\ndef hello():\n pass\n```')
|
|
assert "<pre><code" in result
|
|
assert "language-python" in result
|
|
|
|
|
|
def test_code_block_html_escaped(self):
|
|
result = self.convert('```\n<script>alert(1)</script>\n```')
|
|
assert "<script>" in result
|
|
assert "<script>" not in result
|
|
|
|
def test_headers(self):
|
|
assert "<h1>" in self.convert("# H1")
|
|
assert "<h2>" in self.convert("## H2")
|
|
assert "<h3>" in self.convert("### H3")
|
|
|
|
def test_unordered_list(self):
|
|
result = self.convert("- One\n- Two\n- Three")
|
|
assert "<ul>" in result
|
|
assert result.count("<li>") == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Link URL sanitization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixLinkSanitization:
|
|
def test_safe_https_url(self):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
assert MatrixAdapter._sanitize_link_url("https://example.com") == "https://example.com"
|
|
|
|
|
|
def test_quotes_escaped(self):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
result = MatrixAdapter._sanitize_link_url('http://x"y')
|
|
assert '"' not in result
|
|
assert """ in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reactions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixReactions:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction(self):
|
|
"""_send_reaction should call send_message_event with m.reaction."""
|
|
mock_client = MagicMock()
|
|
# mautrix send_message_event returns EventID string directly
|
|
mock_client.send_message_event = AsyncMock(return_value="$reaction1")
|
|
self.adapter._client = mock_client
|
|
|
|
result = await self.adapter._send_reaction("!room:ex", "$event1", "\U0001f44d")
|
|
assert result == "$reaction1"
|
|
mock_client.send_message_event.assert_called_once()
|
|
call_args = mock_client.send_message_event.call_args
|
|
content = call_args.args[2] if len(call_args.args) > 2 else call_args.kwargs.get("content")
|
|
assert content["m.relates_to"]["rel_type"] == "m.annotation"
|
|
assert content["m.relates_to"]["key"] == "\U0001f44d"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_processing_complete_sends_check(self):
|
|
from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome
|
|
|
|
self.adapter._reactions_enabled = True
|
|
self.adapter._reaction_redaction_delay_seconds = 0.01
|
|
self.adapter._pending_reactions = {("!room:ex", "$msg1"): "$eyes_reaction_123"}
|
|
self.adapter._redact_reaction = AsyncMock(return_value=True)
|
|
self.adapter._send_reaction = AsyncMock(return_value="$check_reaction_456")
|
|
|
|
source = MagicMock()
|
|
source.chat_id = "!room:ex"
|
|
event = MessageEvent(
|
|
text="hello",
|
|
message_type=MessageType.TEXT,
|
|
source=source,
|
|
raw_message={},
|
|
message_id="$msg1",
|
|
)
|
|
await self.adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
|
|
self.adapter._redact_reaction.assert_not_awaited()
|
|
self.adapter._send_reaction.assert_called_once_with("!room:ex", "$msg1", "\u2705")
|
|
await asyncio.sleep(0.03)
|
|
self.adapter._redact_reaction.assert_awaited_once_with(
|
|
"!room:ex",
|
|
"$eyes_reaction_123",
|
|
"processing complete",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approval_reaction_cleanup_is_delayed(self):
|
|
"""Bot approval reaction redactions should not run inline."""
|
|
|
|
self.adapter._reaction_redaction_delay_seconds = 0.01
|
|
self.adapter._redact_reaction = AsyncMock(return_value=True)
|
|
prompt = MagicMock()
|
|
prompt.bot_reaction_events = {
|
|
"\u2705": "$allow_reaction",
|
|
"\u274e": "$deny_reaction",
|
|
}
|
|
|
|
await self.adapter._redact_bot_approval_reactions("!room:ex", prompt)
|
|
|
|
self.adapter._redact_reaction.assert_not_awaited()
|
|
await asyncio.sleep(0.03)
|
|
self.adapter._redact_reaction.assert_any_await(
|
|
"!room:ex",
|
|
"$allow_reaction",
|
|
"approval resolved",
|
|
)
|
|
self.adapter._redact_reaction.assert_any_await(
|
|
"!room:ex",
|
|
"$deny_reaction",
|
|
"approval resolved",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Read receipts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixReadReceipts:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_read_receipt(self):
|
|
"""send_read_receipt should call mautrix's real read-marker API."""
|
|
mock_client = MagicMock()
|
|
mock_client.set_fully_read_marker = AsyncMock(return_value=None)
|
|
self.adapter._client = mock_client
|
|
|
|
result = await self.adapter.send_read_receipt("!room:ex", "$event1")
|
|
assert result is True
|
|
mock_client.set_fully_read_marker.assert_awaited_once_with(
|
|
"!room:ex", "$event1", "$event1"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Media normalization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixImageOnlyMediaNormalization:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._client = MagicMock()
|
|
self.adapter._client.download_media = AsyncMock(return_value=None)
|
|
self.adapter._is_dm_room = AsyncMock(return_value=True)
|
|
self.adapter._get_display_name = AsyncMock(return_value="Alice")
|
|
self.adapter._background_read_receipt = MagicMock()
|
|
self.adapter._mxc_to_http = (
|
|
lambda url: "https://matrix.example.org/_matrix/media/v3/download/example/30.png"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_image_only_filename_body_is_not_forwarded_as_text(self):
|
|
captured_event = None
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter.handle_message = capture
|
|
|
|
await self.adapter._handle_media_message(
|
|
room_id="!room:example.org",
|
|
sender="@alice:example.org",
|
|
event_id="$image1",
|
|
event_ts=0.0,
|
|
source_content={
|
|
"msgtype": "m.image",
|
|
"body": "30.png",
|
|
"url": "mxc://example/30.png",
|
|
"info": {"mimetype": "image/png"},
|
|
},
|
|
relates_to={},
|
|
msgtype="m.image",
|
|
)
|
|
|
|
assert captured_event is not None
|
|
assert captured_event.text == ""
|
|
assert captured_event.media_urls == [
|
|
"https://matrix.example.org/_matrix/media/v3/download/example/30.png"
|
|
]
|
|
assert captured_event.message_type == MessageType.PHOTO
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inbound_oversized_media_is_rejected(self):
|
|
captured_event = None
|
|
|
|
async def capture(msg_event):
|
|
nonlocal captured_event
|
|
captured_event = msg_event
|
|
|
|
self.adapter._max_media_bytes = 10
|
|
self.adapter.handle_message = capture
|
|
|
|
await self.adapter._handle_media_message(
|
|
room_id="!room:example.org",
|
|
sender="@alice:example.org",
|
|
event_id="$image-big",
|
|
event_ts=0.0,
|
|
source_content={
|
|
"msgtype": "m.image",
|
|
"body": "huge.png",
|
|
"url": "mxc://example/huge.png",
|
|
"info": {"mimetype": "image/png", "size": 11},
|
|
},
|
|
relates_to={},
|
|
msgtype="m.image",
|
|
)
|
|
|
|
assert captured_event is None
|
|
self.adapter._client.download_media.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_media_download_follows_safe_redirect(self, monkeypatch):
|
|
"""A redirect to another allowed URL is followed and its body returned."""
|
|
import aiohttp
|
|
import tools.url_safety as url_safety
|
|
|
|
class _Content:
|
|
async def iter_chunked(self, _size):
|
|
yield b"imgbytes"
|
|
|
|
class _RedirectResponse:
|
|
status = 302
|
|
headers = {"Location": "https://cdn.example.com/final.png"}
|
|
content_type = "image/png"
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_args):
|
|
return None
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
class _OkResponse:
|
|
status = 200
|
|
headers = {}
|
|
content_type = "image/png"
|
|
content = _Content()
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_args):
|
|
return None
|
|
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
class _Session:
|
|
def __init__(self):
|
|
self.requested = []
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_args):
|
|
return None
|
|
|
|
def get(self, url, *_args, **_kwargs):
|
|
self.requested.append(url)
|
|
return _RedirectResponse() if len(self.requested) == 1 else _OkResponse()
|
|
|
|
session = _Session()
|
|
monkeypatch.setattr(aiohttp, "ClientSession", lambda **_kwargs: session)
|
|
monkeypatch.setattr(url_safety, "is_safe_url", lambda *_args, **_kwargs: True)
|
|
|
|
data, ct, _fname = await self.adapter._download_external_media_with_cap(
|
|
"https://example.com/image.png"
|
|
)
|
|
|
|
assert data == b"imgbytes"
|
|
assert ct == "image/png"
|
|
assert session.requested == [
|
|
"https://example.com/image.png",
|
|
"https://cdn.example.com/final.png",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_failure_log_redacts_signed_url(self, caplog, monkeypatch):
|
|
from gateway.platforms.base import SendResult
|
|
import tools.url_safety as url_safety
|
|
|
|
signed_url = "https://example.com/image.png?signature=secret-token#frag"
|
|
self.adapter._download_external_media_with_cap = AsyncMock(
|
|
side_effect=ValueError("download failed")
|
|
)
|
|
self.adapter.send = AsyncMock(return_value=SendResult(success=True))
|
|
monkeypatch.setattr(url_safety, "is_safe_url", lambda *_args, **_kwargs: True)
|
|
|
|
await self.adapter.send_image("!room:example.org", signed_url)
|
|
|
|
assert "https://example.com/image.png" in caplog.text
|
|
assert "secret-token" not in caplog.text
|
|
assert "#frag" not in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_failure_response_preserves_caption(self, monkeypatch):
|
|
from gateway.platforms.base import SendResult
|
|
import tools.url_safety as url_safety
|
|
|
|
signed_url = "https://example.com/image.png?signature=secret-token#fragment"
|
|
self.adapter._download_external_media_with_cap = AsyncMock(
|
|
side_effect=ValueError("download failed")
|
|
)
|
|
self.adapter.send = AsyncMock(return_value=SendResult(success=True))
|
|
monkeypatch.setattr(url_safety, "is_safe_url", lambda *_args, **_kwargs: True)
|
|
|
|
await self.adapter.send_image(
|
|
"!room:example.org",
|
|
signed_url,
|
|
caption="Here is the image",
|
|
)
|
|
|
|
sent_text = self.adapter.send.await_args.args[1]
|
|
assert "Here is the image" in sent_text
|
|
assert "signature=" not in sent_text
|
|
assert "secret-token" not in sent_text
|
|
assert "#fragment" not in sent_text
|
|
assert signed_url not in sent_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_image_failure_log_still_redacts_signed_url(self, caplog, monkeypatch):
|
|
from gateway.platforms.base import SendResult
|
|
import tools.url_safety as url_safety
|
|
|
|
signed_url = "https://example.com/image.png?signature=secret-token#fragment"
|
|
self.adapter._download_external_media_with_cap = AsyncMock(
|
|
side_effect=ValueError("download failed")
|
|
)
|
|
self.adapter.send = AsyncMock(return_value=SendResult(success=True))
|
|
monkeypatch.setattr(url_safety, "is_safe_url", lambda *_args, **_kwargs: True)
|
|
|
|
await self.adapter.send_image("!room:example.org", signed_url)
|
|
|
|
assert "https://example.com/image.png" in caplog.text
|
|
assert "signature=" not in caplog.text
|
|
assert "secret-token" not in caplog.text
|
|
assert "#fragment" not in caplog.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Message redaction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixRedaction:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redact_message(self):
|
|
"""redact_message should call client.redact()."""
|
|
mock_client = MagicMock()
|
|
# mautrix redact() returns EventID string
|
|
mock_client.redact = AsyncMock(return_value="$redact_event")
|
|
self.adapter._client = mock_client
|
|
|
|
result = await self.adapter.redact_message("!room:ex", "$ev1", "oops")
|
|
assert result is True
|
|
mock_client.redact.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_redact_no_client(self):
|
|
self.adapter._client = None
|
|
result = await self.adapter.redact_message("!room:ex", "$ev1")
|
|
assert result is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Room creation & invite
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixRoomManagement:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_room(self):
|
|
"""create_room should call client.create_room() returning RoomID string."""
|
|
mock_client = MagicMock()
|
|
# mautrix create_room returns RoomID string directly
|
|
mock_client.create_room = AsyncMock(return_value="!new:example.org")
|
|
self.adapter._client = mock_client
|
|
|
|
room_id = await self.adapter.create_room(name="Test Room", topic="A test")
|
|
assert room_id == "!new:example.org"
|
|
assert "!new:example.org" in self.adapter._joined_rooms
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Presence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixPresence:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_presence_valid(self):
|
|
mock_client = MagicMock()
|
|
mock_client.set_presence = AsyncMock()
|
|
self.adapter._client = mock_client
|
|
|
|
result = await self.adapter.set_presence("online")
|
|
assert result is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Self / bridge / system sender filtering — regression coverage for #15763
|
|
# ("Hall of Mirrors": recursive pairing / echo loops triggered by bridge
|
|
# or bot-self senders bypassing the early-drop guard in _on_room_message).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixSelfSenderFilter:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
def test_exact_match_is_self(self):
|
|
self.adapter._user_id = "@bot:example.org"
|
|
assert self.adapter._is_self_sender("@bot:example.org") is True
|
|
|
|
def test_case_insensitive_match_is_self(self):
|
|
# Some homeservers canonicalize the localpart differently at
|
|
# different API surfaces — a case-sensitive equality check lets
|
|
# the bot's own sender through and triggers the pairing / echo
|
|
# loop in #15763.
|
|
self.adapter._user_id = "@Bot:Example.ORG"
|
|
assert self.adapter._is_self_sender("@bot:example.org") is True
|
|
assert self.adapter._is_self_sender("@BOT:EXAMPLE.ORG") is True
|
|
|
|
|
|
class TestMatrixSystemBridgeFilter:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
|
|
def test_appservice_underscore_prefix_is_bridge(self):
|
|
# Conventional appservice namespace puppets
|
|
assert self.adapter._is_system_or_bridge_sender(
|
|
"@_telegram_12345:bridge.example.org"
|
|
) is True
|
|
assert self.adapter._is_system_or_bridge_sender(
|
|
"@_discord_999:example.org"
|
|
) is True
|
|
assert self.adapter._is_system_or_bridge_sender(
|
|
"@_slackbridge_puppet:example.org"
|
|
) is True
|
|
|
|
|
|
def test_empty_sender_is_system(self):
|
|
assert self.adapter._is_system_or_bridge_sender("") is True
|
|
assert self.adapter._is_system_or_bridge_sender(" ") is True
|
|
|
|
|
|
class TestMatrixOnRoomMessageFilter:
|
|
"""End-to-end coverage of _on_room_message drop conditions."""
|
|
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._user_id = "@bot:example.org"
|
|
self.adapter._startup_ts = 0.0 # accept any event_ts
|
|
self.adapter._handle_text_message = AsyncMock()
|
|
self.adapter._handle_media_message = AsyncMock()
|
|
|
|
@staticmethod
|
|
def _mk_event(sender, body="hi", msgtype="m.text", event_id=None, ts=None, room_id=None):
|
|
import time as _t
|
|
|
|
ev = MagicMock()
|
|
ev.room_id = room_id or "!room:example.org"
|
|
ev.sender = sender
|
|
ev.event_id = event_id or f"$evt-{sender}-{body}"
|
|
ev.timestamp = int((ts or _t.time()) * 1000)
|
|
ev.server_timestamp = ev.timestamp
|
|
ev.content = {"msgtype": msgtype, "body": body}
|
|
return ev
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_own_sender_case_insensitive_dropped(self):
|
|
# Simulate whoami returning a differently-cased copy of our MXID.
|
|
self.adapter._user_id = "@Bot:Example.ORG"
|
|
ev = self._mk_event(sender="@bot:example.org")
|
|
await self.adapter._on_room_message(ev)
|
|
self.adapter._handle_text_message.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bridge_sender_dropped_before_pairing(self):
|
|
ev = self._mk_event(sender="@_telegram_12345:bridge.example.org")
|
|
await self.adapter._on_room_message(ev)
|
|
# Bridge / appservice identities must never flow through to the
|
|
# gateway — otherwise they trigger pairing (#15763).
|
|
self.adapter._handle_text_message.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthorized_user_reaches_text_handler(self):
|
|
"""MATRIX_ALLOWED_USERS is enforced by gateway authz, not adapter intake."""
|
|
self.adapter._allowed_user_ids = {"@alice:example.org"}
|
|
ev = self._mk_event(sender="@mallory:example.org", body="hello bot")
|
|
await self.adapter._on_room_message(ev)
|
|
self.adapter._handle_text_message.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthorized_room_is_dropped(self):
|
|
self.adapter._allowed_room_ids = {"!allowed:example.org"}
|
|
self.adapter._is_dm_room = AsyncMock(return_value=False)
|
|
ev = self._mk_event(
|
|
sender="@alice:example.org",
|
|
body="hello bot",
|
|
room_id="!other:example.org",
|
|
)
|
|
await self.adapter._on_room_message(ev)
|
|
self.adapter._handle_text_message.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notice_message_can_be_enabled(self):
|
|
self.adapter._process_notices = True
|
|
ev = self._mk_event(
|
|
sender="@alice:example.org",
|
|
body="human-authored notice",
|
|
msgtype="m.notice",
|
|
)
|
|
await self.adapter._on_room_message(ev)
|
|
self.adapter._handle_text_message.assert_awaited_once()
|
|
|
|
|
|
class TestMatrixRequireMention:
|
|
"""require_mention should honor config.extra like thread_require_mention."""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_require_mention_false_allows_unmentioned_group_message(self):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"require_mention": False,
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
adapter._is_dm_room = AsyncMock(return_value=False)
|
|
adapter._resolve_room_identity = AsyncMock(
|
|
return_value=MagicMock(display_name="Project Room")
|
|
)
|
|
adapter._get_display_name = AsyncMock(return_value="Alice")
|
|
adapter._background_read_receipt = MagicMock()
|
|
|
|
ctx = await adapter._resolve_message_context(
|
|
room_id="!project:example.org",
|
|
sender="@alice:example.org",
|
|
event_id="$unmentioned",
|
|
body="hello there",
|
|
source_content={"body": "hello there"},
|
|
relates_to={},
|
|
)
|
|
|
|
assert ctx is not None
|
|
|
|
|
|
class TestMatrixFreeResponsePolicy:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._user_id = "@bot:example.org"
|
|
self.adapter._require_mention = True
|
|
self.adapter._free_rooms = {"!free:example.org"}
|
|
self.adapter._is_dm_room = AsyncMock(return_value=False)
|
|
self.adapter._resolve_room_identity = AsyncMock(
|
|
return_value=MagicMock(display_name="Free Room")
|
|
)
|
|
self.adapter._get_display_name = AsyncMock(return_value="Alice")
|
|
self.adapter._background_read_receipt = MagicMock()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_free_response_room_allows_unmentioned_message(self):
|
|
ctx = await self.adapter._resolve_message_context(
|
|
room_id="!free:example.org",
|
|
sender="@alice:example.org",
|
|
event_id="$free",
|
|
body="hello there",
|
|
source_content={"body": "hello there"},
|
|
relates_to={},
|
|
)
|
|
|
|
assert ctx is not None
|
|
|
|
|
|
class TestMatrixClockSkewWarning:
|
|
"""Clock-skew detector for #12614.
|
|
|
|
Reporter's host clock was set ~2 hours ahead of real time. The grace
|
|
filter `event_ts < startup_ts - 5` then drops every live event because
|
|
server timestamps look "older than startup". When this happens well
|
|
after startup (>30s), the adapter logs a one-shot WARNING pointing the
|
|
user at NTP instead of failing silently.
|
|
"""
|
|
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._user_id = "@bot:example.org"
|
|
self.adapter._handle_text_message = AsyncMock()
|
|
self.adapter._handle_media_message = AsyncMock()
|
|
|
|
@staticmethod
|
|
def _mk_event(sender, ts_ms, event_id=None):
|
|
ev = MagicMock()
|
|
ev.room_id = "!room:example.org"
|
|
ev.sender = sender
|
|
ev.event_id = event_id or f"$evt-{sender}-{ts_ms}"
|
|
ev.timestamp = ts_ms
|
|
ev.server_timestamp = ts_ms
|
|
ev.content = {"msgtype": "m.text", "body": "hi"}
|
|
return ev
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_late_drops_emit_one_shot_clock_skew_warning(self, caplog):
|
|
import logging
|
|
import time as _t
|
|
|
|
# Simulate the reporter's environment: host clock is ~2 hours ahead
|
|
# of server time. Startup happened "in the future" relative to the
|
|
# real-world events we're now receiving.
|
|
now = _t.time()
|
|
self.adapter._startup_ts = now - 60 # bot started 60s ago (wall clock)
|
|
# Server events are dated 2h before startup_ts (skewed clock).
|
|
skewed_event_ts_ms = int((self.adapter._startup_ts - 7200) * 1000)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
|
|
for i in range(5):
|
|
ev = self._mk_event(
|
|
sender=f"@alice{i}:example.org", ts_ms=skewed_event_ts_ms
|
|
)
|
|
await self.adapter._on_room_message(ev)
|
|
|
|
# Handler should never be invoked — all events failed the grace check.
|
|
self.adapter._handle_text_message.assert_not_called()
|
|
# Exactly one WARNING from THIS logger should be emitted. Filter by
|
|
# logger name so unrelated stdlib/library warnings can't satisfy the
|
|
# assertion.
|
|
skew_warnings = [
|
|
r for r in caplog.records
|
|
if r.name == "plugins.platforms.matrix.adapter"
|
|
and r.levelname == "WARNING"
|
|
and "set-ntp" in r.getMessage()
|
|
]
|
|
assert len(skew_warnings) == 1, (
|
|
f"expected exactly 1 clock-skew warning, got {len(skew_warnings)}"
|
|
)
|
|
msg = skew_warnings[0].getMessage()
|
|
assert "7200" in msg, f"skew value missing from message: {msg!r}"
|
|
# Pin the counter so a regression in the gating logic (e.g. warning
|
|
# at threshold 1 or 5, or not stopping after warn) is caught.
|
|
assert self.adapter._late_grace_drops == 3
|
|
assert self.adapter._clock_skew_warned is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initial_sync_drops_do_not_warn(self, caplog):
|
|
"""During the first 30s after startup, old events are normal backfill."""
|
|
import logging
|
|
import time as _t
|
|
|
|
now = _t.time()
|
|
# Startup was 1s ago — we're still in the initial-sync window.
|
|
self.adapter._startup_ts = now - 1
|
|
old_ts_ms = int((self.adapter._startup_ts - 3600) * 1000)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="plugins.platforms.matrix.adapter"):
|
|
for i in range(5):
|
|
ev = self._mk_event(
|
|
sender=f"@alice{i}:example.org", ts_ms=old_ts_ms
|
|
)
|
|
await self.adapter._on_room_message(ev)
|
|
|
|
# Backfill drops are silent — no clock-skew warning fired.
|
|
assert self.adapter._clock_skew_warned is False
|
|
skew_warnings = [
|
|
r for r in caplog.records
|
|
if r.name == "plugins.platforms.matrix.adapter"
|
|
and "set-ntp" in r.getMessage()
|
|
]
|
|
assert skew_warnings == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DM auto-thread
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixDmAutoThread:
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._is_dm_room = AsyncMock(return_value=True)
|
|
self.adapter._get_display_name = AsyncMock(return_value="Alice")
|
|
self.adapter._background_read_receipt = MagicMock()
|
|
# Disable require_mention so DMs pass gating
|
|
self.adapter._require_mention = False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_auto_thread_enabled_creates_thread(self):
|
|
"""When dm_auto_thread is True, DM messages get auto-threaded."""
|
|
self.adapter._dm_auto_thread = True
|
|
|
|
ctx = await self.adapter._resolve_message_context(
|
|
room_id="!dm:ex",
|
|
sender="@alice:ex",
|
|
event_id="$ev1",
|
|
body="hello",
|
|
source_content={"body": "hello"},
|
|
relates_to={},
|
|
)
|
|
|
|
assert ctx is not None
|
|
_body, _is_dm, _chat_type, thread_id, _display, _source = ctx
|
|
assert thread_id == "$ev1"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Proxy configuration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixProxyConfig:
|
|
"""Verify that MatrixAdapter resolves and propagates proxy settings."""
|
|
|
|
def _make_adapter(self, monkeypatch, proxy_env=None):
|
|
monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test")
|
|
monkeypatch.setenv("MATRIX_HOMESERVER", "https://matrix.example.org")
|
|
# Clear generic proxy vars so they don't leak from the host
|
|
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
|
|
"https_proxy", "http_proxy", "all_proxy", "MATRIX_PROXY"):
|
|
monkeypatch.delenv(key, raising=False)
|
|
if proxy_env:
|
|
for k, v in proxy_env.items():
|
|
monkeypatch.setenv(k, v)
|
|
with patch.dict("sys.modules", _make_fake_mautrix()):
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
cfg = PlatformConfig(enabled=True, token="syt_test",
|
|
extra={"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org"})
|
|
return MatrixAdapter(cfg)
|
|
|
|
|
|
def test_matrix_proxy_takes_priority(self, monkeypatch):
|
|
adapter = self._make_adapter(monkeypatch,
|
|
proxy_env={"MATRIX_PROXY": "socks5://special:1080",
|
|
"HTTPS_PROXY": "http://generic:8080"})
|
|
assert adapter._proxy_url == "socks5://special:1080"
|
|
|
|
|
|
class TestCreateMatrixSession:
|
|
"""Verify _create_matrix_session applies proxy at the session level."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_proxy_returns_trust_env_session(self):
|
|
with patch.dict("sys.modules", _make_fake_mautrix()):
|
|
from plugins.platforms.matrix.adapter import _create_matrix_session
|
|
session = _create_matrix_session(None)
|
|
try:
|
|
assert session.trust_env is True
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
class TestMatrixDeadInviteHandling:
|
|
"""Tests for _join_room_by_id auto-leaving dead/abandoned rooms.
|
|
|
|
Regression: when a room had no current members, ``join_room`` raised
|
|
``MUnknown: Can't join remote room because no servers that are in the
|
|
room have been provided``. The pending invite stayed in the bot's view
|
|
of the world, so every gateway restart re-attempted the join and
|
|
re-emitted the warning indefinitely. There was no path that ever
|
|
cleared the invite.
|
|
"""
|
|
|
|
def setup_method(self):
|
|
self.adapter = _make_adapter()
|
|
self.adapter._refresh_dm_cache = AsyncMock()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_servers_error_triggers_leave(self):
|
|
join_err = Exception(
|
|
"Can't join remote room because no servers that are in the "
|
|
"room have been provided."
|
|
)
|
|
self.adapter._client = types.SimpleNamespace(
|
|
join_room=AsyncMock(side_effect=join_err),
|
|
leave_room=AsyncMock(),
|
|
)
|
|
|
|
result = await self.adapter._join_room_by_id("!dead:example.org")
|
|
|
|
assert result is False
|
|
self.adapter._client.leave_room.assert_awaited_once()
|
|
# leave_room receives a RoomID-wrapped value; verify the underlying str.
|
|
leave_arg = self.adapter._client.leave_room.await_args.args[0]
|
|
assert str(leave_arg) == "!dead:example.org"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_not_found_error_triggers_leave(self):
|
|
join_err = Exception("M_NOT_FOUND: Room not found")
|
|
self.adapter._client = types.SimpleNamespace(
|
|
join_room=AsyncMock(side_effect=join_err),
|
|
leave_room=AsyncMock(),
|
|
)
|
|
|
|
await self.adapter._join_room_by_id("!gone:example.org")
|
|
self.adapter._client.leave_room.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Device ID resolution when whoami returns None
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDeviceIdNoneResolution:
|
|
"""connect() should resolve device_id when whoami returns None."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_none_device_id_resolved_via_query_keys(self):
|
|
"""query_keys({mxid: []}) with exactly one device should adopt that ID."""
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_access_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"encryption": True,
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = None
|
|
mock_client.state_store = MagicMock()
|
|
mock_client.sync_store = MagicMock()
|
|
mock_client.crypto = None
|
|
mock_client.whoami = AsyncMock(return_value=MagicMock(
|
|
user_id="@bot:example.org", device_id=None,
|
|
))
|
|
|
|
resolve_resp = MagicMock()
|
|
resolve_dev = MagicMock()
|
|
resolve_dev.keys = {"ed25519:RESOLVED_DEV": "fake_ed25519_key"}
|
|
resolve_resp.device_keys = {"@bot:example.org": {"RESOLVED_DEV": resolve_dev}}
|
|
|
|
verify_resp = MagicMock()
|
|
verify_dev = MagicMock()
|
|
verify_dev.keys = {"ed25519:RESOLVED_DEV": "fake_ed25519_key"}
|
|
verify_resp.device_keys = {"@bot:example.org": {"RESOLVED_DEV": verify_dev}}
|
|
mock_client.query_keys = AsyncMock(side_effect=[resolve_resp, verify_resp])
|
|
|
|
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
|
|
mock_client.add_event_handler = MagicMock()
|
|
mock_client.handle_sync = MagicMock(return_value=[])
|
|
mock_client.api = MagicMock()
|
|
mock_client.api.token = "syt_test_access_token"
|
|
mock_client.api.session = MagicMock()
|
|
mock_client.api.session.close = AsyncMock()
|
|
|
|
mock_olm = MagicMock()
|
|
mock_olm.load = AsyncMock()
|
|
mock_olm.share_keys = AsyncMock()
|
|
mock_olm.share_keys_min_trust = None
|
|
mock_olm.send_keys_min_trust = None
|
|
mock_olm.account = MagicMock()
|
|
mock_olm.account.identity_keys = {"ed25519": "fake_ed25519_key"}
|
|
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
|
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
result = await adapter.connect()
|
|
|
|
assert result is True
|
|
assert adapter._device_id_unverified is False
|
|
# Positive path (W1 hardening, salvage of #53997): the resolution query
|
|
# must use an empty device list ({mxid: []}), and once RESOLVED_DEV is
|
|
# adopted the verification query must carry the REAL id, never [None]
|
|
# (the [null] body Synapse/Dendrite reject — the original bug).
|
|
assert mock_client.device_id == "RESOLVED_DEV"
|
|
assert mock_client.query_keys.await_count == 2
|
|
_resolution_call, _verify_call = mock_client.query_keys.await_args_list
|
|
assert _resolution_call.args[0] == {"@bot:example.org": []}
|
|
assert _verify_call.args[0] == {"@bot:example.org": ["RESOLVED_DEV"]}
|
|
assert None not in _verify_call.args[0]["@bot:example.org"]
|
|
|
|
await adapter.disconnect()
|
|
|
|
|
|
class TestVerifyDeviceKeysGuards:
|
|
"""_verify_device_keys_on_server and _reverify_keys_after_upload guards."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_skips_when_device_id_unverified_flag_set(self):
|
|
adapter = _make_adapter()
|
|
adapter._device_id_unverified = True
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.device_id = "SOME_DEVICE"
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.query_keys = AsyncMock()
|
|
|
|
mock_olm = MagicMock()
|
|
mock_olm.account = MagicMock()
|
|
mock_olm.account.identity_keys = {"ed25519": "fake_key"}
|
|
|
|
result = await adapter._verify_device_keys_on_server(mock_client, mock_olm)
|
|
|
|
assert result is True
|
|
mock_client.query_keys.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reconnect-disconnect guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMatrixReconnectDisconnect:
|
|
"""connect() must disconnect existing client before reconnecting."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_calls_disconnect_when_client_already_set(self):
|
|
"""When self._client is set, connect() should call disconnect() first."""
|
|
adapter = _make_adapter()
|
|
|
|
adapter._client = MagicMock()
|
|
adapter._client.api = MagicMock()
|
|
adapter._client.api.session = MagicMock()
|
|
adapter._client.api.session.close = AsyncMock()
|
|
adapter._client.whoami = AsyncMock()
|
|
|
|
adapter.disconnect = AsyncMock()
|
|
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.mxid = "@bot:example.org"
|
|
mock_client.device_id = None
|
|
mock_client.state_store = MagicMock()
|
|
mock_client.sync_store = MagicMock()
|
|
mock_client.crypto = None
|
|
mock_client.whoami = AsyncMock(return_value=MagicMock(
|
|
user_id="@bot:example.org", device_id="NEW_DEV",
|
|
))
|
|
mock_client.query_keys = AsyncMock()
|
|
mock_client.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
|
|
mock_client.add_event_handler = MagicMock()
|
|
mock_client.handle_sync = MagicMock(return_value=[])
|
|
mock_client.api = MagicMock()
|
|
mock_client.api.token = "syt_test_access_token"
|
|
mock_client.api.session = MagicMock()
|
|
mock_client.api.session.close = AsyncMock()
|
|
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
await adapter.connect()
|
|
|
|
adapter.disconnect.assert_awaited_once()
|
|
|
|
|
|
class TestDeviceIdRecoveryOnReconnect:
|
|
"""_device_id_unverified must reset on every connect() call so a
|
|
recovery after a failed resolution clears the stuck-true flag."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flag_clears_when_second_connect_resolves_device_id(self):
|
|
"""Same adapter, first connect fails to resolve, second succeeds. Flag
|
|
must be False afterward and server verification must run on the second
|
|
call."""
|
|
from plugins.platforms.matrix.adapter import MatrixAdapter
|
|
|
|
config = PlatformConfig(
|
|
enabled=True,
|
|
token="syt_test_access_token",
|
|
extra={
|
|
"homeserver": "https://matrix.example.org",
|
|
"user_id": "@bot:example.org",
|
|
"encryption": True,
|
|
},
|
|
)
|
|
adapter = MatrixAdapter(config)
|
|
|
|
fake_mautrix_mods = _make_fake_mautrix()
|
|
|
|
# --- first connect: whoami returns no device_id, query_keys returns
|
|
# zero devices → flag set to True ---
|
|
mock_client1 = MagicMock()
|
|
mock_client1.mxid = "@bot:example.org"
|
|
mock_client1.device_id = None
|
|
mock_client1.state_store = MagicMock()
|
|
mock_client1.sync_store = MagicMock()
|
|
mock_client1.crypto = None
|
|
mock_client1.whoami = AsyncMock(return_value=MagicMock(
|
|
user_id="@bot:example.org", device_id=None,
|
|
))
|
|
resolve_resp = MagicMock()
|
|
resolve_resp.device_keys = {"@bot:example.org": {}}
|
|
mock_client1.query_keys = AsyncMock(return_value=resolve_resp)
|
|
mock_client1.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
|
|
mock_client1.add_event_handler = MagicMock()
|
|
mock_client1.handle_sync = MagicMock(return_value=[])
|
|
mock_client1.api = MagicMock()
|
|
mock_client1.api.token = "syt_test_access_token"
|
|
mock_client1.api.session = MagicMock()
|
|
mock_client1.api.session.close = AsyncMock()
|
|
|
|
mock_olm1 = MagicMock()
|
|
mock_olm1.load = AsyncMock()
|
|
mock_olm1.share_keys = AsyncMock()
|
|
mock_olm1.share_keys_min_trust = None
|
|
mock_olm1.send_keys_min_trust = None
|
|
mock_olm1.account = MagicMock()
|
|
mock_olm1.account.identity_keys = {"ed25519": "fake_key"}
|
|
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client1)
|
|
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm1)
|
|
|
|
import plugins.platforms.matrix.adapter as matrix_mod
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
await adapter.connect()
|
|
|
|
assert adapter._device_id_unverified is True
|
|
await adapter.disconnect()
|
|
|
|
# --- second connect (same adapter, re-attaching): whoami returns a
|
|
# real device_id this time → flag must be False ---
|
|
mock_client2 = MagicMock()
|
|
mock_client2.mxid = "@bot:example.org"
|
|
mock_client2.device_id = None
|
|
mock_client2.state_store = MagicMock()
|
|
mock_client2.sync_store = MagicMock()
|
|
mock_client2.crypto = None
|
|
mock_client2.whoami = AsyncMock(return_value=MagicMock(
|
|
user_id="@bot:example.org", device_id=None,
|
|
))
|
|
resolve_resp2 = MagicMock()
|
|
resolve_dev = MagicMock()
|
|
resolve_dev.keys = {"ed25519:DEV2": "fake_ed25519_key2"}
|
|
resolve_resp2.device_keys = {"@bot:example.org": {"DEV2": resolve_dev}}
|
|
verify_resp = MagicMock()
|
|
verify_dev = MagicMock()
|
|
verify_dev.keys = {"ed25519:DEV2": "fake_ed25519_key2"}
|
|
verify_resp.device_keys = {"@bot:example.org": {"DEV2": verify_dev}}
|
|
mock_client2.query_keys = AsyncMock(side_effect=[resolve_resp2, verify_resp])
|
|
mock_client2.sync = AsyncMock(return_value={"rooms": {"join": {"!room:server": {}}}})
|
|
mock_client2.add_event_handler = MagicMock()
|
|
mock_client2.handle_sync = MagicMock(return_value=[])
|
|
mock_client2.api = MagicMock()
|
|
mock_client2.api.token = "syt_test_access_token"
|
|
mock_client2.api.session = MagicMock()
|
|
mock_client2.api.session.close = AsyncMock()
|
|
|
|
mock_olm2 = MagicMock()
|
|
mock_olm2.load = AsyncMock()
|
|
mock_olm2.share_keys = AsyncMock()
|
|
mock_olm2.share_keys_min_trust = None
|
|
mock_olm2.send_keys_min_trust = None
|
|
mock_olm2.account = MagicMock()
|
|
mock_olm2.account.identity_keys = {"ed25519": "fake_ed25519_key2"}
|
|
|
|
fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client2)
|
|
fake_mautrix_mods["mautrix.crypto"].OlmMachine = MagicMock(return_value=mock_olm2)
|
|
|
|
with patch.object(matrix_mod, "_check_e2ee_deps", return_value=True):
|
|
with patch.dict("sys.modules", fake_mautrix_mods):
|
|
with patch.object(adapter, "_refresh_dm_cache", AsyncMock()):
|
|
with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)):
|
|
result = await adapter.connect()
|
|
|
|
assert result is True
|
|
assert adapter._device_id_unverified is False
|
|
# Verification must genuinely re-run on the second connect — not just
|
|
# the resolution query. Two awaited query_keys calls: resolution
|
|
# ({mxid: []}) then verification ({mxid: [<resolved id>]}). The
|
|
# verification call must carry the REAL resolved device id ("DEV2"),
|
|
# never [None] (the original bug). (W2 hardening, salvage of #53997)
|
|
assert mock_client2.query_keys.await_count == 2
|
|
_resolution_call, _verify_call = mock_client2.query_keys.await_args_list
|
|
assert _resolution_call.args[0] == {"@bot:example.org": []}
|
|
assert _verify_call.args[0] == {"@bot:example.org": ["DEV2"]}
|
|
assert None not in _verify_call.args[0]["@bot:example.org"]
|
|
|
|
await adapter.disconnect()
|
|
|
|
|
|
class TestMatrixDispatchSyncIsolation:
|
|
"""A failing mautrix event handler must not abort the whole sync batch.
|
|
|
|
``_dispatch_sync`` gathers the per-event handler tasks. Without
|
|
``return_exceptions=True`` the first exception aborts the gather and the
|
|
sibling events in the same sync response are silently dropped.
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_sync_isolates_failing_handler(self, caplog):
|
|
import logging
|
|
|
|
adapter = _make_adapter()
|
|
ran = {"ok": False}
|
|
|
|
async def _boom():
|
|
raise RuntimeError("handler boom")
|
|
|
|
async def _ok():
|
|
ran["ok"] = True
|
|
|
|
client = MagicMock()
|
|
client.handle_sync = MagicMock(return_value=[_boom(), _ok()])
|
|
adapter._client = client
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
# Must not raise despite the failing handler.
|
|
await adapter._dispatch_sync({"next_batch": "s1"})
|
|
|
|
assert ran["ok"] is True # the sibling handler still ran
|
|
assert "event handler failed" in caplog.text # failure surfaced, not swallowed
|