package clientapi_test import ( "fmt" "net/http" "net/url" "testing" ) // The streams that are not room events: typing, receipts, read markers, // to-device messages and presence. // ephemeralEvent finds a room's ephemeral EDU of a type in a sync result. func ephemeralEvent(result syncResult, roomID, eventType string) (syncEvent, bool) { for _, e := range result.Rooms.Join[roomID].Ephemeral.Events { if e.Type == eventType { return e, true } } return syncEvent{}, false } func TestTypingReachesTheOtherMembers(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) since := ts.sync(bob, "").NextBatch 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) result := ts.sync(bob, since) typing, ok := ephemeralEvent(result, roomID, "m.typing") if !ok { t.Fatalf("no m.typing in the ephemeral stream: %+v", result.Rooms.Join[roomID].Ephemeral) } users, _ := typing.Content["user_ids"].([]any) if len(users) != 1 || users[0] != alice.UserID { t.Errorf("user_ids = %v, want [%s]", users, alice.UserID) } // While she goes on typing, the next sync has nothing new to say. A // sync that repeated it would return at once every time: clients would // spin instead of long-polling. again := ts.sync(bob, result.NextBatch) if _, ok := ephemeralEvent(again, roomID, "m.typing"); ok { t.Error("an unchanged m.typing was reported again") } // Stopping is reported as the list without them, not as nothing: a // client replaces what it had. 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.sync(bob, again.NextBatch) typing, ok = ephemeralEvent(stopped, roomID, "m.typing") if !ok { t.Fatal("stopping was not reported") } if users, _ := typing.Content["user_ids"].([]any); len(users) != 0 { t.Errorf("user_ids = %v after she stopped, want none", users) } } func TestTypingIsOnlyAboutTheCaller(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"}) rec := ts.put(fmt.Sprintf("/_matrix/client/v3/rooms/%s/typing/%s", url.PathEscape(roomID), url.PathEscape(bob.UserID)), alice.AccessToken, map[string]any{"typing": true}) expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN") } func TestTypingRequiresMembership(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"}) rec := ts.put(fmt.Sprintf("/_matrix/client/v3/rooms/%s/typing/%s", url.PathEscape(roomID), url.PathEscape(bob.UserID)), bob.AccessToken, map[string]any{"typing": true}) expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN") } func TestReceiptReachesTheRoom(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) eventID := ts.sendMessage(alice, roomID, "read me") since := ts.sync(alice, "").NextBatch expectStatus(t, ts.post(fmt.Sprintf("/_matrix/client/v3/rooms/%s/receipt/m.read/%s", url.PathEscape(roomID), url.PathEscape(eventID)), bob.AccessToken, map[string]any{}), http.StatusOK) result := ts.sync(alice, since) receipts, ok := ephemeralEvent(result, roomID, "m.receipt") if !ok { t.Fatalf("no m.receipt in the ephemeral stream: %+v", result.Rooms.Join[roomID].Ephemeral) } // The shape is event ID -> receipt type -> user ID -> data. byType, _ := receipts.Content[eventID].(map[string]any) byUser, _ := byType["m.read"].(map[string]any) if _, found := byUser[bob.UserID]; !found { t.Errorf("m.receipt does not name %s: %+v", bob.UserID, receipts.Content) } } func TestPrivateReceiptStaysWithItsSender(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) eventID := ts.sendMessage(alice, roomID, "read me privately") aliceSince := ts.sync(alice, "").NextBatch bobSince := ts.sync(bob, "").NextBatch expectStatus(t, ts.post(fmt.Sprintf("/_matrix/client/v3/rooms/%s/receipt/m.read.private/%s", url.PathEscape(roomID), url.PathEscape(eventID)), bob.AccessToken, map[string]any{}), http.StatusOK) // Bob sees his own. own := ts.sync(bob, bobSince) if _, ok := ephemeralEvent(own, roomID, "m.receipt"); !ok { t.Error("a private receipt must be reported back to its sender") } // Alice does not. theirs := ts.sync(alice, aliceSince) if e, ok := ephemeralEvent(theirs, roomID, "m.receipt"); ok { t.Errorf("a private receipt leaked to another member: %+v", e.Content) } } func TestReceiptForAnUnknownEventIsNotFound(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/rooms/"+url.PathEscape(roomID)+"/receipt/m.read/$nope", alice.AccessToken, map[string]any{}) expectError(t, rec, http.StatusNotFound, "M_NOT_FOUND") } func TestReceiptOfAnUnknownTypeIsRefused(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, "hello") rec := ts.post(fmt.Sprintf("/_matrix/client/v3/rooms/%s/receipt/m.invented/%s", url.PathEscape(roomID), url.PathEscape(eventID)), alice.AccessToken, map[string]any{}) expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM") } func TestReadMarkersSetTheFullyReadAccountData(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, "up to here") since := ts.sync(alice, "").NextBatch expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/read_markers", alice.AccessToken, map[string]any{ "m.fully_read": eventID, "m.read": eventID, }), http.StatusOK) result := ts.sync(alice, since) room := result.Rooms.Join[roomID] if !contains(typesOf(room.AccountData.Events), "m.fully_read") { t.Errorf("room account data = %v, want m.fully_read", typesOf(room.AccountData.Events)) } if _, ok := ephemeralEvent(result, roomID, "m.receipt"); !ok { t.Error("the m.read in the same request should have become a receipt") } } func TestReadMarkersNeedsAtLeastOneMarker(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/rooms/"+url.PathEscape(roomID)+"/read_markers", alice.AccessToken, map[string]any{}) expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM") } func TestSendToDeviceReachesTheDeviceAndIsThenDrained(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") bobSince := ts.sync(bob, "").NextBatch expectStatus(t, ts.put("/_matrix/client/v3/sendToDevice/m.room_key/txn1", alice.AccessToken, map[string]any{ "messages": map[string]any{ bob.UserID: map[string]any{ bob.DeviceID: map[string]any{"session_id": "abc"}, }, }, }), http.StatusOK) delivered := ts.sync(bob, bobSince) if len(delivered.ToDevice.Events) != 1 { t.Fatalf("to_device = %+v, want one message", delivered.ToDevice.Events) } msg := delivered.ToDevice.Events[0] if msg.Type != "m.room_key" || msg.Sender != alice.UserID { t.Errorf("to_device event = %+v, want an m.room_key from %s", msg, alice.UserID) } if msg.Content["session_id"] != "abc" { t.Errorf("content = %+v, want the body as it was sent", msg.Content) } // Syncing past the batch acknowledges it; the next sync must not // deliver it again. again := ts.sync(bob, delivered.NextBatch) if len(again.ToDevice.Events) != 0 { t.Errorf("to_device = %+v, want nothing after the batch was acknowledged", again.ToDevice.Events) } // Alice's own devices were not addressed and must see nothing. aliceResult := ts.sync(alice, "") if len(aliceResult.ToDevice.Events) != 0 { t.Errorf("the sender received their own to-device message: %+v", aliceResult.ToDevice.Events) } } func TestSendToDeviceWildcardAddressesEveryDevice(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") bobSecond := ts.login(bob, "SECONDDEVICE") firstSince := ts.sync(bob, "").NextBatch secondSince := ts.sync(bobSecond, "").NextBatch expectStatus(t, ts.put("/_matrix/client/v3/sendToDevice/m.room_key/txn1", alice.AccessToken, map[string]any{ "messages": map[string]any{ bob.UserID: map[string]any{"*": map[string]any{"session_id": "shared"}}, }, }), http.StatusOK) for _, session := range []struct { name string acc account since string }{ {"the first device", bob, firstSince}, {"the second device", bobSecond, secondSince}, } { result := ts.sync(session.acc, session.since) if len(result.ToDevice.Events) != 1 { t.Errorf("%s got %+v, want one message", session.name, result.ToDevice.Events) } } } func TestSendToDeviceToARemoteUserIsDroppedNotRefused(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") // Federation is off. Failing the request would stop the local // deliveries in the same batch from happening at all. expectStatus(t, ts.put("/_matrix/client/v3/sendToDevice/m.room_key/txn1", alice.AccessToken, map[string]any{ "messages": map[string]any{ "@carol:elsewhere.example": map[string]any{"ANY": map[string]any{}}, }, }), http.StatusOK) } func TestPresenceRoundTrips(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") expectStatus(t, ts.put("/_matrix/client/v3/presence/"+url.PathEscape(alice.UserID)+"/status", alice.AccessToken, map[string]any{ "presence": "unavailable", "status_msg": "in a meeting", }), http.StatusOK) rec := ts.get("/_matrix/client/v3/presence/"+url.PathEscape(alice.UserID)+"/status", alice.AccessToken) expectStatus(t, rec, http.StatusOK) body := jsonBody(t, rec) if body["presence"] != "unavailable" { t.Errorf("presence = %v, want unavailable", body["presence"]) } if body["status_msg"] != "in a meeting" { t.Errorf("status_msg = %v, want \"in a meeting\"", body["status_msg"]) } } func TestPresenceOfAUserWhoNeverSetItIsOffline(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") rec := ts.get("/_matrix/client/v3/presence/"+url.PathEscape(bob.UserID)+"/status", alice.AccessToken) expectStatus(t, rec, http.StatusOK) if got := jsonBody(t, rec)["presence"]; got != "offline" { t.Errorf("presence = %v, want offline", got) } } func TestPresenceIsOnlyAboutTheCaller(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") bob := ts.register("bob", "hunter2-hunter2-hunter2") rec := ts.put("/_matrix/client/v3/presence/"+url.PathEscape(bob.UserID)+"/status", alice.AccessToken, map[string]any{"presence": "online"}) expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN") } func TestPresenceRejectsAnUnknownState(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "correct-horse-battery-staple") rec := ts.put("/_matrix/client/v3/presence/"+url.PathEscape(alice.UserID)+"/status", alice.AccessToken, map[string]any{"presence": "asleep"}) expectError(t, rec, http.StatusBadRequest, "M_INVALID_PARAM") } func TestPresenceReachesSomebodySharingARoom(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) since := ts.sync(alice, "").NextBatch expectStatus(t, ts.put("/_matrix/client/v3/presence/"+url.PathEscape(bob.UserID)+"/status", bob.AccessToken, map[string]any{"presence": "unavailable", "status_msg": "away"}), http.StatusOK) result := ts.sync(alice, since) var found bool for _, e := range result.Presence.Events { if e.Sender == bob.UserID && e.Content["presence"] == "unavailable" { found = true } } if !found { t.Errorf("presence = %+v, want %s reported as unavailable", result.Presence.Events, bob.UserID) } }