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>
375 lines
14 KiB
Go
375 lines
14 KiB
Go
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)
|
|
}
|
|
}
|