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