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

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:
tajniak81andClaude Opus 5 committed 2026-10-15 18:18:37 +02:00
1 parent b4fb60a8ab
commit 6004bb01de
68 files changed
+9729 -66

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
package clientapi_test
import (
"net/http"
"strings"
"testing"
)
const profilePath = "/_matrix/client/v3/profile"
func TestProfileRoundTrip(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
// A fresh account has neither field, and the spec distinguishes that
// from an empty one: clients fall back to the localpart.
rec := ts.get(profilePath+"/"+acc.UserID, "")
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if _, present := body["displayname"]; present {
t.Errorf("an unset display name must be omitted, got %s", rec.Body.String())
}
rec = ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
token: acc.AccessToken, body: map[string]any{"displayname": "Alice"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/avatar_url",
token: acc.AccessToken, body: map[string]any{"avatar_url": "mxc://warp.example/abc"}})
expectStatus(t, rec, http.StatusOK)
rec = ts.get(profilePath+"/"+acc.UserID, "")
expectStatus(t, rec, http.StatusOK)
body = jsonBody(t, rec)
if body["displayname"] != "Alice" || body["avatar_url"] != "mxc://warp.example/abc" {
t.Errorf("profile = %s", rec.Body.String())
}
// The per-field endpoints return only their own field.
rec = ts.get(profilePath+"/"+acc.UserID+"/displayname", "")
expectStatus(t, rec, http.StatusOK)
body = jsonBody(t, rec)
if body["displayname"] != "Alice" {
t.Errorf("displayname = %v", body["displayname"])
}
if _, present := body["avatar_url"]; present {
t.Error("/displayname must not include the avatar")
}
rec = ts.get(profilePath+"/"+acc.UserID+"/avatar_url", "")
expectStatus(t, rec, http.StatusOK)
if jsonBody(t, rec)["avatar_url"] != "mxc://warp.example/abc" {
t.Errorf("avatar_url = %s", rec.Body.String())
}
}
// TestProfileReadsArePublic covers the rule a member list depends on: a
// client showing somebody's name has no token for them.
func TestProfileReadsArePublic(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
token: acc.AccessToken, body: map[string]any{"displayname": "my_display_name"}})
expectStatus(t, rec, http.StatusOK)
for _, path := range []string{
profilePath + "/" + acc.UserID,
profilePath + "/" + acc.UserID + "/displayname",
} {
rec := ts.get(path, "")
expectStatus(t, rec, http.StatusOK)
if name := jsonBody(t, rec)["displayname"]; name != "my_display_name" {
t.Errorf("GET %s without a token returned %v", path, name)
}
}
}
func TestProfileWritesAreOwnOnly(t *testing.T) {
ts := newTestServer(t, nil)
alice := ts.register("alice", "superuser")
bob := ts.register("bob", "bobspassword")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + alice.UserID + "/displayname",
token: bob.AccessToken, body: map[string]any{"displayname": "not bob"}})
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
// An admin may, which is what lets the panel clean up a profile.
ts.makeAdmin(bob)
rec = ts.do(request{method: http.MethodPut, path: profilePath + "/" + alice.UserID + "/displayname",
token: bob.AccessToken, body: map[string]any{"displayname": "set by an admin"}})
expectStatus(t, rec, http.StatusOK)
}
func TestProfileWritesRequireAToken(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
body: map[string]any{"displayname": "Alice"}})
expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN")
}
func TestProfileOfUnknownUserIs404(t *testing.T) {
ts := newTestServer(t, nil)
expectError(t, ts.get(profilePath+"/@nobody:warp.example", ""),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestProfileRejectsMalformedUserID(t *testing.T) {
ts := newTestServer(t, nil)
expectError(t, ts.get(profilePath+"/@no-server-name", ""),
http.StatusBadRequest, "M_INVALID_PARAM")
}
// TestProfileOfRemoteUserHonoursTheFederationSwitch is where PLAN.md
// section 3 shows up in the Client-Server API: with federation off there is
// no way to answer, and the spec's M_FORBIDDEN with a clear message is the
// honest reply.
func TestProfileOfRemoteUserHonoursTheFederationSwitch(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.get(profilePath+"/@bob:other.example", "")
expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN")
if msg, _ := jsonBody(t, rec)["error"].(string); !strings.Contains(msg, "Federation is disabled") {
t.Errorf("the message should say why, got %q", msg)
}
// With the switch on it is no longer a refusal, only something that is
// not implemented until Phase 5.
if err := ts.settings.SetFederationEnabled(t.Context(), true); err != nil {
t.Fatalf("SetFederationEnabled: %v", err)
}
expectError(t, ts.get(profilePath+"/@bob:other.example", ""),
http.StatusNotFound, "M_NOT_FOUND")
}
func TestProfileRejectsOverlongDisplayName(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.do(request{method: http.MethodPut, path: profilePath + "/" + acc.UserID + "/displayname",
token: acc.AccessToken, body: map[string]any{"displayname": strings.Repeat("x", 1000)}})
expectError(t, rec, http.StatusBadRequest, "M_TOO_LARGE")
}