Files
Warp/server/internal/clientapi/sync_test.go
T
tajniak81andClaude Opus 5.5 fc49f56339 Phase 7: E2EE support
Implements PLAN.md Phase 7: the server half of end-to-end encryption.
Devices upload identity, one-time and fallback keys; other devices query
and claim them, across servers too, and every one-time key is handed out
exactly once. Cross-signing, signature uploads, device list tracking and
server-side key backup complete the set.

internal/keyserver is the component. Keys are stored in canonical JSON
so that re-uploading the same key is a byte comparison, and signatures
uploaded later are kept apart from the key they sign and merged in on
read, so a key's own JSON stays what its owner uploaded. Device keys must
be for the caller's own device; cross-signing keys must be the
uploader's, carry the right usage and, for self- and user-signing keys,
be signed by the master key. Signatures are verified against the key as
stored. Replacing a master key asks for the password; a first upload or
an upload of the keys already stored does not, as the spec allows. A
signature of another user's master key is shown only to the user who
made it and never federated.

Every write that changes what /keys/query answers moves the device list
stream in the same transaction, one position per device, and wakes the
syncs of everyone sharing a room. The stream is the sixth field of a sync
token; older tokens still parse. The syncapi works out device_lists
changed and left from the stream and from membership moves in the
window, skipping invites and knocks, and reports
device_one_time_keys_count and device_unused_fallback_key_types, the
latter as [] when empty. GET /keys/changes uses the same logic.

Over federation: /user/keys/query, /user/keys/claim and
/user/devices/{userId}, and m.device_list_update and m.signing_key_update
both ways. Remote keys are not cached; an update about a remote user
sharing a room moves them in the stream so local clients re-query.
Device display names are not sent to other servers. With federation off,
remote users are reported in failures at once.

Deleting a device deletes its keys, through a new userapi
DeviceObserver. Key backup keeps the better copy of each session by the
spec's rule, never reuses a deleted version's number, and answers writes
to a stale version with M_WRONG_ROOM_KEYS_VERSION.

Storage is migration 0008 for sqlite and postgres, with a PocketBase
stub. The Complement target sets gain the key, device list, fallback,
cross-signing and backup tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 10:34:08 +02:00

668 lines
22 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"} {
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")
}