package clientapi_test import ( "fmt" "net/http" "net/url" "testing" "time" ) // The /sync tests. They go through HTTP rather than calling the syncapi // directly, because what is worth pinning down is the response a client // sees: the shape of the body, which section a room lands in, and whether a // token handed back can be handed in again. // syncResult is the handful of things these tests read out of a response. // It is a typed struct rather than a map because a sync response is nested // four deep, and `body["rooms"].(map[string]any)["join"]` repeated thirty // times is unreadable. type syncResult struct { NextBatch string `json:"next_batch"` Rooms struct { Join map[string]struct { Timeline struct { Events []syncEvent `json:"events"` Limited bool `json:"limited"` PrevBatch string `json:"prev_batch"` } `json:"timeline"` State struct { Events []syncEvent `json:"events"` } `json:"state"` Ephemeral struct { Events []syncEvent `json:"events"` } `json:"ephemeral"` AccountData struct { Events []syncEvent `json:"events"` } `json:"account_data"` Summary *struct { Heroes []string `json:"m.heroes"` JoinedMemberCount int `json:"m.joined_member_count"` InvitedMemberCount int `json:"m.invited_member_count"` } `json:"summary"` } `json:"join"` Invite map[string]struct { InviteState struct { Events []syncEvent `json:"events"` } `json:"invite_state"` } `json:"invite"` Leave map[string]struct { Timeline struct { Events []syncEvent `json:"events"` } `json:"timeline"` State struct { Events []syncEvent `json:"events"` } `json:"state"` } `json:"leave"` } `json:"rooms"` Presence struct { Events []syncEvent `json:"events"` } `json:"presence"` AccountData struct { Events []syncEvent `json:"events"` } `json:"account_data"` ToDevice struct { Events []syncEvent `json:"events"` } `json:"to_device"` DeviceLists struct { Changed []string `json:"changed"` Left []string `json:"left"` } `json:"device_lists"` } type syncEvent struct { EventID string `json:"event_id"` RoomID string `json:"room_id"` Type string `json:"type"` StateKey *string `json:"state_key"` Sender string `json:"sender"` Content map[string]any `json:"content"` } // sync performs one /sync, with no timeout: every test but the long-poll // ones wants the answer as it stands. func (ts *testServer) sync(acc account, since string) syncResult { ts.t.Helper() return ts.syncWith(acc, url.Values{"since": {since}}) } // syncWith performs a /sync with arbitrary query parameters. func (ts *testServer) syncWith(acc account, query url.Values) syncResult { ts.t.Helper() path := "/_matrix/client/v3/sync" if encoded := query.Encode(); encoded != "" { path += "?" + encoded } rec := ts.get(path, acc.AccessToken) expectStatus(ts.t, rec, http.StatusOK) result := decode[syncResult](ts.t, rec) if result.NextBatch == "" { ts.t.Fatalf("/sync returned no next_batch: %s", rec.Body.String()) } return result } // sendMessage sends an m.room.message and returns its event ID. func (ts *testServer) sendMessage(acc account, roomID, body string) string { ts.t.Helper() path := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/m.room.message/%s", url.PathEscape(roomID), url.PathEscape(fmt.Sprintf("txn-%s-%d", body, time.Now().UnixNano()))) rec := ts.put(path, acc.AccessToken, map[string]any{"msgtype": "m.text", "body": body}) expectStatus(ts.t, rec, http.StatusOK) eventID, _ := jsonBody(ts.t, rec)["event_id"].(string) if eventID == "" { ts.t.Fatalf("send returned no event ID: %s", rec.Body.String()) } return eventID } // bodiesOf projects timeline events onto their message bodies, which is // what most of these tests actually assert on. func bodiesOf(events []syncEvent) []string { out := make([]string, 0, len(events)) for _, e := range events { if body, ok := e.Content["body"].(string); ok { out = append(out, body) } } return out } // typesOf projects events onto their types. func typesOf(events []syncEvent) []string { out := make([]string, 0, len(events)) for _, e := range events { out = append(out, e.Type) } return out } func contains(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { return true } } return false } func TestSyncInitialReportsTheRoomAndItsHistory(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"name": "Rome"}) ts.sendMessage(alice, roomID, "hello") result := ts.sync(alice, "") room, ok := result.Rooms.Join[roomID] if !ok { t.Fatalf("the room is not in the join section: %+v", result.Rooms.Join) } if got := bodiesOf(room.Timeline.Events); !contains(got, "hello") { t.Errorf("timeline bodies = %v, want it to contain \"hello\"", got) } // The room was created and filled in fewer events than the default // timeline limit, so the whole of it is in the timeline and there is // nothing before it to report as state. if room.Timeline.Limited { t.Error("a room shorter than the timeline limit must not be reported as limited") } // A sync's events carry no room_id: they are already nested under the // room they belong to. for _, e := range room.Timeline.Events { if e.RoomID != "" { t.Errorf("%s carries a room_id in a sync response", e.Type) break } } // device_lists is empty until Phase 7 and must still be present: a // client reads a missing one as a malformed response. if result.DeviceLists.Changed == nil || result.DeviceLists.Left == nil { t.Error("device_lists must carry both lists even when empty") } } func TestSyncIncrementalReturnsOnlyWhatIsNew(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) ts.sendMessage(alice, roomID, "first") first := ts.sync(alice, "") ts.sendMessage(alice, roomID, "second") second := ts.sync(alice, first.NextBatch) room, ok := second.Rooms.Join[roomID] if !ok { t.Fatalf("the room is not in the join section: %+v", second.Rooms.Join) } got := bodiesOf(room.Timeline.Events) if len(got) != 1 || got[0] != "second" { t.Errorf("timeline bodies = %v, want exactly [second]", got) } // An incremental sync with no gap reports no state: every state change // in the window is a timeline event the client already has. if len(room.State.Events) != 0 { t.Errorf("state = %v, want none on a gapless incremental sync", typesOf(room.State.Events)) } } func TestSyncIncrementalIsEmptyWhenNothingHappened(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) first := ts.sync(alice, "") second := ts.sync(alice, first.NextBatch) if len(second.Rooms.Join) != 0 { t.Errorf("join = %v, want nothing when nothing happened", second.Rooms.Join) } _ = roomID } func TestSyncTokenRoundTripsThroughTheClient(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) first := ts.sync(alice, "") // Syncing twice from the same token must give the same answer: a token // is a position, not a cursor the server advances for itself. a := ts.sync(alice, first.NextBatch) ts.sendMessage(alice, roomID, "once") b := ts.sync(alice, first.NextBatch) c := ts.sync(alice, first.NextBatch) if len(a.Rooms.Join) != 0 { t.Error("the first re-sync should have been empty") } if got := bodiesOf(b.Rooms.Join[roomID].Timeline.Events); len(got) != 1 { t.Errorf("second re-sync timeline = %v, want one event", got) } if got := bodiesOf(c.Rooms.Join[roomID].Timeline.Events); len(got) != 1 { t.Errorf("third re-sync timeline = %v, want the same one event", got) } } func TestSyncRejectsAnUnreadableToken(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") // "s-1" is not here: a negative event position is a /messages token // from inside backfilled history, and a valid ?since=. Only the event // stream goes below zero. for _, since := range []string{"nonsense", "s1_-1", "sx", "s1_2_3_4_5_6_7"} { rec := ts.get("/_matrix/client/v3/sync?since="+url.QueryEscape(since), alice.AccessToken) expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM") } } func TestSyncAcceptsATokenFromAnOlderServer(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) ts.sendMessage(alice, roomID, "hello") // A token with only the event position in it, as a client stored // before the other streams existed. It must be read as "seen nothing" // of those streams rather than refused. result := ts.syncWith(alice, url.Values{"since": {"s1"}}) if len(result.Rooms.Join) == 0 { t.Error("a short token must still sync") } } func TestSyncLongPollWakesOnAnEvent(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) since := ts.sync(alice, "").NextBatch done := make(chan syncResult, 1) go func() { done <- ts.syncWith(alice, url.Values{ "since": {since}, "timeout": {"10000"}, }) }() // The sync has to be blocked before the event is sent, or it would // find the event without ever having waited and the test would pass // without testing anything. waitForListener(t, ts) ts.sendMessage(alice, roomID, "woken") select { case result := <-done: got := bodiesOf(result.Rooms.Join[roomID].Timeline.Events) if !contains(got, "woken") { t.Errorf("timeline = %v, want it to contain \"woken\"", got) } case <-time.After(5 * time.Second): t.Fatal("a blocked /sync was not woken by an event in its room") } } func TestSyncLongPollReturnsEmptyOnTimeout(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") ts.createRoom(alice, nil) since := ts.sync(alice, "").NextBatch start := time.Now() result := ts.syncWith(alice, url.Values{"since": {since}, "timeout": {"150"}}) elapsed := time.Since(start) if len(result.Rooms.Join) != 0 { t.Errorf("join = %v, want nothing", result.Rooms.Join) } if elapsed < 100*time.Millisecond { t.Errorf("a 150ms long poll returned after %v; it did not wait", elapsed) } } // waitForListener blocks until a /sync has registered with the notifier, so // that a test can be sure the sync it started is actually waiting. func waitForListener(t *testing.T, ts *testServer) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if ts.syncAPI.Notifier().Listeners() > 0 { return } time.Sleep(time.Millisecond) } t.Fatal("no /sync ever blocked on the notifier") } func TestSyncReportsAnInviteAsStrippedState(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") roomID := ts.createRoom(alice, map[string]any{"name": "Carthage"}) rec := ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/invite", alice.AccessToken, map[string]any{"user_id": bob.UserID}) expectStatus(t, rec, http.StatusOK) result := ts.sync(bob, "") invite, ok := result.Rooms.Invite[roomID] if !ok { t.Fatalf("the room is not in the invite section: %+v", result.Rooms.Invite) } if !contains(typesOf(invite.InviteState.Events), "m.room.name") { t.Errorf("invite_state = %v, want it to name the room", typesOf(invite.InviteState.Events)) } if !contains(typesOf(invite.InviteState.Events), "m.room.member") { t.Error("invite_state must carry the member events that make the invite legible") } // An invitee gets no timeline at all: they are not a member. if _, joined := result.Rooms.Join[roomID]; joined { t.Error("an invited room must not be in the join section") } } func TestSyncReportsALeaveAndThenStops(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join", bob.AccessToken, map[string]any{}), http.StatusOK) ts.sendMessage(alice, roomID, "while bob is here") since := ts.sync(bob, "").NextBatch expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave", bob.AccessToken, map[string]any{}), http.StatusOK) afterLeave := ts.sync(bob, since) if _, ok := afterLeave.Rooms.Leave[roomID]; !ok { t.Fatalf("the leave was not reported: %+v", afterLeave.Rooms) } // Everything after the leave is somebody else's business. ts.sendMessage(alice, roomID, "after bob left") quiet := ts.sync(bob, afterLeave.NextBatch) for _, section := range []any{quiet.Rooms.Join[roomID], quiet.Rooms.Leave[roomID]} { _ = section } if left, ok := quiet.Rooms.Leave[roomID]; ok { if got := bodiesOf(left.Timeline.Events); contains(got, "after bob left") { t.Errorf("a departed member was sent %v", got) } } } func TestSyncSummaryNamesARoomWithNoName(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") roomID := ts.createRoom(alice, map[string]any{ "preset": "trusted_private_chat", "invite": []string{bob.UserID}, }) result := ts.sync(alice, "") room := result.Rooms.Join[roomID] if room.Summary == nil { t.Fatal("a room with no name must carry a summary to name it by") } if !contains(room.Summary.Heroes, bob.UserID) { t.Errorf("heroes = %v, want it to name %s", room.Summary.Heroes, bob.UserID) } if room.Summary.JoinedMemberCount != 1 { t.Errorf("joined_member_count = %d, want 1", room.Summary.JoinedMemberCount) } if room.Summary.InvitedMemberCount != 1 { t.Errorf("invited_member_count = %d, want 1", room.Summary.InvitedMemberCount) } } func TestSyncLimitedTimelineCarriesTheStateBeforeIt(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) since := ts.sync(alice, "").NextBatch // A state change and then more messages than the timeline limit, so // the state change falls into the gap and has to be reported // separately. expectStatus(t, ts.put("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/state/m.room.topic", alice.AccessToken, map[string]any{"topic": "in the gap"}), http.StatusOK) for i := range 5 { ts.sendMessage(alice, roomID, fmt.Sprintf("message %d", i)) } result := ts.syncWith(alice, url.Values{ "since": {since}, "filter": {`{"room":{"timeline":{"limit":2}}}`}, }) room := result.Rooms.Join[roomID] if !room.Timeline.Limited { t.Fatal("a timeline cut short by the limit must say so") } if room.Timeline.PrevBatch == "" { t.Error("a limited timeline must carry a prev_batch to close the gap with") } if !contains(typesOf(room.State.Events), "m.room.topic") { t.Errorf("state = %v, want the topic change that fell into the gap", typesOf(room.State.Events)) } } func TestSyncFullStateReturnsEverythingAgain(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, map[string]any{"name": "Thebes"}) since := ts.sync(alice, "").NextBatch result := ts.syncWith(alice, url.Values{ "since": {since}, "full_state": {"true"}, "filter": {`{"room":{"timeline":{"limit":1}}}`}, }) room, ok := result.Rooms.Join[roomID] if !ok { t.Fatalf("full_state must report every room: %+v", result.Rooms.Join) } if !contains(typesOf(room.State.Events), "m.room.create") { t.Errorf("state = %v, want the whole state including m.room.create", typesOf(room.State.Events)) } } func TestSyncLazyLoadingLeavesOutUninvolvedMembers(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join", bob.AccessToken, map[string]any{}), http.StatusOK) // Only Alice speaks, so only Alice's member event is needed to render // the timeline. for i := range 4 { ts.sendMessage(alice, roomID, fmt.Sprintf("line %d", i)) } result := ts.syncWith(alice, url.Values{ "filter": {`{"room":{"timeline":{"limit":2,"lazy_load_members":true}}}`}, }) room := result.Rooms.Join[roomID] members := map[string]bool{} for _, e := range room.State.Events { if e.Type == "m.room.member" && e.StateKey != nil { members[*e.StateKey] = true } } if !members[alice.UserID] { t.Error("lazy loading must still send the member event of a timeline sender") } if members[bob.UserID] { t.Error("lazy loading must leave out a member who does not appear in the timeline") } } func TestSyncAppliesATypeFilter(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) since := ts.sync(alice, "").NextBatch ts.sendMessage(alice, roomID, "a message") expectStatus(t, ts.put("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/state/m.room.topic", alice.AccessToken, map[string]any{"topic": "a topic"}), http.StatusOK) result := ts.syncWith(alice, url.Values{ "since": {since}, "filter": {`{"room":{"timeline":{"not_types":["m.room.message"]}}}`}, }) got := typesOf(result.Rooms.Join[roomID].Timeline.Events) if contains(got, "m.room.message") { t.Errorf("timeline = %v, want the message excluded", got) } if !contains(got, "m.room.topic") { t.Errorf("timeline = %v, want the topic kept", got) } } func TestSyncUsesAnUploadedFilter(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) rec := ts.post("/_matrix/client/v3/user/"+url.PathEscape(alice.UserID)+"/filter", alice.AccessToken, map[string]any{ "room": map[string]any{ "timeline": map[string]any{"types": []string{"m.room.message"}}, }, }) expectStatus(t, rec, http.StatusOK) filterID, _ := jsonBody(t, rec)["filter_id"].(string) if filterID == "" { t.Fatalf("no filter ID was returned: %s", rec.Body.String()) } // It comes back exactly as it was uploaded. rec = ts.get("/_matrix/client/v3/user/"+url.PathEscape(alice.UserID)+"/filter/"+filterID, alice.AccessToken) expectStatus(t, rec, http.StatusOK) ts.sendMessage(alice, roomID, "kept") result := ts.syncWith(alice, url.Values{"filter": {filterID}}) got := typesOf(result.Rooms.Join[roomID].Timeline.Events) if !contains(got, "m.room.message") { t.Errorf("timeline = %v, want the message", got) } if contains(got, "m.room.create") { t.Errorf("timeline = %v, want only messages", got) } } func TestGetUnknownFilterIsNotFound(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") rec := ts.get("/_matrix/client/v3/user/"+url.PathEscape(alice.UserID)+"/filter/nope", alice.AccessToken) expectError(t, rec, http.StatusNotFound, "M_NOT_FOUND") } func TestFilterBelongsToItsOwner(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") rec := ts.post("/_matrix/client/v3/user/"+url.PathEscape(bob.UserID)+"/filter", alice.AccessToken, map[string]any{}) expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN") } func TestSyncReportsAccountData(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") since := ts.sync(alice, "").NextBatch expectStatus(t, ts.put("/_matrix/client/v3/user/"+url.PathEscape(alice.UserID)+ "/account_data/m.direct", alice.AccessToken, map[string]any{"@bob:warp.example": []string{}}), http.StatusOK) result := ts.sync(alice, since) if !contains(typesOf(result.AccountData.Events), "m.direct") { t.Errorf("account_data = %v, want m.direct", typesOf(result.AccountData.Events)) } // Account data is not an event: it has a type and a content and // nothing else. for _, e := range result.AccountData.Events { if e.EventID != "" || e.Sender != "" { t.Errorf("account data was reported as an event: %+v", e) } } } func TestMembersAtAnEarlierPosition(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"}) // A token from before Bob joined. before := ts.sync(alice, "").NextBatch expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join", bob.AccessToken, map[string]any{}), http.StatusOK) membersAt := func(at string) []string { t.Helper() path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/members" if at != "" { path += "?at=" + url.QueryEscape(at) } rec := ts.get(path, alice.AccessToken) expectStatus(t, rec, http.StatusOK) body := decode[struct { Chunk []syncEvent `json:"chunk"` }](t, rec) out := make([]string, 0, len(body.Chunk)) for _, e := range body.Chunk { if e.StateKey != nil { out = append(out, *e.StateKey) } } return out } if got := membersAt(""); !contains(got, bob.UserID) { t.Errorf("current members = %v, want it to include %s", got, bob.UserID) } if got := membersAt(before); contains(got, bob.UserID) { t.Errorf("members at an earlier position = %v, want it to leave out %s", got, bob.UserID) } } func TestMembersRejectsABadAtToken(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) rec := ts.get("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/members?at=nonsense", alice.AccessToken) expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM") }