v0.5.0 user support with auth added, tests and doc updated
This commit is contained in:
68
README.md
68
README.md
@ -12,19 +12,21 @@
|
|||||||
|
|
||||||
# Chess
|
# Chess
|
||||||
|
|
||||||
Go backend server providing a RESTful API for chess gameplay. Integrates Stockfish engine for move validation and computer opponents.
|
Go backend server providing a RESTful API for chess gameplay with user authentication. Integrates Stockfish engine for move validation and computer opponents.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- RESTful API for chess operations
|
- RESTful API for chess operations
|
||||||
|
- User registration and JWT authentication
|
||||||
- Stockfish engine integration for validation
|
- Stockfish engine integration for validation
|
||||||
- Human vs human, human vs computer, computer vs computer modes
|
- Human vs human, human vs computer, computer vs computer modes
|
||||||
- Custom FEN position support
|
- Custom FEN position support
|
||||||
- Asynchronous engine move calculation
|
- Asynchronous engine move calculation
|
||||||
- Configurable engine strength and thinking time
|
- Configurable engine strength and thinking time
|
||||||
- Optional SQLite persistence with async writes
|
- SQLite persistence with async writes for games
|
||||||
|
- User management with secure Argon2 password storage
|
||||||
- PID file management for singleton enforcement
|
- PID file management for singleton enforcement
|
||||||
- Database CLI for storage administration
|
- Database CLI for storage and user administration
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@ -33,7 +35,6 @@ Go backend server providing a RESTful API for chess gameplay. Integrates Stockfi
|
|||||||
- SQLite3 (for persistence features)
|
- SQLite3 (for persistence features)
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Arch Linux
|
# Arch Linux
|
||||||
yay -S stockfish
|
yay -S stockfish
|
||||||
@ -43,26 +44,58 @@ pkg install stockfish
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.lixen.com/lixen/chess
|
#git clone https://git.lixen.com/lixen/chess # Mirror
|
||||||
|
git clone https://github.com/lixenwraith/chess
|
||||||
cd chess
|
cd chess
|
||||||
go build ./cmd/chessd
|
go build ./cmd/chessd
|
||||||
|
|
||||||
# Standard mode with persistence
|
# Standard mode with persistence and auth
|
||||||
./chessd -storage-path chess.db
|
./chessd -storage-path chess.db
|
||||||
|
|
||||||
# Development mode with PID lock on localhost custom port
|
# Development mode with all features
|
||||||
./chessd -dev -pid /tmp/chessd.pid -pid-lock -port 9090
|
./chessd -dev -storage-path chess.db -pid /tmp/chessd.pid -pid-lock -port 9090
|
||||||
|
|
||||||
# Database initialization (doesn't run server)
|
# Initialize database with user support
|
||||||
./chessd db init -path chess.db
|
./chessd db init -path chess.db
|
||||||
|
|
||||||
# Query stored games (doesn't run server)
|
# Add users via CLI
|
||||||
./chessd db query -path chess.db -gameId "*"
|
./chessd db user add -path chess.db -username alice -password AlicePass123
|
||||||
|
./chessd db user list -path chess.db
|
||||||
```
|
```
|
||||||
|
|
||||||
Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints.
|
Server listens on `http://localhost:8080`. See [API Reference](./doc/api.md) for endpoints including authentication.
|
||||||
|
|
||||||
|
## User Management
|
||||||
|
|
||||||
|
The chess server supports user accounts with secure authentication:
|
||||||
|
|
||||||
|
### Creating Users
|
||||||
|
```bash
|
||||||
|
# Add user with password
|
||||||
|
./chessd db user add -path chess.db -username alice -email alice@example.com -password SecurePass123
|
||||||
|
|
||||||
|
# Interactive password prompt
|
||||||
|
./chessd db user add -path chess.db -username bob -interactive
|
||||||
|
|
||||||
|
# Import with existing hash
|
||||||
|
./chessd db user add -path chess.db -username charlie -hash '$argon2id$...'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Users
|
||||||
|
```bash
|
||||||
|
# List all users
|
||||||
|
./chessd db user list -path chess.db
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
./chessd db user set-password -path chess.db -username alice -password NewPass456
|
||||||
|
|
||||||
|
# Update email
|
||||||
|
./chessd db user set-email -path chess.db -username alice -email newemail@example.com
|
||||||
|
|
||||||
|
# Delete user
|
||||||
|
./chessd db user delete -path chess.db -username alice
|
||||||
|
```
|
||||||
|
|
||||||
## Web UI
|
## Web UI
|
||||||
|
|
||||||
@ -76,7 +109,7 @@ The chess server includes an embedded web UI for playing games through a browser
|
|||||||
# Custom web UI port
|
# Custom web UI port
|
||||||
./chessd -serve -web-port 3000 -web-host 0.0.0.0
|
./chessd -serve -web-port 3000 -web-host 0.0.0.0
|
||||||
|
|
||||||
# Full example with all features
|
# Full example with authentication enabled
|
||||||
./chessd -dev -serve -web-port 9090 -api-port 8080 -storage-path chess.db
|
./chessd -dev -serve -web-port 9090 -api-port 8080 -storage-path chess.db
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -87,15 +120,16 @@ The chess server includes an embedded web UI for playing games through a browser
|
|||||||
- Move history with algebraic notation
|
- Move history with algebraic notation
|
||||||
- FEN display and custom starting positions
|
- FEN display and custom starting positions
|
||||||
- Real-time server health monitoring
|
- Real-time server health monitoring
|
||||||
|
- User authentication support
|
||||||
- Responsive design for mobile devices
|
- Responsive design for mobile devices
|
||||||
|
|
||||||
Access the UI at `http://localhost:9090` when server is running with `-serve` flag.
|
Access the UI at `http://localhost:9090` when server is running with `-serve` flag.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [API Reference](./doc/api.md) - Endpoint specifications
|
- [API Reference](./doc/api.md) - Endpoint specifications including auth
|
||||||
- [Architecture](./doc/architecture.md) - System design
|
- [Architecture](./doc/architecture.md) - System design with auth layer
|
||||||
- [Development](./doc/development.md) - Build and test instructions
|
- [Development](./doc/development.md) - Build, test, and user management
|
||||||
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
|
- [Stockfish Integration](./doc/stockfish.md) - Engine communication
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@ -2,18 +2,25 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"chess/internal/storage"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chess/internal/storage"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/lixenwraith/auth"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run is the entry point for the CLI mini-app
|
// Run is the entry point for the CLI mini-app
|
||||||
func Run(args []string) error {
|
func Run(args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("subcommand required: init, delete, or query")
|
return fmt.Errorf("subcommand required: init, delete, query, user")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
@ -23,6 +30,11 @@ func Run(args []string) error {
|
|||||||
return runDelete(args[1:])
|
return runDelete(args[1:])
|
||||||
case "query":
|
case "query":
|
||||||
return runQuery(args[1:])
|
return runQuery(args[1:])
|
||||||
|
case "user":
|
||||||
|
if len(args) < 2 {
|
||||||
|
return fmt.Errorf("user subcommand required: add, delete, set-password, set-hash, set-email, set-username, list")
|
||||||
|
}
|
||||||
|
return runUser(args[1], args[2:])
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown subcommand: %s", args[0])
|
return fmt.Errorf("unknown subcommand: %s", args[0])
|
||||||
}
|
}
|
||||||
@ -128,4 +140,422 @@ func runQuery(args []string) error {
|
|||||||
|
|
||||||
fmt.Printf("\nFound %d game(s)\n", len(games))
|
fmt.Printf("\nFound %d game(s)\n", len(games))
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUser(subcommand string, args []string) error {
|
||||||
|
switch subcommand {
|
||||||
|
case "add":
|
||||||
|
return runUserAdd(args)
|
||||||
|
case "delete":
|
||||||
|
return runUserDelete(args)
|
||||||
|
case "set-password":
|
||||||
|
return runUserSetPassword(args)
|
||||||
|
case "set-hash":
|
||||||
|
return runUserSetHash(args)
|
||||||
|
case "set-email":
|
||||||
|
return runUserSetEmail(args)
|
||||||
|
case "set-username":
|
||||||
|
return runUserSetUsername(args)
|
||||||
|
case "list":
|
||||||
|
return runUserList(args)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown user subcommand: %s", subcommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUserAdd(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("user add", flag.ContinueOnError)
|
||||||
|
path := fs.String("path", "", "Database file path (required)")
|
||||||
|
username := fs.String("username", "", "Username (required)")
|
||||||
|
email := fs.String("email", "", "Email address (optional)")
|
||||||
|
password := fs.String("password", "", "Password (use this or -hash, not both)")
|
||||||
|
hash := fs.String("hash", "", "Password hash (use this or -password, not both)")
|
||||||
|
interactive := fs.Bool("interactive", false, "Interactive password prompt")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *path == "" {
|
||||||
|
return fmt.Errorf("database path required")
|
||||||
|
}
|
||||||
|
if *username == "" {
|
||||||
|
return fmt.Errorf("username required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password/hash options
|
||||||
|
if *password != "" && *hash != "" {
|
||||||
|
return fmt.Errorf("cannot specify both -password and -hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHash string
|
||||||
|
|
||||||
|
if *interactive {
|
||||||
|
if *password != "" || *hash != "" {
|
||||||
|
return fmt.Errorf("cannot use -interactive with -password or -hash")
|
||||||
|
}
|
||||||
|
fmt.Print("Enter password: ")
|
||||||
|
pwBytes, err := term.ReadPassword(syscall.Stdin)
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
if len(pwBytes) < 8 {
|
||||||
|
return fmt.Errorf("password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password (Argon2)
|
||||||
|
passwordHash, err = auth.HashPassword(string(pwBytes))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
} else if *hash != "" {
|
||||||
|
passwordHash = *hash
|
||||||
|
} else if *password != "" {
|
||||||
|
if len(*password) < 8 {
|
||||||
|
return fmt.Errorf("password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
// Hash password (Argon2)
|
||||||
|
var err error
|
||||||
|
passwordHash, err = auth.HashPassword(*password)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("password required: use -password, -hash, or -interactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.NewStore(*path, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open store: %w", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Generate user ID with conflict check
|
||||||
|
var userID string
|
||||||
|
for attempts := 0; attempts < 10; attempts++ {
|
||||||
|
userID = uuid.New().String()
|
||||||
|
if _, err := store.GetUserByID(userID); err != nil {
|
||||||
|
// User doesn't exist, ID is unique
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if attempts == 9 {
|
||||||
|
return fmt.Errorf("failed to generate unique user ID after 10 attempts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record := storage.UserRecord{
|
||||||
|
UserID: userID,
|
||||||
|
Username: strings.ToLower(*username),
|
||||||
|
Email: strings.ToLower(*email),
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.CreateUser(record); err != nil {
|
||||||
|
return fmt.Errorf("failed to create user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("User created successfully:\n")
|
||||||
|
fmt.Printf(" ID: %s\n", userID)
|
||||||
|
fmt.Printf(" Username: %s\n", *username)
|
||||||
|
if *email != "" {
|
||||||
|
fmt.Printf(" Email: %s\n", *email)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUserDelete(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("user delete", flag.ContinueOnError)
|
||||||
|
path := fs.String("path", "", "Database file path (required)")
|
||||||
|
username := fs.String("username", "", "Username to delete")
|
||||||
|
userID := fs.String("id", "", "User ID to delete")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *path == "" {
|
||||||
|
return fmt.Errorf("database path required")
|
||||||
|
}
|
||||||
|
if *username == "" && *userID == "" {
|
||||||
|
return fmt.Errorf("either -username or -id required")
|
||||||
|
}
|
||||||
|
if *username != "" && *userID != "" {
|
||||||
|
return fmt.Errorf("specify either -username or -id, not both")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.NewStore(*path, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open store: %w", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
var targetID string
|
||||||
|
if *userID != "" {
|
||||||
|
targetID = *userID
|
||||||
|
} else {
|
||||||
|
user, err := store.GetUserByUsername(*username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user not found: %s", *username)
|
||||||
|
}
|
||||||
|
targetID = user.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.DeleteUser(targetID); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("User deleted: %s\n", targetID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUserSetPassword(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("user set-password", flag.ContinueOnError)
|
||||||
|
path := fs.String("path", "", "Database file path (required)")
|
||||||
|
username := fs.String("username", "", "Username (required)")
|
||||||
|
password := fs.String("password", "", "New password")
|
||||||
|
interactive := fs.Bool("interactive", false, "Interactive password prompt")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *path == "" {
|
||||||
|
return fmt.Errorf("database path required")
|
||||||
|
}
|
||||||
|
if *username == "" {
|
||||||
|
return fmt.Errorf("username required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var newPassword string
|
||||||
|
if *interactive {
|
||||||
|
if *password != "" {
|
||||||
|
return fmt.Errorf("cannot use -interactive with -password")
|
||||||
|
}
|
||||||
|
fmt.Print("Enter new password: ")
|
||||||
|
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
fmt.Println()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read password: %w", err)
|
||||||
|
}
|
||||||
|
newPassword = string(pwBytes)
|
||||||
|
} else if *password != "" {
|
||||||
|
newPassword = *password
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("password required: use -password or -interactive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newPassword) < 8 {
|
||||||
|
return fmt.Errorf("password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.NewStore(*path, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open store: %w", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := store.GetUserByUsername(*username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user not found: %s", *username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password (Argon2)
|
||||||
|
passwordHash, err := auth.HashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
if err := store.UpdateUserPassword(user.UserID, passwordHash); err != nil {
|
||||||
|
return fmt.Errorf("failed to update password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Password updated for user: %s\n", *username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUserSetHash(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("user set-hash", flag.ContinueOnError)
|
||||||
|
path := fs.String("path", "", "Database file path (required)")
|
||||||
|
username := fs.String("username", "", "Username (required)")
|
||||||
|
hash := fs.String("hash", "", "Password hash (required)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *path == "" {
|
||||||
|
return fmt.Errorf("database path required")
|
||||||
|
}
|
||||||
|
if *username == "" {
|
||||||
|
return fmt.Errorf("username required")
|
||||||
|
}
|
||||||
|
if *hash == "" {
|
||||||
|
return fmt.Errorf("password hash required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.ValidatePHCHashFormat(*hash); err != nil {
|
||||||
|
return fmt.Errorf("invalid hash format: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.NewStore(*path, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open store: %w", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := store.GetUserByUsername(*username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user not found: %s", *username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password hash directly
|
||||||
|
if err := store.UpdateUserPassword(user.UserID, *hash); err != nil {
|
||||||
|
return fmt.Errorf("failed to update password hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Password hash updated for user: %s\n", *username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUserSetEmail(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("user set-email", flag.ContinueOnError)
|
||||||
|
path := fs.String("path", "", "Database file path (required)")
|
||||||
|
username := fs.String("username", "", "Username (required)")
|
||||||
|
email := fs.String("email", "", "New email address (required)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *path == "" {
|
||||||
|
return fmt.Errorf("database path required")
|
||||||
|
}
|
||||||
|
if *username == "" {
|
||||||
|
return fmt.Errorf("username required")
|
||||||
|
}
|
||||||
|
if *email == "" {
|
||||||
|
return fmt.Errorf("email required")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.NewStore(*path, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open store: %w", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := store.GetUserByUsername(*username)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user not found: %s", *username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update email
|
||||||
|
if err := store.UpdateUserEmail(user.UserID, strings.ToLower(*email)); err != nil {
|
||||||
|
return fmt.Errorf("failed to update email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Email updated for user: %s\n", *username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUserSetUsername(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("user set-username", flag.ContinueOnError)
|
||||||
|
path := fs.String("path", "", "Database file path (required)")
|
||||||
|
current := fs.String("current", "", "Current username (required)")
|
||||||
|
new := fs.String("new", "", "New username (required)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *path == "" {
|
||||||
|
return fmt.Errorf("database path required")
|
||||||
|
}
|
||||||
|
if *current == "" {
|
||||||
|
return fmt.Errorf("current username required")
|
||||||
|
}
|
||||||
|
if *new == "" {
|
||||||
|
return fmt.Errorf("new username required")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.NewStore(*path, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open store: %w", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := store.GetUserByUsername(*current)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("user not found: %s", *current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update username
|
||||||
|
if err := store.UpdateUserUsername(user.UserID, strings.ToLower(*new)); err != nil {
|
||||||
|
return fmt.Errorf("failed to update username: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Username updated: %s -> %s\n", *current, *new)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runUserList(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("user list", flag.ContinueOnError)
|
||||||
|
path := fs.String("path", "", "Database file path (required)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if *path == "" {
|
||||||
|
return fmt.Errorf("database path required")
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := storage.NewStore(*path, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open store: %w", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
users, err := store.GetAllUsers()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list users: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(users) == 0 {
|
||||||
|
fmt.Println("No users found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print results in tabular format
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "User ID\tUsername\tEmail\tCreated\tLast Login")
|
||||||
|
fmt.Fprintln(w, strings.Repeat("-", 100))
|
||||||
|
|
||||||
|
for _, u := range users {
|
||||||
|
lastLogin := "never"
|
||||||
|
if u.LastLoginAt != nil {
|
||||||
|
lastLogin = u.LastLoginAt.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
email := u.Email
|
||||||
|
if email == "" {
|
||||||
|
email = "(none)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
u.UserID[:8]+"...",
|
||||||
|
u.Username,
|
||||||
|
email,
|
||||||
|
u.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
lastLogin,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
|
||||||
|
fmt.Printf("\nTotal users: %d\n", len(users))
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
@ -1,12 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"chess/cmd/chessd/cli"
|
"crypto/rand"
|
||||||
"chess/internal/http"
|
|
||||||
"chess/internal/processor"
|
|
||||||
"chess/internal/service"
|
|
||||||
"chess/internal/storage"
|
|
||||||
"chess/internal/webserver"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@ -14,6 +9,13 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"chess/cmd/chessd/cli"
|
||||||
|
"chess/internal/http"
|
||||||
|
"chess/internal/processor"
|
||||||
|
"chess/internal/service"
|
||||||
|
"chess/internal/storage"
|
||||||
|
"chess/internal/webserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -75,8 +77,23 @@ func main() {
|
|||||||
log.Printf("Persistent storage disabled (use -storage-path to enable)")
|
log.Printf("Persistent storage disabled (use -storage-path to enable)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Initialize the Service with optional storage
|
// JWT secret management
|
||||||
svc, err := service.New(store)
|
var jwtSecret []byte
|
||||||
|
if *dev {
|
||||||
|
// Fixed secret in dev mode for testing consistency
|
||||||
|
jwtSecret = []byte("dev-secret-minimum-32-characters-long")
|
||||||
|
log.Printf("Using fixed JWT secret (dev mode)")
|
||||||
|
} else {
|
||||||
|
// Generate cryptographically secure secret
|
||||||
|
jwtSecret = make([]byte, 32)
|
||||||
|
if _, err := rand.Read(jwtSecret); err != nil {
|
||||||
|
log.Fatalf("Failed to generate JWT secret: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("JWT secret generated (sessions valid until restart)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Initialize the Service with optional storage and auth
|
||||||
|
svc, err := service.New(store, jwtSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to initialize service: %v", err)
|
log.Fatalf("Failed to initialize service: %v", err)
|
||||||
}
|
}
|
||||||
@ -104,6 +121,7 @@ func main() {
|
|||||||
log.Printf("Chess API Server starting...")
|
log.Printf("Chess API Server starting...")
|
||||||
log.Printf("API Listening on: http://%s", apiAddr)
|
log.Printf("API Listening on: http://%s", apiAddr)
|
||||||
log.Printf("API Version: v1")
|
log.Printf("API Version: v1")
|
||||||
|
log.Printf("Authentication: Enabled (JWT)")
|
||||||
if *dev {
|
if *dev {
|
||||||
log.Printf("Rate Limit: 20 requests/second per IP (DEV MODE)")
|
log.Printf("Rate Limit: 20 requests/second per IP (DEV MODE)")
|
||||||
} else {
|
} else {
|
||||||
@ -112,9 +130,10 @@ func main() {
|
|||||||
if *storagePath != "" {
|
if *storagePath != "" {
|
||||||
log.Printf("Storage: Enabled (%s)", *storagePath)
|
log.Printf("Storage: Enabled (%s)", *storagePath)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Storage: Disabled")
|
log.Printf("Storage: Disabled (auth features unavailable)")
|
||||||
}
|
}
|
||||||
log.Printf("API Endpoints: http://%s/api/v1/games", apiAddr)
|
log.Printf("API Endpoints: http://%s/api/v1/games", apiAddr)
|
||||||
|
log.Printf("Auth Endpoints: http://%s/api/v1/auth/[register|login|me]", apiAddr)
|
||||||
log.Printf("Health: http://%s/health", apiAddr)
|
log.Printf("Health: http://%s/health", apiAddr)
|
||||||
|
|
||||||
if err := app.Listen(apiAddr); err != nil {
|
if err := app.Listen(apiAddr); err != nil {
|
||||||
@ -122,7 +141,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 5. Start Web UI server if requested
|
// 5. Start Web UI server (optional)
|
||||||
if *serve {
|
if *serve {
|
||||||
webAddr := fmt.Sprintf("%s:%d", *webHost, *webPort)
|
webAddr := fmt.Sprintf("%s:%d", *webHost, *webPort)
|
||||||
apiURL := fmt.Sprintf("http://%s", apiAddr)
|
apiURL := fmt.Sprintf("http://%s", apiAddr)
|
||||||
|
|||||||
167
doc/api.md
167
doc/api.md
@ -4,12 +4,92 @@ Base URL: `http://localhost:8080/api/v1`
|
|||||||
|
|
||||||
Content-Type: `application/json` (required for POST/PUT)
|
Content-Type: `application/json` (required for POST/PUT)
|
||||||
|
|
||||||
## Endpoints
|
## Authentication
|
||||||
|
|
||||||
|
The API supports optional JWT authentication for user accounts. When authenticated, games are associated with the user account.
|
||||||
|
|
||||||
|
### Register User
|
||||||
|
`POST /auth/register`
|
||||||
|
|
||||||
|
Creates new user account and returns JWT token.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "alice",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"password": "SecurePass123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `username` (string, required): 1-40 characters, alphanumeric and underscore only
|
||||||
|
- `email` (string, optional): Valid email address
|
||||||
|
- `password` (string, required): Minimum 8 characters, must contain letter and number
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"userId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"username": "alice",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"expiresAt": "2025-01-14T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login
|
||||||
|
`POST /auth/login`
|
||||||
|
|
||||||
|
Authenticates user and returns JWT token.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"identifier": "alice",
|
||||||
|
"password": "SecurePass123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `identifier` (string, required): Username or email address
|
||||||
|
- `password` (string, required): User password
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||||
|
"userId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"username": "alice",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"expiresAt": "2025-01-14T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Current User
|
||||||
|
`GET /auth/me`
|
||||||
|
|
||||||
|
Returns authenticated user information. Requires authentication.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"username": "alice",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"createdAt": "2025-01-07T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Game Endpoints
|
||||||
|
|
||||||
### Health Check
|
### Health Check
|
||||||
`GET /health`
|
`GET /health`
|
||||||
|
|
||||||
Returns server status.
|
Returns server and storage status.
|
||||||
|
|
||||||
**Response (200):**
|
**Response (200):**
|
||||||
```json
|
```json
|
||||||
@ -22,13 +102,18 @@ Returns server status.
|
|||||||
|
|
||||||
Storage states:
|
Storage states:
|
||||||
- `"disabled"` - No storage path configured
|
- `"disabled"` - No storage path configured
|
||||||
- `"ok"` - Database operational
|
- `"ok"` - Database operational with auth enabled
|
||||||
- `"degraded"` - Write failures detected, operating memory-only
|
- `"degraded"` - Write failures detected, operating memory-only
|
||||||
|
|
||||||
### Create Game
|
### Create Game
|
||||||
`POST /games`
|
`POST /games`
|
||||||
|
|
||||||
Creates new game with specified players.
|
Creates new game with specified players. Optional authentication associates game with user.
|
||||||
|
|
||||||
|
**Headers (optional):**
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
@ -47,11 +132,6 @@ Creates new game with specified players.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `type` (integer, required): 1=human, 2=computer
|
|
||||||
- `level` (integer, 0-20): Engine skill level for computer players
|
|
||||||
- `searchTime` (integer, 100-10000ms): Engine thinking time for computer players
|
|
||||||
- `fen` (string): Starting position in FEN notation (default: standard position)
|
|
||||||
|
|
||||||
**Response (201):**
|
**Response (201):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -61,37 +141,19 @@ Creates new game with specified players.
|
|||||||
"state": "ongoing",
|
"state": "ongoing",
|
||||||
"moves": [],
|
"moves": [],
|
||||||
"players": {
|
"players": {
|
||||||
"white": {"id": "...", "color": 1, "type": 1},
|
"white": {"id": "550e8400-...", "color": 1, "type": 1},
|
||||||
"black": {"id": "...", "color": 2, "type": 2, "level": 15, "searchTime": 1000}
|
"black": {"id": "ai-player-...", "color": 2, "type": 2, "level": 15, "searchTime": 1000}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: When authenticated, human player IDs match the user's ID. Anonymous players receive unique UUIDs.
|
||||||
|
|
||||||
### Get Game
|
### Get Game
|
||||||
`GET /games/{gameId}`
|
`GET /games/{gameId}`
|
||||||
|
|
||||||
Returns current game state.
|
Returns current game state.
|
||||||
|
|
||||||
**Response (200):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"gameId": "...",
|
|
||||||
"fen": "...",
|
|
||||||
"turn": "w",
|
|
||||||
"state": "ongoing",
|
|
||||||
"moves": ["e2e4", "e7e5"],
|
|
||||||
"players": {...},
|
|
||||||
"lastMove": {
|
|
||||||
"move": "e7e5",
|
|
||||||
"playerColor": "b",
|
|
||||||
"score": 25,
|
|
||||||
"depth": 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
States: `ongoing`, `pending` (computer thinking), `white wins`, `black wins`, `draw`, `stalemate`
|
|
||||||
|
|
||||||
### Make Move
|
### Make Move
|
||||||
`POST /games/{gameId}/moves`
|
`POST /games/{gameId}/moves`
|
||||||
|
|
||||||
@ -107,53 +169,27 @@ Submits human move or triggers computer move.
|
|||||||
{"move": "cccc"}
|
{"move": "cccc"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns updated game state (200) or error (400).
|
|
||||||
|
|
||||||
### Undo Moves
|
### Undo Moves
|
||||||
`POST /games/{gameId}/undo`
|
`POST /games/{gameId}/undo`
|
||||||
|
|
||||||
Reverts moves from history.
|
Reverts moves from history.
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{"count": 2}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `count` (integer, 1-300): Number of moves to undo (default: 1)
|
|
||||||
|
|
||||||
### Configure Players
|
### Configure Players
|
||||||
`PUT /games/{gameId}/players`
|
`PUT /games/{gameId}/players`
|
||||||
|
|
||||||
Changes player configuration mid-game.
|
Changes player configuration mid-game.
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"white": {"type": 2, "level": 5, "searchTime": 100},
|
|
||||||
"black": {"type": 1}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get Board
|
### Get Board
|
||||||
`GET /games/{gameId}/board`
|
`GET /games/{gameId}/board`
|
||||||
|
|
||||||
Returns ASCII board visualization.
|
Returns ASCII board visualization.
|
||||||
|
|
||||||
**Response (200):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fen": "...",
|
|
||||||
"board": " a b c d e f g h\n8 r n b q k b n r 8\n..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete Game
|
### Delete Game
|
||||||
`DELETE /games/{gameId}`
|
`DELETE /games/{gameId}`
|
||||||
|
|
||||||
Removes game from memory. Returns 204 on success.
|
Removes game from memory. Returns 204 on success.
|
||||||
|
|
||||||
## Error Format
|
## Error Format
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": "Description",
|
"error": "Description",
|
||||||
@ -170,10 +206,23 @@ Error codes:
|
|||||||
- `RATE_LIMIT_EXCEEDED` - Request limit exceeded
|
- `RATE_LIMIT_EXCEEDED` - Request limit exceeded
|
||||||
- `INVALID_REQUEST` - Malformed request
|
- `INVALID_REQUEST` - Malformed request
|
||||||
- `INVALID_CONTENT_TYPE` - Missing/wrong Content-Type header
|
- `INVALID_CONTENT_TYPE` - Missing/wrong Content-Type header
|
||||||
|
- `INVALID_FEN` - Invalid FEN format
|
||||||
|
- `INTERNAL_ERROR` - Server error
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
- Standard: 10 request/second/IP
|
- Standard: 10 requests/second/IP (general endpoints)
|
||||||
- Development (`-dev`): 20 requests/second/IP
|
- Development (`-dev`): 20 requests/second/IP
|
||||||
|
- Registration: 5 requests/minute/IP
|
||||||
|
- Login: 10 requests/minute/IP
|
||||||
|
|
||||||
Exceeding limit returns 429 status.
|
Exceeding limit returns 429 status.
|
||||||
|
|
||||||
|
## JWT Token Format
|
||||||
|
|
||||||
|
Tokens are HS256-signed JWTs valid for 7 days. Include in Authorization header:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Token claims include `sub` (user ID), `username`, `email`, and `exp` (expiration).
|
||||||
@ -3,33 +3,57 @@
|
|||||||
## Components
|
## Components
|
||||||
|
|
||||||
### Transport Layer (`internal/http`)
|
### Transport Layer (`internal/http`)
|
||||||
Fiber web server handling HTTP requests/responses. Implements routing, rate limiting, content-type validation, request parsing. Translates HTTP to internal Command objects.
|
Fiber web server handling HTTP requests/responses. Implements routing, rate limiting, content-type validation, JWT authentication middleware, request parsing. Translates HTTP to internal Command objects.
|
||||||
|
|
||||||
### Processing Layer (`internal/processor`)
|
### Processing Layer (`internal/processor`)
|
||||||
Central command handler containing business logic. Single `Execute(Command)` entry point decouples transport from logic. Uses synchronous UCI engine for validation, asynchronous EngineQueue for computer moves.
|
Central command handler containing business logic. Single `Execute(Command)` entry point decouples transport from logic. Uses synchronous UCI engine for validation, asynchronous EngineQueue for computer moves. Commands include optional user context for authenticated operations.
|
||||||
|
|
||||||
### Service Layer (`internal/service`)
|
### Service Layer (`internal/service`)
|
||||||
In-memory state storage without chess logic. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, and player configuration. Coordinates with storage layer for optional persistence.
|
In-memory state storage with authentication support. Thread-safe game map protected by RWMutex. Manages game lifecycle, snapshots, player configuration, user accounts, and JWT token generation. Coordinates with storage layer for persistence of both games and users.
|
||||||
|
|
||||||
### Storage Layer (`internal/storage`)
|
### Storage Layer (`internal/storage`)
|
||||||
Optional SQLite persistence with async write pattern. Buffered channel (1000 ops) processes writes sequentially in background. Graceful degradation on write failures. WAL mode for development environments.
|
SQLite persistence with async writes for games, synchronous writes for authentication operations. Buffered channel (1000 ops) processes game writes sequentially in background. User operations use direct database access for consistency. Graceful degradation on write failures. WAL mode for development environments.
|
||||||
|
|
||||||
|
### Authentication Module (`internal/service/user.go`, `internal/http/auth.go`)
|
||||||
|
- **Password Hashing**: Argon2id for secure password storage
|
||||||
|
- **JWT Management**: HS256 tokens with 7-day expiration
|
||||||
|
- **User Operations**: Registration, login, profile management
|
||||||
|
- **Session Tracking**: Last login timestamps
|
||||||
|
|
||||||
### Supporting Modules
|
### Supporting Modules
|
||||||
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
|
- **Engine** (`internal/engine`): UCI protocol wrapper for Stockfish process communication
|
||||||
- **Game** (`internal/game`): Game state with snapshot history
|
- **Game** (`internal/game`): Game state with snapshot history and player associations
|
||||||
- **Board** (`internal/board`): FEN parsing and ASCII generation
|
- **Board** (`internal/board`): FEN parsing and ASCII generation
|
||||||
- **Core** (`internal/core`): Shared types, API models, error constants
|
- **Core** (`internal/core`): Shared types, API models, error constants
|
||||||
- **CLI** (`cmd/chessd/cli`): Database management commands
|
- **CLI** (`cmd/chessd/cli`): Database and user management commands
|
||||||
|
|
||||||
## Request Flow
|
## Request Flow
|
||||||
|
|
||||||
### Human Move
|
### User Registration
|
||||||
1. HTTP handler receives `POST /games/{id}/moves` with UCI move
|
1. HTTP handler receives `POST /auth/register` with credentials
|
||||||
2. Creates MakeMoveCommand, calls `processor.Execute()`
|
2. Validates username format and password strength
|
||||||
3. Processor validates move via locked validation engine
|
3. Service layer hashes password with Argon2id
|
||||||
4. If legal, gets new FEN from engine
|
4. Creates user record with unique ID (collision detection)
|
||||||
5. Calls `service.ApplyMove()` to update state
|
5. Generates JWT token
|
||||||
6. Returns GameResponse
|
6. Returns token and user information
|
||||||
|
|
||||||
|
### Authenticated Game Creation
|
||||||
|
1. HTTP handler receives `POST /games` with optional Bearer token
|
||||||
|
2. Middleware validates JWT if present
|
||||||
|
3. Creates CreateGameCommand with user ID context
|
||||||
|
4. Processor creates game with user ID for human players
|
||||||
|
5. Service associates game with authenticated user
|
||||||
|
6. Returns game with player IDs matching user
|
||||||
|
|
||||||
|
### Human Move (Authenticated)
|
||||||
|
1. HTTP handler receives `POST /games/{id}/moves` with move
|
||||||
|
2. Optional JWT validation for user verification
|
||||||
|
3. Creates MakeMoveCommand, calls `processor.Execute()`
|
||||||
|
4. Processor validates move via locked validation engine
|
||||||
|
5. If legal, gets new FEN from engine
|
||||||
|
6. Calls `service.ApplyMove()` to update state
|
||||||
|
7. Persists move with player identification
|
||||||
|
8. Returns GameResponse
|
||||||
|
|
||||||
### Computer Move
|
### Computer Move
|
||||||
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
|
1. HTTP handler receives `POST /games/{id}/moves` with `{"move": "cccc"}`
|
||||||
@ -41,7 +65,14 @@ Optional SQLite persistence with async write pattern. Buffered channel (1000 ops
|
|||||||
|
|
||||||
## Persistence Flow
|
## Persistence Flow
|
||||||
|
|
||||||
### Write Operations
|
### User Write Operations (Synchronous)
|
||||||
|
1. Service layer calls storage method directly (CreateUser, UpdateUserPassword, etc.)
|
||||||
|
2. Operations use database transactions for consistency
|
||||||
|
3. Unique constraint checks within transaction
|
||||||
|
4. Immediate commit or rollback
|
||||||
|
5. Returns success or specific error (duplicate username, etc.)
|
||||||
|
|
||||||
|
### Game Write Operations (Asynchronous)
|
||||||
1. Service layer calls storage method (RecordNewGame, RecordMove, DeleteUndoneMoves)
|
1. Service layer calls storage method (RecordNewGame, RecordMove, DeleteUndoneMoves)
|
||||||
2. Operation queued to buffered channel (non-blocking)
|
2. Operation queued to buffered channel (non-blocking)
|
||||||
3. Writer goroutine processes queue sequentially
|
3. Writer goroutine processes queue sequentially
|
||||||
@ -49,9 +80,10 @@ Optional SQLite persistence with async write pattern. Buffered channel (1000 ops
|
|||||||
5. Failures trigger degradation to memory-only mode
|
5. Failures trigger degradation to memory-only mode
|
||||||
|
|
||||||
### Query Operations
|
### Query Operations
|
||||||
1. CLI invokes Store.QueryGames with filters
|
1. CLI invokes Store.QueryGames or Store.GetUserByUsername with filters
|
||||||
2. Direct database read (no queue)
|
2. Direct database read (no queue)
|
||||||
3. Results formatted as tabular output
|
3. Case-insensitive matching for usernames/emails
|
||||||
|
4. Results formatted as tabular output
|
||||||
|
|
||||||
## Concurrency
|
## Concurrency
|
||||||
|
|
||||||
@ -59,43 +91,78 @@ Optional SQLite persistence with async write pattern. Buffered channel (1000 ops
|
|||||||
- **Game State**: Single RWMutex protects game map (concurrent reads, serial writes)
|
- **Game State**: Single RWMutex protects game map (concurrent reads, serial writes)
|
||||||
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
|
- **Engine Workers**: Fixed pool (2 workers) with dedicated Stockfish processes
|
||||||
- **Validation Engine**: Single mutex-protected instance for synchronous validation
|
- **Validation Engine**: Single mutex-protected instance for synchronous validation
|
||||||
- **Storage Writer**: Single goroutine processes write queue sequentially
|
- **Storage Writer**: Single goroutine processes game write queue sequentially
|
||||||
|
- **User Operations**: Direct database access with transaction isolation
|
||||||
- **PID Lock**: File-based exclusive lock prevents multiple instances
|
- **PID Lock**: File-based exclusive lock prevents multiple instances
|
||||||
|
|
||||||
## Data Structures
|
## Data Structures
|
||||||
|
|
||||||
### Game Snapshot
|
### User Record
|
||||||
|
```go
|
||||||
|
type UserRecord struct {
|
||||||
|
UserID string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
PasswordHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastLoginAt *time.Time
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Game Snapshot with User Context
|
||||||
```go
|
```go
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
FEN string
|
FEN string
|
||||||
PreviousMove string
|
PreviousMove string
|
||||||
NextTurnColor Color
|
NextTurnColor Color
|
||||||
PlayerID string
|
PlayerID string // User ID or generated UUID
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Command Pattern
|
### JWT Claims
|
||||||
Commands encapsulate operations with type and arguments, processed by single Execute method.
|
```go
|
||||||
|
{
|
||||||
|
"sub": "user-id",
|
||||||
|
"username": "alice",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"exp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Pattern with User Context
|
||||||
|
Commands encapsulate operations with type, arguments, and optional user ID for authenticated requests.
|
||||||
|
|
||||||
### Player Configuration
|
### Player Configuration
|
||||||
Players identified by UUID, configured with type (human/computer), skill level, and search time.
|
Players identified by UUID (authenticated users) or generated IDs (anonymous), configured with type (human/computer), skill level, and search time.
|
||||||
|
|
||||||
### Storage Schema
|
### Storage Schema
|
||||||
```sql
|
```sql
|
||||||
|
-- User authentication table
|
||||||
|
users (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||||
|
email TEXT COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login_at DATETIME
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Game storage with player associations
|
||||||
games (
|
games (
|
||||||
game_id TEXT PRIMARY KEY,
|
game_id TEXT PRIMARY KEY,
|
||||||
initial_fen TEXT,
|
initial_fen TEXT,
|
||||||
white_player_id TEXT,
|
white_player_id TEXT, -- User ID or generated UUID
|
||||||
white_type INTEGER,
|
white_type INTEGER,
|
||||||
white_level INTEGER,
|
white_level INTEGER,
|
||||||
white_search_time INTEGER,
|
white_search_time INTEGER,
|
||||||
black_player_id TEXT,
|
black_player_id TEXT, -- User ID or generated UUID
|
||||||
black_type INTEGER,
|
black_type INTEGER,
|
||||||
black_level INTEGER,
|
black_level INTEGER,
|
||||||
black_search_time INTEGER,
|
black_search_time INTEGER,
|
||||||
start_time_utc DATETIME
|
start_time_utc DATETIME
|
||||||
)
|
)
|
||||||
|
|
||||||
|
-- Move history
|
||||||
moves (
|
moves (
|
||||||
move_id INTEGER PRIMARY KEY,
|
move_id INTEGER PRIMARY KEY,
|
||||||
game_id TEXT,
|
game_id TEXT,
|
||||||
@ -106,4 +173,26 @@ moves (
|
|||||||
move_time_utc DATETIME,
|
move_time_utc DATETIME,
|
||||||
FOREIGN KEY (game_id) REFERENCES games(game_id)
|
FOREIGN KEY (game_id) REFERENCES games(game_id)
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Security Architecture
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
1. Password validation enforces minimum complexity
|
||||||
|
2. Argon2id hashing prevents rainbow table attacks
|
||||||
|
3. JWT tokens expire after 7 days
|
||||||
|
4. Case-insensitive username/email matching prevents enumeration
|
||||||
|
5. Constant-time password verification prevents timing attacks
|
||||||
|
|
||||||
|
### Rate Limiting Strategy
|
||||||
|
- General API: 10 req/s per IP (20 in dev mode)
|
||||||
|
- Registration: 5 req/min per IP (prevent spam accounts)
|
||||||
|
- Login: 10 req/min per IP (prevent brute force)
|
||||||
|
- Game operations unaffected for authenticated users
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
- Passwords never stored in plaintext
|
||||||
|
- JWT secret rotates on restart (or fixed in dev mode)
|
||||||
|
- User IDs use UUIDs with collision detection
|
||||||
|
- Transactions ensure data consistency
|
||||||
|
- Case-insensitive queries prevent duplicate accounts
|
||||||
@ -4,13 +4,14 @@
|
|||||||
|
|
||||||
- Go 1.24+
|
- Go 1.24+
|
||||||
- Stockfish in PATH
|
- Stockfish in PATH
|
||||||
|
- SQLite3
|
||||||
- Git
|
- Git
|
||||||
- curl, jq (for testing)
|
- curl, jq (for testing)
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.lixen.com/lixen/chess
|
#git clone https://git.lixen.com/lixen/chess # Mirror
|
||||||
|
git clone https://github.com/lixenwraith/chess
|
||||||
cd chess
|
cd chess
|
||||||
go build ./cmd/chessd
|
go build ./cmd/chessd
|
||||||
```
|
```
|
||||||
@ -23,65 +24,141 @@ go build ./cmd/chessd
|
|||||||
- `-serve`: Enable embedded web UI server
|
- `-serve`: Enable embedded web UI server
|
||||||
- `-web-host`: Web UI server host (default: localhost)
|
- `-web-host`: Web UI server host (default: localhost)
|
||||||
- `-web-port`: Web UI server port (default: 9090)
|
- `-web-port`: Web UI server port (default: 9090)
|
||||||
- `-dev`: Development mode with relaxed rate limits
|
- `-dev`: Development mode with relaxed rate limits and fixed JWT secret
|
||||||
- `-storage-path`: SQLite database file path (enables persistence)
|
- `-storage-path`: SQLite database file path (enables persistence and authentication)
|
||||||
- `-pid`: PID file path for process tracking
|
- `-pid`: PID file path for process tracking
|
||||||
- `-pid-lock`: Enable exclusive locking (requires -pid)
|
- `-pid-lock`: Enable exclusive locking (requires -pid)
|
||||||
|
|
||||||
### Modes
|
### Modes
|
||||||
```bash
|
```bash
|
||||||
# In-memory only
|
# In-memory only (no persistence or auth)
|
||||||
./chessd
|
./chessd
|
||||||
|
|
||||||
# With persistence
|
# With persistence and authentication
|
||||||
./chessd -storage-path ./db/chess.db
|
./chessd -storage-path ./db/chess.db
|
||||||
|
|
||||||
# Singleton enforcement (requires same PID file path across instances)
|
|
||||||
./chessd -pid /var/run/chessd.pid -pid-lock
|
|
||||||
|
|
||||||
# Development with all features
|
# Development with all features
|
||||||
./chessd -dev -storage-path chess.db -pid /tmp/chessd.pid
|
./chessd -dev -storage-path chess.db -pid /tmp/chessd.pid -serve
|
||||||
|
|
||||||
|
# Initialize database with user tables
|
||||||
|
./chessd db init -path chess.db
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Management
|
## Database Management
|
||||||
|
|
||||||
### CLI Commands
|
### Schema Initialization
|
||||||
```bash
|
```bash
|
||||||
# Initialize database schema
|
# Create all tables (users, games, moves)
|
||||||
./chessd db init -path chess.db
|
./chessd db init -path chess.db
|
||||||
|
```
|
||||||
|
|
||||||
# Query games
|
### User Management CLI
|
||||||
./chessd db query -path chess.db [-gameId ID] [-playerId ID]
|
```bash
|
||||||
|
# Add user with password
|
||||||
|
./chessd db user add -path chess.db -username alice -password SecurePass123
|
||||||
|
|
||||||
# Delete database
|
# Add user with email
|
||||||
|
./chessd db user add -path chess.db -username bob -email bob@example.com -password BobPass456
|
||||||
|
|
||||||
|
# Interactive password input
|
||||||
|
./chessd db user add -path chess.db -username charlie -interactive
|
||||||
|
|
||||||
|
# List all users
|
||||||
|
./chessd db user list -path chess.db
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
./chessd db user set-password -path chess.db -username alice -password NewPass789
|
||||||
|
|
||||||
|
# Update email
|
||||||
|
./chessd db user set-email -path chess.db -username alice -email newemail@example.com
|
||||||
|
|
||||||
|
# Update username
|
||||||
|
./chessd db user set-username -path chess.db -current alice -new alice2
|
||||||
|
|
||||||
|
# Import with existing Argon2 hash
|
||||||
|
./chessd db user set-hash -path chess.db -username alice -hash '$argon2id$v=19$m=65536,t=3,p=2$...'
|
||||||
|
|
||||||
|
# Delete user
|
||||||
|
./chessd db user delete -path chess.db -username alice
|
||||||
|
```
|
||||||
|
|
||||||
|
### Game Query CLI
|
||||||
|
```bash
|
||||||
|
# Query all games
|
||||||
|
./chessd db query -path chess.db -gameId "*"
|
||||||
|
|
||||||
|
# Query games for specific user
|
||||||
|
./chessd db query -path chess.db -playerId "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
|
||||||
|
# Query specific game
|
||||||
|
./chessd db query -path chess.db -gameId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
|
||||||
|
|
||||||
|
# Delete database (destructive)
|
||||||
./chessd db delete -path chess.db
|
./chessd db delete -path chess.db
|
||||||
```
|
```
|
||||||
|
|
||||||
Query parameters accept `*` for all records (default if omitted) or specific IDs for filtering.
|
## Authentication Configuration
|
||||||
|
|
||||||
|
### JWT Secret Management
|
||||||
|
- **Production**: Cryptographically secure 32-byte secret generated on startup
|
||||||
|
- **Development** (`-dev`): Fixed secret for testing consistency
|
||||||
|
- **Sessions**: Valid for 7 days, renewed on each login
|
||||||
|
|
||||||
|
### Password Requirements
|
||||||
|
- Minimum 8 characters
|
||||||
|
- At least one letter and one number
|
||||||
|
- Argon2id hashing with secure defaults
|
||||||
|
|
||||||
|
### User Account Features
|
||||||
|
- Case-insensitive username and email matching
|
||||||
|
- Optional email addresses
|
||||||
|
- Last login tracking
|
||||||
|
- Unique constraint enforcement with transaction isolation
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
chess/
|
chess/
|
||||||
├── cmd/chessd/
|
├── cmd/chessd/
|
||||||
│ ├── main.go # Entry point
|
│ ├── main.go # Entry point with auth initialization
|
||||||
│ ├── pid.go # PID file management
|
│ ├── pid.go # PID file management
|
||||||
│ └── cli/ # Database CLI
|
│ └── cli/ # Database and user CLI
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── board/ # FEN/ASCII operations
|
│ ├── board/ # FEN/ASCII operations
|
||||||
│ ├── core/ # Shared types
|
│ ├── core/ # Shared types and API models
|
||||||
│ ├── engine/ # Stockfish UCI wrapper
|
│ ├── engine/ # Stockfish UCI wrapper
|
||||||
│ ├── game/ # Game state
|
│ ├── game/ # Game state with player associations
|
||||||
│ ├── http/ # Fiber handlers
|
│ ├── http/ # Fiber handlers and auth endpoints
|
||||||
│ ├── processor/ # Command processing
|
│ │ ├── handler.go # Game endpoints
|
||||||
│ ├── service/ # State management
|
│ │ ├── auth.go # Authentication endpoints
|
||||||
|
│ │ └── middleware.go # JWT validation
|
||||||
|
│ ├── processor/ # Command processing with user context
|
||||||
|
│ ├── service/ # State and user management
|
||||||
|
│ │ ├── service.go # Core service
|
||||||
|
│ │ ├── game.go # Game operations
|
||||||
|
│ │ └── user.go # User and auth operations
|
||||||
│ └── storage/ # SQLite persistence
|
│ └── storage/ # SQLite persistence
|
||||||
|
│ ├── storage.go # Async writer for games
|
||||||
|
│ ├── game.go # Game persistence
|
||||||
|
│ ├── user.go # User persistence (synchronous)
|
||||||
|
│ └── schema.go # Database schema
|
||||||
└── test/ # Test scripts
|
└── test/ # Test scripts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
See [test documentation](../test/README.md) for details.
|
See [test documentation](../test/README.md) for comprehensive test suites covering API, authentication, and database operations.
|
||||||
|
|
||||||
|
### Quick Test Commands
|
||||||
|
```bash
|
||||||
|
# API functionality tests
|
||||||
|
./test/test-api.sh
|
||||||
|
|
||||||
|
# User authentication and database tests
|
||||||
|
./test/test-db.sh
|
||||||
|
|
||||||
|
# Run test server with sample users
|
||||||
|
./test/test-db-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@ -92,15 +169,30 @@ See [test documentation](../test/README.md) for details.
|
|||||||
- Min search time: 100ms (internal/processor/processor.go)
|
- Min search time: 100ms (internal/processor/processor.go)
|
||||||
- Write queue: 1000 operations (internal/storage/storage.go)
|
- Write queue: 1000 operations (internal/storage/storage.go)
|
||||||
- DB connections: 25 max, 5 idle (internal/storage/storage.go)
|
- DB connections: 25 max, 5 idle (internal/storage/storage.go)
|
||||||
|
- JWT expiration: 7 days (internal/service/user.go)
|
||||||
|
|
||||||
|
### Authentication Configuration
|
||||||
|
- Password minimum: 8 characters with letter and number
|
||||||
|
- Username format: 1-40 characters, alphanumeric and underscore
|
||||||
|
- Email validation: Standard RFC 5322 format
|
||||||
|
- Hash algorithm: Argon2id (memory-hard, side-channel resistant)
|
||||||
|
|
||||||
### Storage Configuration
|
### Storage Configuration
|
||||||
- WAL mode enabled in development for concurrency
|
- WAL mode enabled in development for concurrency
|
||||||
- Foreign key constraints enforced
|
- Foreign key constraints enforced
|
||||||
- Async write pattern with 2-second drain on shutdown
|
- Async write pattern for games with 2-second drain on shutdown
|
||||||
|
- Synchronous writes for user operations (data consistency)
|
||||||
- Degradation to memory-only on write failures
|
- Degradation to memory-only on write failures
|
||||||
|
- Case-insensitive collation for usernames and emails
|
||||||
|
|
||||||
|
### Rate Limiting Configuration
|
||||||
|
- General endpoints: 10 req/s (20 in dev mode)
|
||||||
|
- User registration: 5 req/min
|
||||||
|
- User login: 10 req/min
|
||||||
|
- Rate limit key: IP address from X-Forwarded-For or connection
|
||||||
|
|
||||||
### PID Management
|
### PID Management
|
||||||
- Singleton enforcement requires same PID file path - all instances must use the same -pid value
|
- Singleton enforcement requires same PID file path
|
||||||
- Stale PID detection via signal 0 checking
|
- Stale PID detection via signal 0 checking
|
||||||
- Exclusive file locking with LOCK_EX|LOCK_NB
|
- Exclusive file locking with LOCK_EX|LOCK_NB
|
||||||
- Automatic cleanup on graceful shutdown
|
- Automatic cleanup on graceful shutdown
|
||||||
@ -111,10 +203,35 @@ See [test documentation](../test/README.md) for details.
|
|||||||
- Search time: 100-10000ms
|
- Search time: 100-10000ms
|
||||||
- UCI moves: 4-5 characters ([a-h][1-8][a-h][1-8][qrbn]?)
|
- UCI moves: 4-5 characters ([a-h][1-8][a-h][1-8][qrbn]?)
|
||||||
- Undo count: 1-300
|
- Undo count: 1-300
|
||||||
|
- Username: 1-40 characters, [a-zA-Z0-9_]
|
||||||
|
- Password: 8-128 characters, requires letter and number
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Authentication Security
|
||||||
|
- Passwords hashed with Argon2id before storage
|
||||||
|
- JWT tokens signed with HS256
|
||||||
|
- Constant-time password comparison
|
||||||
|
- Case-insensitive matching prevents user enumeration
|
||||||
|
- Rate limiting on auth endpoints prevents brute force
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
- All user inputs validated and sanitized
|
||||||
|
- SQL injection prevented via parameterized queries
|
||||||
|
- UCI command injection blocked via character validation
|
||||||
|
- FEN strings validated against strict regex pattern
|
||||||
|
|
||||||
|
### Session Management
|
||||||
|
- JWT tokens expire after 7 days
|
||||||
|
- No token refresh mechanism (re-login required)
|
||||||
|
- Tokens include minimal claims (user ID, username, email)
|
||||||
|
- Secret rotates on server restart (except dev mode)
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- No persistence (memory only)
|
- JWT tokens don't support refresh (must re-login after expiry)
|
||||||
- Hardcoded Stockfish path
|
- User deletion doesn't cascade to games (games remain with player IDs)
|
||||||
- Fixed worker pool size
|
- No password recovery mechanism
|
||||||
- No game history beyond current session
|
- No email verification for registration
|
||||||
|
- Fixed worker pool size for engine calculations
|
||||||
|
- No real-time game updates (polling required)
|
||||||
5
go.mod
5
go.mod
@ -1,12 +1,14 @@
|
|||||||
module chess
|
module chess
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-playground/validator/v10 v10.28.0
|
github.com/go-playground/validator/v10 v10.28.0
|
||||||
github.com/gofiber/fiber/v2 v2.52.9
|
github.com/gofiber/fiber/v2 v2.52.9
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226
|
||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
|
golang.org/x/term v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@ -16,6 +18,7 @@ require (
|
|||||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.1 // indirect
|
github.com/klauspost/compress v1.18.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
|||||||
10
go.sum
10
go.sum
@ -18,12 +18,16 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0
|
|||||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||||
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||||
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226 h1:c7wfyZGdy6RkM/b6mIazoYrAS+3qDL7d9M1CFm2e1VA=
|
||||||
|
github.com/lixenwraith/auth v0.0.0-20251104131016-e5a810f4e226/go.mod h1:1Kfy3ggtRbgrzR+qg99SaeUmmnUZKtur8uOSQsbWaPw=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
@ -36,8 +40,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
|||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
|
github.com/tinylib/msgp v1.5.0 h1:GWnqAE54wmnlFazjq2+vgr736Akg58iiHImh+kPY2pc=
|
||||||
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
github.com/tinylib/msgp v1.5.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
@ -51,6 +55,8 @@ golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
232
internal/http/auth.go
Normal file
232
internal/http/auth.go
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
// FILE: internal/http/auth.go
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"chess/internal/core"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||||
|
var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{1,40}$`)
|
||||||
|
|
||||||
|
// RegisterRequest defines the user registration payload
|
||||||
|
type RegisterRequest struct {
|
||||||
|
Username string `json:"username" validate:"required,min=1,max=40"`
|
||||||
|
Email string `json:"email" validate:"omitempty,max=255"`
|
||||||
|
Password string `json:"password" validate:"required,min=8,max=128"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest defines the authentication payload
|
||||||
|
type LoginRequest struct {
|
||||||
|
Identifier string `json:"identifier" validate:"required"` // username or email
|
||||||
|
Password string `json:"password" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthResponse contains JWT token and user information
|
||||||
|
type AuthResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserResponse contains current user information
|
||||||
|
type UserResponse struct {
|
||||||
|
UserID string `json:"userId"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterHandler creates a new user account
|
||||||
|
func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||||
|
var req RegisterRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid request body",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate username format
|
||||||
|
if !usernameRegex.MatchString(req.Username) {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid username format",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: "username must be 1-40 characters, alphanumeric and underscore only",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format if provided
|
||||||
|
if req.Email != "" && !emailRegex.MatchString(req.Email) {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid email format",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: "email must be a valid email address",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password strength
|
||||||
|
if err := validatePassword(req.Password); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "weak password",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize for case-insensitive storage
|
||||||
|
req.Username = strings.ToLower(req.Username)
|
||||||
|
if req.Email != "" {
|
||||||
|
req.Email = strings.ToLower(req.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
|
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
|
||||||
|
Error: "user already exists",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: "username or email already taken",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||||
|
Error: "failed to create user",
|
||||||
|
Code: core.ErrInternalError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
token, err := h.svc.GenerateUserToken(user.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||||
|
Error: "failed to generate token",
|
||||||
|
Code: core.ErrInternalError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(fiber.StatusCreated).JSON(AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
UserID: user.UserID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// validatePassword checks password strength requirements
|
||||||
|
func validatePassword(password string) error {
|
||||||
|
const (
|
||||||
|
minPasswordLength = 8
|
||||||
|
maxPasswordLength = 128
|
||||||
|
)
|
||||||
|
if len(password) < minPasswordLength {
|
||||||
|
return fmt.Errorf("password must be at least 8 characters")
|
||||||
|
}
|
||||||
|
if len(password) > maxPasswordLength {
|
||||||
|
return fmt.Errorf("password must not exceed 128 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for at least one letter and one number
|
||||||
|
hasLetter := false
|
||||||
|
hasNumber := false
|
||||||
|
for _, r := range password {
|
||||||
|
switch {
|
||||||
|
case unicode.IsLetter(r):
|
||||||
|
hasLetter = true
|
||||||
|
case unicode.IsNumber(r):
|
||||||
|
hasNumber = true
|
||||||
|
}
|
||||||
|
if hasLetter && hasNumber {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasLetter || !hasNumber {
|
||||||
|
return fmt.Errorf("password must contain at least one letter and one number")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginHandler authenticates user and returns JWT token
|
||||||
|
func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid request body",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
Details: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize identifier for case-insensitive lookup
|
||||||
|
req.Identifier = strings.ToLower(req.Identifier)
|
||||||
|
|
||||||
|
// Authenticate user
|
||||||
|
user, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
// Always return same error to prevent user enumeration
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid credentials",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
token, err := h.svc.GenerateUserToken(user.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||||
|
Error: "failed to generate token",
|
||||||
|
Code: core.ErrInternalError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
// TODO: for now, non-blocking if login time update fails, log/block in the future
|
||||||
|
_ = h.svc.UpdateLastLogin(user.UserID)
|
||||||
|
|
||||||
|
return c.JSON(AuthResponse{
|
||||||
|
Token: token,
|
||||||
|
UserID: user.UserID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentUserHandler returns authenticated user information
|
||||||
|
func (h *HTTPHandler) GetCurrentUserHandler(c *fiber.Ctx) error {
|
||||||
|
userID, ok := c.Locals("userID").(string)
|
||||||
|
if !ok || userID == "" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||||
|
Error: "unauthorized",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.svc.GetUserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
||||||
|
Error: "user not found",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(UserResponse{
|
||||||
|
UserID: user.UserID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -2,13 +2,14 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"chess/internal/core"
|
|
||||||
"chess/internal/processor"
|
|
||||||
"chess/internal/service"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"chess/internal/core"
|
||||||
|
"chess/internal/processor"
|
||||||
|
"chess/internal/service"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||||
"github.com/gofiber/fiber/v2/middleware/limiter"
|
"github.com/gofiber/fiber/v2/middleware/limiter"
|
||||||
@ -47,27 +48,66 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
|
|||||||
app.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: "*",
|
AllowOrigins: "*",
|
||||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
||||||
AllowHeaders: "Origin,Content-Type,Accept",
|
AllowHeaders: "Origin,Content-Type,Accept,Authorization",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Health check (no rate limit)
|
// Health check (no rate limit)
|
||||||
app.Get("/health", h.Health)
|
app.Get("/health", h.Health)
|
||||||
|
|
||||||
// API v1 routes with rate limiting
|
// API v1 routes
|
||||||
api := app.Group("/api/v1")
|
api := app.Group("/api/v1")
|
||||||
|
|
||||||
// Rate limiter: 10/20 req/sec per IP with expiry
|
// Auth routes with specific rate limiting
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
|
||||||
|
// Register: 5 req/min per IP
|
||||||
|
auth.Post("/register", limiter.New(limiter.Config{
|
||||||
|
Max: 5,
|
||||||
|
Expiration: 1 * time.Minute,
|
||||||
|
KeyGenerator: func(c *fiber.Ctx) string {
|
||||||
|
return c.IP()
|
||||||
|
},
|
||||||
|
LimitReached: func(c *fiber.Ctx) error {
|
||||||
|
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||||
|
Error: "rate limit exceeded",
|
||||||
|
Code: core.ErrRateLimitExceeded,
|
||||||
|
Details: "5 registrations per minute allowed",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}), h.RegisterHandler)
|
||||||
|
|
||||||
|
// Login: 10 req/min per IP
|
||||||
|
auth.Post("/login", limiter.New(limiter.Config{
|
||||||
|
Max: 10,
|
||||||
|
Expiration: 1 * time.Minute,
|
||||||
|
KeyGenerator: func(c *fiber.Ctx) string {
|
||||||
|
return c.IP()
|
||||||
|
},
|
||||||
|
LimitReached: func(c *fiber.Ctx) error {
|
||||||
|
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
|
||||||
|
Error: "rate limit exceeded",
|
||||||
|
Code: core.ErrRateLimitExceeded,
|
||||||
|
Details: "10 login attempts per minute allowed",
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}), h.LoginHandler)
|
||||||
|
|
||||||
|
// Create token validator closure
|
||||||
|
validateToken := svc.ValidateToken
|
||||||
|
|
||||||
|
// Current user (requires auth)
|
||||||
|
auth.Get("/me", AuthRequired(validateToken), h.GetCurrentUserHandler)
|
||||||
|
|
||||||
|
// Game routes with standard rate limiting
|
||||||
maxReq := rateLimitRate
|
maxReq := rateLimitRate
|
||||||
if devMode {
|
if devMode {
|
||||||
maxReq = rateLimitRate * 2 // Loosen rate limiter for testing
|
maxReq = rateLimitRate * 2
|
||||||
}
|
}
|
||||||
api.Use(limiter.New(limiter.Config{
|
api.Use(limiter.New(limiter.Config{
|
||||||
Max: maxReq, // Allow requests per second
|
Max: maxReq,
|
||||||
Expiration: 1 * time.Second, // Per second
|
Expiration: 1 * time.Second,
|
||||||
KeyGenerator: func(c *fiber.Ctx) string {
|
KeyGenerator: func(c *fiber.Ctx) string {
|
||||||
// Check X-Forwarded-For first, then X-Real-IP, then RemoteIP
|
|
||||||
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
if xff := c.Get("X-Forwarded-For"); xff != "" {
|
||||||
// Take the first IP from X-Forwarded-For chain
|
|
||||||
if idx := strings.Index(xff, ","); idx != -1 {
|
if idx := strings.Index(xff, ","); idx != -1 {
|
||||||
return strings.TrimSpace(xff[:idx])
|
return strings.TrimSpace(xff[:idx])
|
||||||
}
|
}
|
||||||
@ -82,9 +122,6 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
|
|||||||
Details: fmt.Sprintf("%d requests per second allowed", maxReq),
|
Details: fmt.Sprintf("%d requests per second allowed", maxReq),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
Storage: nil, // Use in-memory storage (default)
|
|
||||||
SkipFailedRequests: false,
|
|
||||||
SkipSuccessfulRequests: false,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Content-Type validation for POST and PUT requests
|
// Content-Type validation for POST and PUT requests
|
||||||
@ -93,8 +130,8 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
|
|||||||
// Middleware validation for sanitization
|
// Middleware validation for sanitization
|
||||||
api.Use(validationMiddleware)
|
api.Use(validationMiddleware)
|
||||||
|
|
||||||
// Register game routes
|
// Register game routes with auth middleware
|
||||||
api.Post("/games", h.CreateGame)
|
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
|
||||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||||
api.Get("/games/:gameId", h.GetGame)
|
api.Get("/games/:gameId", h.GetGame)
|
||||||
api.Delete("/games/:gameId", h.DeleteGame)
|
api.Delete("/games/:gameId", h.DeleteGame)
|
||||||
@ -179,8 +216,12 @@ func (h *HTTPHandler) CreateGame(c *fiber.Ctx) error {
|
|||||||
var req core.CreateGameRequest
|
var req core.CreateGameRequest
|
||||||
req = *(validatedBody.(*core.CreateGameRequest))
|
req = *(validatedBody.(*core.CreateGameRequest))
|
||||||
|
|
||||||
// Let processor generate game ID via service
|
// Retrieve authenticated user ID if available
|
||||||
|
userID, _ := c.Locals("userID").(string)
|
||||||
|
|
||||||
|
// Generate game ID via service with optional user context
|
||||||
cmd := processor.NewCreateGameCommand(req)
|
cmd := processor.NewCreateGameCommand(req)
|
||||||
|
cmd.UserID = userID // Add user ID to command if authenticated
|
||||||
|
|
||||||
resp := h.proc.Execute(cmd)
|
resp := h.proc.Execute(cmd)
|
||||||
|
|
||||||
|
|||||||
63
internal/http/middleware.go
Normal file
63
internal/http/middleware.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// FILE: internal/http/middleware.go
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"chess/internal/core"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenValidator validates JWT tokens
|
||||||
|
type TokenValidator func(token string) (userID string, claims map[string]any, err error)
|
||||||
|
|
||||||
|
// AuthRequired enforces JWT authentication for protected endpoints
|
||||||
|
func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
token := extractBearerToken(c.Get("Authorization"))
|
||||||
|
if token == "" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||||
|
Error: "missing authorization token",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _, err := validateToken(token)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||||
|
Error: "invalid or expired token",
|
||||||
|
Code: core.ErrInvalidRequest,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Locals("userID", userID)
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OptionalAuth validates JWT if present but allows anonymous access
|
||||||
|
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
token := extractBearerToken(c.Get("Authorization"))
|
||||||
|
if token == "" {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _, err := validateToken(token)
|
||||||
|
if err == nil {
|
||||||
|
c.Locals("userID", userID)
|
||||||
|
}
|
||||||
|
// Continue regardless of token validity
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBearerToken extracts JWT token from Authorization header
|
||||||
|
func extractBearerToken(header string) string {
|
||||||
|
const prefix = "Bearer "
|
||||||
|
if !strings.HasPrefix(header, prefix) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(header, prefix)
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ const (
|
|||||||
// Command is a unified structure for all processor operations
|
// Command is a unified structure for all processor operations
|
||||||
type Command struct {
|
type Command struct {
|
||||||
Type CommandType
|
Type CommandType
|
||||||
|
UserID string
|
||||||
GameID string // For game-specific commands
|
GameID string // For game-specific commands
|
||||||
Args interface{} // Command-specific arguments
|
Args interface{} // Command-specific arguments
|
||||||
}
|
}
|
||||||
|
|||||||
@ -159,8 +159,20 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
|||||||
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest)
|
return p.errorResponse(fmt.Sprintf("FEN parse error: %v", err), core.ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create game in service with validated FEN and turn
|
// Create players with appropriate IDs
|
||||||
if err = p.svc.CreateGame(gameID, args.White, args.Black, validatedFEN, b.Turn()); err != nil {
|
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
|
||||||
|
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
|
||||||
|
|
||||||
|
// Override player IDs for authenticated human players
|
||||||
|
if args.White.Type == core.PlayerHuman && cmd.UserID != "" {
|
||||||
|
whitePlayer.ID = cmd.UserID
|
||||||
|
}
|
||||||
|
if args.Black.Type == core.PlayerHuman && cmd.UserID != "" {
|
||||||
|
blackPlayer.ID = cmd.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create game in service with fully-formed players
|
||||||
|
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn()); err != nil {
|
||||||
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
|
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,8 +218,12 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
|||||||
return p.errorResponse("cannot change players while computer is calculating", core.ErrInvalidRequest)
|
return p.errorResponse("cannot change players while computer is calculating", core.ErrInvalidRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create new player instances
|
||||||
|
whitePlayer := core.NewPlayer(args.White, core.ColorWhite)
|
||||||
|
blackPlayer := core.NewPlayer(args.Black, core.ColorBlack)
|
||||||
|
|
||||||
// Update players in service
|
// Update players in service
|
||||||
if err = p.svc.UpdatePlayers(cmd.GameID, args.White, args.Black); err != nil {
|
if err = p.svc.UpdatePlayers(cmd.GameID, whitePlayer, blackPlayer); err != nil {
|
||||||
return p.errorResponse(fmt.Sprintf("failed to update players: %v", err), core.ErrInternalError)
|
return p.errorResponse(fmt.Sprintf("failed to update players: %v", err), core.ErrInternalError)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -389,7 +405,6 @@ func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
|||||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: gracefully handle deleting game even if pending, discard engine response
|
|
||||||
// Only block deletion if actively computing
|
// Only block deletion if actively computing
|
||||||
if g.State() == core.StatePending {
|
if g.State() == core.StatePending {
|
||||||
return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
|
return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
|
||||||
|
|||||||
188
internal/service/game.go
Normal file
188
internal/service/game.go
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
// FILE: internal/service/game.go
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chess/internal/core"
|
||||||
|
"chess/internal/game"
|
||||||
|
"chess/internal/storage"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateGame registers a new game with pre-constructed players
|
||||||
|
func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, initialFEN string, startingTurn core.Color) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := s.games[id]; exists {
|
||||||
|
return fmt.Errorf("game %s already exists", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store game with provided players
|
||||||
|
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
||||||
|
|
||||||
|
// Persist if storage enabled
|
||||||
|
if s.store != nil {
|
||||||
|
record := storage.GameRecord{
|
||||||
|
GameID: id,
|
||||||
|
InitialFEN: initialFEN,
|
||||||
|
WhitePlayerID: whitePlayer.ID,
|
||||||
|
WhiteType: int(whitePlayer.Type),
|
||||||
|
WhiteLevel: whitePlayer.Level,
|
||||||
|
WhiteSearchTime: whitePlayer.SearchTime,
|
||||||
|
BlackPlayerID: blackPlayer.ID,
|
||||||
|
BlackType: int(blackPlayer.Type),
|
||||||
|
BlackLevel: blackPlayer.Level,
|
||||||
|
BlackSearchTime: blackPlayer.SearchTime,
|
||||||
|
StartTimeUTC: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
s.store.RecordNewGame(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePlayers replaces players in an existing game
|
||||||
|
func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Player) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
g, ok := s.games[gameID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("game not found: %s", gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the game's players
|
||||||
|
g.UpdatePlayers(whitePlayer, blackPlayer)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGame retrieves a game by ID
|
||||||
|
func (s *Service) GetGame(gameID string) (*game.Game, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
g, ok := s.games[gameID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("game not found: %s", gameID)
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateGameID creates a new unique game ID
|
||||||
|
func (s *Service) GenerateGameID() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
// Ensure UUID uniqueness (handle potential conflicts)
|
||||||
|
for {
|
||||||
|
id := uuid.New().String()
|
||||||
|
if _, exists := s.games[id]; !exists {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMove adds a validated move to the game history
|
||||||
|
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
g, ok := s.games[gameID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("game not found: %s", gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine whose turn it was before this move
|
||||||
|
currentTurn := g.NextTurnColor()
|
||||||
|
nextTurn := core.OppositeColor(currentTurn)
|
||||||
|
|
||||||
|
// Add the new position to game history
|
||||||
|
g.AddSnapshot(newFEN, moveUCI, nextTurn)
|
||||||
|
|
||||||
|
// Persist if storage enabled
|
||||||
|
if s.store != nil {
|
||||||
|
moveNumber := len(g.Moves())
|
||||||
|
record := storage.MoveRecord{
|
||||||
|
GameID: gameID,
|
||||||
|
MoveNumber: moveNumber,
|
||||||
|
MoveUCI: moveUCI,
|
||||||
|
FENAfterMove: newFEN,
|
||||||
|
PlayerColor: currentTurn.String(),
|
||||||
|
MoveTimeUTC: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
s.store.RecordMove(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGameState sets the game's end state (checkmate, stalemate, etc)
|
||||||
|
func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
g, ok := s.games[gameID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("game not found: %s", gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.SetState(state)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastMoveResult stores metadata about the last move
|
||||||
|
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
g, ok := s.games[gameID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("game not found: %s", gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
g.SetLastResult(result)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UndoMoves removes the specified number of moves from game history
|
||||||
|
func (s *Service) UndoMoves(gameID string, count int) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
g, ok := s.games[gameID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("game not found: %s", gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
originalMoveCount := len(g.Moves())
|
||||||
|
|
||||||
|
if err := g.UndoMoves(count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete undone moves from storage if enabled
|
||||||
|
if s.store != nil {
|
||||||
|
remainingMoves := originalMoveCount - count
|
||||||
|
s.store.DeleteUndoneMoves(gameID, remainingMoves)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGame removes a game from memory
|
||||||
|
func (s *Service) DeleteGame(gameID string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if _, ok := s.games[gameID]; !ok {
|
||||||
|
return fmt.Errorf("game not found: %s", gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(s.games, gameID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -2,214 +2,29 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"chess/internal/core"
|
|
||||||
"chess/internal/game"
|
"chess/internal/game"
|
||||||
"chess/internal/storage"
|
"chess/internal/storage"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service is a pure state manager for chess games with optional persistence
|
// Service is a pure state manager for chess games with optional persistence
|
||||||
type Service struct {
|
type Service struct {
|
||||||
games map[string]*game.Game
|
games map[string]*game.Game
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
store *storage.Store // nil if persistence disabled
|
store *storage.Store // nil if persistence disabled
|
||||||
|
jwtSecret []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new service instance with optional storage
|
// New creates a new service instance with optional storage
|
||||||
func New(store *storage.Store) (*Service, error) {
|
func New(store *storage.Store, jwtSecret []byte) (*Service, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
games: make(map[string]*game.Game),
|
games: make(map[string]*game.Game),
|
||||||
store: store,
|
store: store,
|
||||||
|
jwtSecret: jwtSecret,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateGame creates game with player configuration
|
|
||||||
func (s *Service) CreateGame(id string, whiteConfig, blackConfig core.PlayerConfig, initialFEN string, startingTurn core.Color) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
if _, exists := s.games[id]; exists {
|
|
||||||
return fmt.Errorf("game %s already exists", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create players with UUIDs and config
|
|
||||||
whitePlayer := core.NewPlayer(whiteConfig, core.ColorWhite)
|
|
||||||
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
|
|
||||||
|
|
||||||
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
|
||||||
|
|
||||||
// Persist if storage enabled
|
|
||||||
if s.store != nil {
|
|
||||||
record := storage.GameRecord{
|
|
||||||
GameID: id,
|
|
||||||
InitialFEN: initialFEN,
|
|
||||||
WhitePlayerID: whitePlayer.ID,
|
|
||||||
WhiteType: int(whitePlayer.Type),
|
|
||||||
WhiteLevel: whitePlayer.Level,
|
|
||||||
WhiteSearchTime: whitePlayer.SearchTime,
|
|
||||||
BlackPlayerID: blackPlayer.ID,
|
|
||||||
BlackType: int(blackPlayer.Type),
|
|
||||||
BlackLevel: blackPlayer.Level,
|
|
||||||
BlackSearchTime: blackPlayer.SearchTime,
|
|
||||||
StartTimeUTC: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
s.store.RecordNewGame(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdatePlayers replaces players in an existing game
|
|
||||||
func (s *Service) UpdatePlayers(gameID string, whiteConfig, blackConfig core.PlayerConfig) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
g, ok := s.games[gameID]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("game not found: %s", gameID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new player instances with new UUIDs
|
|
||||||
whitePlayer := core.NewPlayer(whiteConfig, core.ColorWhite)
|
|
||||||
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
|
|
||||||
|
|
||||||
// Update the game's players
|
|
||||||
g.UpdatePlayers(whitePlayer, blackPlayer)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGame retrieves a game by ID
|
|
||||||
func (s *Service) GetGame(gameID string) (*game.Game, error) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
g, ok := s.games[gameID]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("game not found: %s", gameID)
|
|
||||||
}
|
|
||||||
return g, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateGameID creates a new unique game ID
|
|
||||||
func (s *Service) GenerateGameID() string {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
// Ensure UUID uniqueness (handle potential conflicts)
|
|
||||||
for {
|
|
||||||
id := uuid.New().String()
|
|
||||||
if _, exists := s.games[id]; !exists {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyMove adds a validated move to the game history
|
|
||||||
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
g, ok := s.games[gameID]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("game not found: %s", gameID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine whose turn it was before this move
|
|
||||||
currentTurn := g.NextTurnColor()
|
|
||||||
nextTurn := core.OppositeColor(currentTurn)
|
|
||||||
|
|
||||||
// Add the new position to game history
|
|
||||||
g.AddSnapshot(newFEN, moveUCI, nextTurn)
|
|
||||||
|
|
||||||
// Persist if storage enabled
|
|
||||||
if s.store != nil {
|
|
||||||
moveNumber := len(g.Moves())
|
|
||||||
record := storage.MoveRecord{
|
|
||||||
GameID: gameID,
|
|
||||||
MoveNumber: moveNumber,
|
|
||||||
MoveUCI: moveUCI,
|
|
||||||
FENAfterMove: newFEN,
|
|
||||||
PlayerColor: currentTurn.String(),
|
|
||||||
MoveTimeUTC: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
s.store.RecordMove(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateGameState sets the game's end state (checkmate, stalemate, etc)
|
|
||||||
func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
g, ok := s.games[gameID]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("game not found: %s", gameID)
|
|
||||||
}
|
|
||||||
|
|
||||||
g.SetState(state)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLastMoveResult stores metadata about the last move
|
|
||||||
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
g, ok := s.games[gameID]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("game not found: %s", gameID)
|
|
||||||
}
|
|
||||||
|
|
||||||
g.SetLastResult(result)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UndoMoves removes the specified number of moves from game history
|
|
||||||
func (s *Service) UndoMoves(gameID string, count int) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
g, ok := s.games[gameID]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("game not found: %s", gameID)
|
|
||||||
}
|
|
||||||
|
|
||||||
originalMoveCount := len(g.Moves())
|
|
||||||
|
|
||||||
if err := g.UndoMoves(count); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete undone moves from storage if enabled
|
|
||||||
if s.store != nil {
|
|
||||||
remainingMoves := originalMoveCount - count
|
|
||||||
s.store.DeleteUndoneMoves(gameID, remainingMoves)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteGame removes a game from memory
|
|
||||||
func (s *Service) DeleteGame(gameID string) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := s.games[gameID]; !ok {
|
|
||||||
return fmt.Errorf("game not found: %s", gameID)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(s.games, gameID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStorageHealth returns the storage component status
|
// GetStorageHealth returns the storage component status
|
||||||
func (s *Service) GetStorageHealth() string {
|
func (s *Service) GetStorageHealth() string {
|
||||||
if s.store == nil {
|
if s.store == nil {
|
||||||
|
|||||||
175
internal/service/user.go
Normal file
175
internal/service/user.go
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
// FILE: internal/service/user.go
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"chess/internal/storage"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/lixenwraith/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents a registered user account
|
||||||
|
type User struct {
|
||||||
|
UserID string
|
||||||
|
Username string
|
||||||
|
Email string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates new user with transactional consistency
|
||||||
|
func (s *Service) CreateUser(username, email, password string) (*User, error) {
|
||||||
|
if s.store == nil {
|
||||||
|
return nil, fmt.Errorf("storage disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
passwordHash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate guaranteed unique user ID with proper collision handling
|
||||||
|
userID, err := s.generateUniqueUserID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate unique ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user record
|
||||||
|
user := &User{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use transactional storage method
|
||||||
|
record := storage.UserRecord{
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = s.store.CreateUser(record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticateUser verifies user credentials and returns user information
|
||||||
|
// AuthenticateUser verifies user credentials and returns user information
|
||||||
|
func (s *Service) AuthenticateUser(identifier, password string) (*User, error) {
|
||||||
|
if s.store == nil {
|
||||||
|
return nil, fmt.Errorf("storage disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
var userRecord *storage.UserRecord
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Check if identifier looks like email
|
||||||
|
if strings.Contains(identifier, "@") {
|
||||||
|
userRecord, err = s.store.GetUserByEmail(identifier)
|
||||||
|
} else {
|
||||||
|
userRecord, err = s.store.GetUserByUsername(identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// Always hash to prevent timing attacks
|
||||||
|
auth.HashPassword(password)
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if err := auth.VerifyPassword(password, userRecord.PasswordHash); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &User{
|
||||||
|
UserID: userRecord.UserID,
|
||||||
|
Username: userRecord.Username,
|
||||||
|
Email: userRecord.Email,
|
||||||
|
CreatedAt: userRecord.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLastLogin updates the last login timestamp for a user
|
||||||
|
func (s *Service) UpdateLastLogin(userID string) error {
|
||||||
|
if s.store == nil {
|
||||||
|
return fmt.Errorf("storage disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.store.UpdateUserLastLoginSync(userID, time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update last login time for user %s: %w\n", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID retrieves user information by user ID
|
||||||
|
func (s *Service) GetUserByID(userID string) (*User, error) {
|
||||||
|
if s.store == nil {
|
||||||
|
return nil, fmt.Errorf("storage disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
userRecord, err := s.store.GetUserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &User{
|
||||||
|
UserID: userRecord.UserID,
|
||||||
|
Username: userRecord.Username,
|
||||||
|
Email: userRecord.Email,
|
||||||
|
CreatedAt: userRecord.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateUserToken creates a JWT token for the specified user
|
||||||
|
func (s *Service) GenerateUserToken(userID string) (string, error) {
|
||||||
|
user, err := s.GetUserByID(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := map[string]any{
|
||||||
|
"username": user.Username,
|
||||||
|
"email": user.Email,
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth.GenerateHS256Token(s.jwtSecret, userID, claims, 7*24*time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken verifies JWT token and returns user ID with claims
|
||||||
|
func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
||||||
|
return auth.ValidateHS256Token(s.jwtSecret, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateUniqueUserID creates a unique user ID with collision detection
|
||||||
|
func (s *Service) generateUniqueUserID() (string, error) {
|
||||||
|
const maxAttempts = 10
|
||||||
|
|
||||||
|
for i := 0; i < maxAttempts; i++ {
|
||||||
|
id := uuid.New().String()
|
||||||
|
|
||||||
|
// Check for collision
|
||||||
|
if _, err := s.store.GetUserByID(id); err != nil {
|
||||||
|
// Error means not found, ID is unique
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collision detected, try again
|
||||||
|
if i == maxAttempts-1 {
|
||||||
|
// After max attempts, fail and don't risk collision
|
||||||
|
return "", fmt.Errorf("failed to generate unique ID after %d attempts", maxAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to generate unique user ID")
|
||||||
|
}
|
||||||
138
internal/storage/game.go
Normal file
138
internal/storage/game.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
// FILE: internal/storage/game.go
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecordNewGame asynchronously records a new game
|
||||||
|
func (s *Store) RecordNewGame(record GameRecord) error {
|
||||||
|
if !s.healthStatus.Load() {
|
||||||
|
return nil // Silently drop if degraded
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case s.writeChan <- func(tx *sql.Tx) error {
|
||||||
|
query := `INSERT INTO games (
|
||||||
|
game_id, initial_fen,
|
||||||
|
white_player_id, white_type, white_level, white_search_time,
|
||||||
|
black_player_id, black_type, black_level, black_search_time,
|
||||||
|
start_time_utc
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
|
_, err := tx.Exec(query,
|
||||||
|
record.GameID, record.InitialFEN,
|
||||||
|
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
|
||||||
|
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
|
||||||
|
record.StartTimeUTC,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
// Channel full, drop write
|
||||||
|
log.Printf("Storage write queue full, dropping game record")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordMove asynchronously records a move
|
||||||
|
func (s *Store) RecordMove(record MoveRecord) error {
|
||||||
|
if !s.healthStatus.Load() {
|
||||||
|
return nil // Silently drop if degraded
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case s.writeChan <- func(tx *sql.Tx) error {
|
||||||
|
query := `INSERT INTO moves (
|
||||||
|
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
|
_, err := tx.Exec(query,
|
||||||
|
record.GameID, record.MoveNumber, record.MoveUCI,
|
||||||
|
record.FENAfterMove, record.PlayerColor, record.MoveTimeUTC,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
// Channel full, drop write
|
||||||
|
log.Printf("Storage write queue full, dropping move record")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUndoneMoves asynchronously deletes moves after undo
|
||||||
|
func (s *Store) DeleteUndoneMoves(gameID string, afterMoveNumber int) error {
|
||||||
|
if !s.healthStatus.Load() {
|
||||||
|
return nil // Silently drop if degraded
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case s.writeChan <- func(tx *sql.Tx) error {
|
||||||
|
query := `DELETE FROM moves WHERE game_id = ? AND move_number > ?`
|
||||||
|
_, err := tx.Exec(query, gameID, afterMoveNumber)
|
||||||
|
return err
|
||||||
|
}:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
// Channel full, drop write
|
||||||
|
log.Printf("Storage write queue full, dropping undo operation")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryGames retrieves games with optional filtering
|
||||||
|
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||||
|
query := `SELECT
|
||||||
|
game_id, initial_fen,
|
||||||
|
white_player_id, white_type, white_level, white_search_time,
|
||||||
|
black_player_id, black_type, black_level, black_search_time,
|
||||||
|
start_time_utc
|
||||||
|
FROM games WHERE 1=1`
|
||||||
|
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
// Handle gameID filtering
|
||||||
|
if gameID != "" && gameID != "*" {
|
||||||
|
query += " AND game_id = ?"
|
||||||
|
args = append(args, gameID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle playerID filtering
|
||||||
|
if playerID != "" && playerID != "*" {
|
||||||
|
query += " AND (white_player_id = ? OR black_player_id = ?)"
|
||||||
|
args = append(args, playerID, playerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY start_time_utc DESC"
|
||||||
|
|
||||||
|
rows, err := s.db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query failed: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var games []GameRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var g GameRecord
|
||||||
|
err := rows.Scan(
|
||||||
|
&g.GameID, &g.InitialFEN,
|
||||||
|
&g.WhitePlayerID, &g.WhiteType, &g.WhiteLevel, &g.WhiteSearchTime,
|
||||||
|
&g.BlackPlayerID, &g.BlackType, &g.BlackLevel, &g.BlackSearchTime,
|
||||||
|
&g.StartTimeUTC,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan failed: %w", err)
|
||||||
|
}
|
||||||
|
games = append(games, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("rows iteration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return games, nil
|
||||||
|
}
|
||||||
@ -3,6 +3,16 @@ package storage
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// UserRecord represents a user account in the database
|
||||||
|
type UserRecord struct {
|
||||||
|
UserID string `db:"user_id"`
|
||||||
|
Username string `db:"username"`
|
||||||
|
Email string `db:"email"`
|
||||||
|
PasswordHash string `db:"password_hash"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
LastLoginAt *time.Time `db:"last_login_at"`
|
||||||
|
}
|
||||||
|
|
||||||
// GameRecord represents a row in the games table
|
// GameRecord represents a row in the games table
|
||||||
type GameRecord struct {
|
type GameRecord struct {
|
||||||
GameID string `db:"game_id"`
|
GameID string `db:"game_id"`
|
||||||
@ -31,6 +41,19 @@ type MoveRecord struct {
|
|||||||
|
|
||||||
// Schema defines the SQLite database structure
|
// Schema defines the SQLite database structure
|
||||||
const Schema = `
|
const Schema = `
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||||
|
email TEXT COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users(email) WHERE email IS NOT NULL AND email != '';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS games (
|
CREATE TABLE IF NOT EXISTS games (
|
||||||
game_id TEXT PRIMARY KEY,
|
game_id TEXT PRIMARY KEY,
|
||||||
initial_fen TEXT NOT NULL,
|
initial_fen TEXT NOT NULL,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import (
|
|||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store handles SQLite database operations with async writes
|
// Store handles SQLite database operations with async writes for games and sync writes for auth
|
||||||
type Store struct {
|
type Store struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
path string
|
path string
|
||||||
@ -70,6 +70,11 @@ func NewStore(dataSourceName string, devMode bool) (*Store, error) {
|
|||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsHealthy returns the current health status
|
||||||
|
func (s *Store) IsHealthy() bool {
|
||||||
|
return s.healthStatus.Load()
|
||||||
|
}
|
||||||
|
|
||||||
// writerLoop processes async write operations
|
// writerLoop processes async write operations
|
||||||
func (s *Store) writerLoop() {
|
func (s *Store) writerLoop() {
|
||||||
defer s.wg.Done()
|
defer s.wg.Done()
|
||||||
@ -125,88 +130,6 @@ func (s *Store) executeWrite(fn func(*sql.Tx) error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordNewGame asynchronously records a new game
|
|
||||||
func (s *Store) RecordNewGame(record GameRecord) error {
|
|
||||||
if !s.healthStatus.Load() {
|
|
||||||
return nil // Silently drop if degraded
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case s.writeChan <- func(tx *sql.Tx) error {
|
|
||||||
query := `INSERT INTO games (
|
|
||||||
game_id, initial_fen,
|
|
||||||
white_player_id, white_type, white_level, white_search_time,
|
|
||||||
black_player_id, black_type, black_level, black_search_time,
|
|
||||||
start_time_utc
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
||||||
|
|
||||||
_, err := tx.Exec(query,
|
|
||||||
record.GameID, record.InitialFEN,
|
|
||||||
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
|
|
||||||
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
|
|
||||||
record.StartTimeUTC,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}:
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
// Channel full, drop write
|
|
||||||
log.Printf("Storage write queue full, dropping game record")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordMove asynchronously records a move
|
|
||||||
func (s *Store) RecordMove(record MoveRecord) error {
|
|
||||||
if !s.healthStatus.Load() {
|
|
||||||
return nil // Silently drop if degraded
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case s.writeChan <- func(tx *sql.Tx) error {
|
|
||||||
query := `INSERT INTO moves (
|
|
||||||
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
|
||||||
|
|
||||||
_, err := tx.Exec(query,
|
|
||||||
record.GameID, record.MoveNumber, record.MoveUCI,
|
|
||||||
record.FENAfterMove, record.PlayerColor, record.MoveTimeUTC,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}:
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
// Channel full, drop write
|
|
||||||
log.Printf("Storage write queue full, dropping move record")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteUndoneMoves asynchronously deletes moves after undo
|
|
||||||
func (s *Store) DeleteUndoneMoves(gameID string, afterMoveNumber int) error {
|
|
||||||
if !s.healthStatus.Load() {
|
|
||||||
return nil // Silently drop if degraded
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case s.writeChan <- func(tx *sql.Tx) error {
|
|
||||||
query := `DELETE FROM moves WHERE game_id = ? AND move_number > ?`
|
|
||||||
_, err := tx.Exec(query, gameID, afterMoveNumber)
|
|
||||||
return err
|
|
||||||
}:
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
// Channel full, drop write
|
|
||||||
log.Printf("Storage write queue full, dropping undo operation")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsHealthy returns the current health status
|
|
||||||
func (s *Store) IsHealthy() bool {
|
|
||||||
return s.healthStatus.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close gracefully closes the database connection
|
// Close gracefully closes the database connection
|
||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
// Signal writer to stop
|
// Signal writer to stop
|
||||||
@ -260,57 +183,4 @@ func (s *Store) DeleteDB() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
// QueryGames retrieves games with optional filtering
|
|
||||||
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
|
||||||
query := `SELECT
|
|
||||||
game_id, initial_fen,
|
|
||||||
white_player_id, white_type, white_level, white_search_time,
|
|
||||||
black_player_id, black_type, black_level, black_search_time,
|
|
||||||
start_time_utc
|
|
||||||
FROM games WHERE 1=1`
|
|
||||||
|
|
||||||
var args []interface{}
|
|
||||||
|
|
||||||
// Handle gameID filtering
|
|
||||||
if gameID != "" && gameID != "*" {
|
|
||||||
query += " AND game_id = ?"
|
|
||||||
args = append(args, gameID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle playerID filtering
|
|
||||||
if playerID != "" && playerID != "*" {
|
|
||||||
query += " AND (white_player_id = ? OR black_player_id = ?)"
|
|
||||||
args = append(args, playerID, playerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
query += " ORDER BY start_time_utc DESC"
|
|
||||||
|
|
||||||
rows, err := s.db.Query(query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query failed: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var games []GameRecord
|
|
||||||
for rows.Next() {
|
|
||||||
var g GameRecord
|
|
||||||
err := rows.Scan(
|
|
||||||
&g.GameID, &g.InitialFEN,
|
|
||||||
&g.WhitePlayerID, &g.WhiteType, &g.WhiteLevel, &g.WhiteSearchTime,
|
|
||||||
&g.BlackPlayerID, &g.BlackType, &g.BlackLevel, &g.BlackSearchTime,
|
|
||||||
&g.StartTimeUTC,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan failed: %w", err)
|
|
||||||
}
|
|
||||||
games = append(games, g)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("rows iteration failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return games, nil
|
|
||||||
}
|
}
|
||||||
187
internal/storage/user.go
Normal file
187
internal/storage/user.go
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
// FILE: internal/storage/user.go
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateUser creates user with transaction isolation to prevent race conditions
|
||||||
|
func (s *Store) CreateUser(record UserRecord) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Check uniqueness within transaction
|
||||||
|
exists, err := s.userExists(tx, record.Username, record.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return fmt.Errorf("username or email already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert user
|
||||||
|
query := `INSERT INTO users (
|
||||||
|
user_id, username, email, password_hash, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
|
_, err = tx.Exec(query,
|
||||||
|
record.UserID, record.Username, record.Email,
|
||||||
|
record.PasswordHash, record.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// userExists verifies username/email uniqueness within a transaction
|
||||||
|
func (s *Store) userExists(tx *sql.Tx, username, email string) (bool, error) {
|
||||||
|
var count int
|
||||||
|
query := `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE`
|
||||||
|
args := []interface{}{username}
|
||||||
|
|
||||||
|
if email != "" {
|
||||||
|
query = `SELECT COUNT(*) FROM users WHERE username = ? COLLATE NOCASE OR email = ? COLLATE NOCASE`
|
||||||
|
args = append(args, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tx.QueryRow(query, args...).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserPassword updates user password hash
|
||||||
|
func (s *Store) UpdateUserPassword(userID string, passwordHash string) error {
|
||||||
|
query := `UPDATE users SET password_hash = ? WHERE user_id = ?`
|
||||||
|
_, err := s.db.Exec(query, passwordHash, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserEmail updates user email
|
||||||
|
func (s *Store) UpdateUserEmail(userID string, email string) error {
|
||||||
|
query := `UPDATE users SET email = ? WHERE user_id = ?`
|
||||||
|
_, err := s.db.Exec(query, email, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserUsername updates username
|
||||||
|
func (s *Store) UpdateUserUsername(userID string, username string) error {
|
||||||
|
query := `UPDATE users SET username = ? WHERE user_id = ?`
|
||||||
|
_, err := s.db.Exec(query, username, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllUsers retrieves all users
|
||||||
|
func (s *Store) GetAllUsers() ([]UserRecord, error) {
|
||||||
|
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
|
||||||
|
FROM users ORDER BY created_at DESC`
|
||||||
|
|
||||||
|
rows, err := s.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []UserRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var user UserRecord
|
||||||
|
err := rows.Scan(
|
||||||
|
&user.UserID, &user.Username, &user.Email,
|
||||||
|
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserLastLoginSync updates user last login time
|
||||||
|
func (s *Store) UpdateUserLastLoginSync(userID string, loginTime time.Time) error {
|
||||||
|
query := `UPDATE users SET last_login_at = ? WHERE user_id = ?`
|
||||||
|
|
||||||
|
_, err := s.db.Exec(query, loginTime, userID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update last login for user %s: %w", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername retrieves user by username with case-insensitive matching
|
||||||
|
func (s *Store) GetUserByUsername(username string) (*UserRecord, error) {
|
||||||
|
var user UserRecord
|
||||||
|
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
|
||||||
|
FROM users WHERE username = ? COLLATE NOCASE`
|
||||||
|
|
||||||
|
err := s.db.QueryRow(query, username).Scan(
|
||||||
|
&user.UserID, &user.Username, &user.Email,
|
||||||
|
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByEmail retrieves user by email with case-insensitive matching
|
||||||
|
func (s *Store) GetUserByEmail(email string) (*UserRecord, error) {
|
||||||
|
var user UserRecord
|
||||||
|
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
|
||||||
|
FROM users WHERE email = ? COLLATE NOCASE`
|
||||||
|
|
||||||
|
err := s.db.QueryRow(query, email).Scan(
|
||||||
|
&user.UserID, &user.Username, &user.Email,
|
||||||
|
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID retrieves user by unique user ID
|
||||||
|
func (s *Store) GetUserByID(userID string) (*UserRecord, error) {
|
||||||
|
var user UserRecord
|
||||||
|
query := `SELECT user_id, username, email, password_hash, created_at, last_login_at
|
||||||
|
FROM users WHERE user_id = ?`
|
||||||
|
|
||||||
|
err := s.db.QueryRow(query, userID).Scan(
|
||||||
|
&user.UserID, &user.Username, &user.Email,
|
||||||
|
&user.PasswordHash, &user.CreatedAt, &user.LastLoginAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser removes a user from the database
|
||||||
|
func (s *Store) DeleteUser(userID string) error {
|
||||||
|
if !s.healthStatus.Load() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case s.writeChan <- func(tx *sql.Tx) error {
|
||||||
|
query := `DELETE FROM users WHERE user_id = ?`
|
||||||
|
_, err := tx.Exec(query, userID)
|
||||||
|
return err
|
||||||
|
}:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
log.Printf("Storage write queue full, dropping user deletion")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,7 @@ Tests all API endpoints, error handling, rate limiting, and game logic.
|
|||||||
- Security hardening and input validation
|
- Security hardening and input validation
|
||||||
|
|
||||||
### 2. Database Persistence Tests (`test-db.sh`)
|
### 2. Database Persistence Tests (`test-db.sh`)
|
||||||
|
**Outdated: test-db.sh and test-db-server.sh currently focus on user operations.**
|
||||||
|
|
||||||
Tests database storage, async writes, and data integrity.
|
Tests database storage, async writes, and data integrity.
|
||||||
|
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# FILE: run-server-with-db.sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Check for argument
|
|
||||||
if [ $# -ne 1 ]; then
|
|
||||||
echo "Usage: $0 <path_to_chessd_executable>"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
CHESSD_EXEC="$1"
|
|
||||||
TEST_DB="test.db"
|
|
||||||
PID_FILE="/tmp/chessd_test.pid"
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# Cleanup function
|
|
||||||
cleanup() {
|
|
||||||
echo -e "\n${YELLOW}Cleaning up...${NC}"
|
|
||||||
|
|
||||||
# Kill server if PID file exists
|
|
||||||
if [ -f "$PID_FILE" ]; then
|
|
||||||
PID=$(cat "$PID_FILE")
|
|
||||||
if kill -0 "$PID" 2>/dev/null; then
|
|
||||||
echo "Stopping chessd server (PID: $PID)"
|
|
||||||
kill "$PID" 2>/dev/null || true
|
|
||||||
sleep 0.5
|
|
||||||
kill -9 "$PID" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
rm -f "$PID_FILE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean up database files
|
|
||||||
echo "Removing test database files..."
|
|
||||||
rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm"
|
|
||||||
|
|
||||||
echo -e "${GREEN}Cleanup complete${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set up trap for cleanup on exit
|
|
||||||
trap cleanup EXIT SIGINT SIGTERM
|
|
||||||
|
|
||||||
# Clean slate - remove any existing test DB files
|
|
||||||
rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm"
|
|
||||||
|
|
||||||
# Initialize database
|
|
||||||
echo -e "${CYAN}Initializing test database...${NC}"
|
|
||||||
"$CHESSD_EXEC" db init -path "$TEST_DB"
|
|
||||||
|
|
||||||
# Start server
|
|
||||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
|
||||||
echo -e "${GREEN}Starting chessd server with database persistence${NC}"
|
|
||||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
|
||||||
echo "Executable: $CHESSD_EXEC"
|
|
||||||
echo "Database: $TEST_DB"
|
|
||||||
echo "Mode: Development (WAL enabled)"
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Instructions:${NC}"
|
|
||||||
echo "1. Open another terminal and run: ./test-db.sh"
|
|
||||||
echo "2. Press Ctrl+C here when testing is complete"
|
|
||||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Start chessd in foreground with dev mode and storage
|
|
||||||
"$CHESSD_EXEC" -dev -storage-path "$TEST_DB" -port 8080
|
|
||||||
111
test/test-db-server.sh
Executable file
111
test/test-db-server.sh
Executable file
@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# FILE: run-test-db-server.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CHESSD_EXEC=${1:-"./chessd"}
|
||||||
|
TEST_DB="test.db"
|
||||||
|
PID_FILE="/tmp/chessd_test.pid"
|
||||||
|
API_PORT=${API_PORT:-8080}
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Check executable
|
||||||
|
if [ ! -x "$CHESSD_EXEC" ]; then
|
||||||
|
echo -e "${RED}Error: chessd executable not found or not executable: $CHESSD_EXEC${NC}"
|
||||||
|
echo "Please build the application first: go build ./cmd/chessd"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup function
|
||||||
|
cleanup() {
|
||||||
|
echo -e "\n${YELLOW}Cleaning up...${NC}"
|
||||||
|
|
||||||
|
# Kill server if PID file exists
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
PID=$(cat "$PID_FILE")
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
echo "Stopping chessd server (PID: $PID)"
|
||||||
|
kill "$PID" 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
kill -9 "$PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up database files
|
||||||
|
echo "Removing test database files..."
|
||||||
|
rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm"
|
||||||
|
|
||||||
|
echo -e "${GREEN}Cleanup complete${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set up trap for cleanup on exit
|
||||||
|
trap cleanup EXIT SIGINT SIGTERM
|
||||||
|
|
||||||
|
# Clean slate - remove any existing test DB files
|
||||||
|
echo -e "${CYAN}Preparing test environment...${NC}"
|
||||||
|
rm -f "$TEST_DB" "${TEST_DB}-wal" "${TEST_DB}-shm" "$PID_FILE"
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
echo -e "${CYAN}Initializing test database...${NC}"
|
||||||
|
"$CHESSD_EXEC" db init -path "$TEST_DB"
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to initialize database${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add test users
|
||||||
|
echo -e "${CYAN}Adding test users...${NC}"
|
||||||
|
"$CHESSD_EXEC" db user add -path "$TEST_DB" \
|
||||||
|
-username alice -email alice@test.com -password AlicePass123
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to create user alice${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$CHESSD_EXEC" db user add -path "$TEST_DB" \
|
||||||
|
-username bob -email bob@test.com -password BobSecure456
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Failed to create user bob${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${CYAN}Test users created:${NC}"
|
||||||
|
echo " • alice / AlicePass123"
|
||||||
|
echo " • bob / BobSecure456"
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ Chess API Test Server with User Management ║${NC}"
|
||||||
|
echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Configuration:"
|
||||||
|
echo " Executable: $CHESSD_EXEC"
|
||||||
|
echo " Database: $TEST_DB"
|
||||||
|
echo " Port: $API_PORT"
|
||||||
|
echo " Mode: Development (WAL enabled, relaxed rate limits)"
|
||||||
|
echo " PID File: $PID_FILE"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Instructions:${NC}"
|
||||||
|
echo " 1. Server will start in foreground"
|
||||||
|
echo " 2. Open another terminal and run: ./test-db.sh"
|
||||||
|
echo " 3. Press Ctrl+C here when testing is complete"
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}──────────────────────────────────────────────────────────${NC}"
|
||||||
|
echo "Starting server..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start chessd in foreground with dev mode and storage
|
||||||
|
"$CHESSD_EXEC" \
|
||||||
|
-dev \
|
||||||
|
-storage-path "$TEST_DB" \
|
||||||
|
-api-port "$API_PORT" \
|
||||||
|
-pid "$PID_FILE" \
|
||||||
|
-pid-lock
|
||||||
637
test/test-db.sh
637
test/test-db.sh
@ -1,12 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# FILE: test-db.sh
|
# FILE: test-db.sh
|
||||||
|
|
||||||
# Database Persistence Test Suite for Chess API
|
# Database and User Management Test Suite
|
||||||
# Tests async writes, persistence, and database integrity
|
# Tests user operations, authentication, and game persistence
|
||||||
|
|
||||||
BASE_URL="http://localhost:8080"
|
BASE_URL="http://localhost:8080"
|
||||||
API_URL="${BASE_URL}/api/v1"
|
API_URL="${BASE_URL}/api/v1"
|
||||||
TEST_DB="test.db"
|
TEST_DB="test.db"
|
||||||
|
CHESSD_EXEC=${1:-"./chessd"}
|
||||||
API_DELAY=${API_DELAY:-50}
|
API_DELAY=${API_DELAY:-50}
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
@ -22,6 +23,23 @@ NC='\033[0m'
|
|||||||
PASS=0
|
PASS=0
|
||||||
FAIL=0
|
FAIL=0
|
||||||
|
|
||||||
|
# Test users
|
||||||
|
TEST_USER1="alice"
|
||||||
|
TEST_PASS1="AlicePass123"
|
||||||
|
TEST_EMAIL1="alice@test.com"
|
||||||
|
|
||||||
|
TEST_USER2="bob"
|
||||||
|
TEST_PASS2="BobSecure456"
|
||||||
|
TEST_PASS2_OLD="BobSecure456"
|
||||||
|
TEST_PASS2_NEW="BobNewPass789"
|
||||||
|
|
||||||
|
TEST_USER_API="charlie"
|
||||||
|
TEST_PASS_API="CharliePass123"
|
||||||
|
|
||||||
|
TEST_USER_CLI="dave"
|
||||||
|
TEST_PASS_CLI="DaveSecure111"
|
||||||
|
UNSUPPORTED_HASH='$2a$10$abcdefghijklmnopqrstuv1234567890abcdefghijklmnopqrstuv'
|
||||||
|
|
||||||
# Helper functions
|
# Helper functions
|
||||||
print_header() {
|
print_header() {
|
||||||
echo -e "\n${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
echo -e "\n${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
||||||
@ -69,20 +87,22 @@ assert_json_field() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_db_record() {
|
assert_command() {
|
||||||
local sql_query=$1
|
local command=$1
|
||||||
local expected=$2
|
local expected_exit=$2
|
||||||
local test_name=$3
|
local test_name=$3
|
||||||
|
|
||||||
local actual=$(sqlite3 "$TEST_DB" "$sql_query" 2>/dev/null)
|
local output
|
||||||
|
output=$(eval "$command" 2>&1)
|
||||||
|
local exit_code=$?
|
||||||
|
|
||||||
if [ "$actual" = "$expected" ]; then
|
if [ "$exit_code" = "$expected_exit" ]; then
|
||||||
echo -e "${GREEN} ✓ $test_name: DB query returned '$actual'${NC}"
|
echo -e "${GREEN} ✓ $test_name: Command exit code $exit_code${NC}"
|
||||||
((PASS++))
|
((PASS++))
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
echo -e "${RED} ✗ $test_name: Expected '$expected', got '$actual'${NC}"
|
echo -e "${RED} ✗ $test_name: Expected exit $expected_exit, got $exit_code${NC}"
|
||||||
echo -e "${RED} Query: $sql_query${NC}"
|
echo -e " Command output: $output"
|
||||||
((FAIL++))
|
((FAIL++))
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
@ -101,11 +121,13 @@ api_request() {
|
|||||||
wait_for_state() {
|
wait_for_state() {
|
||||||
local game_id=$1
|
local game_id=$1
|
||||||
local target_state=$2
|
local target_state=$2
|
||||||
local max_attempts=${3:-20}
|
local token=$3
|
||||||
|
local max_attempts=${4:-20}
|
||||||
local attempt=0
|
local attempt=0
|
||||||
|
|
||||||
while [ $attempt -lt $max_attempts ]; do
|
while [ $attempt -lt $max_attempts ]; do
|
||||||
local response=$(api_request GET "$API_URL/games/$game_id")
|
local response=$(api_request GET "$API_URL/games/$game_id" \
|
||||||
|
-H "Authorization: Bearer $token")
|
||||||
local current_state=$(echo "$response" | jq -r '.state' 2>/dev/null)
|
local current_state=$(echo "$response" | jq -r '.state' 2>/dev/null)
|
||||||
|
|
||||||
if [ "$current_state" = "$target_state" ] || [[ "$target_state" = "!pending" && "$current_state" != "pending" ]]; then
|
if [ "$current_state" = "$target_state" ] || [[ "$target_state" = "!pending" && "$current_state" != "pending" ]]; then
|
||||||
@ -127,352 +149,303 @@ for cmd in jq sqlite3 curl; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Check database exists
|
# Check executable exists
|
||||||
if [ ! -f "$TEST_DB" ]; then
|
if [ ! -x "$CHESSD_EXEC" ]; then
|
||||||
echo -e "${RED}Error: Test database '$TEST_DB' not found${NC}"
|
echo -e "${RED}Error: chessd executable not found or not executable: $CHESSD_EXEC${NC}"
|
||||||
echo "Make sure the server is running with: ./run-server-with-db.sh"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start tests
|
# Start tests
|
||||||
print_header "Database Persistence Test Suite"
|
print_header "Database & User Management Test Suite"
|
||||||
echo "Server: $BASE_URL"
|
echo "Executable: $CHESSD_EXEC"
|
||||||
echo "Database: $TEST_DB"
|
echo "Database: $TEST_DB"
|
||||||
echo "Mode: Development with WAL"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
print_header "SECTION 1: Storage Health & Basic Persistence"
|
print_header "SECTION 1: CLI User Operations"
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
test_case "1.1: Storage Health Check"
|
test_case "1.1: database initialization"
|
||||||
RESPONSE=$(api_request GET "$BASE_URL/health")
|
assert_command "$CHESSD_EXEC db init -path $TEST_DB" 0 "initialize database"
|
||||||
assert_json_field "$RESPONSE" '.storage' "ok" "Storage is healthy"
|
|
||||||
|
|
||||||
test_case "1.2: Database Schema Verification"
|
# Create testuser1 first (not charlie)
|
||||||
TABLE_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('games', 'moves');" 2>/dev/null)
|
test_case "1.2: Add First User via CLI"
|
||||||
if [ "$TABLE_COUNT" = "2" ]; then
|
OUTPUT=$($CHESSD_EXEC db user add -path "$TEST_DB" -username "testuser1" \
|
||||||
echo -e "${GREEN} ✓ Database schema verified: games and moves tables exist${NC}"
|
-email "testuser1@test.com" -password "TestPass123" 2>&1)
|
||||||
|
if echo "$OUTPUT" | grep -qi "User created successfully"; then
|
||||||
|
echo -e "${GREEN} ✓ User created: testuser1${NC}"
|
||||||
((PASS++))
|
((PASS++))
|
||||||
else
|
else
|
||||||
echo -e "${RED} ✗ Database schema incomplete: expected 2 tables, found $TABLE_COUNT${NC}"
|
echo -e "${RED} ✗ Failed to create first user${NC}"
|
||||||
((FAIL++))
|
((FAIL++))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test_case "1.3: Game Creation Persistence"
|
test_case "1.3: Add Second User"
|
||||||
|
OUTPUT=$($CHESSD_EXEC db user add -path "$TEST_DB" -username "testuser2" \
|
||||||
|
-password "TestPass456" 2>&1)
|
||||||
|
if echo "$OUTPUT" | grep -qi "User created successfully"; then
|
||||||
|
echo -e "${GREEN} ✓ User created: testuser2${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Failed to create second user${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Now test duplicate prevention with an existing user
|
||||||
|
test_case "1.4: Duplicate Username Prevention"
|
||||||
|
assert_command "$CHESSD_EXEC db user add -path $TEST_DB -username testuser1 -password TestPass789" 1 \
|
||||||
|
"Duplicate username rejected"
|
||||||
|
|
||||||
|
test_case "1.5: Login with Case-Insensitive Username (ALICE)"
|
||||||
|
RESPONSE=$(api_request POST "$API_URL/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identifier\": \"ALICE\", \"password\": \"$TEST_PASS1\"}")
|
||||||
|
if echo "$RESPONSE" | jq -r '.token' 2>/dev/null | grep -qE "^ey[A-Za-z0-9_-]+\.ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$"; then
|
||||||
|
echo -e "${GREEN} ✓ Case-insensitive username login works${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Case-insensitive login failed${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_case "1.6: Update User Email"
|
||||||
|
assert_command "$CHESSD_EXEC db user set-email -path $TEST_DB -username testuser2 -email testuser2_updated@test.com" 0 \
|
||||||
|
"Email update"
|
||||||
|
|
||||||
|
test_case "1.7: Update User Password"
|
||||||
|
assert_command "$CHESSD_EXEC db user set-password -path $TEST_DB -username testuser2 -password NewPass789" 0 \
|
||||||
|
"Password update"
|
||||||
|
|
||||||
|
test_case "2.1: Health Check"
|
||||||
|
RESPONSE=$(api_request GET "$BASE_URL/health")
|
||||||
|
assert_json_field "$RESPONSE" '.storage' "ok" "Storage healthy"
|
||||||
|
|
||||||
|
# Register charlie here for the first time
|
||||||
|
test_case "2.2: Register New User via API"
|
||||||
|
RESPONSE=$(api_request POST "$API_URL/auth/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "charlie", "email": "charlie@test.com", "password": "CharliePass123"}')
|
||||||
|
TOKEN_CHARLIE=$(echo "$RESPONSE" | jq -r '.token' 2>/dev/null)
|
||||||
|
if [ -n "$TOKEN_CHARLIE" ] && [ "$TOKEN_CHARLIE" != "null" ]; then
|
||||||
|
echo -e "${GREEN} ✓ User registered via API${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Registration failed${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_case "2.3: Login with Username"
|
||||||
|
RESPONSE=$(api_request POST "$API_URL/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identifier\": \"$TEST_USER1\", \"password\": \"$TEST_PASS1\"}")
|
||||||
|
TOKEN_ALICE=$(echo "$RESPONSE" | jq -r '.token' 2>/dev/null)
|
||||||
|
USER_ID_ALICE=$(echo "$RESPONSE" | jq -r '.userId' 2>/dev/null)
|
||||||
|
if [ -n "$TOKEN_ALICE" ] && [ "$TOKEN_ALICE" != "null" ]; then
|
||||||
|
echo -e "${GREEN} ✓ Login successful for $TEST_USER1${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Login failed${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_case "2.4: Login with Email"
|
||||||
|
RESPONSE=$(api_request POST "$API_URL/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identifier\": \"$TEST_EMAIL1\", \"password\": \"$TEST_PASS1\"}")
|
||||||
|
if echo "$RESPONSE" | jq -r '.token' 2>/dev/null | grep -q "^ey"; then
|
||||||
|
echo -e "${GREEN} ✓ Email login successful${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Email login failed${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_case "2.5: Invalid Credentials"
|
||||||
|
STATUS=$(api_request POST "$API_URL/auth/login" \
|
||||||
|
-o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identifier\": \"$TEST_USER1\", \"password\": \"WrongPassword\"}")
|
||||||
|
assert_status 401 "$STATUS" "Invalid password rejected"
|
||||||
|
|
||||||
|
test_case "2.6: Get Current User"
|
||||||
|
RESPONSE=$(api_request GET "$API_URL/auth/me" \
|
||||||
|
-H "Authorization: Bearer $TOKEN_ALICE")
|
||||||
|
assert_json_field "$RESPONSE" '.username' "$TEST_USER1" "Username matches"
|
||||||
|
assert_json_field "$RESPONSE" '.email' "$TEST_EMAIL1" "Email matches"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print_header "SECTION 3: Authenticated Game Creation"
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
test_case "3.1: Create Game as Authenticated User"
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
RESPONSE=$(api_request POST "$API_URL/games" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 5, "searchTime": 100}}')
|
-H "Authorization: Bearer $TOKEN_ALICE" \
|
||||||
|
-d '{"white": {"type": 1}, "black": {"type": 2, "searchTime": 100}}')
|
||||||
GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
||||||
BLACK_ID=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null)
|
WHITE_PLAYER_ID=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
|
||||||
WHITE_ID=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -n "$GAME_ID" ] && [ "$GAME_ID" != "null" ]; then
|
if [ "$WHITE_PLAYER_ID" = "$USER_ID_ALICE" ]; then
|
||||||
echo " Game ID: $GAME_ID"
|
echo -e "${GREEN} ✓ Player ID matches User ID for authenticated human${NC}"
|
||||||
sleep 0.5 # Allow async write
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT COUNT(*) FROM games WHERE game_id = '$GAME_ID';" \
|
|
||||||
"1" \
|
|
||||||
"Game record created"
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT black_player_id FROM games WHERE game_id = '$GAME_ID';" \
|
|
||||||
"$BLACK_ID" \
|
|
||||||
"Black player ID matches"
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT black_level FROM games WHERE game_id = '$GAME_ID';" \
|
|
||||||
"5" \
|
|
||||||
"Black AI level persisted"
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT initial_fen FROM games WHERE game_id = '$GAME_ID';" \
|
|
||||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" \
|
|
||||||
"Starting FEN persisted"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
print_header "SECTION 2: Move Persistence & Undo"
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
test_case "2.1: Human Move Persistence"
|
|
||||||
if [ -n "$GAME_ID" ]; then
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games/$GAME_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "e2e4"}')
|
|
||||||
|
|
||||||
sleep 0.5 # Allow async write
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \
|
|
||||||
"1" \
|
|
||||||
"Move record created"
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT move_uci FROM moves WHERE game_id = '$GAME_ID' AND move_number = 1;" \
|
|
||||||
"e2e4" \
|
|
||||||
"Move UCI notation stored"
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT player_color FROM moves WHERE game_id = '$GAME_ID' AND move_number = 1;" \
|
|
||||||
"w" \
|
|
||||||
"Move color recorded"
|
|
||||||
fi
|
|
||||||
|
|
||||||
test_case "2.2: Computer Move Persistence"
|
|
||||||
if [ -n "$GAME_ID" ]; then
|
|
||||||
# Trigger computer move
|
|
||||||
api_request POST "$API_URL/games/$GAME_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "cccc"}' > /dev/null
|
|
||||||
|
|
||||||
wait_for_state "$GAME_ID" "!pending"
|
|
||||||
sleep 0.5 # Allow async write
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \
|
|
||||||
"2" \
|
|
||||||
"Computer move persisted"
|
|
||||||
|
|
||||||
COMPUTER_MOVE=$(sqlite3 "$TEST_DB" "SELECT move_uci FROM moves WHERE game_id = '$GAME_ID' AND move_number = 2;" 2>/dev/null)
|
|
||||||
if [ -n "$COMPUTER_MOVE" ] && [ "$COMPUTER_MOVE" != "e2e4" ]; then
|
|
||||||
echo -e "${GREEN} ✓ Computer move stored: $COMPUTER_MOVE${NC}"
|
|
||||||
((PASS++))
|
|
||||||
else
|
|
||||||
echo -e "${RED} ✗ Computer move not properly stored${NC}"
|
|
||||||
((FAIL++))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
test_case "2.3: Undo Move Database Effect"
|
|
||||||
if [ -n "$GAME_ID" ]; then
|
|
||||||
# Undo last move
|
|
||||||
api_request POST "$API_URL/games/$GAME_ID/undo" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"count": 1}' > /dev/null
|
|
||||||
|
|
||||||
sleep 0.5 # Allow async write
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \
|
|
||||||
"1" \
|
|
||||||
"Undo removed move from DB"
|
|
||||||
|
|
||||||
# Undo again
|
|
||||||
api_request POST "$API_URL/games/$GAME_ID/undo" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"count": 1}' > /dev/null
|
|
||||||
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID';" \
|
|
||||||
"0" \
|
|
||||||
"All moves removed after undo"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
print_header "SECTION 3: Complex Game Scenarios"
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
test_case "3.1: Custom FEN Game Persistence"
|
|
||||||
CUSTOM_FEN="r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 4 4"
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"white\": {\"type\": 2, \"searchTime\": 100}, \"black\": {\"type\": 2, \"searchTime\": 100}, \"fen\": \"$CUSTOM_FEN\"}")
|
|
||||||
|
|
||||||
FEN_GAME_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
if [ -n "$FEN_GAME_ID" ] && [ "$FEN_GAME_ID" != "null" ]; then
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT initial_fen FROM games WHERE game_id = '$FEN_GAME_ID';" \
|
|
||||||
"$CUSTOM_FEN" \
|
|
||||||
"Custom FEN persisted correctly"
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
api_request DELETE "$API_URL/games/$FEN_GAME_ID" > /dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
test_case "3.2: Multiple Games Isolation"
|
|
||||||
# Create two games
|
|
||||||
RESPONSE1=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
|
||||||
GAME_ID1=$(echo "$RESPONSE1" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
RESPONSE2=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 2, "searchTime": 100}, "black": {"type": 1}}')
|
|
||||||
GAME_ID2=$(echo "$RESPONSE2" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -n "$GAME_ID1" ] && [ -n "$GAME_ID2" ]; then
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
# Make moves in first game
|
|
||||||
api_request POST "$API_URL/games/$GAME_ID1/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "d2d4"}' > /dev/null
|
|
||||||
api_request POST "$API_URL/games/$GAME_ID1/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "d7d5"}' > /dev/null
|
|
||||||
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID1';" \
|
|
||||||
"2" \
|
|
||||||
"Game 1 has 2 moves"
|
|
||||||
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT COUNT(*) FROM moves WHERE game_id = '$GAME_ID2';" \
|
|
||||||
"0" \
|
|
||||||
"Game 2 has 0 moves (isolation)"
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
api_request DELETE "$API_URL/games/$GAME_ID1" > /dev/null
|
|
||||||
api_request DELETE "$API_URL/games/$GAME_ID2" > /dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
print_header "SECTION 4: Foreign Key Constraints & Cascade"
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
test_case "4.1: Cascade Delete Verification"
|
|
||||||
# Create game with moves
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
|
||||||
CASCADE_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -n "$CASCADE_ID" ] && [ "$CASCADE_ID" != "null" ]; then
|
|
||||||
# Make several moves
|
|
||||||
api_request POST "$API_URL/games/$CASCADE_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" -d '{"move": "e2e4"}' > /dev/null
|
|
||||||
api_request POST "$API_URL/games/$CASCADE_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" -d '{"move": "e7e5"}' > /dev/null
|
|
||||||
api_request POST "$API_URL/games/$CASCADE_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" -d '{"move": "g1f3"}' > /dev/null
|
|
||||||
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
MOVE_COUNT_BEFORE=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM moves WHERE game_id = '$CASCADE_ID';" 2>/dev/null)
|
|
||||||
echo -e "${BLUE} Moves before delete: $MOVE_COUNT_BEFORE${NC}"
|
|
||||||
|
|
||||||
# Delete game
|
|
||||||
api_request DELETE "$API_URL/games/$CASCADE_ID" > /dev/null
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
# Note: Game deletion is handled in memory, DB records remain
|
|
||||||
# This is by design - games table persists for history
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT COUNT(*) FROM games WHERE game_id = '$CASCADE_ID';" \
|
|
||||||
"1" \
|
|
||||||
"Game record persists in DB (by design)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
print_header "SECTION 5: Async Write Buffer Behavior"
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
test_case "5.1: Rapid Write Buffering"
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
|
||||||
BUFFER_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -n "$BUFFER_ID" ] && [ "$BUFFER_ID" != "null" ]; then
|
|
||||||
# Rapid fire a sequence of legal moves without waiting
|
|
||||||
echo -e "${BLUE} Sending 10 rapid, legal moves...${NC}"
|
|
||||||
moves=("e2e4" "e7e5" "g1f3" "b8c6" "f1b5" "a7a6" "b5c6" "d7c6" "e1g1" "f7f6")
|
|
||||||
for move in "${moves[@]}"; do
|
|
||||||
api_request POST "$API_URL/games/$BUFFER_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" -d "{\"move\": \"$move\"}" > /dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
# Immediate check (may show partial writes)
|
|
||||||
IMMEDIATE_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM moves WHERE game_id = '$BUFFER_ID';" 2>/dev/null)
|
|
||||||
echo -e "${BLUE} Immediate move count: $IMMEDIATE_COUNT${NC}"
|
|
||||||
|
|
||||||
# Wait for async writes to complete
|
|
||||||
sleep 1
|
|
||||||
|
|
||||||
FINAL_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM moves WHERE game_id = '$BUFFER_ID';" 2>/dev/null)
|
|
||||||
if [ "$FINAL_COUNT" = "10" ]; then
|
|
||||||
echo -e "${GREEN} ✓ All 10 moves persisted after buffer flush${NC}"
|
|
||||||
((PASS++))
|
|
||||||
else
|
|
||||||
echo -e "${RED} ✗ Expected 10 moves, found $FINAL_COUNT${NC}"
|
|
||||||
((FAIL++))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
api_request DELETE "$API_URL/games/$BUFFER_ID" > /dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
print_header "SECTION 6: Database Query Endpoints"
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
test_case "6.1: CLI Query Tool Integration"
|
|
||||||
# Create identifiable game
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 15, "searchTime": 200}}')
|
|
||||||
QUERY_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -n "$QUERY_ID" ] && [ "$QUERY_ID" != "null" ]; then
|
|
||||||
sleep 0.5
|
|
||||||
|
|
||||||
# Query using partial game ID (first 8 chars)
|
|
||||||
PARTIAL_ID="${QUERY_ID:0:8}"
|
|
||||||
FOUND=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM games WHERE game_id LIKE '$PARTIAL_ID%';" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$FOUND" = "1" ]; then
|
|
||||||
echo -e "${GREEN} ✓ Game queryable by partial ID${NC}"
|
|
||||||
((PASS++))
|
|
||||||
else
|
|
||||||
echo -e "${RED} ✗ Game not found with partial ID query${NC}"
|
|
||||||
((FAIL++))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify player type storage
|
|
||||||
assert_db_record \
|
|
||||||
"SELECT white_type || ',' || black_type FROM games WHERE game_id = '$QUERY_ID';" \
|
|
||||||
"1,2" \
|
|
||||||
"Player types stored correctly"
|
|
||||||
|
|
||||||
# Clean up
|
|
||||||
api_request DELETE "$API_URL/games/$QUERY_ID" > /dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
print_header "SECTION 7: Storage Degradation Handling"
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
test_case "7.1: Storage Health After Normal Operations"
|
|
||||||
# Check that storage remains healthy after all operations
|
|
||||||
RESPONSE=$(api_request GET "$BASE_URL/health")
|
|
||||||
assert_json_field "$RESPONSE" '.storage' "ok" "Storage still healthy after tests"
|
|
||||||
|
|
||||||
test_case "7.2: WAL Mode Verification"
|
|
||||||
JOURNAL_MODE=$(sqlite3 "$TEST_DB" "PRAGMA journal_mode;" 2>/dev/null)
|
|
||||||
if [ "$JOURNAL_MODE" = "wal" ]; then
|
|
||||||
echo -e "${GREEN} ✓ Database is in WAL mode${NC}"
|
|
||||||
((PASS++))
|
((PASS++))
|
||||||
else
|
else
|
||||||
echo -e "${RED} ✗ Database not in WAL mode: $JOURNAL_MODE${NC}"
|
echo -e "${RED} ✗ Player ID mismatch: expected $USER_ID_ALICE, got $WHITE_PLAYER_ID${NC}"
|
||||||
((FAIL++))
|
((FAIL++))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check WAL file exists
|
test_case "3.2: Anonymous Game Creation"
|
||||||
if [ -f "${TEST_DB}-wal" ]; then
|
RESPONSE=$(api_request POST "$API_URL/games" \
|
||||||
echo -e "${GREEN} ✓ WAL file exists${NC}"
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
||||||
|
ANON_WHITE_ID=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
|
||||||
|
ANON_BLACK_ID=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null)
|
||||||
|
|
||||||
|
# Check UUIDs are different and not user IDs
|
||||||
|
if [ "$ANON_WHITE_ID" != "$ANON_BLACK_ID" ] && \
|
||||||
|
[ "$ANON_WHITE_ID" != "$USER_ID_ALICE" ] && \
|
||||||
|
[[ "$ANON_WHITE_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
||||||
|
echo -e "${GREEN} ✓ Anonymous players get unique UUIDs${NC}"
|
||||||
((PASS++))
|
((PASS++))
|
||||||
else
|
else
|
||||||
echo -e "${YELLOW} ⚠ WAL file not found (may be checkpointed)${NC}"
|
echo -e "${RED} ✗ Anonymous player ID issue${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_case "3.3: Both Players Same Authenticated User"
|
||||||
|
RESPONSE=$(api_request POST "$API_URL/games" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $TOKEN_ALICE" \
|
||||||
|
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
||||||
|
WHITE_ID=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
|
||||||
|
BLACK_ID=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$WHITE_ID" = "$USER_ID_ALICE" ] && [ "$BLACK_ID" = "$USER_ID_ALICE" ]; then
|
||||||
|
echo -e "${GREEN} ✓ Same user can play both sides${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Both sides should be same user${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print_header "SECTION 4: Game Persistence with User IDs"
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
test_case "4.1: Verify Game Storage with User ID"
|
||||||
|
if [ -n "$GAME_ID" ]; then
|
||||||
|
sleep 0.5 # Allow async write
|
||||||
|
|
||||||
|
DB_WHITE_ID=$(sqlite3 "$TEST_DB" "SELECT white_player_id FROM games WHERE game_id = '$GAME_ID';" 2>/dev/null)
|
||||||
|
if [ "$DB_WHITE_ID" = "$USER_ID_ALICE" ]; then
|
||||||
|
echo -e "${GREEN} ✓ User ID correctly persisted in database${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Database has wrong player ID${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_case "4.2: Query Games by User ID"
|
||||||
|
GAMES_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM games WHERE white_player_id = '$USER_ID_ALICE' OR black_player_id = '$USER_ID_ALICE';" 2>/dev/null)
|
||||||
|
if [ "$GAMES_COUNT" -ge "2" ]; then
|
||||||
|
echo -e "${GREEN} ✓ User's games queryable: found $GAMES_COUNT games${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Expected at least 2 games for user${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print_header "SECTION 5: Password Operations"
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Now TEST_PASS2_NEW is defined, this test should work
|
||||||
|
test_case "5.1: Update User Password via CLI for 'bob'"
|
||||||
|
assert_command "$CHESSD_EXEC db user set-password -path $TEST_DB -username $TEST_USER2 -password $TEST_PASS2_NEW" 0 \
|
||||||
|
"CLI password update for '$TEST_USER2'"
|
||||||
|
|
||||||
|
test_case "5.2: Login with NEW Password for 'bob'"
|
||||||
|
RESPONSE=$(api_request POST "$API_URL/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identifier\": \"$TEST_USER2\", \"password\": \"$TEST_PASS2_NEW\"}")
|
||||||
|
if echo "$RESPONSE" | jq -r '.token' 2>/dev/null | grep -q "^ey"; then
|
||||||
|
echo -e "${GREEN} ✓ Login works with new password for '$TEST_USER2'${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Login failed with new password for '$TEST_USER2'${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_case "5.3: OLD Password Rejected for 'bob'"
|
||||||
|
STATUS=$(api_request POST "$API_URL/auth/login" \
|
||||||
|
-o /dev/null -w "%{http_code}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identifier\": \"$TEST_USER2\", \"password\": \"$TEST_PASS2_OLD\"}")
|
||||||
|
assert_status 401 "$STATUS" "Old password correctly rejected for '$TEST_USER2'"
|
||||||
|
|
||||||
|
test_case "5.4: Add new user '$TEST_USER_CLI' via CLI for hash test"
|
||||||
|
assert_command "$CHESSD_EXEC db user add -path $TEST_DB -username $TEST_USER_CLI -password $TEST_PASS_CLI" 0 \
|
||||||
|
"Add user '$TEST_USER_CLI' for hash test"
|
||||||
|
|
||||||
|
test_case "5.5: CLI rejects unsupported hash format"
|
||||||
|
assert_command "$CHESSD_EXEC db user set-hash -path $TEST_DB -username $TEST_USER_CLI -hash '$UNSUPPORTED_HASH'" 1 \
|
||||||
|
"Unsupported bcrypt hash rejected by CLI"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
print_header "SECTION 6: Edge Cases"
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
test_case "6.1: Case-Insensitive Username"
|
||||||
|
RESPONSE=$(api_request POST "$API_URL/auth/login" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"identifier\": \"ALICE\", \"password\": \"$TEST_PASS1\"}")
|
||||||
|
if echo "$RESPONSE" | jq -r '.token' 2>/dev/null | grep -qE "^ey[A-Za-z0-9_-]+\.ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$"; then
|
||||||
|
echo -e "${GREEN} ✓ Case-insensitive username login${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Case sensitivity issue${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_case "6.2: Concurrent Registration Handling"
|
||||||
|
# Try to register same username simultaneously
|
||||||
|
{
|
||||||
|
api_request POST "$API_URL/auth/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "concurrent", "password": "TestPass123"}' &
|
||||||
|
api_request POST "$API_URL/auth/register" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username": "concurrent", "password": "TestPass456"}' &
|
||||||
|
} > /dev/null 2>&1
|
||||||
|
wait
|
||||||
|
|
||||||
|
# Check only one user was created
|
||||||
|
USER_COUNT=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM users WHERE username = 'concurrent';" 2>/dev/null)
|
||||||
|
if [ "$USER_COUNT" = "1" ]; then
|
||||||
|
echo -e "${GREEN} ✓ Concurrent registration handled correctly${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ Race condition: $USER_COUNT users created${NC}"
|
||||||
|
((FAIL++))
|
||||||
|
fi
|
||||||
|
|
||||||
|
test_case "6.3: Delete User"
|
||||||
|
# First, get a user to delete
|
||||||
|
OUTPUT=$($CHESSD_EXEC db user add -path "$TEST_DB" -username "deleteme" \
|
||||||
|
-password "TempPass123" 2>&1)
|
||||||
|
TEMP_ID=$(echo "$OUTPUT" | grep "ID:" | awk '{print $2}')
|
||||||
|
|
||||||
|
assert_command "$CHESSD_EXEC db user delete -path $TEST_DB -username deleteme" 0 \
|
||||||
|
"User deletion by username"
|
||||||
|
|
||||||
|
# Verify deletion
|
||||||
|
USER_EXISTS=$(sqlite3 "$TEST_DB" "SELECT COUNT(*) FROM users WHERE user_id = '$TEMP_ID';" 2>/dev/null)
|
||||||
|
if [ "$USER_EXISTS" = "0" ]; then
|
||||||
|
echo -e "${GREEN} ✓ User successfully deleted from database${NC}"
|
||||||
|
((PASS++))
|
||||||
|
else
|
||||||
|
echo -e "${RED} ✗ User still exists after deletion${NC}"
|
||||||
|
((FAIL++))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
@ -494,7 +467,7 @@ echo -e "Success Rate: ${SUCCESS_RATE}%"
|
|||||||
echo -e "${CYAN}══════════════════════════════════════${NC}"
|
echo -e "${CYAN}══════════════════════════════════════${NC}"
|
||||||
|
|
||||||
if [ $FAIL -eq 0 ]; then
|
if [ $FAIL -eq 0 ]; then
|
||||||
echo -e "\n${GREEN}🎉 All database tests passed!${NC}"
|
echo -e "\n${GREEN}🎉 All database and user tests passed!${NC}"
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
echo -e "\n${RED}⚠️ Some tests failed. Review the output above.${NC}"
|
echo -e "\n${RED}⚠️ Some tests failed. Review the output above.${NC}"
|
||||||
|
|||||||
@ -1,272 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# FILE: test-player-config.sh
|
|
||||||
|
|
||||||
# Player Configuration Deep Test Suite
|
|
||||||
# Tests all aspects of player configuration changes mid-game
|
|
||||||
# Debug-focused: prints full responses for analysis
|
|
||||||
|
|
||||||
BASE_URL="http://localhost:8080"
|
|
||||||
API_URL="${BASE_URL}/api/v1"
|
|
||||||
|
|
||||||
# Colors
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
MAGENTA='\033[0;35m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
# Helper to pretty-print JSON
|
|
||||||
print_json() {
|
|
||||||
local label=$1
|
|
||||||
local json=$2
|
|
||||||
echo -e "${CYAN}>>> $label:${NC}"
|
|
||||||
echo "$json" | jq '.' 2>/dev/null || echo "$json"
|
|
||||||
echo ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Helper to extract and display specific fields
|
|
||||||
show_players() {
|
|
||||||
local json=$1
|
|
||||||
local white_type=$(echo "$json" | jq -r '.players.white.type' 2>/dev/null)
|
|
||||||
local black_type=$(echo "$json" | jq -r '.players.black.type' 2>/dev/null)
|
|
||||||
local white_id=$(echo "$json" | jq -r '.players.white.id' 2>/dev/null)
|
|
||||||
local black_id=$(echo "$json" | jq -r '.players.black.id' 2>/dev/null)
|
|
||||||
|
|
||||||
echo -e "${YELLOW}Players State:${NC}"
|
|
||||||
echo " White: type=$white_type, id=$white_id"
|
|
||||||
echo " Black: type=$black_type, id=$black_id"
|
|
||||||
}
|
|
||||||
|
|
||||||
# API request wrapper
|
|
||||||
api_request() {
|
|
||||||
local method=$1
|
|
||||||
local url=$2
|
|
||||||
shift 2
|
|
||||||
curl -s "$@" -X "$method" "$url"
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_pending() {
|
|
||||||
local game_id=$1
|
|
||||||
local max_wait=3
|
|
||||||
local waited=0
|
|
||||||
|
|
||||||
while [ $waited -lt $max_wait ]; do
|
|
||||||
local response=$(api_request GET "$API_URL/games/$game_id")
|
|
||||||
local state=$(echo "$response" | jq -r '.state' 2>/dev/null)
|
|
||||||
if [ "$state" != "Pending" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 0.2
|
|
||||||
waited=$((waited + 1))
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
|
||||||
echo -e "${CYAN}Player Configuration Deep Test Suite${NC}"
|
|
||||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
|
||||||
|
|
||||||
# Test 1: Basic player type changes
|
|
||||||
echo -e "\n${GREEN}TEST 1: Create H-v-C, immediately change to C-v-H${NC}"
|
|
||||||
echo "------------------------------------------------------"
|
|
||||||
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 5}}')
|
|
||||||
GAME1_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
print_json "Initial H-v-C game created" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Change configuration
|
|
||||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME1_ID/players" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 2, "level": 10}, "black": {"type": 1}}')
|
|
||||||
|
|
||||||
print_json "After configuration change (should be C-v-H)" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Verify with GET
|
|
||||||
RESPONSE=$(api_request GET "$API_URL/games/$GAME1_ID")
|
|
||||||
print_json "GET verification" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
api_request DELETE "$API_URL/games/$GAME1_ID" > /dev/null
|
|
||||||
|
|
||||||
# Test 2: Change during active game
|
|
||||||
echo -e "\n${GREEN}TEST 2: H-v-H game with moves, then change to H-v-C${NC}"
|
|
||||||
echo "------------------------------------------------------"
|
|
||||||
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
|
||||||
GAME2_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
print_json "Initial H-v-H game" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Make some moves
|
|
||||||
echo -e "\n${BLUE}Making moves: e2e4, e7e5, g1f3${NC}"
|
|
||||||
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games/$GAME2_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "e2e4"}')
|
|
||||||
echo "Move 1 (e2e4): $(echo "$RESPONSE" | jq -r '.state' 2>/dev/null)"
|
|
||||||
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games/$GAME2_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "e7e5"}')
|
|
||||||
echo "Move 2 (e7e5): $(echo "$RESPONSE" | jq -r '.state' 2>/dev/null)"
|
|
||||||
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games/$GAME2_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "g1f3"}')
|
|
||||||
echo "Move 3 (g1f3): $(echo "$RESPONSE" | jq -r '.state' 2>/dev/null)"
|
|
||||||
|
|
||||||
# Get current state
|
|
||||||
RESPONSE=$(api_request GET "$API_URL/games/$GAME2_ID")
|
|
||||||
print_json "Game state after 3 moves" "$RESPONSE"
|
|
||||||
echo "Move history: $(echo "$RESPONSE" | jq -r '.moves' 2>/dev/null)"
|
|
||||||
|
|
||||||
# Change to H-v-C
|
|
||||||
echo -e "\n${BLUE}Changing configuration to H-v-C${NC}"
|
|
||||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME2_ID/players" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 8, "searchTime": 200}}')
|
|
||||||
|
|
||||||
print_json "After config change (should be H-v-C)" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Trigger computer move
|
|
||||||
echo -e "\n${BLUE}Triggering computer move (black)${NC}"
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games/$GAME2_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "cccc"}')
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | jq -r '.state' 2>/dev/null | grep -q "Pending"; then
|
|
||||||
echo "Computer move triggered, waiting..."
|
|
||||||
wait_for_pending "$GAME2_ID"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Get final state with history
|
|
||||||
RESPONSE=$(api_request GET "$API_URL/games/$GAME2_ID")
|
|
||||||
print_json "Final game state with computer move" "$RESPONSE"
|
|
||||||
echo -e "${MAGENTA}Complete move history: $(echo "$RESPONSE" | jq -r '.moves' 2>/dev/null)${NC}"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
api_request DELETE "$API_URL/games/$GAME2_ID" > /dev/null
|
|
||||||
|
|
||||||
# Test 3: Multiple configuration changes
|
|
||||||
echo -e "\n${GREEN}TEST 3: Multiple configuration changes${NC}"
|
|
||||||
echo "------------------------------------------------------"
|
|
||||||
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 2, "level": 15}, "black": {"type": 2, "level": 15}}')
|
|
||||||
GAME3_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
print_json "Initial C-v-C game" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Change 1: C-v-C to H-v-H
|
|
||||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME3_ID/players" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
|
||||||
print_json "Change 1: Now H-v-H" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Change 2: H-v-H to H-v-C
|
|
||||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME3_ID/players" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 2, "level": 20}}')
|
|
||||||
print_json "Change 2: Now H-v-C" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Change 3: H-v-C to C-v-H
|
|
||||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME3_ID/players" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 2, "level": 1}, "black": {"type": 1}}')
|
|
||||||
print_json "Change 3: Now C-v-H" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Final verification
|
|
||||||
RESPONSE=$(api_request GET "$API_URL/games/$GAME3_ID")
|
|
||||||
print_json "Final GET verification" "$RESPONSE"
|
|
||||||
show_players "$RESPONSE"
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
api_request DELETE "$API_URL/games/$GAME3_ID" > /dev/null
|
|
||||||
|
|
||||||
# Test 4: Error cases
|
|
||||||
echo -e "\n${GREEN}TEST 4: Error handling${NC}"
|
|
||||||
echo "------------------------------------------------------"
|
|
||||||
|
|
||||||
# Try to change during pending state
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 2, "searchTime": 500}}')
|
|
||||||
GAME4_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
# Make human move
|
|
||||||
api_request POST "$API_URL/games/$GAME4_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "e2e4"}' > /dev/null
|
|
||||||
|
|
||||||
# Trigger computer move
|
|
||||||
api_request POST "$API_URL/games/$GAME4_ID/moves" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"move": "cccc"}' > /dev/null
|
|
||||||
|
|
||||||
# Immediately try to change config (should fail)
|
|
||||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME4_ID/players" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 2}, "black": {"type": 1}}')
|
|
||||||
|
|
||||||
print_json "Config change during Pending (should error)" "$RESPONSE"
|
|
||||||
|
|
||||||
wait_for_pending "$GAME4_ID"
|
|
||||||
api_request DELETE "$API_URL/games/$GAME4_ID" > /dev/null
|
|
||||||
|
|
||||||
# Test 5: Verify player IDs change
|
|
||||||
echo -e "\n${GREEN}TEST 5: Verify player IDs change on reconfiguration${NC}"
|
|
||||||
echo "------------------------------------------------------"
|
|
||||||
|
|
||||||
RESPONSE=$(api_request POST "$API_URL/games" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
|
||||||
GAME5_ID=$(echo "$RESPONSE" | jq -r '.gameId' 2>/dev/null)
|
|
||||||
|
|
||||||
WHITE_ID_1=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
|
|
||||||
BLACK_ID_1=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null)
|
|
||||||
echo "Initial IDs: White=$WHITE_ID_1, Black=$BLACK_ID_1"
|
|
||||||
|
|
||||||
# Change configuration (even to same types)
|
|
||||||
RESPONSE=$(api_request PUT "$API_URL/games/$GAME5_ID/players" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"white": {"type": 1}, "black": {"type": 1}}')
|
|
||||||
|
|
||||||
WHITE_ID_2=$(echo "$RESPONSE" | jq -r '.players.white.id' 2>/dev/null)
|
|
||||||
BLACK_ID_2=$(echo "$RESPONSE" | jq -r '.players.black.id' 2>/dev/null)
|
|
||||||
echo "After reconfig: White=$WHITE_ID_2, Black=$BLACK_ID_2"
|
|
||||||
|
|
||||||
if [ "$WHITE_ID_1" != "$WHITE_ID_2" ]; then
|
|
||||||
echo -e "${GREEN}✓ White player ID changed (expected)${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ White player ID unchanged (unexpected)${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$BLACK_ID_1" != "$BLACK_ID_2" ]; then
|
|
||||||
echo -e "${GREEN}✓ Black player ID changed (expected)${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗ Black player ID unchanged (unexpected)${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
api_request DELETE "$API_URL/games/$GAME5_ID" > /dev/null
|
|
||||||
|
|
||||||
echo -e "\n${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
|
||||||
echo -e "${CYAN}Test Complete${NC}"
|
|
||||||
echo -e "${CYAN}═══════════════════════════════════════════════════════════${NC}"
|
|
||||||
Reference in New Issue
Block a user