package clientapi_test import ( "fmt" "net/http" "net/url" "testing" "time" "github.com/dariuszjaniak/warp/server/internal/httputil" ) // Simplified Sliding Sync (MSC4186). const slidingPath = "/_matrix/client/unstable/org.matrix.simplified_msc3575/sync" type slidingResult struct { Pos string `json:"pos"` Lists map[string]struct { Count int `json:"count"` } `json:"lists"` Rooms map[string]struct { Name string `json:"name"` Initial bool `json:"initial"` RequiredState []syncEvent `json:"required_state"` Timeline []syncEvent `json:"timeline"` PrevBatch string `json:"prev_batch"` Limited bool `json:"limited"` NumLive *int `json:"num_live"` BumpStamp int64 `json:"bump_stamp"` JoinedCount *int `json:"joined_count"` InviteState []map[string]any `json:"invite_state"` Heroes []map[string]any `json:"heroes"` } `json:"rooms"` Extensions struct { ToDevice *struct { NextBatch string `json:"next_batch"` Events []map[string]any `json:"events"` } `json:"to_device"` E2EE *struct { DeviceLists struct { Changed []string `json:"changed"` } `json:"device_lists"` DeviceOneTimeKeysCount map[string]int `json:"device_one_time_keys_count"` } `json:"e2ee"` AccountData *struct { Global []map[string]any `json:"global"` Rooms map[string][]map[string]any `json:"rooms"` } `json:"account_data"` Receipts *struct { Rooms map[string]map[string]any `json:"rooms"` } `json:"receipts"` Typing *struct { Rooms map[string]map[string]any `json:"rooms"` } `json:"typing"` } `json:"extensions"` } func (ts *testServer) slidingRaw(acc account, pos string, timeout time.Duration, body map[string]any) (int, slidingResult, map[string]any) { ts.t.Helper() query := url.Values{} if pos != "" { query.Set("pos", pos) } query.Set("timeout", fmt.Sprint(timeout.Milliseconds())) rec := ts.post(slidingPath+"?"+query.Encode(), acc.AccessToken, body) if rec.Code != http.StatusOK { return rec.Code, slidingResult{}, jsonBody(ts.t, rec) } return rec.Code, decode[slidingResult](ts.t, rec), nil } func (ts *testServer) sliding(acc account, pos string, body map[string]any) slidingResult { ts.t.Helper() code, result, errBody := ts.slidingRaw(acc, pos, 0, body) if code != http.StatusOK { ts.t.Fatalf("sliding sync answered %d: %v", code, errBody) } if result.Pos == "" { ts.t.Fatal("sliding sync returned no pos") } return result } func roomList(ranges [][2]int, timelineLimit int, requiredState [][]string) map[string]any { return map[string]any{ "lists": map[string]any{ "all": map[string]any{ "ranges": ranges, "timeline_limit": timelineLimit, "required_state": requiredState, }, }, } } func TestSlidingSyncListWindowAndIncrementals(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") oldest := ts.createRoom(alice, map[string]any{"name": "Oldest"}) middle := ts.createRoom(alice, map[string]any{"name": "Middle"}) newest := ts.createRoom(alice, map[string]any{"name": "Newest"}) ts.sendMessage(alice, oldest, "o1") ts.sendMessage(alice, middle, "m1") ts.sendMessage(alice, newest, "n1") ts.sendMessage(alice, newest, "n2") body := roomList([][2]int{{0, 1}}, 2, [][]string{{"m.room.name", ""}}) first := ts.sliding(alice, "", body) if first.Lists["all"].Count != 3 { t.Fatalf("list count = %d, want 3", first.Lists["all"].Count) } if len(first.Rooms) != 2 { t.Fatalf("rooms = %v, want the two most recently active", keysOf(first.Rooms)) } room, ok := first.Rooms[newest] if !ok || !room.Initial || room.Name != "Newest" { t.Fatalf("newest room = %+v", room) } if got := bodiesOf(room.Timeline); len(got) != 2 || got[1] != "n2" || !room.Limited || room.PrevBatch == "" { t.Errorf("newest timeline = %v (limited %v, prev_batch %q)", got, room.Limited, room.PrevBatch) } if len(room.RequiredState) != 1 || room.RequiredState[0].Type != "m.room.name" { t.Errorf("required_state = %+v", room.RequiredState) } if room.JoinedCount == nil || *room.JoinedCount != 1 || room.BumpStamp == 0 { t.Errorf("counts and bump stamp = %v %d", room.JoinedCount, room.BumpStamp) } if _, ok := first.Rooms[middle]; !ok { t.Errorf("middle room missing from the window: %v", keysOf(first.Rooms)) } // Nothing has happened: nothing is sent. idle := ts.sliding(alice, first.Pos, body) if len(idle.Rooms) != 0 { t.Errorf("an idle incremental sent %v", keysOf(idle.Rooms)) } // A message in a room already sent comes as an increment. ts.sendMessage(alice, middle, "m2") inc := ts.sliding(alice, idle.Pos, body) got, ok := inc.Rooms[middle] if !ok || got.Initial || len(got.Timeline) != 1 || got.Timeline[0].Content["body"] != "m2" { t.Fatalf("increment for middle = %+v (rooms %v)", got, keysOf(inc.Rooms)) } if got.NumLive == nil || *got.NumLive != 1 { t.Errorf("num_live = %v, want 1", got.NumLive) } // The oldest room becomes active, moves into the window, and is sent // in full because this connection has never seen it. ts.sendMessage(alice, oldest, "o2") bumped := ts.sliding(alice, inc.Pos, body) if o, ok := bumped.Rooms[oldest]; !ok || !o.Initial { t.Errorf("the bumped room = %+v (rooms %v)", o, keysOf(bumped.Rooms)) } // A retry of an earlier pos still works; a made-up one does not. if retry := ts.sliding(alice, inc.Pos, body); retry.Pos == "" { t.Error("retrying the previous pos failed") } code, _, errBody := ts.slidingRaw(alice, "nonsense.5", 0, body) if code != http.StatusBadRequest || errBody["errcode"] != httputil.ErrCodeUnknownPos { t.Errorf("an unknown pos answered %d %v, want M_UNKNOWN_POS", code, errBody) } } func TestSlidingSyncInvitesSubscriptionsAndLeaves(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "correct-horse-battery-staple") joined := ts.createRoom(bob, nil) ts.sendMessage(bob, joined, "hello") invited := ts.createRoom(alice, map[string]any{"name": "Party", "invite": []string{bob.UserID}}) body := map[string]any{ "lists": map[string]any{ "invites": map[string]any{"ranges": [][2]int{{0, 10}}, "filters": map[string]any{"is_invite": true}}, "rooms": map[string]any{ "ranges": [][2]int{{0, 10}}, "filters": map[string]any{"is_invite": false}, "timeline_limit": 5, "required_state": [][]string{{"m.room.member", "$LAZY"}}, }, }, } first := ts.sliding(bob, "", body) if first.Lists["invites"].Count != 1 || first.Lists["rooms"].Count != 1 { t.Fatalf("list counts = %+v", first.Lists) } if inv := first.Rooms[invited]; len(inv.InviteState) == 0 || inv.Timeline != nil { t.Errorf("the invite = %+v, want stripped state and no timeline", inv) } lazy := first.Rooms[joined].RequiredState if len(lazy) != 1 || lazy[0].Type != "m.room.member" || *lazy[0].StateKey != bob.UserID { t.Errorf("lazy-loaded members = %+v, want only bob, who sent the timeline", lazy) } // A subscription to a room outside every list. public := ts.createRoom(alice, map[string]any{"name": "Public", "preset": "public_chat"}) expectStatus(t, ts.post("/_matrix/client/v3/join/"+url.PathEscape(public), bob.AccessToken, map[string]any{}), http.StatusOK) sub := ts.sliding(bob, first.Pos, map[string]any{ "room_subscriptions": map[string]any{public: map[string]any{"timeline_limit": 1, "required_state": [][]string{{"*", "*"}}}}, }) if r, ok := sub.Rooms[public]; !ok || !r.Initial || len(r.RequiredState) < 5 { t.Fatalf("subscribed room = %+v", r) } // Joining the invited room turns it into a room sent in full. expectStatus(t, ts.post("/_matrix/client/v3/join/"+url.PathEscape(invited), bob.AccessToken, map[string]any{}), http.StatusOK) afterJoin := ts.sliding(bob, sub.Pos, body) if r, ok := afterJoin.Rooms[invited]; !ok || !r.Initial || len(r.Timeline) == 0 { t.Errorf("the room just joined = %+v", r) } // Leaving is reported, with the leave in the timeline. expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(invited)+"/leave", bob.AccessToken, map[string]any{}), http.StatusOK) afterLeave := ts.sliding(bob, afterJoin.Pos, body) r, ok := afterLeave.Rooms[invited] if !ok || len(r.Timeline) == 0 || r.Timeline[len(r.Timeline)-1].Type != "m.room.member" { t.Errorf("the room just left = %+v (rooms %v)", r, keysOf(afterLeave.Rooms)) } } func TestSlidingSyncExtensions(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) eventID := ts.sendMessage(alice, roomID, "read me") body := roomList([][2]int{{0, 0}}, 1, nil) body["extensions"] = map[string]any{ "to_device": map[string]any{"enabled": true}, "e2ee": map[string]any{"enabled": true}, "account_data": map[string]any{"enabled": true}, "receipts": map[string]any{"enabled": true}, "typing": map[string]any{"enabled": true}, } expectStatus(t, ts.put("/_matrix/client/v3/user/"+url.PathEscape(alice.UserID)+"/account_data/com.example.setting", alice.AccessToken, map[string]any{"on": true}), http.StatusOK) expectStatus(t, ts.post(fmt.Sprintf("/_matrix/client/v3/rooms/%s/receipt/m.read/%s", url.PathEscape(roomID), url.PathEscape(eventID)), alice.AccessToken, map[string]any{}), http.StatusOK) expectStatus(t, ts.put("/_matrix/client/v3/sendToDevice/m.test/t1", alice.AccessToken, map[string]any{ "messages": map[string]any{alice.UserID: map[string]any{alice.DeviceID: map[string]any{"hello": "device"}}}, }), http.StatusOK) first := ts.sliding(alice, "", body) ext := first.Extensions if ext.ToDevice == nil || len(ext.ToDevice.Events) != 1 || ext.ToDevice.NextBatch == "" { t.Fatalf("to_device = %+v", ext.ToDevice) } if ext.E2EE == nil || ext.E2EE.DeviceOneTimeKeysCount == nil { t.Errorf("e2ee = %+v", ext.E2EE) } foundSetting := false if ext.AccountData != nil { for _, e := range ext.AccountData.Global { if e["type"] == "com.example.setting" { foundSetting = true } } } if !foundSetting { t.Errorf("account_data = %+v", ext.AccountData) } if ext.Receipts == nil || ext.Receipts.Rooms[roomID] == nil { t.Errorf("receipts = %+v", ext.Receipts) } // Coming back with the to-device token acknowledges the batch. body["extensions"].(map[string]any)["to_device"] = map[string]any{"enabled": true, "since": ext.ToDevice.NextBatch} second := ts.sliding(alice, first.Pos, body) if second.Extensions.ToDevice == nil || len(second.Extensions.ToDevice.Events) != 0 { t.Errorf("an acknowledged batch came again: %+v", second.Extensions.ToDevice) } // Typing is sent when it starts and again when it stops. expectStatus(t, ts.put(fmt.Sprintf("/_matrix/client/v3/rooms/%s/typing/%s", url.PathEscape(roomID), url.PathEscape(alice.UserID)), alice.AccessToken, map[string]any{"typing": true, "timeout": 30000}), http.StatusOK) typing := ts.sliding(alice, second.Pos, body) if typing.Extensions.Typing == nil || typing.Extensions.Typing.Rooms[roomID] == nil { t.Fatalf("typing = %+v", typing.Extensions.Typing) } again := ts.sliding(alice, typing.Pos, body) if len(again.Extensions.Typing.Rooms) != 0 { t.Errorf("unchanged typing was sent again: %+v", again.Extensions.Typing.Rooms) } expectStatus(t, ts.put(fmt.Sprintf("/_matrix/client/v3/rooms/%s/typing/%s", url.PathEscape(roomID), url.PathEscape(alice.UserID)), alice.AccessToken, map[string]any{"typing": false}), http.StatusOK) stopped := ts.sliding(alice, again.Pos, body) if stopped.Extensions.Typing.Rooms[roomID] == nil { t.Errorf("stopping typing was not sent: %+v", stopped.Extensions.Typing.Rooms) } } func TestSlidingSyncLongPoll(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") roomID := ts.createRoom(alice, nil) body := roomList([][2]int{{0, 0}}, 5, nil) first := ts.sliding(alice, "", body) done := make(chan slidingResult, 1) start := time.Now() go func() { _, result, _ := ts.slidingRaw(alice, first.Pos, 10*time.Second, body) done <- result }() time.Sleep(200 * time.Millisecond) ts.sendMessage(alice, roomID, "wake up") select { case result := <-done: if r, ok := result.Rooms[roomID]; !ok || len(r.Timeline) != 1 { t.Errorf("the woken response = %+v", result.Rooms) } if time.Since(start) > 5*time.Second { t.Errorf("the long poll took %s to wake", time.Since(start)) } case <-time.After(15 * time.Second): t.Fatal("the long poll never returned") } } func keysOf[V any](m map[string]V) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } return out }