Merge branches 'matthew/msc4233-knock-via' and 'matthew/auto-join-on-knock-accept' into matthew/knock-push-rules

This commit is contained in:
Matthew Hodgson
2026-07-13 22:33:21 +01:00
11 changed files with 289 additions and 26 deletions
+122 -2
View File
@@ -36,7 +36,7 @@ from synapse.events.auto_accept_invites import InviteAutoAccepter
from synapse.handlers.sync import JoinedSyncResult, SyncRequestKey
from synapse.module_api import ModuleApi
from synapse.rest import admin
from synapse.rest.client import login, room
from synapse.rest.client import knock, login, room
from synapse.server import HomeServer
from synapse.types import StreamToken, UserID, UserInfo, create_requester
from synapse.util.clock import Clock
@@ -58,10 +58,114 @@ class AutoAcceptInvitesTestCase(FederatingHomeserverTestCase):
servlets = [
admin.register_servlets,
knock.register_servlets,
login.register_servlets,
room.register_servlets,
]
def _create_knockable_room_with_pending_knock(
self,
) -> tuple[str, str, str, str]:
"""Create a knockable room and have a second local user knock on it.
Returns a tuple of (room_id, creator_id, creator_tok, knocker_id).
"""
creator_id = self.register_user("creator", "pass")
creator_tok = self.login("creator", "pass")
knocker_id = self.register_user("knocker", "pass")
knocker_tok = self.login("knocker", "pass")
room_id = self.helper.create_room_as(
creator_id,
is_public=False,
tok=creator_tok,
)
self.helper.send_state(
room_id,
EventTypes.JoinRules,
{"join_rule": "knock"},
tok=creator_tok,
)
self.helper.knock(room=room_id, user=knocker_id, tok=knocker_tok)
return room_id, creator_id, creator_tok, knocker_id
def test_auto_join_on_accepted_knock(self) -> None:
"""A user whose knock is accepted (invited by a room member) is
automatically joined to the room, even with no `auto_accept_invites`
configuration at all."""
(
room_id,
creator_id,
creator_tok,
knocker_id,
) = self._create_knockable_room_with_pending_knock()
# The creator accepts the knock by inviting the knocker.
self.helper.invite(
room_id,
creator_id,
knocker_id,
tok=creator_tok,
)
# The knocker is automatically joined to the room.
join_updates, _ = sync_join(self, knocker_id)
self.assertEqual(len(join_updates), 1)
self.assertEqual(join_updates[0].room_id, room_id)
@override_config(
{
"auto_accept_invites": {
"enabled_for_accepted_knocks": False,
},
}
)
def test_no_auto_join_on_accepted_knock_when_disabled(self) -> None:
"""With `enabled_for_accepted_knocks` off, an accepted knock stays an
ordinary invite."""
(
room_id,
creator_id,
creator_tok,
knocker_id,
) = self._create_knockable_room_with_pending_knock()
self.helper.invite(
room_id,
creator_id,
knocker_id,
tok=creator_tok,
)
join_updates, _ = sync_join(self, knocker_id)
self.assertEqual(len(join_updates), 0)
def test_plain_invite_not_auto_accepted_by_default(self) -> None:
"""A plain invite (no prior knock) is not auto-accepted just because
the accepted-knocks logic is enabled by default."""
inviting_user_id = self.register_user("inviter2", "pass")
inviting_user_tok = self.login("inviter2", "pass")
invited_user_id = self.register_user("invitee2", "pass")
self.login("invitee2", "pass")
room_id = self.helper.create_room_as(
inviting_user_id, is_public=False, tok=inviting_user_tok
)
self.helper.invite(
room_id,
inviting_user_id,
invited_user_id,
tok=inviting_user_tok,
)
join_updates, _ = sync_join(self, invited_user_id)
self.assertEqual(len(join_updates), 0)
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
hs = self.setup_test_homeserver()
self.handler = hs.get_federation_handler()
@@ -561,7 +665,12 @@ class InviteAutoAccepterInternalTestCase(TestCase):
"""
def setUp(self) -> None:
self.module = create_module()
# These tests exercise the plain-invite acceptance path, which is
# gated on `enabled` in `on_new_event` (the accepted-knock path is
# what's on by default).
self.module = create_module(
config_override={"auto_accept_invites": {"enabled": True}}
)
self.user_id = "@peter:test"
self.invitee = "@lesley:test"
self.remote_invitee = "@thomas:remote"
@@ -772,6 +881,11 @@ class MockEvent:
"""Checks if the event is a state event by checking if it has a state key."""
return self.state_key is not None
def auth_event_ids(self) -> list[str]:
"""The module walks the auth events when checking whether an invite
accepts a knock; a mocked event has none."""
return []
@property
def membership(self) -> str:
"""Extracts the membership from the event. Should only be called on an event
@@ -800,6 +914,12 @@ def create_module(
module_api = Mock(spec=ModuleApi)
module_api.is_mine.side_effect = lambda a: a.split(":")[1] == "test"
module_api.worker_name = worker_name
# The module reaches into the datastore to walk an invite's auth events;
# none of the mocked events have any.
module_api._store = Mock()
module_api._store.get_event.side_effect = lambda *_args, **_kwargs: (
make_awaitable(None)
)
module_api.sleep.return_value = lambda *_args, **_kwargs: make_awaitable(None)
module_api.get_userinfo_by_id.return_value = UserInfo(
user_id=UserID.from_string("@user:test"),
@@ -28,14 +28,13 @@ from twisted.internet.testing import MemoryReactor
from synapse.api.constants import EventContentFields, EventTypes, Membership
from synapse.api.room_versions import RoomVersion, RoomVersions
from synapse.events import EventBase
from synapse.events import builder
from synapse.events import EventBase, builder
from synapse.events.snapshot import EventContext
from synapse.events.utils import strip_event
from synapse.http.matrixfederationclient import ByteParser
from synapse.http.types import QueryParams
from synapse.rest import admin
from synapse.rest.client import knock, login, room
from synapse.rest.client import knock, login, room, sync
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock
@@ -75,11 +74,18 @@ class KnockViaServerTestCase(unittest.FederatingHomeserverTestCase):
knock.register_servlets,
room.register_servlets,
login.register_servlets,
sync.register_servlets,
]
def default_config(self) -> JsonDict:
conf = super().default_config()
conf["experimental_features"] = {"msc4233_enabled": True}
conf["experimental_features"] = {
"msc4233_enabled": True,
# For the state_after test: out-of-band leaves must be reflected
# in `state_after` for clients that no longer apply timeline
# events to state.
"msc4222_enabled": True,
}
return conf
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
@@ -417,6 +423,60 @@ class KnockViaServerTestCase(unittest.FederatingHomeserverTestCase):
break
time.sleep(0.1)
def test_denied_knock_in_state_after(self) -> None:
"""The out-of-band leave retracting the knock is reflected in
`state_after` (MSC4222) on sync: it is an outlier, so it never enters
the current state delta stream, and clients using `state_after` do
not apply timeline events to state."""
knock_result = self._knock_on_remote_room()
# Sync up to just after the knock.
channel = self.make_request(
"GET",
"/_matrix/client/v3/sync?timeout=0&org.matrix.msc4222.use_state_after=true",
access_token=knock_result.local_user1_tok,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
since_token = channel.json_body["next_batch"]
self._send_denial_over_federation(
knock_result,
auth_events=[
knock_result.room_create_event.event_id,
knock_result.knock_event_id,
],
)
with test_timeout(3, "Denial of the knock was not processed"):
while True:
membership, _ = self.get_success(
self.store.get_local_current_membership_for_user_in_room(
knock_result.local_user1_id, knock_result.remote_room_id
)
)
if membership == Membership.LEAVE:
break
time.sleep(0.1)
channel = self.make_request(
"GET",
f"/_matrix/client/v3/sync?timeout=0&org.matrix.msc4222.use_state_after=true&since={since_token}",
access_token=knock_result.local_user1_tok,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
leave_room = channel.json_body["rooms"]["leave"][knock_result.remote_room_id]
state_after_events = leave_room["org.matrix.msc4222.state_after"]["events"]
self.assertTrue(
any(
e["type"] == EventTypes.Member
and e["state_key"] == knock_result.local_user1_id
and e["content"]["membership"] == Membership.LEAVE
for e in state_after_events
),
leave_room,
)
def test_denied_knock_ignored_without_knock_in_auth_events(self) -> None:
"""A leave event for our knocked user which does not reference the
knock in its auth events is ignored."""
@@ -495,7 +555,7 @@ class DenyKnockFederationSendTestCase(unittest.FederatingHomeserverTestCase):
def test_deny_sends_leave_to_knocking_server(self) -> None:
"""Kicking a remote user whose membership is knock sends the leave
event to their (otherwise uninvolved) server."""
user_id = self.register_user("u1", "pass")
self.register_user("u1", "pass")
user_token = self.login("u1", "pass")
fake_knocking_user_id = f"@user:{self.OTHER_SERVER_NAME}"
@@ -585,9 +645,7 @@ class DenyKnockFederationSendTestCase(unittest.FederatingHomeserverTestCase):
while True:
leave_pdus = [
pdu
for pdu in sent_pdus_by_destination.get(
self.OTHER_SERVER_NAME, []
)
for pdu in sent_pdus_by_destination.get(self.OTHER_SERVER_NAME, [])
if pdu.get("type") == EventTypes.Member
and pdu.get("state_key") == fake_knocking_user_id
and pdu.get("content", {}).get("membership") == Membership.LEAVE
+4 -1
View File
@@ -1465,7 +1465,10 @@ def start_test_homeserver(
for module, module_config in hs.config.modules.loaded_modules:
module(config=module_config, api=module_api)
if hs.config.auto_accept_invites.enabled:
if (
hs.config.auto_accept_invites.enabled
or hs.config.auto_accept_invites.enabled_for_accepted_knocks
):
# Start the local auto_accept_invites module.
m = InviteAutoAccepter(hs.config.auto_accept_invites, module_api)
logger.info("Loaded local module %s", m)