CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
apps/web: a Matrix client for the browser on Vue 3, Tailwind and matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional, separate service: warp-web embeds the built app, serves /config.json and proxies only /_matrix/client and /_matrix/media to the homeserver (or, with -public-homeserver, lets the browser call it directly). Sign-in and sign-up with registration tokens, room list, timeline with back-pagination, text, images, video and files over authenticated media, local echo with sent/read/failed states, receipts, typing, invites, creating, joining and leaving rooms, room info, settings, phone layout. End-to-end encryption through the Rust crypto: encrypted messages and files, emoji verification, key backup setup and restore. apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package, and the icons the client needs. syncapi fixes found with the client: - A room joined inside the sync window was sent as the join alone with limited: false, so clients thought they had the whole room. It is now read like an initial sync; a profile change is not mistaken for a join. - A live typing notification was reported on every sync, so every long poll returned at once while anybody typed. Typing now has a position in the sync token and is reported when it changes, including the empty list when the last person stops. Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job with a proxy smoke test, docs. PLAN.md moves out of the repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
706 lines
24 KiB
Go
706 lines
24 KiB
Go
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_8_9"} {
|
|
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 TestSyncIncrementalSendsARoomJoinedInTheWindowInFull(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", "name": "Carthage"})
|
|
ts.sendMessage(alice, roomID, "before bob joined")
|
|
|
|
since := ts.sync(bob, "").NextBatch
|
|
expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join",
|
|
bob.AccessToken, map[string]any{}), http.StatusOK)
|
|
|
|
// To bob the room is new: he gets it as an initial sync would give it,
|
|
// the history he may see and the state, not just his own join.
|
|
room, ok := ts.sync(bob, since).Rooms.Join[roomID]
|
|
if !ok {
|
|
t.Fatal("the joined room is not in the join section")
|
|
}
|
|
if got := bodiesOf(room.Timeline.Events); !contains(got, "before bob joined") {
|
|
t.Errorf("timeline = %v, want the history before the join", got)
|
|
}
|
|
all := append(typesOf(room.State.Events), typesOf(room.Timeline.Events)...)
|
|
for _, want := range []string{"m.room.create", "m.room.name", "m.room.power_levels"} {
|
|
if !contains(all, want) {
|
|
t.Errorf("state and timeline = %v, want %s", all, want)
|
|
}
|
|
}
|
|
|
|
// A later change to bob's own profile moves his membership too, but is
|
|
// no new join: that sync carries only what is new.
|
|
since = ts.sync(bob, since).NextBatch
|
|
expectStatus(t, ts.put("/_matrix/client/v3/profile/"+url.PathEscape(bob.UserID)+"/displayname",
|
|
bob.AccessToken, map[string]any{"displayname": "Bob"}), http.StatusOK)
|
|
room = ts.sync(bob, since).Rooms.Join[roomID]
|
|
if got := bodiesOf(room.Timeline.Events); contains(got, "before bob joined") {
|
|
t.Errorf("a profile change resent the history: %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")
|
|
}
|