v0.11.0 harden persistence and prepare game replays
This commit is contained in:
@@ -91,9 +91,14 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
}
|
||||
|
||||
// Create user (temp by default via API)
|
||||
user, err := h.svc.CreateUser(req.Username, req.Email, req.Password, false)
|
||||
// Create the user and initial session atomically (temp by default via API).
|
||||
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrPermanentSlotsFull) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "registration temporarily unavailable",
|
||||
@@ -114,15 +119,6 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Create session for new user
|
||||
sessionID, err := h.svc.CreateUserSession(user.UserID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to create session",
|
||||
Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
token, err := h.svc.GenerateUserToken(user.UserID, sessionID)
|
||||
if err != nil {
|
||||
@@ -193,6 +189,11 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
|
||||
// Authenticate user and create session (invalidates previous session)
|
||||
user, sessionID, err := h.svc.AuthenticateUser(req.Identifier, req.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid credentials",
|
||||
Code: core.ErrInvalidRequest,
|
||||
@@ -263,4 +264,3 @@ func (h *HTTPHandler) LogoutHandler(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(fiber.Map{"message": "logged out"})
|
||||
}
|
||||
|
||||
|
||||
+104
-10
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -29,7 +30,7 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle
|
||||
return &HTTPHandler{proc: proc, svc: svc}
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc, svc)
|
||||
|
||||
@@ -43,9 +44,13 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
|
||||
|
||||
// Global middleware (order matters)
|
||||
app.Use(recover.New())
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} ${status} ${method} ${path} ${latency}\n",
|
||||
}))
|
||||
if logRequests {
|
||||
app.Use(logger.New(logger.Config{
|
||||
Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n",
|
||||
TimeFormat: time.RFC3339,
|
||||
TimeZone: "UTC",
|
||||
}))
|
||||
}
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
||||
@@ -136,12 +141,14 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool)
|
||||
|
||||
// Register game routes with auth middleware
|
||||
api.Post("/games", OptionalAuth(validateToken), h.CreateGame) // Optional auth for player ID association
|
||||
api.Get("/games/:gameId/history", h.GetGameHistory)
|
||||
api.Put("/games/:gameId/players", h.ConfigurePlayers)
|
||||
api.Get("/games/:gameId", h.GetGame)
|
||||
api.Delete("/games/:gameId", h.DeleteGame)
|
||||
api.Post("/games/:gameId/moves", OptionalAuth(validateToken), h.MakeMove)
|
||||
api.Post("/games/:gameId/undo", h.UndoMove)
|
||||
api.Get("/games/:gameId/board", h.GetBoard)
|
||||
api.Get("/users/me/games", AuthRequired(validateToken), h.GetCurrentUserGames)
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -191,10 +198,15 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
|
||||
|
||||
// Health check endpoint with storage status
|
||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
storageHealth := h.svc.GetStorageHealth()
|
||||
status := "healthy"
|
||||
if storageHealth == "degraded" {
|
||||
status = "degraded"
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"status": status,
|
||||
"time": time.Now().Unix(),
|
||||
"storage": h.svc.GetStorageHealth(),
|
||||
"storage": storageHealth,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -324,7 +336,7 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// First check if game exists and get current state
|
||||
g, err := h.svc.GetGame(gameID)
|
||||
g, err := h.svc.GetGameView(gameID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
||||
Error: "game not found",
|
||||
@@ -332,8 +344,8 @@ func (h *HTTPHandler) GetGame(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
currentMoveCount := len(g.Moves())
|
||||
st := g.State()
|
||||
currentMoveCount := len(g.Moves)
|
||||
st := g.State
|
||||
settled := st != core.StateOngoing && st != core.StatePending
|
||||
// If move count already different, return immediately
|
||||
if moveCount != currentMoveCount || settled {
|
||||
@@ -414,6 +426,8 @@ func (h *HTTPHandler) MakeMove(c *fiber.Ctx) error {
|
||||
statusCode = fiber.StatusNotFound
|
||||
case core.ErrUnauthorized:
|
||||
statusCode = fiber.StatusForbidden
|
||||
case core.ErrConflict:
|
||||
statusCode = fiber.StatusConflict
|
||||
}
|
||||
return c.Status(statusCode).JSON(resp.Error)
|
||||
}
|
||||
@@ -470,7 +484,7 @@ func (h *HTTPHandler) UndoMove(c *fiber.Ctx) error {
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// DeleteGame ends and cleans up a game
|
||||
// DeleteGame unloads a live game while retaining its durable history.
|
||||
func (h *HTTPHandler) DeleteGame(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
|
||||
@@ -519,3 +533,83 @@ func (h *HTTPHandler) GetBoard(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(resp.Data)
|
||||
}
|
||||
|
||||
// GetGameHistory serves persisted replay data. Histories are public by game ID,
|
||||
// matching the existing public live-game read model.
|
||||
func (h *HTTPHandler) GetGameHistory(c *fiber.Ctx) error {
|
||||
gameID := c.Params("gameId")
|
||||
if !isValidUUID(gameID) {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid game ID format", Code: core.ErrInvalidRequest,
|
||||
Details: "game ID must be a valid UUID",
|
||||
})
|
||||
}
|
||||
|
||||
history, err := h.svc.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrStorageDisabled), errors.Is(err, service.ErrStorageUnavailable):
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "game history storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
case errors.Is(err, service.ErrGameNotFound):
|
||||
return c.Status(fiber.StatusNotFound).JSON(core.ErrorResponse{
|
||||
Error: "game history not found", Code: core.ErrGameNotFound,
|
||||
})
|
||||
default:
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to load game history", Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.JSON(history)
|
||||
}
|
||||
|
||||
// GetCurrentUserGames returns a bounded list suitable for CLI and web game
|
||||
// pickers. The extra row used to compute nextOffset stays internal.
|
||||
func (h *HTTPHandler) GetCurrentUserGames(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.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
limit, err := queryInt(c, "limit", 50, 1, 100)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
|
||||
})
|
||||
}
|
||||
offset, err := queryInt(c, "offset", 0, 0, 1_000_000)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(core.ErrorResponse{
|
||||
Error: "invalid pagination", Code: core.ErrInvalidRequest, Details: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
games, err := h.svc.GetUserGames(userID, limit, offset)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "stored games unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(core.ErrorResponse{
|
||||
Error: "failed to load stored games", Code: core.ErrInternalError,
|
||||
})
|
||||
}
|
||||
return c.JSON(games)
|
||||
}
|
||||
|
||||
func queryInt(c *fiber.Ctx, name string, defaultValue, minimum, maximum int) (int, error) {
|
||||
raw := c.Query(name)
|
||||
if raw == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < minimum || value > maximum {
|
||||
return 0, fmt.Errorf("%s must be between %d and %d", name, minimum, maximum)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
@@ -18,15 +20,20 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "missing authorization token",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
userID, claims, err := validateToken(token)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token",
|
||||
Code: core.ErrInvalidRequest,
|
||||
Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +45,8 @@ func AuthRequired(validateToken TokenValidator) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalAuth validates JWT if present but allows anonymous access
|
||||
// OptionalAuth permits an absent token but rejects an invalid token instead of
|
||||
// silently downgrading an intended authenticated request to anonymous access.
|
||||
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
token := extractBearerToken(c.Get("Authorization"))
|
||||
@@ -47,11 +55,20 @@ func OptionalAuth(validateToken TokenValidator) fiber.Handler {
|
||||
}
|
||||
|
||||
userID, claims, err := validateToken(token)
|
||||
if err == nil {
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
|
||||
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
|
||||
})
|
||||
}
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
|
||||
Error: "invalid or expired token", Code: core.ErrUnauthorized,
|
||||
})
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
if sessionID, ok := claims["session_id"].(string); ok {
|
||||
c.Locals("sessionID", sessionID)
|
||||
}
|
||||
return c.Next()
|
||||
}
|
||||
@@ -64,4 +81,4 @@ func extractBearerToken(header string) string {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(header, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"chess/internal/server/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
func TestOptionalAuthAllowsAbsenceButRejectsInvalidToken(t *testing.T) {
|
||||
app := fiber.New()
|
||||
app.Get("/optional", OptionalAuth(func(string) (string, map[string]any, error) {
|
||||
return "", nil, errors.New("invalid token")
|
||||
}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
})
|
||||
|
||||
request := httptest.NewRequest("GET", "/optional", nil)
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != fiber.StatusNoContent {
|
||||
t.Fatalf("anonymous status = %d, want %d", response.StatusCode, fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
request = httptest.NewRequest("GET", "/optional", nil)
|
||||
request.Header.Set("Authorization", "Bearer invalid")
|
||||
response, err = app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != fiber.StatusUnauthorized {
|
||||
t.Fatalf("invalid-token status = %d, want %d", response.StatusCode, fiber.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddlewareReportsStorageUnavailable(t *testing.T) {
|
||||
for _, middleware := range []func(TokenValidator) fiber.Handler{AuthRequired, OptionalAuth} {
|
||||
app := fiber.New()
|
||||
app.Get("/protected", middleware(func(string) (string, map[string]any, error) {
|
||||
return "", nil, service.ErrStorageUnavailable
|
||||
}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
})
|
||||
|
||||
request := httptest.NewRequest("GET", "/protected", nil)
|
||||
request.Header.Set("Authorization", "Bearer token")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.StatusCode != fiber.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", response.StatusCode, fiber.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user