mirror of
https://github.com/element-hq/synapse.git
synced 2025-11-14 00:01:55 -05:00
aka PEP 585, added in Python 3.9 - https://peps.python.org/pep-0585/ - https://docs.astral.sh/ruff/rules/non-pep585-annotation/
506 lines
17 KiB
Python
506 lines
17 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>.
|
|
#
|
|
# Originally licensed under the Apache License, Version 2.0:
|
|
# <http://www.apache.org/licenses/LICENSE-2.0>.
|
|
#
|
|
# [This file includes modifications made by New Vector Limited]
|
|
#
|
|
#
|
|
import itertools
|
|
from typing import Optional, Sequence
|
|
|
|
from twisted.internet import defer
|
|
from twisted.test.proto_helpers import MemoryReactor
|
|
|
|
from synapse.api.constants import EventTypes, JoinRules, Membership
|
|
from synapse.api.room_versions import RoomVersions
|
|
from synapse.events import EventBase
|
|
from synapse.federation.federation_base import event_from_pdu_json
|
|
from synapse.rest import admin
|
|
from synapse.rest.client import login, room
|
|
from synapse.server import HomeServer
|
|
from synapse.state import StateResolutionStore
|
|
from synapse.state.v2 import (
|
|
StateResolutionStore as StateResolutionStoreInterface,
|
|
_get_auth_chain_difference,
|
|
_seperate,
|
|
resolve_events_with_store,
|
|
)
|
|
from synapse.types import StateMap
|
|
from synapse.util.clock import Clock
|
|
|
|
from tests import unittest
|
|
from tests.state.test_v2 import TestStateResolutionStore
|
|
|
|
ALICE = "@alice:example.com"
|
|
BOB = "@bob:example.com"
|
|
CHARLIE = "@charlie:example.com"
|
|
EVELYN = "@evelyn:example.com"
|
|
ZARA = "@zara:example.com"
|
|
|
|
ROOM_ID = "!test:example.com"
|
|
|
|
MEMBERSHIP_CONTENT_JOIN = {"membership": Membership.JOIN}
|
|
MEMBERSHIP_CONTENT_INVITE = {"membership": Membership.INVITE}
|
|
MEMBERSHIP_CONTENT_LEAVE = {"membership": Membership.LEAVE}
|
|
|
|
|
|
ORIGIN_SERVER_TS = 0
|
|
|
|
|
|
def monotonic_timestamp() -> int:
|
|
global ORIGIN_SERVER_TS
|
|
ORIGIN_SERVER_TS += 1
|
|
return ORIGIN_SERVER_TS
|
|
|
|
|
|
class FakeClock:
|
|
async def sleep(self, duration_ms: float) -> None:
|
|
defer.succeed(None)
|
|
|
|
|
|
class StateResV21TestCase(unittest.HomeserverTestCase):
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def prepare(
|
|
self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer
|
|
) -> None:
|
|
self.state = self.hs.get_state_handler()
|
|
persistence = self.hs.get_storage_controllers().persistence
|
|
assert persistence is not None
|
|
self._persistence = persistence
|
|
self._state_storage_controller = self.hs.get_storage_controllers().state
|
|
self._state_deletion = self.hs.get_datastores().state_deletion
|
|
self.store = self.hs.get_datastores().main
|
|
|
|
self.register_user("user", "pass")
|
|
self.token = self.login("user", "pass")
|
|
|
|
def test_state_reset_replay_conflicted_subgraph(self) -> None:
|
|
# 1. Alice creates a room.
|
|
e1_create = self.create_event(
|
|
EventTypes.Create,
|
|
"",
|
|
sender=ALICE,
|
|
content={"creator": ALICE},
|
|
auth_events=[],
|
|
)
|
|
# 2. Alice joins it.
|
|
e2_ma = self.create_event(
|
|
EventTypes.Member,
|
|
ALICE,
|
|
sender=ALICE,
|
|
content=MEMBERSHIP_CONTENT_JOIN,
|
|
auth_events=[],
|
|
prev_events=[e1_create.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 3. Alice is the creator
|
|
e3_power1 = self.create_event(
|
|
EventTypes.PowerLevels,
|
|
"",
|
|
sender=ALICE,
|
|
content={"users": {}},
|
|
auth_events=[e2_ma.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 4. Alice sets the room to public.
|
|
e4_jr = self.create_event(
|
|
EventTypes.JoinRules,
|
|
"",
|
|
sender=ALICE,
|
|
content={"join_rule": JoinRules.PUBLIC},
|
|
auth_events=[e2_ma.event_id, e3_power1.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 5. Bob joins the room.
|
|
e5_mb = self.create_event(
|
|
EventTypes.Member,
|
|
BOB,
|
|
sender=BOB,
|
|
content=MEMBERSHIP_CONTENT_JOIN,
|
|
auth_events=[e3_power1.event_id, e4_jr.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 6. Charlie joins the room.
|
|
e6_mc = self.create_event(
|
|
EventTypes.Member,
|
|
CHARLIE,
|
|
sender=CHARLIE,
|
|
content=MEMBERSHIP_CONTENT_JOIN,
|
|
auth_events=[e3_power1.event_id, e4_jr.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 7. Alice promotes Bob.
|
|
e7_power2 = self.create_event(
|
|
EventTypes.PowerLevels,
|
|
"",
|
|
sender=ALICE,
|
|
content={"users": {BOB: 50}},
|
|
auth_events=[e2_ma.event_id, e3_power1.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 8. Bob promotes Charlie.
|
|
e8_power3 = self.create_event(
|
|
EventTypes.PowerLevels,
|
|
"",
|
|
sender=BOB,
|
|
content={"users": {BOB: 50, CHARLIE: 50}},
|
|
auth_events=[e5_mb.event_id, e7_power2.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 9. Eve joins the room.
|
|
e9_me1 = self.create_event(
|
|
EventTypes.Member,
|
|
EVELYN,
|
|
sender=EVELYN,
|
|
content=MEMBERSHIP_CONTENT_JOIN,
|
|
auth_events=[e8_power3.event_id, e4_jr.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 10. Eve changes her name, /!\\ but cites old power levels /!\
|
|
e10_me2 = self.create_event(
|
|
EventTypes.Member,
|
|
EVELYN,
|
|
sender=EVELYN,
|
|
content=MEMBERSHIP_CONTENT_JOIN,
|
|
auth_events=[
|
|
e3_power1.event_id,
|
|
e4_jr.event_id,
|
|
e9_me1.event_id,
|
|
],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 11. Zara joins the room, citing the most recent power levels.
|
|
e11_mz = self.create_event(
|
|
EventTypes.Member,
|
|
ZARA,
|
|
sender=ZARA,
|
|
content=MEMBERSHIP_CONTENT_JOIN,
|
|
auth_events=[e8_power3.event_id, e4_jr.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
|
|
# Event 10 above is DODGY: it directly cites old auth events, but indirectly
|
|
# cites new ones. If the state after event 10 contains old power level and old
|
|
# join events, we are vulnerable to a reset.
|
|
|
|
dodgy_state_after_eve_rename: StateMap[str] = {
|
|
(EventTypes.Create, ""): e1_create.event_id,
|
|
(EventTypes.Member, ALICE): e2_ma.event_id,
|
|
(EventTypes.Member, BOB): e5_mb.event_id,
|
|
(EventTypes.Member, CHARLIE): e6_mc.event_id,
|
|
(EventTypes.Member, EVELYN): e10_me2.event_id,
|
|
(EventTypes.PowerLevels, ""): e3_power1.event_id, # old and /!\\ DODGY /!\
|
|
(EventTypes.JoinRules, ""): e4_jr.event_id,
|
|
}
|
|
|
|
sensible_state_after_zara_joins: StateMap[str] = {
|
|
(EventTypes.Create, ""): e1_create.event_id,
|
|
(EventTypes.Member, ALICE): e2_ma.event_id,
|
|
(EventTypes.Member, BOB): e5_mb.event_id,
|
|
(EventTypes.Member, CHARLIE): e6_mc.event_id,
|
|
(EventTypes.Member, ZARA): e11_mz.event_id,
|
|
(EventTypes.PowerLevels, ""): e8_power3.event_id,
|
|
(EventTypes.JoinRules, ""): e4_jr.event_id,
|
|
}
|
|
|
|
expected: StateMap[str] = {
|
|
(EventTypes.Create, ""): e1_create.event_id,
|
|
(EventTypes.Member, ALICE): e2_ma.event_id,
|
|
(EventTypes.Member, BOB): e5_mb.event_id,
|
|
(EventTypes.Member, CHARLIE): e6_mc.event_id,
|
|
# Expect ME2 replayed first: it's in the POWER 1 epoch
|
|
# Then ME1, in the POWER 3 epoch
|
|
(EventTypes.Member, EVELYN): e9_me1.event_id,
|
|
(EventTypes.Member, ZARA): e11_mz.event_id,
|
|
(EventTypes.PowerLevels, ""): e8_power3.event_id,
|
|
(EventTypes.JoinRules, ""): e4_jr.event_id,
|
|
}
|
|
|
|
self.get_resolution_and_verify_expected(
|
|
[dodgy_state_after_eve_rename, sensible_state_after_zara_joins],
|
|
[
|
|
e1_create,
|
|
e2_ma,
|
|
e3_power1,
|
|
e4_jr,
|
|
e5_mb,
|
|
e6_mc,
|
|
e7_power2,
|
|
e8_power3,
|
|
e9_me1,
|
|
e10_me2,
|
|
e11_mz,
|
|
],
|
|
expected,
|
|
)
|
|
|
|
def test_state_reset_start_empty_set(self) -> None:
|
|
# The join rules reset to missing, when:
|
|
# - join rules were in conflict
|
|
# - the membership of those join rules' senders were not in conflict
|
|
# - those memberships are all leaves.
|
|
|
|
# 1. Alice creates a room.
|
|
e1_create = self.create_event(
|
|
EventTypes.Create,
|
|
"",
|
|
sender=ALICE,
|
|
content={"creator": ALICE},
|
|
auth_events=[],
|
|
)
|
|
# 2. Alice joins it.
|
|
e2_ma1 = self.create_event(
|
|
EventTypes.Member,
|
|
ALICE,
|
|
sender=ALICE,
|
|
content=MEMBERSHIP_CONTENT_JOIN,
|
|
auth_events=[],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 3. Alice makes Bob an admin.
|
|
e3_power = self.create_event(
|
|
EventTypes.PowerLevels,
|
|
"",
|
|
sender=ALICE,
|
|
content={"users": {BOB: 100}},
|
|
auth_events=[e2_ma1.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 4. Alice sets the room to public.
|
|
e4_jr1 = self.create_event(
|
|
EventTypes.JoinRules,
|
|
"",
|
|
sender=ALICE,
|
|
content={"join_rule": JoinRules.PUBLIC},
|
|
auth_events=[e2_ma1.event_id, e3_power.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 5. Bob joins.
|
|
e5_mb = self.create_event(
|
|
EventTypes.Member,
|
|
BOB,
|
|
sender=BOB,
|
|
content=MEMBERSHIP_CONTENT_JOIN,
|
|
auth_events=[e3_power.event_id, e4_jr1.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 6. Alice sets join rules to invite.
|
|
e6_jr2 = self.create_event(
|
|
EventTypes.JoinRules,
|
|
"",
|
|
sender=ALICE,
|
|
content={"join_rule": JoinRules.INVITE},
|
|
auth_events=[e2_ma1.event_id, e3_power.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
# 7. Alice then leaves.
|
|
e7_ma2 = self.create_event(
|
|
EventTypes.Member,
|
|
ALICE,
|
|
sender=ALICE,
|
|
content=MEMBERSHIP_CONTENT_LEAVE,
|
|
auth_events=[e3_power.event_id, e2_ma1.event_id],
|
|
room_id=e1_create.room_id,
|
|
)
|
|
|
|
correct_state: StateMap[str] = {
|
|
(EventTypes.Create, ""): e1_create.event_id,
|
|
(EventTypes.Member, ALICE): e7_ma2.event_id,
|
|
(EventTypes.Member, BOB): e5_mb.event_id,
|
|
(EventTypes.PowerLevels, ""): e3_power.event_id,
|
|
(EventTypes.JoinRules, ""): e6_jr2.event_id,
|
|
}
|
|
|
|
# Imagine that another server gives us incorrect state on a fork
|
|
# (via e.g. backfill). It cites the old join rules.
|
|
incorrect_state: StateMap[str] = {
|
|
(EventTypes.Create, ""): e1_create.event_id,
|
|
(EventTypes.Member, ALICE): e7_ma2.event_id,
|
|
(EventTypes.Member, BOB): e5_mb.event_id,
|
|
(EventTypes.PowerLevels, ""): e3_power.event_id,
|
|
(EventTypes.JoinRules, ""): e4_jr1.event_id,
|
|
}
|
|
|
|
# Resolving those two should give us the new join rules.
|
|
expected: StateMap[str] = {
|
|
(EventTypes.Create, ""): e1_create.event_id,
|
|
(EventTypes.Member, ALICE): e7_ma2.event_id,
|
|
(EventTypes.Member, BOB): e5_mb.event_id,
|
|
(EventTypes.PowerLevels, ""): e3_power.event_id,
|
|
(EventTypes.JoinRules, ""): e6_jr2.event_id,
|
|
}
|
|
|
|
self.get_resolution_and_verify_expected(
|
|
[correct_state, incorrect_state],
|
|
[e1_create, e2_ma1, e3_power, e4_jr1, e5_mb, e6_jr2, e7_ma2],
|
|
expected,
|
|
)
|
|
|
|
async def _get_auth_difference_and_conflicted_subgraph(
|
|
self,
|
|
room_id: str,
|
|
state_maps: Sequence[StateMap[str]],
|
|
event_map: Optional[dict[str, EventBase]],
|
|
state_res_store: StateResolutionStoreInterface,
|
|
) -> set[str]:
|
|
_, conflicted_state = _seperate(state_maps)
|
|
conflicted_set: Optional[set[str]] = set(
|
|
itertools.chain.from_iterable(conflicted_state.values())
|
|
)
|
|
if event_map is None:
|
|
event_map = {}
|
|
return await _get_auth_chain_difference(
|
|
room_id,
|
|
state_maps,
|
|
event_map,
|
|
state_res_store,
|
|
conflicted_set,
|
|
)
|
|
|
|
def get_resolution_and_verify_expected(
|
|
self,
|
|
state_maps: Sequence[StateMap[str]],
|
|
events: list[EventBase],
|
|
expected: StateMap[str],
|
|
) -> None:
|
|
room_id = events[0].room_id
|
|
# First we try everything in-memory to check that the test case works.
|
|
event_map = {ev.event_id: ev for ev in events}
|
|
for ev in events:
|
|
print(f"{ev.event_id} => {ev.type} {ev.state_key} => {ev.content}")
|
|
resolution = self.get_success(
|
|
resolve_events_with_store(
|
|
FakeClock(),
|
|
room_id,
|
|
events[0].room_version,
|
|
state_maps,
|
|
event_map=event_map,
|
|
state_res_store=TestStateResolutionStore(event_map),
|
|
)
|
|
)
|
|
self.assertEqual(resolution, expected)
|
|
|
|
got_auth_diff = self.get_success(
|
|
self._get_auth_difference_and_conflicted_subgraph(
|
|
room_id,
|
|
state_maps,
|
|
event_map,
|
|
TestStateResolutionStore(event_map),
|
|
)
|
|
)
|
|
# we should never see the create event in the auth diff. If we do, this implies the
|
|
# conflicted subgraph is wrong and is returning too many old events.
|
|
assert events[0].event_id not in got_auth_diff
|
|
|
|
# now let's make the room exist on the DB, some queries rely on there being a row in
|
|
# the rooms table when persisting
|
|
self.get_success(
|
|
self.store.store_room(
|
|
room_id,
|
|
events[0].sender,
|
|
True,
|
|
events[0].room_version,
|
|
)
|
|
)
|
|
|
|
def resolve_and_check() -> None:
|
|
event_map = {ev.event_id: ev for ev in events}
|
|
store = StateResolutionStore(
|
|
self._persistence.main_store,
|
|
self._state_deletion,
|
|
)
|
|
resolution = self.get_success(
|
|
resolve_events_with_store(
|
|
FakeClock(),
|
|
room_id,
|
|
RoomVersions.HydraV11,
|
|
state_maps,
|
|
event_map=event_map,
|
|
state_res_store=store,
|
|
)
|
|
)
|
|
self.assertEqual(resolution, expected)
|
|
got_auth_diff2 = self.get_success(
|
|
self._get_auth_difference_and_conflicted_subgraph(
|
|
room_id,
|
|
state_maps,
|
|
event_map,
|
|
store,
|
|
)
|
|
)
|
|
# no matter how many events are persisted, the overall diff should always be the same.
|
|
self.assertEquals(got_auth_diff, got_auth_diff2)
|
|
|
|
# now we will drip feed in `events` one-by-one, persisting them then resolving with the
|
|
# rest. This ensures we correctly handle mixed persisted/unpersisted events. We will finish
|
|
# with doing the test with all persisted events.
|
|
while len(events) > 0:
|
|
event_to_persist = events.pop(0)
|
|
self.persist_event(event_to_persist)
|
|
# now retest
|
|
resolve_and_check()
|
|
|
|
def persist_event(
|
|
self, event: EventBase, state: Optional[StateMap[str]] = None
|
|
) -> None:
|
|
"""Persist the event, with optional state"""
|
|
context = self.get_success(
|
|
self.state.compute_event_context(
|
|
event,
|
|
state_ids_before_event=state,
|
|
partial_state=None if state is None else False,
|
|
)
|
|
)
|
|
self.get_success(self._persistence.persist_event(event, context))
|
|
|
|
def create_event(
|
|
self,
|
|
event_type: str,
|
|
state_key: Optional[str],
|
|
sender: str,
|
|
content: dict,
|
|
auth_events: list[str],
|
|
prev_events: Optional[list[str]] = None,
|
|
room_id: Optional[str] = None,
|
|
) -> EventBase:
|
|
"""Short-hand for event_from_pdu_json for fields we typically care about.
|
|
Tests can override by just calling event_from_pdu_json directly."""
|
|
if prev_events is None:
|
|
prev_events = []
|
|
|
|
pdu = {
|
|
"type": event_type,
|
|
"state_key": state_key,
|
|
"content": content,
|
|
"sender": sender,
|
|
"depth": 5,
|
|
"prev_events": prev_events,
|
|
"auth_events": auth_events,
|
|
"origin_server_ts": monotonic_timestamp(),
|
|
}
|
|
if event_type != EventTypes.Create:
|
|
if room_id is None:
|
|
raise Exception("must specify a room_id to create_event")
|
|
pdu["room_id"] = room_id
|
|
return event_from_pdu_json(
|
|
pdu,
|
|
RoomVersions.HydraV11,
|
|
)
|