Files
Warp/server/internal/storage/users_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

495 lines
17 KiB
Go

package storage_test
import (
"database/sql"
"errors"
"os"
"testing"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// migratedTestDB opens a driver, brings its schema up to date and empties
// the userapi tables, which is what every user-store test needs before it
// can touch one.
func migratedTestDB(t *testing.T, driver string) storage.Database {
t.Helper()
db := openTestDB(t, driver)
if err := db.Migrate(t.Context()); err != nil {
t.Fatalf("Migrate: %v", err)
}
resetUserTables(t, driver)
return db
}
// resetUserTables empties the userapi tables so that each test starts from a
// known state.
//
// SQLite gets a fresh file per test and needs nothing. The PostgreSQL tests
// all share the one database the environment points at, so without this the
// second test to create "alice" would collide with the first — and would do
// so only in CI, where PostgreSQL is available.
func resetUserTables(t *testing.T, driver string) {
t.Helper()
if driver != storage.DriverPostgres {
return
}
// The storage interface has no "delete an account", on purpose:
// deactivation is Phase 9 and deletion may never exist. Clearing the
// tables is a test concern, so the test does it directly.
db, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
if err != nil {
t.Fatalf("open postgres to reset the user tables: %v", err)
}
defer func() { _ = db.Close() }()
if _, err := db.ExecContext(t.Context(), `TRUNCATE
user_account_data, user_refresh_tokens, user_access_tokens,
user_devices, user_accounts`); err != nil {
t.Fatalf("reset the user tables: %v", err)
}
}
// newAccount is the minimum CreateAccount needs.
func newAccount(localpart string) storage.NewAccount {
return storage.NewAccount{
Localpart: localpart,
PasswordHash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaA",
CreatedAtMS: 1_700_000_000_000,
}
}
func TestUserAccounts(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.Account(ctx, "nobody"); !errors.Is(err, storage.ErrNoSuchUser) {
t.Errorf("Account of an unknown user = %v, want ErrNoSuchUser", err)
}
acc, err := users.CreateAccount(ctx, newAccount("alice"))
if err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if acc.Localpart != "alice" || acc.Admin || acc.Deactivated {
t.Errorf("CreateAccount returned %+v", acc)
}
// A taken localpart must be reported as such, not as a generic
// database failure: registration turns it into M_USER_IN_USE.
if _, err := users.CreateAccount(ctx, newAccount("alice")); !errors.Is(err, storage.ErrUserExists) {
t.Errorf("duplicate CreateAccount = %v, want ErrUserExists", err)
}
stored, err := users.Account(ctx, "alice")
if err != nil {
t.Fatalf("Account: %v", err)
}
if stored.CreatedAtMS != 1_700_000_000_000 {
t.Errorf("created_at_ms = %d, want 1700000000000", stored.CreatedAtMS)
}
hash, err := users.PasswordHash(ctx, "alice")
if err != nil || hash == "" {
t.Fatalf("PasswordHash = %q, %v", hash, err)
}
if err := users.SetPasswordHash(ctx, "alice", "newhash"); err != nil {
t.Fatalf("SetPasswordHash: %v", err)
}
if hash, _ = users.PasswordHash(ctx, "alice"); hash != "newhash" {
t.Errorf("PasswordHash after update = %q", hash)
}
if err := users.SetAdmin(ctx, "alice", true); err != nil {
t.Fatalf("SetAdmin: %v", err)
}
if err := users.SetDisplayName(ctx, "alice", "Alice"); err != nil {
t.Fatalf("SetDisplayName: %v", err)
}
if err := users.SetAvatarURL(ctx, "alice", "mxc://warp.example/abc"); err != nil {
t.Fatalf("SetAvatarURL: %v", err)
}
stored, err = users.Account(ctx, "alice")
if err != nil {
t.Fatalf("Account: %v", err)
}
if !stored.Admin {
t.Error("the admin flag did not survive the round trip")
}
if stored.DisplayName != "Alice" || stored.AvatarURL != "mxc://warp.example/abc" {
t.Errorf("profile = %+v", stored)
}
// Updating an absent account is ErrNoSuchUser, so that callers
// do not have to look it up first.
if err := users.SetDisplayName(ctx, "nobody", "x"); !errors.Is(err, storage.ErrNoSuchUser) {
t.Errorf("SetDisplayName on an unknown user = %v, want ErrNoSuchUser", err)
}
})
}
}
func TestUserAccountListingAndStats(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
for _, localpart := range []string{"amy", "bob", "bobby", "zoe_x"} {
if _, err := users.CreateAccount(ctx, newAccount(localpart)); err != nil {
t.Fatalf("CreateAccount(%s): %v", localpart, err)
}
}
if err := users.SetAdmin(ctx, "amy", true); err != nil {
t.Fatalf("SetAdmin: %v", err)
}
if err := users.SetDisplayName(ctx, "zoe_x", "Zoe"); err != nil {
t.Fatalf("SetDisplayName: %v", err)
}
all, total, err := users.ListAccounts(ctx, storage.AccountFilter{})
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
if total != 4 || len(all) != 4 {
t.Fatalf("ListAccounts returned %d of %d, want 4 of 4", len(all), total)
}
if all[0].Localpart != "amy" || all[3].Localpart != "zoe_x" {
t.Errorf("accounts are not ordered by localpart: %v", all)
}
page, total, err := users.ListAccounts(ctx, storage.AccountFilter{Limit: 2, Offset: 1})
if err != nil {
t.Fatalf("ListAccounts paged: %v", err)
}
if total != 4 || len(page) != 2 || page[0].Localpart != "bob" {
t.Errorf("paged listing = %v (total %d)", page, total)
}
found, total, err := users.ListAccounts(ctx, storage.AccountFilter{Search: "BOB"})
if err != nil {
t.Fatalf("ListAccounts search: %v", err)
}
if total != 2 || len(found) != 2 {
t.Errorf("case-insensitive search for bob found %d, want 2", total)
}
// A display name is searchable too.
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "zoe"}); err != nil {
t.Fatalf("ListAccounts: %v", err)
} else if total != 1 {
t.Errorf("searching display names found %d, want 1", total)
}
// "_" is a LIKE wildcard; escaping it is what keeps this from
// matching every three-letter localpart.
if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "e_x"}); err != nil {
t.Fatalf("ListAccounts: %v", err)
} else if total != 1 {
t.Errorf("searching for a literal underscore found %d, want 1", total)
}
stats, err := users.Stats(ctx)
if err != nil {
t.Fatalf("Stats: %v", err)
}
if stats.Users != 4 || stats.Admins != 1 || stats.Deactivated != 0 || stats.Devices != 0 {
t.Errorf("Stats = %+v", stats)
}
})
}
}
func TestUserSessions(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
session := storage.NewSession{
Localpart: "alice",
DeviceID: "DEV1",
DisplayName: "first login",
CreatedAtMS: 1000,
AccessTokenHash: "hash-a",
LastSeenIP: "10.0.0.1",
UserAgent: "warp-test",
}
device, err := users.CreateSession(ctx, session)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
if device.ID != "DEV1" || device.DisplayName != "first login" {
t.Errorf("CreateSession returned %+v", device)
}
// A session for an unknown account must fail loudly rather than
// leave a device with no owner.
orphan := session
orphan.Localpart, orphan.AccessTokenHash = "nobody", "hash-orphan"
if _, err := users.CreateSession(ctx, orphan); !errors.Is(err, storage.ErrNoSuchUser) {
t.Errorf("CreateSession for an unknown user = %v, want ErrNoSuchUser", err)
}
info, err := users.TokenInfo(ctx, "hash-a")
if err != nil {
t.Fatalf("TokenInfo: %v", err)
}
if info.Localpart != "alice" || info.DeviceID != "DEV1" || info.ExpiresAtMS != 0 {
t.Errorf("TokenInfo = %+v", info)
}
if _, err := users.TokenInfo(ctx, "nope"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("TokenInfo of an unknown token = %v, want ErrNoSuchToken", err)
}
// Logging in again on the same device keeps the device and its
// display name, and revokes the token it had.
again := session
again.AccessTokenHash = "hash-b"
again.DisplayName = "ignored on an existing device"
again.CreatedAtMS = 2000
device, err = users.CreateSession(ctx, again)
if err != nil {
t.Fatalf("CreateSession (same device): %v", err)
}
if device.DisplayName != "first login" {
t.Errorf("display name = %q; initial_device_display_name applies to new devices only", device.DisplayName)
}
if device.CreatedAtMS != 1000 {
t.Errorf("created_at_ms = %d; re-login must not reset it", device.CreatedAtMS)
}
if _, err := users.TokenInfo(ctx, "hash-a"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("the previous access token of a re-used device was not revoked")
}
if _, err := users.TokenInfo(ctx, "hash-b"); err != nil {
t.Errorf("the new access token does not work: %v", err)
}
stored, err := users.Device(ctx, "alice", "DEV1")
if err != nil {
t.Fatalf("Device: %v", err)
}
if stored.LastSeenMS != 2000 || stored.LastSeenIP != "10.0.0.1" {
t.Errorf("device = %+v", stored)
}
if _, err := users.Device(ctx, "alice", "GONE"); !errors.Is(err, storage.ErrNoSuchDevice) {
t.Errorf("Device of an unknown device = %v, want ErrNoSuchDevice", err)
}
if err := users.TouchDevice(ctx, "alice", "DEV1", "10.0.0.2", "warp-test/2", 3000); err != nil {
t.Fatalf("TouchDevice: %v", err)
}
if stored, err = users.Device(ctx, "alice", "DEV1"); err != nil {
t.Fatal(err)
} else if stored.LastSeenMS != 3000 || stored.LastSeenIP != "10.0.0.2" {
t.Errorf("TouchDevice did not record the visit: %+v", stored)
}
if err := users.SetDeviceDisplayName(ctx, "alice", "DEV1", "renamed"); err != nil {
t.Fatalf("SetDeviceDisplayName: %v", err)
}
if err := users.SetDeviceDisplayName(ctx, "alice", "GONE", "x"); !errors.Is(err, storage.ErrNoSuchDevice) {
t.Errorf("renaming an unknown device = %v, want ErrNoSuchDevice", err)
}
second := storage.NewSession{
Localpart: "alice",
DeviceID: "DEV2",
CreatedAtMS: 4000,
AccessTokenHash: "hash-c",
}
if _, err := users.CreateSession(ctx, second); err != nil {
t.Fatalf("CreateSession (second device): %v", err)
}
devices, err := users.Devices(ctx, "alice")
if err != nil {
t.Fatalf("Devices: %v", err)
}
if len(devices) != 2 || devices[0].ID != "DEV1" || devices[1].ID != "DEV2" {
t.Errorf("Devices = %v", devices)
}
deleted, err := users.DeleteDevices(ctx, "alice", []string{"DEV1", "NEVER-EXISTED"})
if err != nil {
t.Fatalf("DeleteDevices: %v", err)
}
if deleted != 1 {
t.Errorf("DeleteDevices reported %d, want 1", deleted)
}
if _, err := users.TokenInfo(ctx, "hash-b"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("deleting a device must revoke its access tokens")
}
deleted, err = users.DeleteAllDevices(ctx, "alice")
if err != nil {
t.Fatalf("DeleteAllDevices: %v", err)
}
if deleted != 1 {
t.Errorf("DeleteAllDevices reported %d, want 1", deleted)
}
if _, err := users.TokenInfo(ctx, "hash-c"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("logout/all must revoke every token")
}
if devices, err = users.Devices(ctx, "alice"); err != nil {
t.Fatal(err)
} else if len(devices) != 0 {
t.Errorf("devices survived DeleteAllDevices: %v", devices)
}
})
}
}
func TestUserRefreshTokens(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := users.CreateSession(ctx, storage.NewSession{
Localpart: "alice",
DeviceID: "DEV1",
CreatedAtMS: 1000,
AccessTokenHash: "access-1",
AccessTokenExpiresAtMS: 5000,
RefreshTokenHash: "refresh-1",
RefreshTokenExpiresAtMS: 0,
}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
info, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "refresh-1",
NewAccessTokenHash: "access-2",
AccessTokenExpiresAtMS: 9000,
NewRefreshTokenHash: "refresh-2",
RefreshedAtMS: 4000,
})
if err != nil {
t.Fatalf("RefreshSession: %v", err)
}
if info.Localpart != "alice" || info.DeviceID != "DEV1" {
t.Errorf("RefreshSession returned %+v", info)
}
// Both halves of the old pair are gone: a replayed refresh token
// is the signal that one of the two copies is a thief.
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "refresh-1",
NewAccessTokenHash: "access-3",
NewRefreshTokenHash: "refresh-3",
RefreshedAtMS: 4100,
}); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("replaying a spent refresh token = %v, want ErrNoSuchToken", err)
}
if _, err := users.TokenInfo(ctx, "access-1"); !errors.Is(err, storage.ErrNoSuchToken) {
t.Error("refreshing must invalidate the previous access token")
}
if info, err = users.TokenInfo(ctx, "access-2"); err != nil {
t.Fatalf("the refreshed access token does not work: %v", err)
} else if info.ExpiresAtMS != 9000 {
t.Errorf("expires_at_ms = %d, want 9000", info.ExpiresAtMS)
}
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "nonexistent",
NewAccessTokenHash: "access-4",
RefreshedAtMS: 4200,
}); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("unknown refresh token = %v, want ErrNoSuchToken", err)
}
})
}
}
func TestUserRefreshTokenExpiry(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := users.CreateSession(ctx, storage.NewSession{
Localpart: "alice",
DeviceID: "DEV1",
CreatedAtMS: 1000,
AccessTokenHash: "access-1",
RefreshTokenHash: "refresh-1",
RefreshTokenExpiresAtMS: 2000,
}); err != nil {
t.Fatalf("CreateSession: %v", err)
}
if _, err := users.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: "refresh-1",
NewAccessTokenHash: "access-2",
RefreshedAtMS: 2001,
}); !errors.Is(err, storage.ErrNoSuchToken) {
t.Errorf("expired refresh token = %v, want ErrNoSuchToken", err)
}
})
}
}
func TestUserAccountData(t *testing.T) {
for _, driver := range testDrivers {
t.Run(driver, func(t *testing.T) {
ctx := t.Context()
users := migratedTestDB(t, driver).Users()
if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if _, err := users.AccountData(ctx, "alice", "", "m.push_rules"); !errors.Is(err, storage.ErrNoSuchAccountData) {
t.Errorf("unset account data = %v, want ErrNoSuchAccountData", err)
}
global := []byte(`{"theme":"dark"}`)
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", global); err != nil {
t.Fatalf("SetAccountData: %v", err)
}
got, err := users.AccountData(ctx, "alice", "", "im.warp.prefs")
if err != nil {
t.Fatalf("AccountData: %v", err)
}
if string(got) != string(global) {
t.Errorf("account data came back as %s, want %s", got, global)
}
// Overwriting replaces rather than conflicting.
if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", []byte(`{"theme":"light"}`)); err != nil {
t.Fatalf("SetAccountData (overwrite): %v", err)
}
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
t.Fatal(err)
} else if string(got) != `{"theme":"light"}` {
t.Errorf("overwritten account data = %s", got)
}
// Room account data is a separate key space from global data of
// the same type.
if err := users.SetAccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs", []byte(`{"muted":true}`)); err != nil {
t.Fatalf("SetAccountData (room): %v", err)
}
if got, err = users.AccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs"); err != nil {
t.Fatal(err)
} else if string(got) != `{"muted":true}` {
t.Errorf("room account data = %s", got)
}
if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil {
t.Fatal(err)
} else if string(got) != `{"theme":"light"}` {
t.Errorf("room account data overwrote the global value: %s", got)
}
})
}
}