package clientapi_test import ( "bytes" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "testing" "github.com/dariuszjaniak/warp/server/internal/clientapi" "github.com/dariuszjaniak/warp/server/internal/config" "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/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 settings *settings.Service fedGate *federation.Gate } // 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) } rooms, err := roomserver.New(roomserver.Options{ ServerName: testServerName, Store: db.Rooms(), Profiles: users, SigningKey: key, FederationEnabled: settingsService.FederationEnabled, Logger: slog.New(slog.DiscardHandler), }) if err != nil { t.Fatalf("roomserver.New: %v", err) } opts := clientapi.Options{ ServerName: testServerName, Users: users, Rooms: rooms, Settings: settingsService, Federation: gate, 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, settings: settingsService, fedGate: gate, } } // 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 } }