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