Files
chess/internal/server/service/user_test.go
T

192 lines
6.0 KiB
Go

package service
import (
"errors"
"testing"
"time"
"github.com/lixenwraith/chess/internal/server/chess"
"github.com/lixenwraith/chess/internal/server/core"
"github.com/lixenwraith/chess/internal/server/storage"
"github.com/google/uuid"
"github.com/lixenwraith/auth"
)
var testJWTSecret = []byte("test-secret-test-secret-test-secret")
func TestValidateTokenRequiresScopedTokenBoundToSession(t *testing.T) {
svc := newPersistentTestService(t)
user, sessionID, err := svc.RegisterUser("alice", "", "Password1")
if err != nil {
t.Fatal(err)
}
validToken, err := svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
t.Fatal(err)
}
gotUserID, claims, err := svc.ValidateToken(validToken)
if err != nil || gotUserID != user.UserID || claims["session_id"] != sessionID {
t.Fatalf("valid token rejected: user=%q claims=%v err=%v", gotUserID, claims, err)
}
scoped, err := auth.NewJWT(testJWTSecret,
auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{JWTAudience}))
if err != nil {
t.Fatal(err)
}
unscoped, err := auth.NewJWT(testJWTSecret)
if err != nil {
t.Fatal(err)
}
foreignAudience, err := auth.NewJWT(testJWTSecret,
auth.WithIssuer(JWTIssuer), auth.WithAudience([]string{"other-service"}))
if err != nil {
t.Fatal(err)
}
for name, mint := range map[string]func() (string, error){
"missing session": func() (string, error) { return scoped.GenerateToken(user.UserID, nil) },
"wrong subject": func() (string, error) {
return scoped.GenerateToken(uuid.NewString(), map[string]any{"session_id": sessionID})
},
"malformed subject": func() (string, error) {
return scoped.GenerateToken("other-user", map[string]any{"session_id": sessionID})
},
"no issuer or audience": func() (string, error) {
return unscoped.GenerateToken(user.UserID, map[string]any{"session_id": sessionID})
},
"foreign audience": func() (string, error) {
return foreignAudience.GenerateToken(user.UserID, map[string]any{"session_id": sessionID})
},
} {
token, err := mint()
if err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(token); err == nil {
t.Errorf("%s: token accepted", name)
}
}
if err := svc.InvalidateSession(sessionID); err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(validToken); err == nil {
t.Fatal("invalidated session was accepted")
}
}
func TestAuthenticateUserFailuresAreIndistinguishable(t *testing.T) {
svc := newPersistentTestService(t)
if _, _, err := svc.RegisterUser("alice", "alice@example.com", "Password1"); err != nil {
t.Fatal(err)
}
if _, _, err := svc.RegisterUser("ALICE", "", "Password1"); !errors.Is(err, ErrUserExists) {
t.Fatalf("duplicate registration = %v, want ErrUserExists", err)
}
user, sessionID, err := svc.AuthenticateUser("alice@example.com", "Password1")
if err != nil || sessionID == "" || user.Username != "alice" {
t.Fatalf("login by email = %+v, %q, %v", user, sessionID, err)
}
for name, attempt := range map[string][2]string{
"unknown user": {"nobody", "Password1"},
"unknown email": {"nobody@example.com", "Password1"},
"wrong password": {"alice", "Password2"},
} {
if _, _, err := svc.AuthenticateUser(attempt[0], attempt[1]); !errors.Is(err, ErrInvalidCredentials) {
t.Errorf("%s: error = %v, want ErrInvalidCredentials", name, err)
}
}
}
func TestRegistrationCapIsConfigurable(t *testing.T) {
svc := newPersistentTestService(t)
svc.SetMaxUsers(1)
if _, _, err := svc.RegisterUser("alice", "", "Password1"); err != nil {
t.Fatal(err)
}
if _, _, err := svc.RegisterUser("bob", "", "Password1"); !errors.Is(err, ErrAtCapacity) {
t.Fatalf("registration past the cap = %v, want ErrAtCapacity", err)
}
svc.SetMaxUsers(0)
if _, _, err := svc.RegisterUser("bob", "", "Password1"); err != nil {
t.Fatalf("registration without a cap: %v", err)
}
}
func TestPasswordHashingIsBounded(t *testing.T) {
svc, err := New(nil, testJWTSecret)
if err != nil {
t.Fatal(err)
}
var releases []func()
for range MaxConcurrentKDF {
release, err := svc.acquireKDF()
if err != nil {
t.Fatal(err)
}
releases = append(releases, release)
}
started := time.Now()
if _, err := svc.hashPassword("Password1"); !errors.Is(err, ErrAuthBusy) {
t.Fatalf("saturated KDF = %v, want ErrAuthBusy", err)
}
if waited := time.Since(started); waited < kdfWaitTimeout {
t.Fatalf("gave up after %v, want %v", waited, kdfWaitTimeout)
}
releases[0]()
if _, err := svc.hashPassword("Password1"); err != nil {
t.Fatalf("hash after release: %v", err)
}
}
func TestNewRejectsShortJWTSecret(t *testing.T) {
if _, err := New((*storage.Store)(nil), make([]byte, 31)); err == nil {
t.Fatal("31-byte JWT secret accepted")
}
}
func TestDeleteAccountReleasesLiveClaims(t *testing.T) {
svc := newPersistentTestService(t)
user, sessionID, err := svc.RegisterUser("alice", "", "Password1")
if err != nil {
t.Fatal(err)
}
token, err := svc.GenerateUserToken(user.UserID, sessionID)
if err != nil {
t.Fatal(err)
}
gameID := uuid.NewString()
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
white.ID, white.ClaimedBy = user.UserID, user.UserID
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerComputer, Level: 1, SearchTime: 100}, core.ColorBlack)
if err := svc.CreateGame(gameID, white, black, chess.StartFEN, core.ColorWhite, core.StateOngoing, core.TermNone); err != nil {
t.Fatal(err)
}
if err := svc.DeleteAccount(user.UserID, sessionID, "Password2"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("wrong password: %v, want ErrInvalidCredentials", err)
}
if _, _, err := svc.ValidateToken(token); err != nil {
t.Fatalf("a refused deletion ended the session: %v", err)
}
if err := svc.DeleteAccount(user.UserID, sessionID, "Password1"); err != nil {
t.Fatal(err)
}
if _, _, err := svc.ValidateToken(token); err == nil {
t.Error("token still valid after deletion")
}
view, err := svc.GetGameView(gameID)
if err != nil {
t.Fatal(err)
}
if owner := view.WhitePlayer.ClaimedBy; owner != "" {
t.Errorf("live game still claimed by the deleted account: %q", owner)
}
}