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) } }