Files
Warp/server/internal/clientapi/clientapi_test.go
T
tajniak81andClaude Opus 5.5 76dfc98a86
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
Group voice and video calls with MatrixRTC and LiveKit
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>
2026-10-18 19:33:29 +02:00

385 lines
11 KiB
Go

package clientapi_test
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/dariuszjaniak/warp/server/internal/clientapi"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/delayedevents"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/storage"
_ "github.com/dariuszjaniak/warp/server/internal/storage/drivers"
"github.com/dariuszjaniak/warp/server/internal/syncapi"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
const testServerName = "warp.example"
// testServer is a Client-Server API on a throwaway SQLite database.
//
// It is built from the real components rather than fakes: the point of these
// tests is the behaviour a client sees, and a fake userapi would only prove
// that the handlers call the methods the fake expects.
type testServer struct {
t *testing.T
handler http.Handler
users *userapi.API
rooms *roomserver.API
syncAPI *syncapi.API
settings *settings.Service
fedGate *federation.Gate
db storage.Database
// delayed is the delayed event service, on a clock of its own that
// tests move with advance.
delayed *delayedevents.Service
clock *testClock
}
// testClock is a clock a test moves by hand.
type testClock struct {
mu sync.Mutex
now time.Time
}
func (c *testClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *testClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.now = c.now.Add(d)
}
// teeNotifier passes the roomserver's emit stage on to the syncapi and,
// once it is built, to the delayed event service, which needs the
// roomserver to exist first.
type teeNotifier struct {
first roomserver.Notifier
then roomserver.Notifier
}
func (n *teeNotifier) EventPersisted(ctx context.Context, note roomserver.Notification) {
n.first.EventPersisted(ctx, note)
if n.then != nil {
n.then.EventPersisted(ctx, note)
}
}
// newTestServer builds a server. tweak adjusts the options before the
// component is created, which is how a test turns on registration tokens or
// rate limiting.
func newTestServer(t *testing.T, tweak func(*clientapi.Options)) *testServer {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "warp.db"))
db, err := storage.Open(t.Context(), storage.Config{
Driver: storage.DriverSQLite,
ConnectionString: "file:" + path + "?_pragma=busy_timeout(5000)",
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("migrate: %v", err)
}
users, err := userapi.New(userapi.Options{
ServerName: testServerName,
Store: db.Users(),
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("userapi.New: %v", err)
}
// Registration is on and the limiter is off by default: almost every
// test needs an account, and almost none is about rate limiting.
settingsService, err := settings.New(t.Context(), db.Settings(), settings.Defaults{
RegistrationEnabled: true,
RegistrationAllowRuntimeToggle: true,
FederationAllowRuntimeToggle: true,
}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("settings.New: %v", err)
}
gate := federation.NewGate(settingsService.FederationEnabled, nil)
// A throwaway signing key: the roomserver signs every event it builds,
// and the room endpoints are not registered without one.
key, err := signing.Generate("test")
if err != nil {
t.Fatalf("signing.Generate: %v", err)
}
// The notifier is the seam between the two: the roomserver's emit
// stage and the syncapi's wake-up are the same object, exactly as in
// a real server.
notifier := syncapi.NewNotifier()
emit := &teeNotifier{first: notifier}
rooms, err := roomserver.New(roomserver.Options{
ServerName: testServerName,
Store: db.Rooms(),
Profiles: users,
SigningKey: key,
FederationEnabled: settingsService.FederationEnabled,
Notifier: emit,
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("roomserver.New: %v", err)
}
// Presence is on in tests and off in a default config: the tests are
// the only thing that exercises it, and a switch nothing turns on is
// a switch nothing tests.
sync, err := syncapi.New(syncapi.Options{
ServerName: testServerName,
Rooms: rooms,
Accounts: users,
Store: db.Sync(),
Notifier: notifier,
PresenceEnabled: true,
Logger: slog.New(slog.DiscardHandler),
})
if err != nil {
t.Fatalf("syncapi.New: %v", err)
}
t.Cleanup(sync.Close)
// Delayed events are on, with the scheduler left stopped: a test
// moves the clock and calls SendDue itself.
clock := &testClock{now: time.Now()}
delayed, err := delayedevents.New(delayedevents.Options{
Store: db.DelayedEvents(),
Rooms: rooms,
MaxDelay: time.Hour,
MaxPerUser: 5,
DescribeError: clientapi.DescribeRoomError,
Logger: slog.New(slog.DiscardHandler),
Now: clock.Now,
})
if err != nil {
t.Fatalf("delayedevents.New: %v", err)
}
t.Cleanup(delayed.Close)
emit.then = delayed
opts := clientapi.Options{
ServerName: testServerName,
Users: users,
Rooms: rooms,
Sync: sync,
Settings: settingsService,
Federation: gate,
DelayedEvents: delayed,
OpenIDTokens: db.OpenIDTokens(),
Logger: slog.New(slog.DiscardHandler),
}
if tweak != nil {
tweak(&opts)
}
api, err := clientapi.New(opts)
if err != nil {
t.Fatalf("clientapi.New: %v", err)
}
router := httputil.NewRouter(nil)
api.RegisterRoutes(router)
return &testServer{
t: t,
handler: router,
users: users,
rooms: rooms,
syncAPI: sync,
settings: settingsService,
fedGate: gate,
db: db,
delayed: delayed,
clock: clock,
}
}
// request is one HTTP call under test.
type request struct {
method string
path string
body any
token string
remote string
}
// do performs a request. A nil body sends none at all, which is what the
// endpoints whose body is optional have to cope with.
func (ts *testServer) do(req request) *httptest.ResponseRecorder {
ts.t.Helper()
var reader io.Reader
if req.body != nil {
raw, err := json.Marshal(req.body)
if err != nil {
ts.t.Fatalf("marshalling the request body: %v", err)
}
reader = bytes.NewReader(raw)
}
httpReq := httptest.NewRequest(req.method, req.path, reader)
if req.body != nil {
httpReq.Header.Set("Content-Type", "application/json")
}
if req.token != "" {
httpReq.Header.Set("Authorization", "Bearer "+req.token)
}
if req.remote != "" {
httpReq.RemoteAddr = req.remote
}
rec := httptest.NewRecorder()
ts.handler.ServeHTTP(rec, httpReq)
return rec
}
// get is the common read-only case.
func (ts *testServer) get(path, token string) *httptest.ResponseRecorder {
ts.t.Helper()
return ts.do(request{method: http.MethodGet, path: path, token: token})
}
// decode parses a response body as JSON.
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var out T
body := rec.Body.Bytes()
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("response is not JSON (%s): %v", body, err)
}
return out
}
// jsonBody parses a response into a generic map.
func jsonBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
return decode[map[string]any](t, rec)
}
// expectStatus fails unless the response has the given status.
func expectStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body.String())
}
}
// expectError fails unless the response is the given Matrix error.
func expectError(t *testing.T, rec *httptest.ResponseRecorder, status int, errcode string) {
t.Helper()
expectStatus(t, rec, status)
body := jsonBody(t, rec)
if got, _ := body["errcode"].(string); got != errcode {
t.Errorf("errcode = %q, want %q (body: %s)", got, errcode, rec.Body.String())
}
if msg, _ := body["error"].(string); msg == "" {
t.Error("the error envelope must carry a human-readable message")
}
}
// account is a registered user plus its first session, which is what most
// tests need before they can do anything.
type account struct {
UserID string
Localpart string
DeviceID string
AccessToken string
Password string
}
// register walks the full two-step registration flow, as a client does.
func (ts *testServer) register(localpart, password string) account {
ts.t.Helper()
rec := ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/register",
body: map[string]any{"username": localpart, "password": password},
})
expectStatus(ts.t, rec, http.StatusUnauthorized)
session, _ := jsonBody(ts.t, rec)["session"].(string)
if session == "" {
ts.t.Fatalf("the challenge carried no session: %s", rec.Body.String())
}
rec = ts.do(request{
method: http.MethodPost,
path: "/_matrix/client/v3/register",
body: map[string]any{
"username": localpart,
"password": password,
"auth": map[string]any{"type": "m.login.dummy", "session": session},
},
})
expectStatus(ts.t, rec, http.StatusOK)
body := jsonBody(ts.t, rec)
acc := account{Password: password}
acc.UserID, _ = body["user_id"].(string)
acc.AccessToken, _ = body["access_token"].(string)
acc.DeviceID, _ = body["device_id"].(string)
acc.Localpart = userapi.NormaliseLocalpart(localpart)
if acc.AccessToken == "" || acc.DeviceID == "" {
ts.t.Fatalf("registration returned no session: %s", rec.Body.String())
}
return acc
}
// login creates a second session for an existing account.
func (ts *testServer) login(acc account, deviceID string) account {
ts.t.Helper()
body := map[string]any{
"type": "m.login.password",
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
"password": acc.Password,
}
if deviceID != "" {
body["device_id"] = deviceID
}
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/login", body: body})
expectStatus(ts.t, rec, http.StatusOK)
parsed := jsonBody(ts.t, rec)
out := acc
out.AccessToken, _ = parsed["access_token"].(string)
out.DeviceID, _ = parsed["device_id"].(string)
return out
}
// makeAdmin grants the admin flag to an existing account.
func (ts *testServer) makeAdmin(acc account) {
ts.t.Helper()
if err := ts.users.SetAdmin(ts.t.Context(), acc.Localpart, true); err != nil {
ts.t.Fatalf("SetAdmin: %v", err)
}
}
// withRateLimit is a tweak that turns on the limiter with a given policy.
func withRateLimit(limit config.RateLimit) func(*clientapi.Options) {
return func(o *clientapi.Options) { o.RateLimit = limit }
}