diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 4343cb482f2..63b894ef3cc 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -1,6 +1,6 @@ # GitHub actions workflow which builds the release artifacts. -name: Build release artifacts +name: Build Tchap release artifacts on: # we build on PRs and develop to (hopefully) get early warning @@ -8,10 +8,12 @@ on: # building wheels on ARM. pull_request: push: - branches: ["develop", "release-*"] + branches: [tchap, develop_tchap] + + # we do the full build on releases. + release: + types: [published] - # we do the full build on tags. - tags: ["v*"] merge_group: workflow_dispatch: @@ -44,74 +46,17 @@ jobs: outputs: distros: ${{ steps.set-distros.outputs.distros }} - # now build the packages with a matrix build. - build-debs: - needs: get-distros - name: "Build .deb packages" - runs-on: ubuntu-latest - strategy: - matrix: - distro: ${{ fromJson(needs.get-distros.outputs.distros) }} - - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - path: src - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - - name: Set up docker layer caching - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 - with: - path: /tmp/.buildx-cache - key: ${{ runner.os }}-buildx-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-buildx- - - - name: Set up python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.x" - - - name: Build the packages - # see https://github.com/docker/build-push-action/issues/252 - # for the cache magic here - run: | - ./src/scripts-dev/build_debian_packages.py \ - --docker-build-arg=--cache-from=type=local,src=/tmp/.buildx-cache \ - --docker-build-arg=--cache-to=type=local,mode=max,dest=/tmp/.buildx-cache-new \ - --docker-build-arg=--progress=plain \ - --docker-build-arg=--load \ - "${{ matrix.distro }}" - rm -rf /tmp/.buildx-cache - mv /tmp/.buildx-cache-new /tmp/.buildx-cache - - - name: Artifact name - id: artifact-name - # We can't have colons in the upload name of the artifact, so we convert - # e.g. `debian:sid` to `sid`. - env: - DISTRO: ${{ matrix.distro }} - run: | - echo "ARTIFACT_NAME=${DISTRO#*:}" >> "$GITHUB_OUTPUT" - - - name: Upload debs as artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: debs-${{ steps.artifact-name.outputs.ARTIFACT_NAME }} - path: debs/* +### Deactivate build-debs pipeline : not required for Tchap Synapse ### build-wheels: name: Build wheels on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: - os: - - ubuntu-24.04 - - ubuntu-24.04-arm +### Deactivate macos pipeline : not required for Tchap Synapse ### + os: [ubuntu-24.04] +### Deactivate aarch64 pipeline : not required for Tchap Synapse ### + arch: [x86_64] # is_pr is a flag used to exclude certain jobs from the matrix on PRs. # It is not read by the rest of the workflow. is_pr: @@ -144,6 +89,9 @@ jobs: # The platforms that we build for are determined by the # `tool.cibuildwheel.skip` option in `pyproject.toml`. + ### Deactivate other version : not required for Tchap Synapse ### + CIBW_BUILD: cp310-manylinux_x86_64 + # We skip testing wheels for the following platforms in CI: # # pp3*-* (PyPy wheels) broke in CI (TODO: investigate). @@ -181,20 +129,15 @@ jobs: name: "Attach assets to release" if: ${{ !failure() && !cancelled() && startsWith(github.ref, 'refs/tags/') }} needs: - - build-debs +### Deactivate pipeline : not required for Tchap Synapse ### +# - build-debs - build-wheels - build-sdist runs-on: ubuntu-latest steps: - name: Download all workflow run artifacts uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 - - name: Build a tarball for the debs - # We need to merge all the debs uploads into one folder, then compress - # that. - run: | - mkdir debs - mv debs*/* debs/ - tar -cvJf debs.tar.xz debs +### Deactivate debs archiving : not required for Tchap Synapse ### - name: Attach to release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -202,5 +145,6 @@ jobs: gh release upload "${{ github.ref_name }}" \ Sdist/* \ Wheel*/* \ - debs.tar.xz \ --repo ${{ github.repository }} +### Deactivate debs archiving : not required for Tchap Synapse ### +# debs.tar.xz \ diff --git a/build_conf.sh b/build_conf.sh new file mode 100755 index 00000000000..12323ffdf50 --- /dev/null +++ b/build_conf.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +DOCKER_DEMO_REPO="/Users/olivier/workspace/tchap/tchap-docker-integration" +SYNAPSE_DATA=$(pwd) +OUT="homeserver.yaml" +OUT2="log_config.yaml" + +# 1/7 Copy the base homeserver.yaml +cp docs/sample_config.yaml "$OUT" +cp docs/sample_log_config.yaml "$OUT2" + +# 2/7 Merge $DOCKER_DEMO_REPO configuration into the base (overlays) +yq eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' "$OUT" "$DOCKER_DEMO_REPO/data/synapse/homeserver.yaml" -i + +# 3/7 Force SQLite with a configurable path +yq eval -i ".database = {\"name\":\"sqlite3\",\"args\":{\"database\":\"${SYNAPSE_DATA}/data/sqlite3/homeserver.db\"}}" "$OUT" + +# 4/7 Force paths relative to SYNAPSE_DATA +yq eval -i ".pid_file = \"${SYNAPSE_DATA}/data/homeserver.pid\"" "$OUT" +yq eval -i ".log_config = \"${SYNAPSE_DATA}/log_config.yaml\"" "$OUT" +yq eval -i ".media_store_path = \"${SYNAPSE_DATA}/data/media_store\"" "$OUT" +yq eval -i ".signing_key_path = \"${SYNAPSE_DATA}/data/tchapgouv.com.signing.key\"" "$OUT" + +# 5/7 Add bind_addresses to the main listener +yq eval -i '.listeners[0].bind_addresses = ["::1","127.0.0.1"]' "$OUT" + +# 6/7 Unused configuration +yq eval -i 'del(.instance_map)' "$OUT" +yq eval -i 'del(.redis)' "$OUT" +yq eval -i 'del(.background_updates)' "$OUT" +yq eval -i '.send_federation = false' "$OUT" +yq eval -i '.report_stats = false' "$OUT" +yq eval -i '.suppress_key_server_warning = true' "$OUT" + +echo "==> homeserver.yaml generated!" + +# 7/7 Build log_config.yaml +yq eval -i ".handlers.file.filename = \"${SYNAPSE_DATA}/data/homeserver.log\"" "$OUT2" + +echo "==> log_config.yaml generated!" diff --git a/synapse/api/auth/mas.py b/synapse/api/auth/mas.py index 79c15a53296..fabc591693f 100644 --- a/synapse/api/auth/mas.py +++ b/synapse/api/auth/mas.py @@ -28,6 +28,7 @@ from synapse.api.auth.base import BaseAuth from synapse.api.errors import ( AuthError, + Codes, #:tchap: HttpResponseException, InvalidClientTokenError, SynapseError, @@ -106,6 +107,7 @@ def __init__(self, hs: "HomeServer"): self.server_name = hs.hostname self._clock = hs.get_clock() self._config = hs.config.mas + self._account_validity_handler = hs.get_account_validity_handler() #:tchap: self._http_client = hs.get_proxied_http_client() self._rust_http_client = HttpClient( @@ -140,7 +142,7 @@ def __init__(self, hs: "HomeServer"): clock=self._clock, name="mas_token_introspection", server_name=self.server_name, - timeout=Duration(minutes=2), + timeout=Duration(minutes=20), # don't log because the keys are access tokens enable_logging=False, ) @@ -291,6 +293,22 @@ async def get_user_by_req( allow_expired=allow_expired, ) + #:tchap: + # Deny the request if the user account has expired. + if not allow_expired: + if await self._account_validity_handler.is_user_expired( + requester.user.to_string() + ): + # Raise the error if either an account validity module has determined + # the account has expired, or the legacy account validity + # implementation is enabled and determined the account has expired + raise AuthError( + 403, + "User account has expired", + errcode=Codes.EXPIRED_ACCOUNT, + ) + #:tchap: end + await self._record_request(request, requester) request.requester = requester diff --git a/synapse/api/constants.py b/synapse/api/constants.py index 21139a3867a..1c83adabbe6 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -42,7 +42,7 @@ # # in short, we somewhat arbitrarily limit requests to 200 * 64K (about 12.5M) # -MAX_REQUEST_SIZE = 200 * MAX_PDU_SIZE +MAX_REQUEST_SIZE = 1000 * MAX_PDU_SIZE # Max/min size of ints in canonical JSON CANONICALJSON_MAX_INT = (2**53) - 1 diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index a8c9305704e..59bfbdac98a 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -611,3 +611,10 @@ def read_config( # Note that sticky events persisted before this feature is enabled will not be # considered sticky by the local homeserver. self.msc4354_enabled: bool = experimental.get("msc4354_enabled", False) + + # MSC4258: Federated User Search + self.msc4258_enabled: bool = experimental.get("msc4258_enabled", False) + + self.msc4258_federation_search_timeout: int = experimental.get( + "msc4258_federation_search_timeout", 2000 + ) diff --git a/synapse/config/server.py b/synapse/config/server.py index ca94c224ea5..0d5ea7da861 100644 --- a/synapse/config/server.py +++ b/synapse/config/server.py @@ -698,6 +698,11 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None: ): raise ConfigError("allowed_avatar_mimetypes must be a list") + # The events to exclude from join state updates. + self.join_states_update_excludes = config.get("join_states_update_excludes", []) + if not isinstance(self.join_states_update_excludes, list): + raise ConfigError("join_states_update_excludes must be a list") + listeners = config.get("listeners", []) if not isinstance(listeners, list): raise ConfigError("Expected a list", ("listeners",)) diff --git a/synapse/federation/federation_client.py b/synapse/federation/federation_client.py index ba738ad65e7..c1729ed1bd3 100644 --- a/synapse/federation/federation_client.py +++ b/synapse/federation/federation_client.py @@ -42,6 +42,8 @@ import attr from prometheus_client import Counter +from twisted.internet import defer + from synapse.api.constants import Direction, EventContentFields, EventTypes, Membership from synapse.api.errors import ( CodeMessageException, @@ -69,6 +71,7 @@ from synapse.federation.transport.client import SendJoinResponse from synapse.http.client import is_unknown_endpoint from synapse.http.types import QueryParams +from synapse.logging.context import make_deferred_yieldable from synapse.logging.opentracing import SynapseTags, log_kv, set_tag, tag_args, trace from synapse.metrics import SERVER_NAME_LABEL from synapse.types import JsonDict, StrCollection, UserID, get_domain_from_id @@ -136,6 +139,9 @@ def __init__(self, hs: "HomeServer"): self._clock.looping_call(self._clear_tried_cache, Duration(minutes=1)) self.state = hs.get_state_handler() self.transport_layer = hs.get_federation_transport_client() + self.user_directory_search_timeout = ( + hs.config.experimental.msc4258_federation_search_timeout + ) self.server_name = hs.hostname self.signing_key = hs.signing_key @@ -1977,6 +1983,99 @@ def filter_user_id(user_id: str) -> bool: return filtered_statuses, filtered_failures + async def user_directory_search( + self, + requester: str, + destination: str, + search_term: str, + timeout: int, + limit: int = 10, + ) -> JsonDict: + """Search for users in the user directory of a remote server. + Args: + requester: The user that initiated the search. + destination: The server to query. + search_term: The search term to look for. + limit: Maximum number of results to return. + Returns: + The search results containing a list of users matching the search term. + """ + try: + response = await self.transport_layer.user_directory_search( + requester, destination, search_term, limit, timeout + ) + return response + except Exception as e: + # If something goes wrong, we still want to return what we have + logger.exception( + "Error searching user directory across federation[destination=%s] : %s", + destination, + e, + ) + return {"limited": False, "results": []} + + async def search_user_directory_across_federation( + self, + requester: str, + destinations: Collection[str], + search_term: str, + limit: int = 10, + ) -> JsonDict: + """Search for users across multiple federated servers. + Args: + requester: The user that initiated the search. + destinations: The servers to query. + search_term: The search term to look for. + limit: Maximum number of results to return per server. + Returns: + Combined search results from all servers. + """ + + if not destinations: + return {"limited": False, "results": []} + + # Query each server individually and collect results + combined_results = [] + limited = False + + # Create a list of deferreds to query each server + query_tasks = [] + for destination in destinations: + if not self._is_mine_server_name(destination): + # Convert coroutine to Deferred + deferred = defer.ensureDeferred( + self.user_directory_search( + requester, + destination, + search_term, + self.user_directory_search_timeout, + limit, + ) + ) + query_tasks.append(deferred) + + # Execute all queries in parallel + if query_tasks: + server_results = await make_deferred_yieldable( + defer.gatherResults( + query_tasks, + consumeErrors=True, + ) + ) + + # Process results from each server + for result in server_results: + if result.get("limited", False): + limited = True + combined_results.extend(result.get("results", [])) + + # Limit the total number of results + if len(combined_results) > limit: + combined_results = combined_results[:limit] + limited = True + + return {"limited": limited, "results": combined_results} + async def federation_download_media( self, destination: str, diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index eff6d637894..9c1b83277a4 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -86,7 +86,14 @@ from synapse.storage.databases.main.lock import Lock from synapse.storage.databases.main.roommember import extract_heroes_from_room_summary from synapse.storage.roommember import MemberSummary -from synapse.types import JsonDict, StateMap, UserID, get_domain_from_id +from synapse.types import ( + JsonDict, + JsonMapping, + StateMap, + UserID, + get_domain_from_id, + get_localpart_from_id, +) from synapse.util import unwrapFirstError from synapse.util.async_helpers import Linearizer, concurrently_execute, gather_results from synapse.util.caches.response_cache import ResponseCache @@ -1399,6 +1406,32 @@ async def check_server_matches_acl(self, server_name: str, room_id: str) -> None ): raise AuthError(code=403, msg="Server is banned from room") + async def on_user_directory_search_request( + self, requester_id: str, origin: str, search_term: str, limit: int + ) -> tuple[int, JsonMapping]: + """Handle a search request from a remote server + Args: + origin: The server that sent the search request + search_term: The term to search for + limit: Maximum number of results to return + Returns: + A tuple of (response code, response json) + """ + # Get the user directory handler + user_directory_handler = self.hs.get_user_directory_handler() + + # Use a dummy user_id built from the requester to perform the search + # This ensures we only return results that would be visible to users on that server + localpart = get_localpart_from_id(requester_id) + dummy_user_id = f"{localpart}:{origin}" + + # Perform the search + results = await user_directory_handler.search_users( + dummy_user_id, search_term, limit + ) + + return 200, results + class FederationHandlerRegistry: """Allows classes to register themselves as handlers for a given EDU or diff --git a/synapse/federation/transport/client.py b/synapse/federation/transport/client.py index 35d3c30c699..76a18120c64 100644 --- a/synapse/federation/transport/client.py +++ b/synapse/federation/transport/client.py @@ -864,6 +864,38 @@ async def get_account_status( destination=destination, path=path, data={"user_ids": user_ids} ) + async def user_directory_search( + self, + requester: str, + destination: str, + search_term: str, + limit: int, + timeout: int, + ) -> JsonDict: + """ + Search for users in the user directory of a remote server. + Args: + requester: The user that initiated the search. + destination: The server to query. + search_term: The search term to look for. + limit: Maximum number of results to return. + timeout: timeout in milliseconds to get the response from destination. + Returns: + The search results. + """ + path = _create_path( + FEDERATION_UNSTABLE_PREFIX, "/org.matrix.msc4258" + "/user_directory/search" + ) + content = {"requester": requester, "search_term": search_term, "limit": limit} + return await self.client.post_json( + destination, + path=path, + data=content, + # ignore backoff for user search as we will set a small user_directory_search_timeout + ignore_backoff=True, + timeout=timeout, + ) + async def download_media_r0( self, destination: str, diff --git a/synapse/federation/transport/server/federation.py b/synapse/federation/transport/server/federation.py index d783e6da518..fb597cdc046 100644 --- a/synapse/federation/transport/server/federation.py +++ b/synapse/federation/transport/server/federation.py @@ -47,7 +47,7 @@ from synapse.http.site import SynapseRequest from synapse.media._base import DEFAULT_MAX_TIMEOUT_MS, MAXIMUM_ALLOWED_MAX_TIMEOUT_MS from synapse.media.thumbnailer import ThumbnailProvider -from synapse.types import JsonDict +from synapse.types import JsonDict, JsonMapping, get_domain_from_id from synapse.util import SYNAPSE_VERSION from synapse.util.ratelimitutils import FederationRateLimiter @@ -893,6 +893,59 @@ async def on_GET( self.media_repo.mark_recently_accessed(None, media_id) +class FederationUserDirectorySearchServlet(BaseFederationServerServlet): + """ + Implements a federation API endpoint for searching a server's user directory. + POST /_matrix/federation/v3/user_directory/search + Request: + { + "requester": "@user:example.com", + "search_term": "search query", + "limit": 10 + } + Response: + { + "limited": false, + "results": [ + { + "user_id": "@user:example.com", + "display_name": "Display Name", + "avatar_url": "mxc://example.com/avatar", + "m.user_directory.visibility": "local" + } + ] + } + """ + + PATH = "/user_directory/search" + PREFIX = FEDERATION_UNSTABLE_PREFIX + "/org.matrix.msc4258" + RATELIMIT = True + + async def on_POST( + self, origin: str, content: JsonDict, query: dict[bytes, list[bytes]] + ) -> tuple[int, JsonMapping]: + requester = content.get("requester") + if requester is None or get_domain_from_id(requester) != origin: + raise SynapseError(400, "Missing or invalid requester", Codes.BAD_JSON) + search_term = content.get("search_term") + if not search_term or not isinstance(search_term, str): + raise SynapseError(400, "Missing or invalid search_term", Codes.BAD_JSON) + + # Not triggering any search for less than 3 chars + if search_term and len(search_term) < 4: + return 200, {"limited": False, "results": []} + + limit = content.get("limit", 10) + if not isinstance(limit, int): + raise SynapseError(400, "Invalid limit", Codes.BAD_JSON) + + limit = max(min(limit, 50), 0) # Clamp limit between 0 and 50 + + return await self.handler.on_user_directory_search_request( + requester, origin, search_term, limit + ) + + FEDERATION_SERVLET_CLASSES: tuple[type[BaseFederationServlet], ...] = ( FederationSendServlet, FederationEventServlet, @@ -925,4 +978,5 @@ async def on_GET( FederationV1SendKnockServlet, FederationMakeKnockServlet, FederationAccountStatusServlet, + FederationUserDirectorySearchServlet, ) diff --git a/synapse/handlers/profile.py b/synapse/handlers/profile.py index d123bcdd367..a9cd482c77d 100644 --- a/synapse/handlers/profile.py +++ b/synapse/handlers/profile.py @@ -88,6 +88,7 @@ def __init__(self, hs: "HomeServer"): self.allowed_avatar_mimetypes: list[str] | None = ( hs.config.server.allowed_avatar_mimetypes ) + self.join_states_update_excludes: set[str] = set(hs.config.server.join_states_update_excludes) self._is_mine_server_name = hs.is_mine_server_name @@ -740,6 +741,8 @@ async def _update_join_states_task( ) for room_id in room_ids: + if room_id in self.join_states_update_excludes: + continue handler = self.hs.get_room_member_handler() try: # Assume the target_user isn't a guest, diff --git a/synapse/handlers/user_directory.py b/synapse/handlers/user_directory.py index e5c4de03c5a..9c1fb5ead63 100644 --- a/synapse/handlers/user_directory.py +++ b/synapse/handlers/user_directory.py @@ -39,7 +39,7 @@ from synapse.storage.databases.main.state_deltas import StateDelta from synapse.storage.databases.main.user_directory import SearchResult from synapse.storage.roommember import ProfileInfo -from synapse.types import UserID +from synapse.types import JsonMapping, UserID from synapse.util.duration import Duration from synapse.util.metrics import Measure from synapse.util.retryutils import NotRetryingDestination @@ -115,6 +115,10 @@ def __init__(self, hs: "HomeServer"): self.show_locked_users = hs.config.userdirectory.show_locked_users self._spam_checker_module_callbacks = hs.get_module_api_callbacks().spam_checker self._hs = hs + self.federation_client = hs.get_federation_client() + self.federation_domain_whitelist = ( + hs.config.federation.federation_domain_whitelist + ) # The current position in the current_state_delta stream self.pos: int | None = None @@ -182,6 +186,39 @@ async def search_users( return results + async def get_federated_search_results( + self, user_id: str, search_term: str, limit: int + ) -> JsonMapping: + """Get search results from federated servers. + Args: + user_id: The user performing the search + search_term: The term to search for + limit: Maximum number of results to return + Returns: + Search results from federated servers + """ + + # Get the list of servers from federation + if not self.federation_domain_whitelist: + return {"limited": False, "results": []} + authorized_servers = set(self.federation_domain_whitelist.keys()) + # Remove our own server + authorized_servers.discard(self._hs.hostname) + servers = list(authorized_servers) + + # If no remote servers to query, return empty results + if not servers: + return {"limited": False, "results": []} + + # Query federated servers + federated_results = ( + await self.federation_client.search_user_directory_across_federation( + user_id, servers, search_term, limit + ) + ) + + return federated_results + def notify_new_event(self) -> None: """Called when there may be more deltas to process""" if not self.update_user_directory: diff --git a/synapse/handlers/worker_lock.py b/synapse/handlers/worker_lock.py index 1537a18cc05..be1819d37fd 100644 --- a/synapse/handlers/worker_lock.py +++ b/synapse/handlers/worker_lock.py @@ -217,6 +217,7 @@ def release_lock(self) -> None: """Release the lock (by resolving the deferred)""" if not self.deferred.called: with PreserveLoggingContext(): + self._retry_interval = 0.1 self.deferred.callback(None) async def __aenter__(self) -> None: @@ -306,6 +307,7 @@ def release_lock(self) -> None: """Release the lock (by resolving the deferred)""" if not self.deferred.called: with PreserveLoggingContext(): + self._retry_interval = 0.1 self.deferred.callback(None) async def __aenter__(self) -> None: diff --git a/synapse/module_api/callbacks/third_party_event_rules_callbacks.py b/synapse/module_api/callbacks/third_party_event_rules_callbacks.py index 65f5a6b1837..2509a097dff 100644 --- a/synapse/module_api/callbacks/third_party_event_rules_callbacks.py +++ b/synapse/module_api/callbacks/third_party_event_rules_callbacks.py @@ -286,7 +286,13 @@ async def check_event_allowed( # Ensure that the event is frozen, to make sure that the module is not tempted # to try to modify it. Any attempt to modify it at this point will invalidate # the hashes and signatures. - event.freeze() + #:tchap: + # unable to create the event once it is frozen + # quick fix, do not freeze the event before passing it to the external modules + # not ideal but for tchap we control what we do in the module + # should + #event.freeze() + #:tchap:end for callback in self._check_event_allowed_callbacks: try: diff --git a/synapse/rest/client/user_directory.py b/synapse/rest/client/user_directory.py index fa1342d0bfd..4762c157f03 100644 --- a/synapse/rest/client/user_directory.py +++ b/synapse/rest/client/user_directory.py @@ -52,9 +52,16 @@ def __init__(self, hs: "HomeServer"): clock=hs.get_clock(), cfg=hs.config.ratelimiting.rc_user_directory, ) + self.msc4258_enabled = self.hs.config.experimental.msc4258_enabled async def on_POST(self, request: SynapseRequest) -> tuple[int, JsonMapping]: - """Searches for users in directory + """Searches for users in directory, including federated results + + Request: + { + "search_term": "search query", + "limit": 10, + } Returns: dict of the form:: @@ -88,12 +95,69 @@ async def on_POST(self, request: SynapseRequest) -> tuple[int, JsonMapping]: except Exception: raise SynapseError(400, "`search_term` is required field") - results = await self.user_directory_handler.search_users( + # Not triggering any search for less than 3 chars if MSC4258 is enabled + if self.msc4258_enabled and search_term and len(search_term) < 4: + return 200, {"limited": False, "results": []} + + # Get local results first + local_results = await self.user_directory_handler.search_users( user_id, search_term, limit ) - return 200, results + # If MSC4258 is not enabled this should work as before + if not self.msc4258_enabled: + return 200, local_results + + # Return local result if we have reach limit (no need to call federation search) + if len(local_results) > limit: + return 200, local_results + + # Try to get federated results + federated_results = ( + await self.user_directory_handler.get_federated_search_results( + user_id, search_term, limit + ) + ) + + return 200, merge_search_results(local_results, federated_results, limit) def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: UserDirectorySearchRestServlet(hs).register(http_server) + + +def merge_search_results( + local_results: JsonMapping, federated_results: JsonMapping, limit: int +) -> JsonMapping: + """ + Merge local results and federated results. + We prioritize the local result then federated results. + """ + concatenation = local_results["results"] + federated_results["results"] + + # Remove duplicates as local homeservers may know some of the federated users + seen = set() + results = [] + for user in concatenation: + if user["user_id"] not in seen: + seen.add(user["user_id"]) + results.append(user) + + limited = False + # Limit the total number of results + if len(results) > limit: + results = results[:limit] + limited = True + + # Sort results by display name (case insensitive) + results.sort( + key=lambda user: ( + user.get("display_name", "").lower() if user.get("display_name") else "", + user.get("user_id", ""), + ) + ) + + return { + "limited": limited, + "results": results, + } diff --git a/tchap/README_TCHAP.md b/tchap/README_TCHAP.md new file mode 100644 index 00000000000..ffeee599f72 --- /dev/null +++ b/tchap/README_TCHAP.md @@ -0,0 +1,21 @@ +## To run + +1. Execute [Tchap Docker Integration](https://github.com/tchapgouv/tchap-docker-integration) +- COMPOSE_PROFILES should have `with_local_synapse` +2. Install Synapse Dependencies: +``` +pip install pipx +pipx install poetry +poetry install --extras all +``` +3. In `build_conf.sh`, set the path of the Tchap Docker Integration repo in `$DOCKER_DEMO_REPO` +4. Execute the build of the homeserver configuration file +``` +./build_conf.sh +``` +5. Execute Synapse +``` +poetry run python -m synapse.app.homeserver -c homeserver.yaml +``` + +NOTE : this version use Sqlite database \ No newline at end of file diff --git a/tests/federation/test_federation_client.py b/tests/federation/test_federation_client.py index 0535aed1073..c07c605dacb 100644 --- a/tests/federation/test_federation_client.py +++ b/tests/federation/test_federation_client.py @@ -20,16 +20,19 @@ # from unittest import mock +from unittest.mock import AsyncMock import twisted.web.client from twisted.internet import defer from twisted.internet.testing import MemoryReactor +from synapse.api.errors import HttpResponseException from synapse.api.room_versions import RoomVersions from synapse.events import EventBase from synapse.rest import admin -from synapse.rest.client import login, room +from synapse.rest.client import login, register, room, user_directory from synapse.server import HomeServer +from synapse.types import JsonDict from synapse.util.clock import Clock from tests.test_utils import FakeResponse, event_injection @@ -41,6 +44,8 @@ class FederationClientTest(FederatingHomeserverTestCase): admin.register_servlets, room.register_servlets, login.register_servlets, + user_directory.register_servlets, + register.register_servlets, ] def prepare( @@ -59,6 +64,17 @@ def prepare( self.creator = f"@creator:{self.OTHER_SERVER_NAME}" self.test_room_id = "!room_id" + self.federation_client = homeserver.get_federation_client() + self.transport_layer = self.federation_client.transport_layer + + def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer: + """Create a homeserver with federation enabled and user directory enabled.""" + config = self.default_config() + config["user_directory"] = { + "enabled": True, + "search_all_users": True, + } + return self.setup_test_homeserver(config=config) def test_get_room_state(self) -> None: # mock up some events to use in the response. @@ -314,3 +330,189 @@ def test_backfill_invalid_signature_records_failed_pull_attempts( # This is 2 because it failed once from `self.OTHER_SERVER_NAME` and the # other from "yet.another.server" self.assertEqual(backfill_num_attempts, 2) + + def test_user_directory_search(self) -> None: + """Test that the federation client correctly handles user directory search requests.""" + # Mock the transport layer's user_directory_search method + mock_results = { + "limited": False, + "results": [ + { + "user_id": "@user:other.example.com", + "display_name": "Test User", + "avatar_url": "mxc://example.com/avatar", + } + ], + } + self.transport_layer.user_directory_search = AsyncMock( # type: ignore[method-assign] + return_value=mock_results + ) + + # Call the federation client method + result = self.get_success( + self.federation_client.user_directory_search( + "@requester:example.com", "other.example.com", "test", 2000, 10 + ) + ) + + # Check that the result is correct + self.assertEqual(result, mock_results) + + # Check that user_directory_search was called with the correct arguments + self.transport_layer.user_directory_search.assert_called_once_with( + "@requester:example.com", "other.example.com", "test", 10, 2000 + ) + + def test_user_directory_search_endpoint_not_found(self) -> None: + """Test that the federation client handles 404 responses correctly.""" + # Mock the transport layer to raise a 404 error + self.transport_layer.user_directory_search = AsyncMock( # type: ignore[method-assign] + side_effect=HttpResponseException( + 404, "Not Found", b'{"errcode": "M_NOT_FOUND"}' + ) + ) + + # Call the federation client method + result = self.get_success( + self.federation_client.user_directory_search( + "@requester:example.com", "other.example.com", "test", 10 + ) + ) + + # Check that the result is an empty result set + self.assertEqual(result, {"limited": False, "results": []}) + + def test_search_user_directory_across_federation(self) -> None: + """Test that the federation client correctly handles searching across multiple servers.""" + + # Mock the user_directory_search method to return different results for different servers + async def mock_user_directory_search( + requester: str, destination: str, search_term: str, timeout: int, limit: int + ) -> JsonDict: + if destination == "server1.example.com": + return { + "limited": False, + "results": [ + { + "user_id": "@user1:server1.example.com", + "display_name": "User 1", + "avatar_url": "mxc://example.com/avatar1", + } + ], + } + elif destination == "server2.example.com": + return { + "limited": False, + "results": [ + { + "user_id": "@user2:server2.example.com", + "display_name": "User 2", + "avatar_url": "mxc://example.com/avatar2", + } + ], + } + else: + return {"limited": False, "results": []} + + self.federation_client.user_directory_search = AsyncMock( # type: ignore[method-assign] + side_effect=mock_user_directory_search + ) + + # Call the federation client method + result = self.get_success( + self.federation_client.search_user_directory_across_federation( + "@requester:example.com", + ["server1.example.com", "server2.example.com"], + "test", + 10, + ) + ) + + # Check that the result contains results from both servers + self.assertEqual( + result, + { + "limited": False, + "results": [ + { + "user_id": "@user1:server1.example.com", + "display_name": "User 1", + "avatar_url": "mxc://example.com/avatar1", + }, + { + "user_id": "@user2:server2.example.com", + "display_name": "User 2", + "avatar_url": "mxc://example.com/avatar2", + }, + ], + }, + ) + + def test_search_user_directory_across_federation_with_limit(self) -> None: + """Test that the federation client correctly applies limits when searching across multiple servers.""" + + # Mock the user_directory_search method to return many results + async def mock_user_directory_search( + requester: str, destination: str, search_term: str, timeout: int, limit: int + ) -> JsonDict: + return { + "limited": False, + "results": [ + { + "user_id": f"@user{i}:{destination}", + "display_name": f"User {i}", + "avatar_url": f"mxc://example.com/avatar{i}", + } + for i in range(10) + ], + } + + self.federation_client.user_directory_search = AsyncMock( # type: ignore[method-assign] + side_effect=mock_user_directory_search + ) + + # Call the federation client method with a limit of 5 + result = self.get_success( + self.federation_client.search_user_directory_across_federation( + "@requester:example.com", + ["server1.example.com", "server2.example.com"], + "test", + 5, + ) + ) + + # Check that the result is limited to 5 users + self.assertEqual(len(result["results"]), 5) + self.assertTrue(result["limited"]) + + def test_search_user_directory_across_federation_empty_destinations(self) -> None: + """Test that the federation client handles empty destination lists correctly.""" + # Call the federation client method with an empty destination list + result = self.get_success( + self.federation_client.search_user_directory_across_federation( + "@requester:example.com", [], "test", 10 + ) + ) + + # Check that the result is an empty result set + self.assertEqual(result, {"limited": False, "results": []}) + + def test_search_user_directory_across_federation_server_error(self) -> None: + """Test that the federation client handles server errors correctly.""" + # Mock the _try_destination_list method to return None (indicating all servers failed) + self.federation_client.transport_layer.user_directory_search = AsyncMock( # type: ignore[method-assign] + side_effect=HttpResponseException(500, "Internal Server Error", b"{}") + ) + + # Call the federation client method + result = self.get_success( + self.federation_client.search_user_directory_across_federation( + "@requester:example.com", + ["server1.example.com", "server2.example.com"], + "test", + 10, + ) + ) + + # Check that the result is an empty result set + self.assertEqual(result, {"limited": False, "results": []}) diff --git a/tests/federation/test_federation_server.py b/tests/federation/test_federation_server.py index 275e5dfa1d8..b7304d1612a 100644 --- a/tests/federation/test_federation_server.py +++ b/tests/federation/test_federation_server.py @@ -55,6 +55,14 @@ class FederationServerTests(unittest.FederatingHomeserverTestCase): login.register_servlets, ] + def default_config(self) -> JsonDict: + config = super().default_config() + config["user_directory"] = { + "enabled": True, + "search_all_users": True, + } + return config + @parameterized.expand([(b"",), (b"foo",), (b'{"limit": Infinity}',)]) def test_bad_request(self, query_content: bytes) -> None: """ @@ -93,6 +101,64 @@ async def failing_handler(_origin: str, _content: JsonDict) -> None: ) self.assertEqual(500, channel.code, channel.result) + def test_federation_user_directory_search_servlet(self) -> None: + """Test that the federation user directory search servlet works correctly.""" + self.register_user("userlambda", "password") + + # Make a request to the servlet + channel = self.make_signed_federation_request( + "POST", + "/_matrix/federation/unstable/org.matrix.msc4258/user_directory/search", + content={ + "requester": "@requester:other.example.com", + "search_term": "user", + "limit": 10, + }, + ) + + # Check that the response is correct + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body.get("limited", None), False) + results = channel.json_body.get("results", []) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].get("user_id"), "@userlambda:test") + + def test_federation_user_directory_search_servlet_invalid_request(self) -> None: + """Test that the federation user directory search servlet rejects invalid requests.""" + self.register_user("user", "password") + + # Make a request with missing search_term + channel = self.make_signed_federation_request( + "POST", + "/_matrix/federation/unstable/org.matrix.msc4258/user_directory/search", + content={"limit": 10}, + ) + + # Check that the response is an error + self.assertEqual(channel.code, 400) + self.assertEqual(channel.json_body["errcode"], "M_BAD_JSON") + + def test_federation_user_directory_search_servlet_no_results(self) -> None: + """Test that the federation user directory search servlet works correctly.""" + self.register_user("user", "password") + + # Make a request to the servlet + channel = self.make_signed_federation_request( + "POST", + "/_matrix/federation/unstable/org.matrix.msc4258/user_directory/search", + content={ + "requester": "@requester:other.example.com", + "search_term": "nonexistent", + "limit": 10, + }, + ) + + # Check that the response is correct + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body.get("limited", None), False) + results = channel.json_body.get("results", []) + self.assertEqual(len(results), 0) + def _create_acl_event(content: JsonDict) -> EventBase: return make_event_from_dict( diff --git a/tests/rest/client/test_user_directory.py b/tests/rest/client/test_user_directory.py new file mode 100644 index 00000000000..3324ab6837b --- /dev/null +++ b/tests/rest/client/test_user_directory.py @@ -0,0 +1,489 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2025 New Vector, Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# +# + +from typing import Any +from unittest.mock import AsyncMock + +from twisted.internet import defer +from twisted.test.proto_helpers import MemoryReactor + +import synapse.rest.admin +from synapse.api.errors import RequestSendFailed +from synapse.rest.client import login, register, user_directory +from synapse.server import HomeServer +from synapse.types import JsonMapping +from synapse.util.clock import Clock + +from tests import unittest + + +class UserDirectorySearchTestCase(unittest.HomeserverTestCase): + servlets = [ + user_directory.register_servlets, + synapse.rest.admin.register_servlets, + login.register_servlets, + register.register_servlets, + ] + + user_directory_handler: Any + search_users_mock: Any + bob_token: Any + alice_token: Any + + def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer: + config = self.default_config() + config["user_directory"] = {"enabled": True, "search_all_users": True} + config["experimental_features"] = {"msc4258_enabled": True} + config["federation_domain_whitelist"] = ["test", "test2", "test3"] + # Override with additional config + if self._extra_config: + config.update(self._extra_config) + return self.setup_test_homeserver(config=config) + + def prepare( + self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer + ) -> None: + # Initialize instance variables + self.user_directory_handler = homeserver.get_user_directory_handler() + + # Create test users + self.register_user("alice", "password") + self.register_user("bob", "password") + self.register_user("charlie", "password") + + # Mock the search_users method to return controlled results + self.search_users_mock = AsyncMock() + self.user_directory_handler.search_users = self.search_users_mock + self.federation_client_user_directory_search_mock = AsyncMock() + self.user_directory_handler.federation_client.transport_layer.user_directory_search = self.federation_client_user_directory_search_mock + + # Create access tokens for testing + self.bob_token = self.get_success( + homeserver.get_auth_handler().create_access_token_for_user_id( + "@bob:test", device_id=None, valid_until_ms=None + ) + ) + self.alice_token = self.get_success( + homeserver.get_auth_handler().create_access_token_for_user_id( + "@alice:test", device_id=None, valid_until_ms=None + ) + ) + + def test_search_users(self) -> None: + """Test that a search without a token works as expected.""" + # Set up the mock to return some results + self.search_users_mock.return_value = { + "limited": False, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + }, + ], + } + + async def mock_federation( + requester: str, destination: str, search_term: str, limit: int, timeout: int + ) -> JsonMapping: + if destination == "test2": + return { + "limited": False, + "results": [ + { + "user_id": "@john-marvelous:test2", + "display_name": "John Marvelous", + "avatar_url": "mxc://test2/john-marvelous", + } + ], + } + else: + return {"limited": False, "results": []} + + self.federation_client_user_directory_search_mock.side_effect = mock_federation + + # Make a request to the search endpoint + channel = self.make_request( + "POST", + "/_matrix/client/v3/user_directory/search", + {"search_term": "alice"}, + access_token=self.bob_token, + ) + + # Check that the response is correct + self.assertEqual(channel.code, 200) + self.assertEqual( + channel.json_body, + { + "limited": False, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + }, + { + "user_id": "@john-marvelous:test2", + "display_name": "John Marvelous", + "avatar_url": "mxc://test2/john-marvelous", + }, + ], + }, + ) + + # Check that the search_users method was called with the correct arguments + self.search_users_mock.assert_called_once_with("@bob:test", "alice", 10) + self.federation_client_user_directory_search_mock.assert_any_call( + "@bob:test", "test2", "alice", 10, 2000 + ) + self.federation_client_user_directory_search_mock.assert_any_call( + "@bob:test", "test3", "alice", 10, 2000 + ) + self.assertEqual( + self.federation_client_user_directory_search_mock.call_count, 2 + ) + + def test_search_users_when_local_hs_has_discovered_same_user(self) -> None: + """Test that a search without a token works as expected.""" + # Set up the mock to return some results + self.search_users_mock.return_value = { + "limited": False, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + }, + # Local homeserver may have discovered this user + { + "user_id": "@john-marvelous:test2", + "display_name": "John Marvelous", + "avatar_url": "mxc://test2/john-marvelous", + }, + ], + } + + async def mock_federation( + requester: str, destination: str, search_term: str, timeout: int, limit: int + ) -> JsonMapping: + if destination == "test2": + return { + "limited": False, + "results": [ + { + "user_id": "@john-marvelous:test2", + "display_name": "John Marvelous", + "avatar_url": "mxc://test2/john-marvelous", + } + ], + } + else: + return {"limited": False, "results": []} + + self.federation_client_user_directory_search_mock.side_effect = mock_federation + + # Make a request to the search endpoint + channel = self.make_request( + "POST", + "/_matrix/client/v3/user_directory/search", + {"search_term": "alice"}, + access_token=self.bob_token, + ) + + # Check that the response is correct + self.assertEqual(channel.code, 200) + self.assertEqual( + channel.json_body, + { + "limited": False, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + }, + { + "user_id": "@john-marvelous:test2", + "display_name": "John Marvelous", + "avatar_url": "mxc://test2/john-marvelous", + }, + ], + }, + ) + + # Check that the search_users method was called with the correct arguments + self.search_users_mock.assert_called_once_with("@bob:test", "alice", 10) + self.federation_client_user_directory_search_mock.assert_any_call( + "@bob:test", "test2", "alice", 10, 2000 + ) + self.federation_client_user_directory_search_mock.assert_any_call( + "@bob:test", "test3", "alice", 10, 2000 + ) + self.assertEqual( + self.federation_client_user_directory_search_mock.call_count, 2 + ) + + def test_search_users_with_timeout(self) -> None: + """Test that a search without a token works as expected.""" + # Set up the mock to return some results + self.search_users_mock.return_value = { + "limited": False, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + } + ], + } + + async def mock_federation( + requester: str, destination: str, search_term: str, timeout: int, limit: int + ) -> JsonMapping: + if destination == "test2": + return { + "limited": False, + "results": [ + { + "user_id": "@john-marvelous:test2", + "display_name": "John Marvelous", + "avatar_url": "mxc://test2/john-marvelous", + } + ], + } + else: + raise RequestSendFailed( + defer.TimeoutError("Timed out after 2 seconds"), + can_retry=False, + ) + + self.federation_client_user_directory_search_mock.side_effect = mock_federation + + # Make a request to the search endpoint + channel = self.make_request( + "POST", + "/_matrix/client/v3/user_directory/search", + {"search_term": "alice"}, + access_token=self.bob_token, + ) + + # Check that the response is correct + self.assertEqual(channel.code, 200) + self.assertEqual( + channel.json_body, + { + "limited": False, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + }, + { + "user_id": "@john-marvelous:test2", + "display_name": "John Marvelous", + "avatar_url": "mxc://test2/john-marvelous", + }, + ], + }, + ) + + # Check that the search_users method was called with the correct arguments + self.search_users_mock.assert_called_once_with("@bob:test", "alice", 10) + self.federation_client_user_directory_search_mock.assert_any_call( + "@bob:test", "test2", "alice", 10, 2000 + ) + self.federation_client_user_directory_search_mock.assert_any_call( + "@bob:test", "test3", "alice", 10, 2000 + ) + self.assertEqual( + self.federation_client_user_directory_search_mock.call_count, 2 + ) + + def test_search_with_limit(self) -> None: + """Test that a search with a limit works as expected.""" + # Set up the mock to return some results + self.search_users_mock.return_value = { + "limited": True, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + } + ], + } + self.federation_client_user_directory_search_mock.return_value = { + "limited": False, + "results": [], + } + + # Make a request to the search endpoint with a limit + channel = self.make_request( + "POST", + "/_matrix/client/v3/user_directory/search", + {"search_term": "alice", "limit": 5}, + access_token=self.bob_token, + ) + + # Check that the response is correct + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body["limited"], False) + self.assertEqual(len(channel.json_body["results"]), 1) + + # Check that the search_users method was called with the correct arguments + self.search_users_mock.assert_called_once_with("@bob:test", "alice", 5) + + def test_multiple_pending_searches(self) -> None: + """Test that multiple pending searches with the same token all get notified.""" + # Set up the mock to return some results for the initial search + self.search_users_mock.return_value = { + "limited": True, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + } + ], + } + self.federation_client_user_directory_search_mock.return_value = { + "limited": False, + "results": [], + } + + # Make an initial request + self.make_request( + "POST", + "/_matrix/client/v3/user_directory/search", + {"search_term": "test"}, + access_token=self.bob_token, + ) + + # Set up the mock for the follow-up searches with token + # We'll use a side effect to return different results for each call + results_for_bob = { + "limited": False, + "results": [ + { + "user_id": "@charlie:test", + "display_name": "Charlie", + "avatar_url": None, + } + ], + } + + results_for_alice = { + "limited": False, + "results": [ + { + "user_id": "@charlie:test", + "display_name": "Charlie", + "avatar_url": None, + } + ], + } + + # Mock the get_federated_search_results method to return results + get_federated_results_mock = AsyncMock() + get_federated_results_mock.side_effect = [results_for_bob, results_for_alice] + self.user_directory_handler.get_federated_search_results = ( + get_federated_results_mock + ) + + # Make multiple requests to the search endpoint with the same token + channel1 = self.make_request( + "POST", + "/_matrix/client/v3/user_directory/search", + {"search_term": "test"}, + access_token=self.bob_token, + ) + + channel2 = self.make_request( + "POST", + "/_matrix/client/v3/user_directory/search", + {"search_term": "test"}, + access_token=self.alice_token, + ) + + # Check that both responses are correct + self.assertEqual(channel1.code, 200) + self.assertEqual(channel2.code, 200) + + # Check that get_federated_search_results was called twice, once for each request + self.assertEqual(get_federated_results_mock.call_count, 2) + + @unittest.override_config({"experimental_features": {"msc4258_enabled": False}}) + def test_search_users_if_msc4258_is_not_enabled(self) -> None: + """Test that a search without a token works as expected.""" + # Set up the mock to return some results + self.search_users_mock.return_value = { + "limited": False, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + } + ], + } + + async def mock_federation( + requester: str, destination: str, search_term: str, timeout: int, limit: int + ) -> JsonMapping: + if destination == "test2": + return { + "limited": False, + "results": [ + { + "user_id": "@john-marvelous:test2", + "display_name": "John Marvelous", + "avatar_url": "mxc://test2/john-marvelous", + } + ], + } + else: + return {"limited": False, "results": []} + + self.federation_client_user_directory_search_mock.side_effect = mock_federation + + # Make a request to the search endpoint + channel = self.make_request( + "POST", + "/_matrix/client/v3/user_directory/search", + {"search_term": "alice"}, + access_token=self.bob_token, + ) + + # Check that the response is correct + self.assertEqual(channel.code, 200) + self.assertEqual( + channel.json_body, + { + "limited": False, + "results": [ + { + "user_id": "@alice:test", + "display_name": "Alice", + "avatar_url": None, + } + ], + }, + ) + + # Check that the search_users method was called with the correct arguments + self.search_users_mock.assert_called_once_with("@bob:test", "alice", 10) + self.federation_client_user_directory_search_mock.assert_not_called() + self.federation_client_user_directory_search_mock.assert_not_called()