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>
526 lines
18 KiB
Go
526 lines
18 KiB
Go
package clientapi_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/clientapi"
|
|
"github.com/dariuszjaniak/warp/server/internal/config"
|
|
)
|
|
|
|
const loginPath = "/_matrix/client/v3/login"
|
|
|
|
func TestLoginFlows(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
|
|
rec := ts.get(loginPath, "")
|
|
expectStatus(t, rec, http.StatusOK)
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
|
|
flows, _ := jsonBody(t, rec)["flows"].([]any)
|
|
if len(flows) == 0 {
|
|
t.Fatal("flows must not be empty")
|
|
}
|
|
// Clients read the flat `type`; a strict reading of the spec wants
|
|
// `stages`. Both are sent, so both are checked.
|
|
for _, flow := range flows {
|
|
entry, _ := flow.(map[string]any)
|
|
if entry["type"] != "m.login.password" {
|
|
t.Errorf("flow type = %v, want m.login.password", entry["type"])
|
|
}
|
|
if stages, ok := entry["stages"].([]any); !ok || len(stages) == 0 {
|
|
t.Errorf("flow %v has no stages", entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoginWithPassword(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
|
|
"password": "superuser",
|
|
}})
|
|
expectStatus(t, rec, http.StatusOK)
|
|
|
|
body := jsonBody(t, rec)
|
|
for _, field := range []string{"user_id", "access_token", "device_id"} {
|
|
if value, _ := body[field].(string); value == "" {
|
|
t.Errorf("login response is missing %s: %s", field, rec.Body.String())
|
|
}
|
|
}
|
|
if body["user_id"] != acc.UserID {
|
|
t.Errorf("user_id = %v, want %s", body["user_id"], acc.UserID)
|
|
}
|
|
// Logging in must not reuse the registration's device, or a second
|
|
// client would silently take over the first one's session.
|
|
if body["device_id"] == acc.DeviceID {
|
|
t.Error("a login without a device_id must create a new device")
|
|
}
|
|
}
|
|
|
|
// TestLoginAcceptsEveryIdentifierForm covers the three shapes clients send:
|
|
// the full user ID, the bare localpart, and the deprecated top-level `user`.
|
|
func TestLoginAcceptsEveryIdentifierForm(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
ts.register("alice", "superuser")
|
|
|
|
for name, body := range map[string]map[string]any{
|
|
"full user ID": {
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "@alice:warp.example"},
|
|
"password": "superuser",
|
|
},
|
|
"localpart only": {
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
|
"password": "superuser",
|
|
},
|
|
"deprecated user field": {
|
|
"type": "m.login.password",
|
|
"user": "alice",
|
|
"password": "superuser",
|
|
},
|
|
"uppercase user ID": {
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "@ALICE:warp.example"},
|
|
"password": "superuser",
|
|
},
|
|
"uppercase server name": {
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "@alice:WARP.example"},
|
|
"password": "superuser",
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: body})
|
|
expectStatus(t, rec, http.StatusOK)
|
|
if got := jsonBody(t, rec)["user_id"]; got != "@alice:warp.example" {
|
|
t.Errorf("user_id = %v, want @alice:warp.example", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginHonoursRequestedDeviceID(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": acc.UserID},
|
|
"password": "superuser",
|
|
"device_id": "test_device_id",
|
|
"initial_device_display_name": "device display",
|
|
}})
|
|
expectStatus(t, rec, http.StatusOK)
|
|
if got := jsonBody(t, rec)["device_id"]; got != "test_device_id" {
|
|
t.Errorf("device_id = %v, want test_device_id", got)
|
|
}
|
|
}
|
|
|
|
// TestLoginOnExistingDeviceRevokesItsToken is the spec's rule for a login
|
|
// naming a device that already exists: the device stays, its old token does
|
|
// not.
|
|
func TestLoginOnExistingDeviceRevokesItsToken(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
|
|
first := ts.login(acc, "SHARED")
|
|
second := ts.login(acc, "SHARED")
|
|
if first.AccessToken == second.AccessToken {
|
|
t.Fatal("the second login returned the same token")
|
|
}
|
|
|
|
expectError(t, ts.get("/_matrix/client/v3/account/whoami", first.AccessToken),
|
|
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
|
|
expectStatus(t, ts.get("/_matrix/client/v3/account/whoami", second.AccessToken), http.StatusOK)
|
|
}
|
|
|
|
// TestLoginRejectionsAreIndistinguishable keeps the login endpoint from
|
|
// becoming a user directory: every failure is the same answer.
|
|
func TestLoginRejectionsAreIndistinguishable(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
ts.register("alice", "superuser")
|
|
|
|
for name, body := range map[string]map[string]any{
|
|
"wrong password": {
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
|
"password": "wrong_password",
|
|
},
|
|
"unknown user": {
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "i-dont-exist"},
|
|
"password": "superuser",
|
|
},
|
|
"malformed user ID": {
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "@not-a-user-id"},
|
|
"password": "superuser",
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: body})
|
|
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoginRejectsForeignUser(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "@bob:other.example"},
|
|
"password": "superuser",
|
|
}})
|
|
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
|
|
}
|
|
|
|
func TestLoginRejectsUnsupportedType(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
|
|
"type": "m.login.token",
|
|
"token": "whatever",
|
|
}})
|
|
expectError(t, rec, http.StatusBadRequest, "M_UNKNOWN")
|
|
}
|
|
|
|
func TestLoginRequiresAnIdentifier(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
|
|
"type": "m.login.password",
|
|
"password": "superuser",
|
|
}})
|
|
expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM")
|
|
}
|
|
|
|
func TestLogout(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
second := ts.login(acc, "")
|
|
|
|
rec := ts.get("/_matrix/client/v3/devices", acc.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
if devices, _ := jsonBody(t, rec)["devices"].([]any); len(devices) != 2 {
|
|
t.Fatalf("expected 2 devices before logging out, got %d", len(devices))
|
|
}
|
|
|
|
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout", token: second.AccessToken})
|
|
expectStatus(t, rec, http.StatusOK)
|
|
|
|
// The token is dead and the device is gone, not merely signed out.
|
|
expectError(t, ts.get("/_matrix/client/v3/sync", second.AccessToken),
|
|
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
|
|
|
|
rec = ts.get("/_matrix/client/v3/devices", acc.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
devices, _ := jsonBody(t, rec)["devices"].([]any)
|
|
if len(devices) != 1 {
|
|
t.Fatalf("expected 1 device after logging out, got %d", len(devices))
|
|
}
|
|
if devices[0].(map[string]any)["device_id"] != acc.DeviceID {
|
|
t.Error("the wrong device was removed")
|
|
}
|
|
}
|
|
|
|
func TestLogoutAll(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
second := ts.login(acc, "")
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout/all", token: second.AccessToken})
|
|
expectStatus(t, rec, http.StatusOK)
|
|
|
|
for name, token := range map[string]string{"the caller": second.AccessToken, "the other session": acc.AccessToken} {
|
|
t.Run(name, func(t *testing.T) {
|
|
expectError(t, ts.get("/_matrix/client/v3/sync", token),
|
|
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLogoutRequiresAToken(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout"})
|
|
expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN")
|
|
|
|
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout", token: "invalidAccessToken"})
|
|
expectError(t, rec, http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
|
|
}
|
|
|
|
// TestRefreshToken covers the rotation: a refresh swaps both halves of the
|
|
// pair, and the spent ones stop working.
|
|
func TestRefreshToken(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
ts.register("alice", "superuser")
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
|
"password": "superuser",
|
|
"refresh_token": true,
|
|
}})
|
|
expectStatus(t, rec, http.StatusOK)
|
|
|
|
body := jsonBody(t, rec)
|
|
oldAccess, _ := body["access_token"].(string)
|
|
refresh, _ := body["refresh_token"].(string)
|
|
if refresh == "" {
|
|
t.Fatalf("refresh_token: true produced no refresh token: %s", rec.Body.String())
|
|
}
|
|
|
|
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh",
|
|
body: map[string]any{"refresh_token": refresh}})
|
|
expectStatus(t, rec, http.StatusOK)
|
|
|
|
refreshed := jsonBody(t, rec)
|
|
newAccess, _ := refreshed["access_token"].(string)
|
|
newRefresh, _ := refreshed["refresh_token"].(string)
|
|
if newAccess == "" || newRefresh == "" || newRefresh == refresh {
|
|
t.Fatalf("refresh did not rotate the pair: %s", rec.Body.String())
|
|
}
|
|
|
|
expectStatus(t, ts.get("/_matrix/client/v3/account/whoami", newAccess), http.StatusOK)
|
|
expectError(t, ts.get("/_matrix/client/v3/account/whoami", oldAccess),
|
|
http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
|
|
|
|
// Replaying a spent refresh token is a soft logout, not a second
|
|
// rotation.
|
|
rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh",
|
|
body: map[string]any{"refresh_token": refresh}})
|
|
expectError(t, rec, http.StatusUnauthorized, "M_UNKNOWN_TOKEN")
|
|
if jsonBody(t, rec)["soft_logout"] != true {
|
|
t.Error("a dead refresh token should be reported as a soft logout")
|
|
}
|
|
}
|
|
|
|
func TestRefreshRequiresAToken(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh",
|
|
body: map[string]any{}})
|
|
expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM")
|
|
}
|
|
|
|
// TestLoginWithoutRefreshTokenOmitsIt keeps the response honest for the
|
|
// clients that do not support rotation.
|
|
func TestLoginWithoutRefreshTokenOmitsIt(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
ts.register("alice", "superuser")
|
|
|
|
rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
|
"password": "superuser",
|
|
}})
|
|
expectStatus(t, rec, http.StatusOK)
|
|
|
|
body := jsonBody(t, rec)
|
|
if _, present := body["refresh_token"]; present {
|
|
t.Error("a login that did not ask for a refresh token must not get one")
|
|
}
|
|
if _, present := body["expires_in_ms"]; present {
|
|
t.Error("a token that does not expire must not report a lifetime")
|
|
}
|
|
}
|
|
|
|
func TestLoginIsRateLimited(t *testing.T) {
|
|
ts := newTestServer(t, withRateLimit(config.RateLimit{
|
|
Enabled: true,
|
|
Default: config.RateLimitBucket{Burst: 100, Interval: time.Millisecond},
|
|
Login: config.RateLimitBucket{Burst: 2, Interval: time.Minute},
|
|
Register: config.RateLimitBucket{Burst: 100, Interval: time.Millisecond},
|
|
}))
|
|
|
|
attempt := func() int {
|
|
return ts.do(request{
|
|
method: http.MethodPost,
|
|
path: loginPath,
|
|
remote: "192.0.2.10:4321",
|
|
body: map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
|
"password": "wrong",
|
|
},
|
|
}).Code
|
|
}
|
|
|
|
for i := range 2 {
|
|
if status := attempt(); status != http.StatusForbidden {
|
|
t.Fatalf("attempt %d status = %d, want 403", i+1, status)
|
|
}
|
|
}
|
|
|
|
rec := ts.do(request{
|
|
method: http.MethodPost,
|
|
path: loginPath,
|
|
remote: "192.0.2.10:4321",
|
|
body: map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
|
"password": "wrong",
|
|
},
|
|
})
|
|
expectError(t, rec, http.StatusTooManyRequests, "M_LIMIT_EXCEEDED")
|
|
retryAfter, _ := jsonBody(t, rec)["retry_after_ms"].(float64)
|
|
if retryAfter <= 0 {
|
|
t.Errorf("retry_after_ms = %v, want a positive number", retryAfter)
|
|
}
|
|
|
|
// Another address has its own budget: one noisy client must not lock
|
|
// everybody out.
|
|
ts2 := ts.do(request{
|
|
method: http.MethodPost,
|
|
path: loginPath,
|
|
remote: "198.51.100.7:4321",
|
|
body: map[string]any{
|
|
"type": "m.login.password",
|
|
"identifier": map[string]any{"type": "m.id.user", "user": "alice"},
|
|
"password": "wrong",
|
|
},
|
|
})
|
|
if ts2.Code == http.StatusTooManyRequests {
|
|
t.Error("the limiter is keyed globally rather than per client")
|
|
}
|
|
|
|
// And the budget is per endpoint class, so /login running dry does not
|
|
// take the rest of the API with it.
|
|
if rec := ts.get("/_matrix/client/v3/login", ""); rec.Code != http.StatusOK {
|
|
t.Errorf("GET /login status = %d; limiters must be per endpoint", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestAccessTokenInQueryString covers the form older clients and media URLs
|
|
// still use.
|
|
func TestAccessTokenInQueryString(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
|
|
rec := ts.get("/_matrix/client/v3/account/whoami?access_token="+acc.AccessToken, "")
|
|
expectStatus(t, rec, http.StatusOK)
|
|
if jsonBody(t, rec)["user_id"] != acc.UserID {
|
|
t.Errorf("whoami = %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMalformedAuthorizationHeaderIsRejected(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
|
|
req := request{method: http.MethodGet, path: "/_matrix/client/v3/account/whoami"}
|
|
for name, header := range map[string]string{
|
|
"no scheme": acc.AccessToken,
|
|
"wrong scheme": "Basic " + acc.AccessToken,
|
|
"empty bearer": "Bearer ",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
httpReq := requestWithRawAuth(t, req, header)
|
|
rec := serve(ts, httpReq)
|
|
expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN")
|
|
})
|
|
}
|
|
|
|
// The scheme itself is case-insensitive, per RFC 9110.
|
|
rec := serve(ts, requestWithRawAuth(t, req, "bearer "+acc.AccessToken))
|
|
expectStatus(t, rec, http.StatusOK)
|
|
}
|
|
|
|
func TestWhoami(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
|
|
rec := ts.get("/_matrix/client/v3/account/whoami", acc.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
|
|
body := jsonBody(t, rec)
|
|
if body["user_id"] != acc.UserID || body["device_id"] != acc.DeviceID {
|
|
t.Errorf("whoami = %s", rec.Body.String())
|
|
}
|
|
if body["is_guest"] != false {
|
|
t.Errorf("is_guest = %v, want false", body["is_guest"])
|
|
}
|
|
}
|
|
|
|
func TestCapabilities(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
|
|
// The endpoint is authenticated: what a server lets you do depends on
|
|
// who you are.
|
|
expectError(t, ts.get("/_matrix/client/v3/capabilities", ""),
|
|
http.StatusUnauthorized, "M_MISSING_TOKEN")
|
|
|
|
rec := ts.get("/_matrix/client/v3/capabilities", acc.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
|
|
caps, _ := jsonBody(t, rec)["capabilities"].(map[string]any)
|
|
for _, key := range []string{"m.room_versions", "m.change_password", "m.set_displayname"} {
|
|
if _, present := caps[key]; !present {
|
|
t.Errorf("capabilities is missing %s: %s", key, rec.Body.String())
|
|
}
|
|
}
|
|
versions, _ := caps["m.room_versions"].(map[string]any)
|
|
if versions["default"] != clientapi.DefaultRoomVersion {
|
|
t.Errorf("default room version = %v, want %s", versions["default"], clientapi.DefaultRoomVersion)
|
|
}
|
|
if available, _ := versions["available"].(map[string]any); len(available) == 0 {
|
|
t.Error("m.room_versions.available must list at least the default")
|
|
}
|
|
}
|
|
|
|
// TestSyncPlaceholder documents what the Phase 1 /sync does: it answers a
|
|
// valid token with an empty, well-formed response, and refuses a revoked
|
|
// one. Phase 4 replaces it with the real syncapi.
|
|
func TestSyncPlaceholder(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
acc := ts.register("alice", "superuser")
|
|
|
|
rec := ts.get("/_matrix/client/v3/sync", acc.AccessToken)
|
|
expectStatus(t, rec, http.StatusOK)
|
|
|
|
body := jsonBody(t, rec)
|
|
if next, _ := body["next_batch"].(string); next == "" {
|
|
t.Error("/sync must return a next_batch token")
|
|
}
|
|
rooms, _ := body["rooms"].(map[string]any)
|
|
for _, key := range []string{"join", "invite", "leave"} {
|
|
if _, present := rooms[key]; !present {
|
|
t.Errorf("rooms is missing %s: %s", key, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnknownClientEndpoint(t *testing.T) {
|
|
ts := newTestServer(t, nil)
|
|
|
|
rec := ts.get("/_matrix/client/v3/rooms/!a:b/messages", "")
|
|
expectError(t, rec, http.StatusNotFound, "M_UNRECOGNIZED")
|
|
}
|
|
|
|
// serve runs a prepared request through the handler.
|
|
func serve(ts *testServer, req *http.Request) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
ts.handler.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// requestWithRawAuth builds a request carrying an Authorization header
|
|
// verbatim, so the parsing of the header itself can be tested.
|
|
func requestWithRawAuth(t *testing.T, req request, header string) *http.Request {
|
|
t.Helper()
|
|
httpReq := httptest.NewRequest(req.method, req.path, nil)
|
|
httpReq.Header.Set("Authorization", header)
|
|
return httpReq
|
|
}
|