synapse/tests/rest/client/test_thread_subscriptions.py
Eric Eastwood 5a9ca1e3d9
Introduce Clock.call_when_running(...) to include logcontext by default (#18944)
Introduce `Clock.call_when_running(...)` to wrap startup code in a
logcontext, ensuring we can identify which server generated the logs.

Background:

>  Ideally, nothing from the Synapse homeserver would be logged against the `sentinel` 
>  logcontext as we want to know which server the logs came from. In practice, this is not 
>  always the case yet especially outside of request handling. 
>   
>  Global things outside of Synapse (e.g. Twisted reactor code) should run in the 
>  `sentinel` logcontext. It's only when it calls into application code that a logcontext 
>  gets activated. This means the reactor should be started in the `sentinel` logcontext, 
>  and any time an awaitable yields control back to the reactor, it should reset the 
>  logcontext to be the `sentinel` logcontext. This is important to avoid leaking the 
>  current logcontext to the reactor (which would then get picked up and associated with 
>  the next thing the reactor does). 
>
> *-- `docs/log_contexts.md`

Also adds a lint to prefer `Clock.call_when_running(...)` over
`reactor.callWhenRunning(...)`

Part of https://github.com/element-hq/synapse/issues/18905
2025-09-22 10:27:59 -05:00

352 lines
14 KiB
Python

#
# 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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
from http import HTTPStatus
from twisted.internet.testing import MemoryReactor
from synapse.api.errors import Codes
from synapse.rest import admin
from synapse.rest.client import login, profile, room, thread_subscriptions
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock
from tests import unittest
PREFIX = "/_matrix/client/unstable/io.element.msc4306/rooms"
class ThreadSubscriptionsTestCase(unittest.HomeserverTestCase):
servlets = [
admin.register_servlets_for_client_rest_resource,
login.register_servlets,
profile.register_servlets,
room.register_servlets,
thread_subscriptions.register_servlets,
]
def default_config(self) -> JsonDict:
config = super().default_config()
config["experimental_features"] = {"msc4306_enabled": True}
return config
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.user_id = self.register_user("user", "password")
self.token = self.login("user", "password")
self.other_user_id = self.register_user("other_user", "password")
self.other_token = self.login("other_user", "password")
# Create a room and send a message to use as a thread root
self.room_id = self.helper.create_room_as(self.user_id, tok=self.token)
self.helper.join(self.room_id, self.other_user_id, tok=self.other_token)
(self.root_event_id,) = self.helper.send_messages(
self.room_id, 1, tok=self.token
)
# Send a message in the thread
self.threaded_events = self.helper.send_messages(
self.room_id,
2,
content_fn=lambda idx: {
"body": f"Thread message {idx}",
"msgtype": "m.text",
"m.relates_to": {
"rel_type": "m.thread",
"event_id": self.root_event_id,
},
},
tok=self.token,
)
def test_get_thread_subscription_unsubscribed(self) -> None:
"""Test retrieving thread subscription when not subscribed."""
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND)
self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND")
def test_get_thread_subscription_nonexistent_thread(self) -> None:
"""Test retrieving subscription settings for a nonexistent thread."""
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/$nonexistent:example.org/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND)
self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND")
def test_get_thread_subscription_no_access(self) -> None:
"""Test that a user can't get thread subscription for a thread they can't access."""
self.register_user("no_access", "password")
no_access_token = self.login("no_access", "password")
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=no_access_token,
)
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND)
self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND")
def test_subscribe_manual_then_automatic(self) -> None:
"""Test subscribing to a thread, first a manual subscription then an automatic subscription.
The manual subscription wins over the automatic one."""
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
{},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
# Assert the subscription was saved
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
self.assertEqual(channel.json_body, {"automatic": False})
# Now also register an automatic subscription; it should not
# override the manual subscription
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
{"automatic": self.threaded_events[0]},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
# Assert the manual subscription was not overridden
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
self.assertEqual(channel.json_body, {"automatic": False})
def test_subscribe_automatic_then_manual(self) -> None:
"""Test subscribing to a thread, first an automatic subscription then a manual subscription.
The manual subscription wins over the automatic one."""
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
{
"automatic": self.threaded_events[0],
},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.text_body)
# Assert the subscription was saved
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
self.assertEqual(channel.json_body, {"automatic": True})
# Now also register a manual subscription
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
{},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
# Assert the manual subscription was not overridden
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
self.assertEqual(channel.json_body, {"automatic": False})
def test_unsubscribe(self) -> None:
"""Test subscribing to a thread, then unsubscribing."""
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
{
"automatic": self.threaded_events[0],
},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
# Assert the subscription was saved
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
self.assertEqual(channel.json_body, {"automatic": True})
channel = self.make_request(
"DELETE",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND)
self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND")
def test_set_thread_subscription_nonexistent_thread(self) -> None:
"""Test setting subscription settings for a nonexistent thread."""
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/$nonexistent:example.org/subscription",
{},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND)
self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND")
def test_set_thread_subscription_no_access(self) -> None:
"""Test that a user can't set thread subscription for a thread they can't access."""
self.register_user("no_access2", "password")
no_access_token = self.login("no_access2", "password")
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
{},
access_token=no_access_token,
)
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND)
self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND")
def test_invalid_body(self) -> None:
"""Test that sending invalid subscription settings is rejected."""
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
# non-Event ID `automatic`
{"automatic": True},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST)
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
# non-Event ID `automatic`
{"automatic": "$malformedEventId"},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST)
def test_auto_subscribe_cause_event_not_in_thread(self) -> None:
"""
Test making an automatic subscription, where the cause event is not
actually in the thread.
This is an error.
"""
(unrelated_event_id,) = self.helper.send_messages(
self.room_id, 1, tok=self.token
)
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
{"automatic": unrelated_event_id},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.text_body)
self.assertEqual(channel.json_body["errcode"], Codes.MSC4306_NOT_IN_THREAD)
def test_auto_resubscription_conflict(self) -> None:
"""
Test that an automatic subscription that conflicts with an unsubscription
is skipped.
"""
# Reuse the test that subscribes and unsubscribes
self.test_unsubscribe()
# Now no matter which event we present as the cause of an automatic subscription,
# the automatic subscription is skipped.
# This is because the unsubscription happened after all of the events.
for event in self.threaded_events:
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
{
"automatic": event,
},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.CONFLICT, channel.text_body)
self.assertEqual(
channel.json_body["errcode"],
Codes.MSC4306_CONFLICTING_UNSUBSCRIPTION,
channel.text_body,
)
# Check the subscription was not made
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.NOT_FOUND)
# But if a new event is sent after the unsubscription took place,
# that one can be used for an automatic subscription
(later_event_id,) = self.helper.send_messages(
self.room_id,
1,
content_fn=lambda _: {
"body": "Thread message after unsubscription",
"msgtype": "m.text",
"m.relates_to": {
"rel_type": "m.thread",
"event_id": self.root_event_id,
},
},
tok=self.token,
)
channel = self.make_request(
"PUT",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
{
"automatic": later_event_id,
},
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.text_body)
# Check the subscription was made
channel = self.make_request(
"GET",
f"{PREFIX}/{self.room_id}/thread/{self.root_event_id}/subscription",
access_token=self.token,
)
self.assertEqual(channel.code, HTTPStatus.OK)
self.assertEqual(channel.json_body, {"automatic": True})