Files
synapse/tests/storage/test_sticky_events.py
T
Olivier 'reivilibre 52c05c5ca4 Introduce spam_checker_spammy internal event metadata. (#19453)
Follows: #19365

Part of: MSC4354 Sticky Events (experimental feature #19409)

This PR introduces a `spam_checker_spammy` flag, analogous to
`policy_server_spammy`, as an explicit flag
that an event was decided to be spammy by a spam-checker module.

The original Sticky Events PR (#18968) just reused
`policy_server_spammy`, but it didn't sit right with me
because we (at least appear to be experimenting with features that)
allow users to opt-in to seeing
`policy_server_spammy` events (presumably for moderation purposes).

Keeping these flags separate felt best, therefore.

As for why we need this flag: soon soft-failed status won't be
permanent, at least for sticky events.
The spam checker modules currently work by making events soft-failed.
We want to prevent spammy events from getting
reconsidered/un-soft-failed, so it seems like we need
a flag to track spam-checker spamminess *separately* from soft-failed.

Should be commit-by-commit friendly, but is also small.

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
2026-04-15 16:53:23 +01:00

431 lines
16 KiB
Python

#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations 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>.
import sqlite3
from twisted.internet.testing import MemoryReactor
from synapse.api.constants import (
EventContentFields,
EventTypes,
Membership,
StickyEvent,
StickyEventField,
)
from synapse.api.room_versions import RoomVersions
from synapse.rest import admin
from synapse.rest.client import login, register, room
from synapse.server import HomeServer
from synapse.types import JsonDict, create_requester
from synapse.util.clock import Clock
from synapse.util.duration import Duration
from tests import unittest
from tests.test_utils.event_injection import inject_event
from tests.utils import USE_POSTGRES_FOR_TESTS
class StickyEventsTestCase(unittest.HomeserverTestCase):
"""
Tests for the storage functions related to MSC4354: Sticky Events
"""
if not USE_POSTGRES_FOR_TESTS and sqlite3.sqlite_version_info < (3, 40, 0):
# We need the JSON functionality in SQLite
skip = f"SQLite version is too old to support sticky events: {sqlite3.sqlite_version_info} (See https://github.com/element-hq/synapse/issues/19428)"
servlets = [
room.register_servlets,
login.register_servlets,
register.register_servlets,
admin.register_servlets,
]
def default_config(self) -> JsonDict:
config = super().default_config()
config["experimental_features"] = {"msc4354_enabled": True}
return config
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.store = self.hs.get_datastores().main
# Register an account and create a room
self.user_id = self.register_user("user", "pass")
self.token = self.login(self.user_id, "pass")
self.room_id = self.helper.create_room_as(self.user_id, tok=self.token)
def test_get_updated_sticky_events(self) -> None:
"""Test getting updated sticky events between stream IDs."""
# Get the starting stream_id
start_id = self.store.get_max_sticky_events_stream_id()
event_id_1 = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 1", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
mid_id = self.store.get_max_sticky_events_stream_id()
event_id_2 = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 2", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
end_id = self.store.get_max_sticky_events_stream_id()
# Get all updates
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 2)
self.assertEqual(updates[0].event_id, event_id_1)
self.assertEqual(updates[0].soft_failed, False)
self.assertEqual(updates[1].event_id, event_id_2)
self.assertEqual(updates[1].soft_failed, False)
# Get only the second update
updates = self.get_success(
self.store.get_updated_sticky_events(from_id=mid_id, to_id=end_id, limit=10)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, event_id_2)
self.assertEqual(updates[0].soft_failed, False)
def test_delete_expired_sticky_events(self) -> None:
"""Test deletion of expired sticky events."""
# Insert an expired event by advancing time past its duration
self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(milliseconds=1),
content={"body": "expired message", "msgtype": "m.text"},
tok=self.token,
)
self.reactor.advance(0.002)
# Insert a non-expired event
event_id_2 = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "non-expired message", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
end_id = self.store.get_max_sticky_events_stream_id()
# Delete expired events
self.get_success(self.store._delete_expired_sticky_events())
# Check that only the non-expired event remains
sticky_events = self.get_success(
self.store.db_pool.simple_select_list(
table="sticky_events", keyvalues=None, retcols=("stream_id", "event_id")
)
)
self.assertEqual(
sticky_events,
[
(end_id, event_id_2),
],
)
def test_get_updated_sticky_events_with_limit(self) -> None:
"""Test that the limit parameter works correctly."""
# Get the starting stream_id
start_id = self.store.get_max_sticky_events_stream_id()
event_id_1 = self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 1", "msgtype": "m.text"},
tok=self.token,
)["event_id"]
self.helper.send_sticky_event(
self.room_id,
EventTypes.Message,
duration=Duration(minutes=1),
content={"body": "message 2", "msgtype": "m.text"},
tok=self.token,
)
# Get only the first update
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=start_id + 2, limit=1
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, event_id_1)
def test_outlier_events_not_in_table(self) -> None:
"""
Tests the behaviour of outliered and then de-outliered events in the
sticky_events table: they should only be added once they are de-outliered.
"""
persist_controller = self.hs.get_storage_controllers().persistence
assert persist_controller is not None
user1_id = self.register_user("user1", "pass")
user2_id = self.register_user("user2", "pass")
user2_tok = self.login(user2_id, "pass")
start_id = self.store.get_max_sticky_events_stream_id()
room_id = self.helper.create_room_as(
user2_id, tok=user2_tok, room_version=RoomVersions.V10.identifier
)
# Create a membership event
event_dict = {
"type": EventTypes.Member,
"state_key": user1_id,
"sender": user1_id,
"room_id": room_id,
"content": {EventContentFields.MEMBERSHIP: Membership.JOIN},
StickyEvent.EVENT_FIELD_NAME: StickyEventField(
duration_ms=Duration(hours=1).as_millis()
),
}
# Create the event twice: once as an outlier, once as a non-outlier.
# It's not at all obvious, but event creation before is deterministic
# (provided we don't change the forward extremities of the room!),
# so these two events are actually the same event with the same event ID.
(
event_outlier,
unpersisted_context_outlier,
) = self.get_success(
self.hs.get_event_creation_handler().create_event(
requester=create_requester(user1_id),
event_dict=event_dict,
outlier=True,
)
)
(
event_non_outlier,
unpersisted_context_non_outlier,
) = self.get_success(
self.hs.get_event_creation_handler().create_event(
requester=create_requester(user1_id),
event_dict=event_dict,
outlier=False,
)
)
# Safety check that we're testing what we think we are
self.assertEqual(event_outlier.event_id, event_non_outlier.event_id)
# Now persist the event as an outlier first of all
# FIXME: Should we use an `EventContext.for_outlier(...)` here?
# Doesn't seem to matter for this test.
context_outlier = self.get_success(
unpersisted_context_outlier.persist(event_outlier)
)
self.get_success(
persist_controller.persist_event(
event_outlier,
context_outlier,
)
)
# Since the event is outliered, it won't show up in the sticky_events table...
sticky_events = self.get_success(
self.store.db_pool.simple_select_list(
table="sticky_events", keyvalues=None, retcols=("stream_id", "event_id")
)
)
self.assertEqual(len(sticky_events), 0)
# Now persist the event properly so that it gets de-outliered.
context_non_outlier = self.get_success(
unpersisted_context_non_outlier.persist(event_non_outlier)
)
self.get_success(
persist_controller.persist_event(
event_non_outlier,
context_non_outlier,
)
)
end_id = self.store.get_max_sticky_events_stream_id()
# Check the event made it into the sticky_events table
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, event_non_outlier.event_id)
def test_soft_failed_events_are_tracked(self) -> None:
"""
Tests that sticky events marked as soft_failed ARE inserted
into the sticky_events table, as their soft-failed status can be re-evaluated later,
as per MSC4354.
"""
user_id = self.register_user("testuser", "pass")
token = self.login(user_id, "pass")
room_id = self.helper.create_room_as(user_id, tok=token)
start_id = self.store.get_max_sticky_events_stream_id()
# Create and persist a sticky event that is soft-failed
soft_failed_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "spam checker spammy message", "msgtype": "m.text"},
internal_metadata={"soft_failed": True},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
end_id = self.store.get_max_sticky_events_stream_id()
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, soft_failed_sticky_event.event_id)
def test_policy_server_spammy_events_are_not_tracked(self) -> None:
"""
Tests that sticky events marked as policy_server_spammy are NOT inserted
into the sticky_events table, as they are exempt from the soft-failed
re-evaluation logic.
"""
user_id = self.register_user("testuser", "pass")
token = self.login(user_id, "pass")
room_id = self.helper.create_room_as(user_id, tok=token)
start_id = self.store.get_max_sticky_events_stream_id()
# Create and persist a sticky event that is marked policy_server_spammy
# N.B. policy_server_spammy events are always soft-failed too
_spammy_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "spam checker spammy message", "msgtype": "m.text"},
internal_metadata={"soft_failed": True, "policy_server_spammy": True},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
# Also insert a valid sticky event as a canary for the test setup
valid_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "normal sticky", "msgtype": "m.text"},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
end_id = self.store.get_max_sticky_events_stream_id()
# Verify only the regular event was inserted
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, valid_sticky_event.event_id)
def test_spam_checker_spammy_events_are_not_tracked(self) -> None:
"""
Tests that sticky events marked as spam_checker_spammy are NOT inserted
into the sticky_events table, as they are exempt from the soft-failed
re-evaluation logic.
"""
user_id = self.register_user("testuser", "pass")
token = self.login(user_id, "pass")
room_id = self.helper.create_room_as(user_id, tok=token)
start_id = self.store.get_max_sticky_events_stream_id()
# Create and persist a sticky event that is marked spam_checker_spammy
# N.B. spam_checker_spammy events are always soft-failed too
_spammy_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "spam checker spammy message", "msgtype": "m.text"},
internal_metadata={"soft_failed": True, "spam_checker_spammy": True},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
# Also insert a valid sticky event as a canary for the test setup
valid_sticky_event = self.get_success(
inject_event(
self.hs,
room_id=room_id,
sender=user_id,
type=EventTypes.Message,
content={"body": "normal sticky", "msgtype": "m.text"},
# Corresponds to StickyEvent.EVENT_FIELD_NAME
msc4354_sticky=StickyEventField(
duration_ms=Duration(minutes=1).as_millis()
),
)
)
end_id = self.store.get_max_sticky_events_stream_id()
# Verify only the valid sticky event was inserted
updates = self.get_success(
self.store.get_updated_sticky_events(
from_id=start_id, to_id=end_id, limit=10
)
)
self.assertEqual(len(updates), 1)
self.assertEqual(updates[0].event_id, valid_sticky_event.event_id)