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>
324 lines
10 KiB
Go
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)
|
|
}
|
|
}
|