diff --git a/tests/federation/test_federation_server.py b/tests/federation/test_federation_server.py index 1bf0817bd2..92549d7cc8 100644 --- a/tests/federation/test_federation_server.py +++ b/tests/federation/test_federation_server.py @@ -19,8 +19,9 @@ # # import logging +import urllib.parse from http import HTTPStatus -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock from parameterized import parameterized @@ -39,7 +40,9 @@ from synapse.rest.client import login, room from synapse.server import HomeServer from synapse.storage.controllers.state import server_acl_evaluator_from_event from synapse.types import JsonDict +from synapse.types.state import StateEventQuery from synapse.util.clock import Clock +from synapse.util.json import json_encoder from tests import unittest from tests.test_utils.event_builders import make_test_event, make_test_pdu_event @@ -76,6 +79,119 @@ class FederationServerTests(unittest.FederatingHomeserverTestCase): self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) self.assertEqual(channel.json_body["errcode"], "M_NOT_JSON") + @override_config({"experimental_features": {"msc4507_enabled": True}}) + def test_hierarchy_additional_state_query_parameter(self) -> None: + """The federation hierarchy servlet parses repeated MSC4507 JSON params.""" + u1 = self.register_user("u1", "pass") + u1_token = self.login("u1", "pass") + + room_id = self.helper.create_room_as(u1, tok=u1_token) + event_type = "m.some_event" + self.helper.send_state( + room_id, + event_type=event_type, + body={"foo": ["bar"]}, + tok=u1_token, + ) + self.helper.send_state( + room_id, + event_type=EventTypes.MSC4507PublicState, + body={"public_state": {event_type: {"state_keys": [""]}}}, + tok=u1_token, + ) + + query = urllib.parse.urlencode( + [ + ( + "org.matrix.msc4507.additional_state", + json_encoder.encode({"type": event_type, "state_key": ""}), + ) + ] + ) + + channel = self.make_signed_federation_request( + "GET", f"/_matrix/federation/v1/hierarchy/{room_id}?{query}" + ) + + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + self.assertEqual( + channel.json_body["room"]["org.matrix.msc4507.additional_state"], + [ + { + "type": event_type, + "state_key": "", + "content": {"foo": ["bar"]}, + "sender": u1, + } + ], + ) + + @override_config({"experimental_features": {"msc4507_enabled": True}}) + def test_hierarchy_additional_state_percent_encoded_state_key(self) -> None: + """MSC4507 query values are percent-decoded exactly once.""" + room_id = "!unknown:test" + event_type = "m.some_event_type" + state_key = "%22" + room_summary_handler = self.hs.get_room_summary_handler() + room_summary_handler.get_federation_hierarchy = AsyncMock(return_value={}) # type: ignore[method-assign] + + query = urllib.parse.urlencode( + [ + ( + "org.matrix.msc4507.additional_state", + json_encoder.encode({"type": event_type, "state_key": state_key}), + ) + ] + ) + self.assertIn("%2522", query) + + channel = self.make_signed_federation_request( + "GET", f"/_matrix/federation/v1/hierarchy/{room_id}?{query}" + ) + + self.assertEqual(HTTPStatus.OK, channel.code, channel.result) + room_summary_handler.get_federation_hierarchy.assert_awaited_once_with( + self.OTHER_SERVER_NAME, + room_id, + False, + (StateEventQuery(event_type, state_key),), + ) + + @override_config({"experimental_features": {"msc4507_enabled": True}}) + def test_hierarchy_additional_state_rejects_non_string_state_key(self) -> None: + """MSC4507 query objects must omit state_key or use a string.""" + query = urllib.parse.urlencode( + [ + ( + "org.matrix.msc4507.additional_state", + json_encoder.encode( + {"type": "m.some_event_type", "state_key": None} + ), + ) + ] + ) + + channel = self.make_signed_federation_request( + "GET", f"/_matrix/federation/v1/hierarchy/!unknown:test?{query}" + ) + + self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) + self.assertEqual(channel.json_body["errcode"], "M_INVALID_PARAM") + + @override_config({"experimental_features": {"msc4507_enabled": True}}) + def test_hierarchy_additional_state_rejects_invalid_json(self) -> None: + """MSC4507 query objects must be valid JSON.""" + query = urllib.parse.urlencode( + [("org.matrix.msc4507.additional_state", "not json")] + ) + + channel = self.make_signed_federation_request( + "GET", f"/_matrix/federation/v1/hierarchy/!unknown:test?{query}" + ) + + self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result) + self.assertEqual(channel.json_body["errcode"], "M_NOT_JSON") + def test_failed_edu_causes_500(self) -> None: """If the EDU handler fails, /send should return a 500.""" diff --git a/tests/handlers/test_room_summary.py b/tests/handlers/test_room_summary.py index 0f8de6e7b9..f526a03176 100644 --- a/tests/handlers/test_room_summary.py +++ b/tests/handlers/test_room_summary.py @@ -36,11 +36,17 @@ from synapse.api.constants import ( from synapse.api.errors import AuthError, NotFoundError, SynapseError from synapse.api.room_versions import RoomVersions from synapse.federation.transport.client import TransportLayerClient -from synapse.handlers.room_summary import _child_events_comparison_key, _RoomEntry +from synapse.handlers.room_summary import ( + _child_events_comparison_key, + _get_permitted_public_state, + _parse_public_state, + _RoomEntry, +) from synapse.rest import admin from synapse.rest.client import login, room from synapse.server import HomeServer from synapse.types import JsonDict, UserID, create_requester +from synapse.types.state import StateEventQuery, StateFilter from synapse.util.clock import Clock from tests import unittest @@ -120,6 +126,62 @@ class TestSpaceSummarySort(unittest.TestCase): self.assertEqual([ev2, ev1], _order(ev1, ev2)) +class PublicStateTestCase(unittest.TestCase): + def test_parse_public_state(self) -> None: + """Public state rules are validated and normalized independently.""" + public_state = _parse_public_state( + { + "wildcard": {}, + "limited": {"state_keys": ["one", "two", "one"]}, + "empty": {"state_keys": []}, + "invalid_rule": [], + "invalid_state_keys": {"state_keys": ["one", 2]}, + } + ) + + self.assertEqual( + public_state, + StateFilter.from_types( + [("wildcard", None), ("limited", "one"), ("limited", "two")] + ), + ) + + def test_get_permitted_public_state(self) -> None: + """Requested state is intersected with normalized public state rules.""" + public_state = StateFilter.from_types( + [ + ("wildcard", None), + ("limited", "one"), + ("limited", "two"), + ("overlap", "one"), + ("overlap", "two"), + ] + ) + + permitted_state = _get_permitted_public_state( + [ + StateEventQuery("wildcard", "requested"), + StateEventQuery("limited"), + StateEventQuery("overlap", "two"), + StateEventQuery("overlap", "three"), + StateEventQuery("private"), + ], + public_state, + ) + + self.assertEqual( + permitted_state, + StateFilter.from_types( + [ + ("wildcard", "requested"), + ("limited", "one"), + ("limited", "two"), + ("overlap", "two"), + ] + ), + ) + + class SpaceSummaryTestCase(unittest.HomeserverTestCase): servlets = [ admin.register_servlets_for_client_rest_resource, @@ -246,6 +308,255 @@ class SpaceSummaryTestCase(unittest.HomeserverTestCase): ) self._assert_hierarchy(result, expected) + @override_config({"experimental_features": {"msc4507_enabled": True}}) + def test_federation_hierarchy_additional_state(self) -> None: + """Federation hierarchy can include requested public state for each room.""" + event_type = "m.security_labels" + self.helper.send_state( + self.space, + event_type=event_type, + body={"labels": ["space-label"]}, + tok=self.token, + ) + self.helper.send_state( + self.room, + event_type=event_type, + body={"labels": ["room-label"]}, + tok=self.token, + ) + for room_id in (self.space, self.room): + self.helper.send_state( + room_id, + event_type=EventTypes.MSC4507PublicState, + body={"public_state": {event_type: {"state_keys": [""]}}}, + tok=self.token, + ) + + result = self.get_success( + self.handler.get_federation_hierarchy( + "remote.example.com", + self.space, + suggested_only=False, + additional_state=(StateEventQuery(event_type, ""),), + ) + ) + + self.assertEqual( + result["room"]["org.matrix.msc4507.additional_state"], + [ + { + "type": event_type, + "state_key": "", + "content": {"labels": ["space-label"]}, + "sender": self.user, + } + ], + ) + self.assertEqual( + result["children"][0]["org.matrix.msc4507.additional_state"], + [ + { + "type": event_type, + "state_key": "", + "content": {"labels": ["room-label"]}, + "sender": self.user, + } + ], + ) + + @override_config({"experimental_features": {"msc4507_enabled": True}}) + def test_federation_hierarchy_additional_state_requires_public_state( + self, + ) -> None: + """Requested state is omitted if the room has not declared it public.""" + event_type = "m.security_labels" + self.helper.send_state( + self.space, + event_type=event_type, + body={"labels": ["secret"]}, + tok=self.token, + ) + + result = self.get_success( + self.handler.get_federation_hierarchy( + "remote.example.com", + self.space, + suggested_only=False, + additional_state=(StateEventQuery(event_type, ""),), + ) + ) + + self.assertEqual(result["room"]["org.matrix.msc4507.additional_state"], []) + + @override_config({"experimental_features": {"msc4507_enabled": True}}) + def test_federation_hierarchy_additional_state_supports_state_key_wildcard( + self, + ) -> None: + """Omitting state_key requests all public state keys for that type.""" + event_type = "com.example.some_state" + self.helper.send_state( + self.space, + event_type=event_type, + state_key="allowed", + body={"value": "allowed"}, + tok=self.token, + ) + self.helper.send_state( + self.space, + event_type=event_type, + state_key="private", + body={"value": "private"}, + tok=self.token, + ) + self.helper.send_state( + self.space, + event_type=EventTypes.MSC4507PublicState, + body={"public_state": {event_type: {}}}, + tok=self.token, + ) + + result = self.get_success( + self.handler.get_federation_hierarchy( + "remote.example.com", + self.space, + suggested_only=False, + additional_state=(StateEventQuery(event_type),), + ) + ) + + self.assertEqual( + result["room"]["org.matrix.msc4507.additional_state"], + [ + { + "type": event_type, + "state_key": "allowed", + "content": {"value": "allowed"}, + "sender": self.user, + }, + { + "type": event_type, + "state_key": "private", + "content": {"value": "private"}, + "sender": self.user, + }, + ], + ) + + self.helper.send_state( + self.space, + event_type=EventTypes.MSC4507PublicState, + body={"public_state": {event_type: {"state_keys": ["allowed"]}}}, + tok=self.token, + ) + + result = self.get_success( + self.handler.get_federation_hierarchy( + "remote.example.com", + self.space, + suggested_only=False, + additional_state=(StateEventQuery(event_type),), + ) + ) + + self.assertEqual( + result["room"]["org.matrix.msc4507.additional_state"], + [ + { + "type": event_type, + "state_key": "allowed", + "content": {"value": "allowed"}, + "sender": self.user, + } + ], + ) + + @override_config({"experimental_features": {"msc4507_enabled": True}}) + def test_federation_hierarchy_additional_state_omitted_without_query( + self, + ) -> None: + """The federation hierarchy response is unchanged without the MSC query.""" + event_type = "m.security_labels" + self.helper.send_state( + self.space, + event_type=event_type, + body={"labels": ["public"]}, + tok=self.token, + ) + self.helper.send_state( + self.space, + event_type=EventTypes.MSC4507PublicState, + body={"public_state": {event_type: {"state_keys": [""]}}}, + tok=self.token, + ) + + result = self.get_success( + self.handler.get_federation_hierarchy( + "remote.example.com", self.space, suggested_only=False + ) + ) + + self.assertNotIn("org.matrix.msc4507.additional_state", result["room"]) + + def test_federation_hierarchy_additional_state_disabled_by_default( + self, + ) -> None: + """The MSC4507 hierarchy response field is gated by experimental config.""" + event_type = "m.security_labels" + self.helper.send_state( + self.space, + event_type=event_type, + body={"labels": ["public"]}, + tok=self.token, + ) + self.helper.send_state( + self.space, + event_type=EventTypes.MSC4507PublicState, + body={"public_state": {event_type: {"state_keys": [""]}}}, + tok=self.token, + ) + + result = self.get_success( + self.handler.get_federation_hierarchy( + "remote.example.com", + self.space, + suggested_only=False, + additional_state=(StateEventQuery(event_type, ""),), + ) + ) + + self.assertNotIn("org.matrix.msc4507.additional_state", result["room"]) + + @override_config({"experimental_features": {"msc4507_enabled": True}}) + def test_msc4507_public_state_default_power_level(self) -> None: + """New rooms require PL100 to send the MSC4507 public-state event.""" + state_ids = self.get_success( + self.hs.get_storage_controllers().state.get_current_state_ids(self.space) + ) + power_levels = self.get_success( + self.hs.get_datastores().main.get_event( + state_ids[(EventTypes.PowerLevels, "")] + ) + ) + + self.assertEqual( + power_levels.content["events"][EventTypes.MSC4507PublicState], 100 + ) + + def test_msc4507_public_state_default_power_level_disabled_by_default( + self, + ) -> None: + """The MSC4507 public-state PL rule is gated by experimental config.""" + state_ids = self.get_success( + self.hs.get_storage_controllers().state.get_current_state_ids(self.space) + ) + power_levels = self.get_success( + self.hs.get_datastores().main.get_event( + state_ids[(EventTypes.PowerLevels, "")] + ) + ) + + self.assertNotIn(EventTypes.MSC4507PublicState, power_levels.content["events"]) + @override_config({"rc_room_creation": {"burst_count": 1000, "per_second": 1}}) def test_large_space(self) -> None: """Test a space with a large number of rooms.""" @@ -1024,6 +1335,7 @@ class SpaceSummaryTestCase(unittest.HomeserverTestCase): destination: str, room_id: str, suggested_only: bool, + additional_state: Iterable[StateEventQuery] = (), ) -> JsonDict: nonlocal federation_requests federation_requests += 1