Files
Warp/server/internal/clientapi/ephemeral_test.go
T
tajniak81andClaude Opus 5.5 b6f3be6ac4
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
Web client (warp-web) with E2EE, and two sync fixes
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>
2026-10-18 16:22:11 +02:00

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)
}
}