192 lines
6.0 KiB
Go
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)
|
|
}
|
|
}
|