|
| 1 | +// This file is licensed under the Affero General Public License (AGPL) version 3. |
| 2 | +// |
| 3 | +// Copyright (C) 2026 Element Creations Ltd |
| 4 | +// |
| 5 | +// This program is free software: you can redistribute it and/or modify |
| 6 | +// it under the terms of the GNU Affero General Public License as |
| 7 | +// published by the Free Software Foundation, either version 3 of the |
| 8 | +// License, or (at your option) any later version. |
| 9 | +// |
| 10 | +// See the GNU Affero General Public License for more details: |
| 11 | +// <https://www.gnu.org/licenses/agpl-3.0.html>. |
| 12 | + |
| 13 | +package synapse_tests |
| 14 | + |
| 15 | +import ( |
| 16 | + "encoding/json" |
| 17 | + "fmt" |
| 18 | + "net/url" |
| 19 | + "slices" |
| 20 | + "strings" |
| 21 | + "testing" |
| 22 | + |
| 23 | + "github.com/matrix-org/complement" |
| 24 | + "github.com/matrix-org/complement/b" |
| 25 | + "github.com/matrix-org/complement/client" |
| 26 | + "github.com/matrix-org/complement/helpers" |
| 27 | + "github.com/matrix-org/gomatrixserverlib/spec" |
| 28 | + "github.com/tidwall/gjson" |
| 29 | +) |
| 30 | + |
| 31 | +func TestMessagesOverFederation(t *testing.T) { |
| 32 | + deployment := complement.Deploy(t, 2) |
| 33 | + defer deployment.Destroy(t) |
| 34 | + |
| 35 | + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{ |
| 36 | + LocalpartSuffix: "alice", |
| 37 | + }) |
| 38 | + bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{ |
| 39 | + LocalpartSuffix: "bob", |
| 40 | + }) |
| 41 | + |
| 42 | + // The typical convention to find backfill points is from the backward extremities in |
| 43 | + // the DAG. Backward extremities are the oldest events we know of in the room but we |
| 44 | + // only know of them because some other event referenced them by prev_event and aren't |
| 45 | + // known to the homeserver yet (meaning we don't know their depth specifically). So we |
| 46 | + // can only do approximate depth comparisons (use the depth of the known events |
| 47 | + // they're connected to). And we don't know if those backward extremities point to a |
| 48 | + // long chain/fork of history that could stretch back far enough to be visible. |
| 49 | + // |
| 50 | + // This means a naive homeserver implementation that looks for backward extremities <= |
| 51 | + // depth of the `/messages?dir=b&from=xxx` token may overlook a backfill point that could |
| 52 | + // reveal more history in the window the user is currently paginating in. |
| 53 | + // |
| 54 | + // This could be a near miss as this test is specifically stressing or a more deep miss |
| 55 | + // as the backward extremity could reveal an entire fork of history that stretches |
| 56 | + // back far enough to be visible. |
| 57 | + // |
| 58 | + // In Synapse, we consider "nearby" as anything within range of the `limit` specified |
| 59 | + // in `/messages?dir=b&from=xxx&limit=xxx`. |
| 60 | + // |
| 61 | + // This test lives in our in-repo Complement tests for Synapse because the Matrix spec |
| 62 | + // doesn't have any rules for how a homeserver should backfill. Practically speaking, |
| 63 | + // homeservers that don't do anything for this problem will just hide messages from |
| 64 | + // clients. This underscores the fact why it's necessary for homeservers to indicate that |
| 65 | + // there is a gap (using MSC3871) at the very least. |
| 66 | + // |
| 67 | + // -------------------------------------------------- |
| 68 | + // |
| 69 | + // Even with MSC3871 gaps, the tested behavior here is necessary as the gap prev/next |
| 70 | + // tokens point before/after the event (remember: tokens are positions between |
| 71 | + // events), so if you use `/messages?dir=b&from=<gap prev_pagination_token>`, we can't |
| 72 | + // rely on naive depth comparison. MSC3871 Complement tests will also exercise this. |
| 73 | + // Example: |
| 74 | + // |
| 75 | + // t0 t1 t2 t3 t4 |
| 76 | + // [A] <--- [B] <--- [C] <--- [bob join 4] |
| 77 | + // |
| 78 | + // When Bob calls `/messages?dir=b&backfill=false`, he sees a gap (`{ event_id: "bob |
| 79 | + // join 4", prev_pagination_token: "t3", next_pagination_token: "t4" }`) and tries to |
| 80 | + // fill it in with `/messages?dir=b&from=t3&limit=10&backfill=true`. To find backfill |
| 81 | + // points, Synapse will compare `t3` with the backward extremity at an approximate |
| 82 | + // depth of 4. Which is why we take `t3`, add the `limit=10` and then do the |
| 83 | + // comparison (find any backfill points with an approximate depth <= 13). |
| 84 | + t.Run("Backfill from nearby backward extremities past token", func(t *testing.T) { |
| 85 | + // Alice creates the room |
| 86 | + roomID := alice.MustCreateRoom(t, map[string]interface{}{ |
| 87 | + // The `public_chat` preset includes `history_visibility: "shared"` ("Previous |
| 88 | + // events are always accessible to newly joined members. All events in the |
| 89 | + // room are accessible, even those sent when the member was not a part of the |
| 90 | + // room."), which is what we want to test. |
| 91 | + "preset": "public_chat", |
| 92 | + }) |
| 93 | + |
| 94 | + // Keep track of the order |
| 95 | + eventIDs := make([]string, 0) |
| 96 | + // Map from event_id to event info |
| 97 | + eventMap := make(map[string]EventInfo) |
| 98 | + |
| 99 | + // Send some message history into the room |
| 100 | + numberOfMessagesToSend := 3 |
| 101 | + messageDrafts := make([]MessageDraft, 0, numberOfMessagesToSend) |
| 102 | + for i := 0; i < numberOfMessagesToSend; i++ { |
| 103 | + messageDrafts = append( |
| 104 | + messageDrafts, |
| 105 | + MessageDraft{alice, fmt.Sprintf("message history %d", i+1)}, |
| 106 | + ) |
| 107 | + } |
| 108 | + sendAndTrackMessages(t, roomID, messageDrafts, &eventIDs, &eventMap) |
| 109 | + |
| 110 | + // Bob joins the room |
| 111 | + bob.MustJoinRoom(t, roomID, []spec.ServerName{ |
| 112 | + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), |
| 113 | + }) |
| 114 | + bobJoinEventID := getStateID(t, bob, roomID, "m.room.member", bob.UserID) |
| 115 | + |
| 116 | + // Make it easy to cross-reference the events being talked about in the logs |
| 117 | + for eventIndex, eventID := range eventIDs { |
| 118 | + t.Logf("Message %d -> event_id=%s", eventIndex, eventID) |
| 119 | + } |
| 120 | + |
| 121 | + // Use a `/context` request to get a pagination token just before Bob's join event |
| 122 | + // (remember: tokens are positions between events) |
| 123 | + // |
| 124 | + // Usually a client would just use `/messages?dir=b` to start getting history |
| 125 | + // after joining but this is valid as well. To illustrate a more real example of |
| 126 | + // this, someone can use `/timestamp_to_event` to jump back in history and |
| 127 | + // `/context` to start paginating history. |
| 128 | + contextRes := bob.MustDo( |
| 129 | + t, |
| 130 | + "GET", |
| 131 | + []string{"_matrix", "client", "v3", "rooms", roomID, "context", bobJoinEventID}, |
| 132 | + client.WithContentType("application/json"), |
| 133 | + client.WithQueries(url.Values{ |
| 134 | + "limit": []string{"0"}, |
| 135 | + }), |
| 136 | + ) |
| 137 | + contextResResBody := client.ParseJSON(t, contextRes) |
| 138 | + // > `start`: A token that can be used to paginate backwards with. |
| 139 | + // > - https://spec.matrix.org/v1.17/client-server-api/#get_matrixclientv3roomsroomidcontexteventid |
| 140 | + paginationToken := client.GetJSONFieldStr(t, contextResResBody, "start") |
| 141 | + |
| 142 | + // Paginate backwards from the join event |
| 143 | + messagesRes := bob.MustDo( |
| 144 | + t, |
| 145 | + "GET", |
| 146 | + []string{"_matrix", "client", "v3", "rooms", roomID, "messages"}, |
| 147 | + client.WithContentType("application/json"), |
| 148 | + client.WithQueries(url.Values{ |
| 149 | + "dir": []string{"b"}, |
| 150 | + "limit": []string{"100"}, |
| 151 | + "from": []string{paginationToken}, |
| 152 | + }), |
| 153 | + ) |
| 154 | + messagesResBody := client.ParseJSON(t, messagesRes) |
| 155 | + |
| 156 | + // Since `dir=b`, these will be in reverse chronological order |
| 157 | + actualEventIDsFromRequest := extractEventIDsFromMessagesResponse(t, messagesResBody) |
| 158 | + |
| 159 | + // Put them in chronological order to match the expected list |
| 160 | + chronologicalActualEventIds := slices.Clone(actualEventIDsFromRequest) |
| 161 | + slices.Reverse(chronologicalActualEventIds) |
| 162 | + |
| 163 | + // Assert timeline order |
| 164 | + assertEventsInOrder(t, chronologicalActualEventIds, eventIDs) |
| 165 | + }) |
| 166 | + |
| 167 | + // TODO: Backfill test to make sure we backfill from forks when viewing history (see |
| 168 | + // docstring above). |
| 169 | + // |
| 170 | + // 1. Alice (hs1, engineered homeserver) creates a room with events A, B |
| 171 | + // 1. Bob (hs2) joins the room |
| 172 | + // 1. Bob leaves the room |
| 173 | + // 1. Alice creates a fork from A with some history (1, 2, 3) and connects it back with a new event C |
| 174 | + // 1. Bob joins back |
| 175 | + // 1. Bob paginates `/messages?dir=b&from=<token-after-b>` |
| 176 | + // 1. Ensure Bob sees events: B, 2, 1, A |
| 177 | + // |
| 178 | + // 1 <--- 2 <----- 3 |
| 179 | + // / \ |
| 180 | + // A <------- B ▲ <--- C <-- D |
| 181 | + // | |
| 182 | + // Paginate backwards from this point |
| 183 | + // t.Run("Backfill from nearby backward extremities past token (fork)", func(t *testing.T) { |
| 184 | +} |
| 185 | + |
| 186 | +// These utilities match what we're using in the Complement repo (see |
| 187 | +// `matrix-org/complement` -> `tests/csapi/room_messages_test.go`) |
| 188 | + |
| 189 | +type MessageDraft struct { |
| 190 | + Sender *client.CSAPI |
| 191 | + Message string |
| 192 | +} |
| 193 | + |
| 194 | +type EventInfo struct { |
| 195 | + MessageDraft MessageDraft |
| 196 | + EventID string |
| 197 | +} |
| 198 | + |
| 199 | +func sendMessageDrafts( |
| 200 | + t *testing.T, |
| 201 | + roomID string, |
| 202 | + messageDrafts []MessageDraft, |
| 203 | +) []string { |
| 204 | + t.Helper() |
| 205 | + |
| 206 | + eventIDs := make([]string, len(messageDrafts)) |
| 207 | + for messageDraftIndex, messageDraft := range messageDrafts { |
| 208 | + eventID := messageDraft.Sender.SendEventSynced(t, roomID, b.Event{ |
| 209 | + Type: "m.room.message", |
| 210 | + Content: map[string]interface{}{ |
| 211 | + "msgtype": "m.text", |
| 212 | + "body": messageDraft.Message, |
| 213 | + }, |
| 214 | + }) |
| 215 | + eventIDs[messageDraftIndex] = eventID |
| 216 | + } |
| 217 | + |
| 218 | + return eventIDs |
| 219 | +} |
| 220 | + |
| 221 | +// sendAndTrackMessages sends the given message drafts to the room, keeping track of the |
| 222 | +// new events in the list of `eventIDs` and `eventMap`. Returns the list of new event |
| 223 | +// IDs that were sent. |
| 224 | +func sendAndTrackMessages( |
| 225 | + t *testing.T, |
| 226 | + roomID string, |
| 227 | + messageDrafts []MessageDraft, |
| 228 | + eventIDs *[]string, |
| 229 | + eventMap *map[string]EventInfo, |
| 230 | +) []string { |
| 231 | + t.Helper() |
| 232 | + |
| 233 | + newEventIDs := sendMessageDrafts(t, roomID, messageDrafts) |
| 234 | + |
| 235 | + *eventIDs = append(*eventIDs, newEventIDs...) |
| 236 | + for i, eventID := range newEventIDs { |
| 237 | + (*eventMap)[eventID] = EventInfo{ |
| 238 | + MessageDraft: messageDrafts[i], |
| 239 | + EventID: eventID, |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + return newEventIDs |
| 244 | +} |
| 245 | + |
| 246 | +// extractEventIDsFromMessagesResponse extracts the event IDs from the given |
| 247 | +// `/messages` response body. |
| 248 | +func extractEventIDsFromMessagesResponse( |
| 249 | + t *testing.T, |
| 250 | + messagesResBody json.RawMessage, |
| 251 | +) []string { |
| 252 | + t.Helper() |
| 253 | + |
| 254 | + wantKey := "chunk" |
| 255 | + keyRes := gjson.GetBytes(messagesResBody, wantKey) |
| 256 | + if !keyRes.Exists() { |
| 257 | + t.Fatalf("extractEventIDsFromMessagesResponse: missing key '%s'", wantKey) |
| 258 | + } |
| 259 | + if !keyRes.IsArray() { |
| 260 | + t.Fatalf( |
| 261 | + "extractEventIDsFromMessagesResponse: key '%s' is not an array (was %s)", |
| 262 | + wantKey, |
| 263 | + keyRes.Type, |
| 264 | + ) |
| 265 | + } |
| 266 | + |
| 267 | + var eventIDs []string |
| 268 | + actualEvents := keyRes.Array() |
| 269 | + for _, event := range actualEvents { |
| 270 | + eventIDs = append(eventIDs, event.Get("event_id").Str) |
| 271 | + } |
| 272 | + |
| 273 | + return eventIDs |
| 274 | +} |
| 275 | + |
| 276 | +func filterEventIDs(t *testing.T, actualEventIDs []string, expectedEventIDs []string) []string { |
| 277 | + t.Helper() |
| 278 | + |
| 279 | + relevantActualEventIDs := make([]string, 0, len(expectedEventIDs)) |
| 280 | + for _, eventID := range actualEventIDs { |
| 281 | + if slices.Contains(expectedEventIDs, eventID) { |
| 282 | + relevantActualEventIDs = append(relevantActualEventIDs, eventID) |
| 283 | + } |
| 284 | + } |
| 285 | + |
| 286 | + return relevantActualEventIDs |
| 287 | +} |
| 288 | + |
| 289 | +// assertEventsInOrder asserts all `actualEventIDs` are present and in order according |
| 290 | +// to `expectedEventIDs`. Other unrelated events can be in between. |
| 291 | +func assertEventsInOrder(t *testing.T, actualEventIDs []string, expectedEventIDs []string) { |
| 292 | + t.Helper() |
| 293 | + |
| 294 | + relevantActualEventIDs := filterEventIDs(t, actualEventIDs, expectedEventIDs) |
| 295 | + |
| 296 | + if len(relevantActualEventIDs) != len(expectedEventIDs) { |
| 297 | + t.Fatalf( |
| 298 | + "expected %d events in timeline (got %d relevant events filtered down from %d events)\n%s", |
| 299 | + len(expectedEventIDs), |
| 300 | + len(relevantActualEventIDs), |
| 301 | + len(actualEventIDs), |
| 302 | + generateEventOrderDiffString(relevantActualEventIDs, expectedEventIDs), |
| 303 | + ) |
| 304 | + } |
| 305 | + |
| 306 | + for i, eventID := range relevantActualEventIDs { |
| 307 | + if eventID != expectedEventIDs[i] { |
| 308 | + t.Fatalf( |
| 309 | + "expected event ID %s (got %s) at index %d\n%s", |
| 310 | + expectedEventIDs[i], |
| 311 | + eventID, |
| 312 | + i, |
| 313 | + generateEventOrderDiffString(relevantActualEventIDs, expectedEventIDs), |
| 314 | + ) |
| 315 | + } |
| 316 | + } |
| 317 | +} |
| 318 | + |
| 319 | +func generateEventOrderDiffString(actualEventIDs []string, expectedEventIDs []string) string { |
| 320 | + expectedLines := make([]string, len(expectedEventIDs)) |
| 321 | + for i, expectedEventID := range expectedEventIDs { |
| 322 | + isExpectedInActual := slices.Contains(actualEventIDs, expectedEventID) |
| 323 | + isMissingIndicatorString := " " |
| 324 | + if !isExpectedInActual { |
| 325 | + isMissingIndicatorString = "?" |
| 326 | + } |
| 327 | + |
| 328 | + expectedLines[i] = fmt.Sprintf("%2d: %s %s", i, isMissingIndicatorString, expectedEventID) |
| 329 | + } |
| 330 | + expectedDiffString := strings.Join(expectedLines, "\n") |
| 331 | + |
| 332 | + actualLines := make([]string, len(actualEventIDs)) |
| 333 | + for actualEventIndex, actualEventID := range actualEventIDs { |
| 334 | + isActualInExpected := slices.Contains(expectedEventIDs, actualEventID) |
| 335 | + isActualInExpectedIndicatorString := " " |
| 336 | + if isActualInExpected { |
| 337 | + isActualInExpectedIndicatorString = "+" |
| 338 | + } |
| 339 | + |
| 340 | + expectedIndex := slices.Index(expectedEventIDs, actualEventID) |
| 341 | + expectedIndexString := "" |
| 342 | + if actualEventIndex != expectedIndex { |
| 343 | + expectedDirectionString := "⬆️" |
| 344 | + if expectedIndex > actualEventIndex { |
| 345 | + expectedDirectionString = "⬇️" |
| 346 | + } |
| 347 | + |
| 348 | + expectedIndexString = fmt.Sprintf( |
| 349 | + " (expected index %d %s)", |
| 350 | + expectedIndex, |
| 351 | + expectedDirectionString, |
| 352 | + ) |
| 353 | + } |
| 354 | + |
| 355 | + actualLines[actualEventIndex] = fmt.Sprintf("%2d: %s %s%s", |
| 356 | + actualEventIndex, isActualInExpectedIndicatorString, actualEventID, expectedIndexString, |
| 357 | + ) |
| 358 | + } |
| 359 | + actualDiffString := strings.Join(actualLines, "\n") |
| 360 | + |
| 361 | + return fmt.Sprintf( |
| 362 | + "Actual events ('+' = found expected items):\n%s\nExpected events ('?' = missing expected items):\n%s", |
| 363 | + actualDiffString, |
| 364 | + expectedDiffString, |
| 365 | + ) |
| 366 | +} |
| 367 | + |
| 368 | +func getStateID( |
| 369 | + t *testing.T, |
| 370 | + c *client.CSAPI, |
| 371 | + roomID string, |
| 372 | + stateType string, |
| 373 | + stateKey string, |
| 374 | +) string { |
| 375 | + t.Helper() |
| 376 | + |
| 377 | + stateRes := c.MustDo(t, "GET", []string{"_matrix", "client", "v3", "rooms", roomID, "state"}) |
| 378 | + stateResBody := client.ParseJSON(t, stateRes) |
| 379 | + eventJSON := gjson.ParseBytes(stateResBody) |
| 380 | + if !eventJSON.IsArray() { |
| 381 | + t.Fatalf("expected array of state events but found %s", eventJSON.Type) |
| 382 | + } |
| 383 | + |
| 384 | + events := eventJSON.Array() |
| 385 | + |
| 386 | + for _, event := range events { |
| 387 | + if event.Get("type").Str == stateType && event.Get("state_key").Str == stateKey { |
| 388 | + return event.Get("event_id").Str |
| 389 | + } |
| 390 | + } |
| 391 | + |
| 392 | + t.Fatalf("Unable to find state event for (%s, %s). Room state: %s", stateType, stateKey, events) |
| 393 | + return "" |
| 394 | +} |
0 commit comments