v0.5.0 user support with auth added, tests and doc updated

This commit is contained in:
2025-11-05 02:56:41 -05:00
parent 59486bfe32
commit a3f4db96fa
25 changed files with 2409 additions and 1172 deletions

View File

@ -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

View File

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

View File

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

View File

@ -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).

View File

@ -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

View File

@ -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
View File

@ -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
View File

@ -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
View 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,
})
}

View File

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

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

View File

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

View File

@ -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
View 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
}

View File

@ -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
View 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
View 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
}

View File

@ -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,

View File

@ -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
View 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
}
}

View File

@ -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.

View File

@ -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
View 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

View File

@ -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}"

View File

@ -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}"