Files
Warp/server/internal/userapi/userapi_test.go
T
tajniak81andClaude Opus 5 6004bb01de
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
Phase 1: accounts and sessions
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>
2026-10-15 18:18:37 +02:00

324 lines
10 KiB
Go

package userapi_test
import (
"errors"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"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"
// fixedClock is a clock a test can wind forward, so token expiry does not
// have to be waited for.
type fixedClock struct{ at time.Time }
func (c *fixedClock) now() time.Time { return c.at }
func (c *fixedClock) add(d time.Duration) { c.at = c.at.Add(d) }
// newTestAPI builds a userapi on a throwaway SQLite database.
func newTestAPI(t *testing.T, tweak func(*userapi.Options)) (*userapi.API, *fixedClock) {
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,
}, 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)
}
clock := &fixedClock{at: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
opts := userapi.Options{
ServerName: testServerName,
Store: db.Users(),
Logger: slog.New(slog.DiscardHandler),
Now: clock.now,
}
if tweak != nil {
tweak(&opts)
}
api, err := userapi.New(opts)
if err != nil {
t.Fatalf("userapi.New: %v", err)
}
return api, clock
}
func TestNewRejectsIncompleteOptions(t *testing.T) {
if _, err := userapi.New(userapi.Options{Store: nil, ServerName: "x"}); err == nil {
t.Error("a userapi without a store must not be built")
}
if _, err := userapi.New(userapi.Options{ServerName: ""}); err == nil {
t.Error("a userapi without a server name must not be built")
}
}
func TestCreateAccountNormalisesAndValidates(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
acc, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "Alice",
Password: "sUp3rs3kr1t",
})
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Localpart != "alice" {
t.Errorf("localpart = %q, want alice", acc.Localpart)
}
if api.UserID(acc.Localpart) != "@alice:warp.example" {
t.Errorf("UserID = %q", api.UserID(acc.Localpart))
}
// Normalising on the way in is what makes the name really taken.
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "ALICE", Password: "x",
}); !errors.Is(err, userapi.ErrUserExists) {
t.Errorf("creating @alice twice = %v, want ErrUserExists", err)
}
for name, localpart := range map[string]string{
"empty": "",
"illegal rune": "not valid",
"non-ASCII": "ünïcode",
"colon": "a:b",
"too long": strings.Repeat("a", 250),
} {
t.Run(name, func(t *testing.T) {
_, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: localpart, Password: "x"})
if !errors.Is(err, userapi.ErrInvalidLocalpart) {
t.Errorf("CreateAccount(%q) = %v, want ErrInvalidLocalpart", localpart, err)
}
})
}
}
// TestCheckPasswordRejectsEveryWayTheSame keeps the login path from becoming
// a user directory.
func TestCheckPasswordRejectsEveryWayTheSame(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "sUp3rs3kr1t",
}); err != nil {
t.Fatal(err)
}
// An account with no password: `warp create-account` can make one.
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "nopassword"}); err != nil {
t.Fatal(err)
}
if err := api.CheckPassword(ctx, "alice", "sUp3rs3kr1t"); err != nil {
t.Errorf("the right password was rejected: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "wrong"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Errorf("wrong password = %v, want ErrWrongPassword", err)
}
// An unknown account is ErrWrongPassword, not ErrNoSuchUser: the
// caller must not be able to tell the difference.
if err := api.CheckPassword(ctx, "nobody", "sUp3rs3kr1t"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Errorf("unknown account = %v, want ErrWrongPassword", err)
}
if err := api.CheckPassword(ctx, "nopassword", "anything"); !errors.Is(err, userapi.ErrNoPassword) {
t.Errorf("passwordless account = %v, want ErrNoPassword", err)
}
}
func TestSetPassword(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "first",
}); err != nil {
t.Fatal(err)
}
if err := api.SetPassword(ctx, "alice", "second"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "second"); err != nil {
t.Errorf("the new password does not work: %v", err)
}
if err := api.CheckPassword(ctx, "alice", "first"); !errors.Is(err, userapi.ErrWrongPassword) {
t.Error("the old password still works")
}
}
func TestAuthenticate(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{
Localpart: "alice", Password: "x", Admin: true,
}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if session.UserID != "@alice:warp.example" || session.DeviceID == "" || session.AccessToken == "" {
t.Fatalf("CreateSession returned %+v", session)
}
auth, err := api.Authenticate(ctx, session.AccessToken)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if auth.UserID != session.UserID || auth.DeviceID != session.DeviceID || !auth.Admin {
t.Errorf("Authenticate = %+v", auth)
}
for name, token := range map[string]string{"empty": "", "unknown": "warp_at_nope"} {
t.Run(name, func(t *testing.T) {
if _, err := api.Authenticate(ctx, token); !errors.Is(err, userapi.ErrNoSuchToken) {
t.Errorf("Authenticate(%q) = %v, want ErrNoSuchToken", token, err)
}
})
}
}
// TestAccessTokenExpiry covers the lifetime configuration and the reason
// expiry is reported separately: it becomes a soft logout, not a hard one.
func TestAccessTokenExpiry(t *testing.T) {
api, clock := newTestAPI(t, func(o *userapi.Options) {
o.AccessTokenLifetime = time.Hour
})
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatal(err)
}
if session.ExpiresInMS != time.Hour.Milliseconds() {
t.Errorf("ExpiresInMS = %d, want one hour", session.ExpiresInMS)
}
if _, err := api.Authenticate(ctx, session.AccessToken); err != nil {
t.Fatalf("a fresh token must work: %v", err)
}
clock.add(time.Hour + time.Second)
if _, err := api.Authenticate(ctx, session.AccessToken); !errors.Is(err, userapi.ErrTokenExpired) {
t.Errorf("Authenticate of an expired token = %v, want ErrTokenExpired", err)
}
}
func TestRefreshSessionRequiresARefreshToken(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
// A session that did not ask for one cannot be refreshed.
plain, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice"})
if err != nil {
t.Fatal(err)
}
if plain.RefreshToken != "" {
t.Error("a session that did not ask for a refresh token got one")
}
if _, err := api.RefreshSession(ctx, "warp_rt_nope"); !errors.Is(err, userapi.ErrNoSuchToken) {
t.Errorf("RefreshSession with an unknown token = %v, want ErrNoSuchToken", err)
}
withRefresh, err := api.CreateSession(ctx, userapi.SessionRequest{
Localpart: "alice", WithRefreshToken: true,
})
if err != nil {
t.Fatal(err)
}
refreshed, err := api.RefreshSession(ctx, withRefresh.RefreshToken)
if err != nil {
t.Fatalf("RefreshSession: %v", err)
}
if refreshed.DeviceID != withRefresh.DeviceID {
t.Errorf("refreshing changed the device: %s to %s", withRefresh.DeviceID, refreshed.DeviceID)
}
if refreshed.AccessToken == withRefresh.AccessToken {
t.Error("refreshing returned the same access token")
}
}
// TestRecordActivityIsThrottled covers the decision not to write to the
// database on every authenticated request.
func TestRecordActivityIsThrottled(t *testing.T) {
api, clock := newTestAPI(t, nil)
ctx := t.Context()
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
session, err := api.CreateSession(ctx, userapi.SessionRequest{Localpart: "alice", IP: "10.0.0.1"})
if err != nil {
t.Fatal(err)
}
auth, err := api.Authenticate(ctx, session.AccessToken)
if err != nil {
t.Fatal(err)
}
api.RecordActivity(ctx, auth, "10.0.0.2", "first")
device, err := api.Device(ctx, "alice", session.DeviceID)
if err != nil {
t.Fatal(err)
}
firstSeen := device.LastSeenMS
if device.LastSeenIP != "10.0.0.2" {
t.Errorf("last_seen_ip = %q, want 10.0.0.2", device.LastSeenIP)
}
// A second visit a moment later is not written.
clock.add(time.Second)
api.RecordActivity(ctx, auth, "10.0.0.3", "second")
if device, err = api.Device(ctx, "alice", session.DeviceID); err != nil {
t.Fatal(err)
}
if device.LastSeenIP != "10.0.0.2" || device.LastSeenMS != firstSeen {
t.Errorf("a visit one second later was written: %+v", device)
}
// A later one is.
clock.add(2 * time.Minute)
api.RecordActivity(ctx, auth, "10.0.0.4", "third")
if device, err = api.Device(ctx, "alice", session.DeviceID); err != nil {
t.Fatal(err)
}
if device.LastSeenIP != "10.0.0.4" || device.LastSeenMS <= firstSeen {
t.Errorf("a visit two minutes later was not written: %+v", device)
}
}
func TestAccountExists(t *testing.T) {
api, _ := newTestAPI(t, nil)
ctx := t.Context()
exists, err := api.AccountExists(ctx, "alice")
if err != nil || exists {
t.Fatalf("AccountExists on an empty database = %v, %v", exists, err)
}
if _, err := api.CreateAccount(ctx, userapi.NewAccountRequest{Localpart: "alice", Password: "x"}); err != nil {
t.Fatal(err)
}
if exists, err = api.AccountExists(ctx, "alice"); err != nil || !exists {
t.Fatalf("AccountExists = %v, %v", exists, err)
}
}