Phase 1: accounts and sessions
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 / 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
Implements PLAN.md Phase 1. A client can now register, log in, manage its devices and set its profile; an operator can create accounts and flip the runtime switches. There are still no rooms and no real /sync. userapi: accounts, argon2id passwords in PHC format, devices, access and refresh tokens stored as SHA-256 hashes, account data and the admin flag. Its storage interface is storage.UserStore, implemented once in sqlstore with `?` placeholders; the postgres driver supplies a rebind function and unique-violation recognition rather than keeping a second copy of thirty statements. Timestamps in these tables are int64 milliseconds, the unit the Client-Server API uses on the wire. Client-Server API: register (user-interactive auth with m.login.dummy and optional registration tokens), register/available, token validity, login with m.login.password, logout, logout/all, refresh, account/whoami, capabilities, devices/* with password-gated deletion, profile/*, global and per-room account data. GET /sync is a deliberate placeholder: it answers a valid token with an empty but well-formed response and refuses a revoked one. Phase 1 has no rooms, so that is the whole truth, and it is what makes session revocation observable. Phase 4 replaces it with the syncapi. Auth middleware resolves a bearer token (header or the legacy query parameter) to a device and user, answering an expired token with the soft logout the spec asks for. Rate limiting is a token bucket per endpoint class keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms. adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix token, GET/PUT /federation and /registration driving internal/settings and reporting a config-pinned switch as a 409, GET/POST /users, GET /stats. warp create-account [--admin] creates the first account straight in the database, so a new install with registration off has a way in. Complement is wired up for the federation=off profile: an image meeting its contract, an entrypoint that configures Warp entirely from WARP_* variables, and a runner that builds, clones and executes the target set (TestRegistration, TestLogin, TestDeviceManagement). It has not been run yet -- there is no container runtime on the development machine -- so the three tests' assertions were read from the Complement sources and pinned as httptest tests instead, and CI gained a job that runs the real thing. Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern, meant for the built binary, also matched the directory server/cmd/warp and silently excluded the entire command package from the repository. The binary patterns are now anchored to the repository root, and cmd/warp is committed for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4fb60a8ab
commit
6004bb01de
68 files changed
+9729
-66
No files matched your search
@@ -0,0 +1,268 @@
|
||||
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/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
|
||||
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)
|
||||
|
||||
opts := clientapi.Options{
|
||||
ServerName: testServerName,
|
||||
Users: users,
|
||||
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,
|
||||
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 }
|
||||
}
|
||||
Reference in new issue
Block a user