Files
hermes-agent/tests/gateway/test_matrix_pending_invite_auth.py

121 lines
4.3 KiB
Python

"""Tests for the inviter allowlist gate on pending-invite reconciliation.
``_on_invite`` only auto-joins when the inviter is allow-listed, but the
reconcile pass over sync's ``rooms.invite`` runs after event dispatch and
sees every pending invite — including ones ``_on_invite`` just rejected,
and ones that arrived while the gateway was down. An unconditional join
there re-admitted rejected live invites milliseconds later and auto-joined
arbitrary federated invites on restart. ``_schedule_pending_invite_joins``
must apply the same gate, reading the inviter from the stripped invite
state.
"""
import time
from unittest.mock import AsyncMock
import pytest
from gateway.config import PlatformConfig
def _make_adapter():
"""Create a MatrixAdapter with mocked config and a one-user allowlist."""
from plugins.platforms.matrix.adapter import MatrixAdapter
config = PlatformConfig(
enabled=True,
token="syt_test_token",
extra={
"homeserver": "https://matrix.example.org",
"user_id": "@hermes:example.org",
},
)
adapter = MatrixAdapter(config)
adapter._allowed_user_ids = {"@alice:example.org"}
adapter._join_room_by_id = AsyncMock(return_value=True)
adapter._record_dm_room = AsyncMock()
return adapter
def _member_invite_event(
state_key="@hermes:example.org",
sender="@alice:example.org",
is_direct=True,
membership="invite",
):
"""Create a stripped m.room.member event as found in invite_state."""
return {
"type": "m.room.member",
"state_key": state_key,
"sender": sender,
"content": {"membership": membership, "is_direct": is_direct},
}
def _invite_sync_data(room_id="!pending_room:example.org", invite_state=None):
"""Create a sync payload with one pending invite room."""
room = {} if invite_state is None else {"invite_state": invite_state}
return {"rooms": {"invite": {room_id: room}}, "next_batch": "s1"}
async def _drain_invite_tasks(adapter):
"""Await any tasks _schedule_invite_join spawned."""
for task in list(adapter._invite_join_tasks.values()):
await task
class TestPendingInviteAuthorization:
"""_schedule_pending_invite_joins applies _on_invite's inviter gate.
Rejection mirrors _on_invite exactly: no join is scheduled (so no
entry lands in _invite_join_tasks), nothing is recorded in m.direct,
and the pending invite is otherwise left untouched.
"""
@pytest.mark.asyncio
async def test_allowed_inviter_is_joined(self):
adapter = _make_adapter()
sync_data = _invite_sync_data(invite_state={"events": [_member_invite_event()]})
adapter._schedule_pending_invite_joins(sync_data)
await _drain_invite_tasks(adapter)
adapter._join_room_by_id.assert_awaited_once_with("!pending_room:example.org")
adapter._record_dm_room.assert_awaited_once_with(
"!pending_room:example.org", "@alice:example.org"
)
@pytest.mark.parametrize(
"invite_state",
[
pytest.param(
{"events": [_member_invite_event(sender="@mallory:evil.example")]},
id="non-allowed-inviter",
),
pytest.param(None, id="no-invite-state"),
pytest.param({"events": []}, id="empty-events"),
pytest.param(
{"events": [_member_invite_event(state_key="@other:example.org")]},
id="member-event-for-other-user",
),
pytest.param(
{"events": [_member_invite_event(sender="")]},
id="missing-inviter",
),
],
)
@pytest.mark.asyncio
async def test_unauthorized_or_unknown_inviter_is_not_joined(self, invite_state):
"""An inviter outside the allowlist, or one that cannot be read
from the stripped invite state at all, fails closed like
_on_invite: no join is scheduled and nothing is recorded."""
adapter = _make_adapter()
sync_data = _invite_sync_data(invite_state=invite_state)
adapter._schedule_pending_invite_joins(sync_data)
await _drain_invite_tasks(adapter)
adapter._join_room_by_id.assert_not_awaited()
adapter._record_dm_room.assert_not_awaited()
assert adapter._invite_join_tasks == {}