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
Every call in the web client now goes through MatrixRTC with a LiveKit
SFU, the model Element Call and Element X use; the legacy 1:1 m.call.*
calls are gone. In a room of two the other person is rung and can answer
or decline; in a bigger room a banner offers to join the call. Calls show
a grid of participant tiles with mute, camera, screen sharing and leave,
and media is end-to-end encrypted in encrypted rooms.
Server:
- MSC4140 delayed events (send/state with a delay, plus the
delayed_events list/restart/cancel/send API), stored and scheduled, so
a member whose client drops off is taken out of the call.
- POST /user/{userId}/openid/request_token, which lk-jwt-service uses to
hand out LiveKit tokens.
- org.matrix.msc4143.rtc_foci in /.well-known/matrix/client, configured
under client_api.rtc.
- Room presets let members send call membership state.
- Migration 0014 for both SQL dialects.
Deploy: LiveKit and lk-jwt-service in compose (calls.yml) and the helm
chart. Docs: docs/calls.md, configuration and deployment.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
12 KiB
Go
301 lines
12 KiB
Go
package clientapi_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
|
"github.com/dariuszjaniak/warp/server/internal/httputil"
|
|
)
|
|
|
|
const delayedEventsPath = "/_matrix/client/unstable/org.matrix.msc4140/delayed_events"
|
|
|
|
// callMemberPath is where a member of a room's group call puts its state.
|
|
func callMemberPath(roomID, stateKey string) string {
|
|
return "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) +
|
|
"/state/org.matrix.msc3401.call.member/" + url.PathEscape(stateKey)
|
|
}
|
|
|
|
// scheduleDelayed PUTs a delayed state event and returns its delay ID.
|
|
func (ts *testServer) scheduleDelayed(path string, delayMS int, acc account, content map[string]any) string {
|
|
ts.t.Helper()
|
|
rec := ts.put(path+"?org.matrix.msc4140.delay="+strconv.Itoa(delayMS), acc.AccessToken, content)
|
|
expectStatus(ts.t, rec, http.StatusOK)
|
|
delayID, _ := jsonBody(ts.t, rec)["delay_id"].(string)
|
|
if delayID == "" {
|
|
ts.t.Fatalf("a delayed send returned no delay_id: %s", rec.Body.String())
|
|
}
|
|
return delayID
|
|
}
|
|
|
|
// tick moves the clock and sends whatever has come due.
|
|
func (ts *testServer) tick(d time.Duration) {
|
|
ts.t.Helper()
|
|
ts.clock.advance(d)
|
|
ts.delayed.SendDue(ts.t.Context())
|
|
}
|
|
|
|
type delayedList struct {
|
|
Scheduled []struct {
|
|
DelayID string `json:"delay_id"`
|
|
RoomID string `json:"room_id"`
|
|
Type string `json:"type"`
|
|
StateKey *string `json:"state_key"`
|
|
Content map[string]any `json:"content"`
|
|
Delay int64 `json:"delay"`
|
|
RunningSince int64 `json:"running_since"`
|
|
} `json:"scheduled"`
|
|
Finalised []struct {
|
|
DelayedEvent struct {
|
|
DelayID string `json:"delay_id"`
|
|
} `json:"delayed_event"`
|
|
Outcome string `json:"outcome"`
|
|
Reason string `json:"reason"`
|
|
EventID string `json:"event_id"`
|
|
Error map[string]any `json:"error"`
|
|
} `json:"finalised"`
|
|
}
|
|
|
|
func (ts *testServer) delayedList(acc account) delayedList {
|
|
ts.t.Helper()
|
|
rec := ts.get(delayedEventsPath, acc.AccessToken)
|
|
expectStatus(ts.t, rec, http.StatusOK)
|
|
return decode[delayedList](ts.t, rec)
|
|
}
|
|
|
|
// TestDelayedStateEventIsSentWhenDue is the MatrixRTC case: a member's
|
|
// "left the call" is scheduled when it joins, and arrives on its own once
|
|
// the delay runs out.
|
|
func TestDelayedStateEventIsSentWhenDue(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
|
stateKey := "_" + alice.UserID + "_" + alice.DeviceID + "_m.call"
|
|
path := callMemberPath(roomID, stateKey)
|
|
|
|
delayID := ts.scheduleDelayed(path, 5000, alice, map[string]any{})
|
|
|
|
// Nothing is in the room yet.
|
|
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
|
|
|
list := ts.delayedList(alice)
|
|
if len(list.Scheduled) != 1 || list.Scheduled[0].DelayID != delayID {
|
|
t.Fatalf("scheduled = %+v", list.Scheduled)
|
|
}
|
|
got := list.Scheduled[0]
|
|
if got.RoomID != roomID || got.Type != "org.matrix.msc3401.call.member" ||
|
|
got.StateKey == nil || *got.StateKey != stateKey || got.Delay != 5000 {
|
|
t.Errorf("the scheduled event is %+v", got)
|
|
}
|
|
|
|
ts.tick(4 * time.Second)
|
|
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
|
|
|
ts.tick(2 * time.Second)
|
|
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
|
|
|
|
list = ts.delayedList(alice)
|
|
if len(list.Scheduled) != 0 {
|
|
t.Errorf("still scheduled after sending: %+v", list.Scheduled)
|
|
}
|
|
if len(list.Finalised) != 1 || list.Finalised[0].Outcome != "send" ||
|
|
list.Finalised[0].Reason != "delay" || list.Finalised[0].EventID == "" {
|
|
t.Errorf("finalised = %+v", list.Finalised)
|
|
}
|
|
|
|
// Looked up on its own, it is the same.
|
|
rec := ts.get(delayedEventsPath+"/"+delayID, alice.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
if jsonBody(t, rec)["outcome"] != "send" {
|
|
t.Errorf("GET by ID = %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestDelayedEventRestart: a client still in the call keeps pushing its
|
|
// leave back, and it never arrives.
|
|
func TestDelayedEventRestart(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
|
path := callMemberPath(roomID, "_"+alice.UserID+"_D_m.call")
|
|
|
|
delayID := ts.scheduleDelayed(path, 5000, alice, map[string]any{})
|
|
|
|
for range 3 {
|
|
ts.tick(4 * time.Second)
|
|
// The action endpoints need no token: the delay ID is the
|
|
// capability.
|
|
expectStatus(t, ts.post(delayedEventsPath+"/"+delayID+"/restart", "", nil), http.StatusOK)
|
|
}
|
|
ts.tick(4 * time.Second)
|
|
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
|
|
|
ts.tick(2 * time.Second)
|
|
expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK)
|
|
|
|
// Once it has been sent it cannot be restarted.
|
|
rec := ts.post(delayedEventsPath+"/"+delayID+"/restart", "", nil)
|
|
expectError(t, rec, http.StatusNotFound, httputil.ErrCodeNotFound)
|
|
}
|
|
|
|
// TestDelayedEventActions covers cancel and send, in both the path and the
|
|
// body spelling.
|
|
func TestDelayedEventActions(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
|
escaped := url.PathEscape(roomID)
|
|
|
|
// Cancelled with the action in the body: never sent.
|
|
topic := "/_matrix/client/v3/rooms/" + escaped + "/state/m.room.topic"
|
|
cancelled := ts.scheduleDelayed(topic, 1000, alice, map[string]any{"topic": "never"})
|
|
expectStatus(t, ts.post(delayedEventsPath+"/"+cancelled, alice.AccessToken,
|
|
map[string]any{"action": "cancel"}), http.StatusOK)
|
|
ts.tick(2 * time.Second)
|
|
expectError(t, ts.get(topic, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
|
|
|
// Sent at once with the action in the path: a timeline event, which
|
|
// is how a client sends a message it held back.
|
|
send := "/_matrix/client/v3/rooms/" + escaped + "/send/m.room.message/txn1"
|
|
sent := ts.scheduleDelayed(send, 60000, alice, map[string]any{"msgtype": "m.text", "body": "later"})
|
|
expectStatus(t, ts.post(delayedEventsPath+"/"+sent+"/send", "", nil), http.StatusOK)
|
|
|
|
list := ts.delayedList(alice)
|
|
if len(list.Scheduled) != 0 || len(list.Finalised) != 2 {
|
|
t.Fatalf("list = %+v", list)
|
|
}
|
|
outcomes := map[string]string{}
|
|
for _, f := range list.Finalised {
|
|
outcomes[f.DelayedEvent.DelayID] = f.Outcome + "/" + f.Reason
|
|
}
|
|
if outcomes[cancelled] != "cancel/action" || outcomes[sent] != "send/action" {
|
|
t.Errorf("outcomes = %v", outcomes)
|
|
}
|
|
|
|
// status narrows the list.
|
|
rec := ts.get(delayedEventsPath+"?status=scheduled", alice.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
if l := decode[delayedList](t, rec); len(l.Finalised) != 0 {
|
|
t.Errorf("status=scheduled listed finalised events: %s", rec.Body.String())
|
|
}
|
|
|
|
expectError(t, ts.post(delayedEventsPath+"/nope/cancel", "", nil), http.StatusNotFound, httputil.ErrCodeNotFound)
|
|
expectError(t, ts.post(delayedEventsPath+"/"+sent+"/explode", "", nil),
|
|
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
|
|
}
|
|
|
|
// TestDelayedEventFailureIsRecorded: an event that can no longer be sent
|
|
// is finalised with the error the client would have got.
|
|
func TestDelayedEventFailureIsRecorded(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
|
path := callMemberPath(roomID, "_"+alice.UserID+"_D_m.call")
|
|
|
|
ts.scheduleDelayed(path, 1000, alice, map[string]any{})
|
|
expectStatus(t, ts.post("/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave",
|
|
alice.AccessToken, map[string]any{}), http.StatusOK)
|
|
ts.tick(2 * time.Second)
|
|
|
|
list := ts.delayedList(alice)
|
|
if len(list.Finalised) != 1 || list.Finalised[0].Reason != "error" ||
|
|
list.Finalised[0].Error["errcode"] != httputil.ErrCodeForbidden {
|
|
t.Errorf("finalised = %+v", list.Finalised)
|
|
}
|
|
}
|
|
|
|
// TestDelayedStateIsCancelledByOthers: someone else setting the same state
|
|
// cancels a delayed event that would have overwritten it, and the owner's
|
|
// own updates do not. It also proves an ordinary member may join a call.
|
|
func TestDelayedStateIsCancelledByOthers(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
bob := ts.register("bob", "correct-horse-battery-staple")
|
|
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)
|
|
|
|
aliceKey := callMemberPath(roomID, "_"+alice.UserID+"_A_m.call")
|
|
ts.scheduleDelayed(aliceKey, 5000, alice, map[string]any{})
|
|
|
|
// Alice refreshing her own membership leaves the delayed leave alone.
|
|
expectStatus(t, ts.put(aliceKey, alice.AccessToken, map[string]any{"application": "m.call"}), http.StatusOK)
|
|
// Bob, an ordinary member, may put call state of his own.
|
|
expectStatus(t, ts.put(callMemberPath(roomID, "_"+bob.UserID+"_B_m.call"), bob.AccessToken,
|
|
map[string]any{"application": "m.call"}), http.StatusOK)
|
|
// The cancellation runs off the emit stage, so give it a moment.
|
|
time.Sleep(100 * time.Millisecond)
|
|
if l := ts.delayedList(alice); len(l.Scheduled) != 1 {
|
|
t.Fatalf("alice's own update cancelled her delayed event: %+v", l)
|
|
}
|
|
|
|
// Bob writing the same piece of state cancels it.
|
|
expectStatus(t, ts.put(aliceKey, bob.AccessToken, map[string]any{}), http.StatusOK)
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for {
|
|
l := ts.delayedList(alice)
|
|
if len(l.Scheduled) == 0 {
|
|
if len(l.Finalised) != 1 || l.Finalised[0].Outcome != "cancel" {
|
|
t.Errorf("finalised = %+v", l.Finalised)
|
|
}
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the overwritten delayed event was never cancelled")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestDelayedEventLimits(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
|
topic := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/state/m.room.topic"
|
|
|
|
// Longer than the server allows: the MSC's own code and the maximum
|
|
// come with the error.
|
|
rec := ts.put(topic+"?org.matrix.msc4140.delay=7200000", alice.AccessToken, map[string]any{"topic": "x"})
|
|
expectError(t, rec, http.StatusBadRequest, httputil.ErrCodeUnknown)
|
|
body := jsonBody(t, rec)
|
|
if body["org.matrix.msc4140.errcode"] != "M_MAX_DELAY_EXCEEDED" || body["org.matrix.msc4140.max_delay"] != float64(3600000) {
|
|
t.Errorf("error = %s", rec.Body.String())
|
|
}
|
|
|
|
expectError(t, ts.put(topic+"?org.matrix.msc4140.delay=soon", alice.AccessToken, map[string]any{"topic": "x"}),
|
|
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
|
|
expectError(t, ts.put(topic+"?org.matrix.msc4140.parent_delay_id=x", alice.AccessToken, map[string]any{"topic": "x"}),
|
|
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
|
|
|
|
// Five at once is the test server's limit.
|
|
for range 5 {
|
|
ts.scheduleDelayed(topic, 1000, alice, map[string]any{"topic": "x"})
|
|
}
|
|
rec = ts.put(topic+"?org.matrix.msc4140.delay=1000", alice.AccessToken, map[string]any{"topic": "x"})
|
|
expectError(t, rec, http.StatusBadRequest, "M_LIMIT_EXCEEDED")
|
|
|
|
// Nobody else can read them.
|
|
bob := ts.register("bob", "correct-horse-battery-staple")
|
|
if l := ts.delayedList(bob); len(l.Scheduled) != 0 {
|
|
t.Errorf("bob sees alice's delayed events: %+v", l)
|
|
}
|
|
expectError(t, ts.get(delayedEventsPath, ""), http.StatusUnauthorized, "M_MISSING_TOKEN")
|
|
}
|
|
|
|
// TestDelayedEventsDisabled: without the service the parameter is refused,
|
|
// rather than the event being sent at once, and the endpoints are absent.
|
|
func TestDelayedEventsDisabled(t *testing.T) {
|
|
ts := newTestServer(t, func(o *clientapi.Options) { o.DelayedEvents = nil })
|
|
alice := ts.register("alice", "correct-horse-battery-staple")
|
|
roomID := ts.createRoom(alice, map[string]any{"preset": "public_chat"})
|
|
path := callMemberPath(roomID, "_"+alice.UserID+"_A_m.call")
|
|
|
|
expectError(t, ts.put(path+"?org.matrix.msc4140.delay=1000", alice.AccessToken, map[string]any{}),
|
|
http.StatusBadRequest, httputil.ErrCodeInvalidParam)
|
|
expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeNotFound)
|
|
expectError(t, ts.get(delayedEventsPath, alice.AccessToken), http.StatusNotFound, httputil.ErrCodeUnrecognized)
|
|
}
|