diff --git a/backend/services/admin-service/cmd/main.go b/backend/services/admin-service/cmd/main.go new file mode 100644 index 0000000..735e8e3 --- /dev/null +++ b/backend/services/admin-service/cmd/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func main() { + app := fiber.New(fiber.Config{ + AppName: "Know Foolery Admin Service v1.0.0", + ServerHeader: "Admin Service", + }) + + // Middleware + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path} ${latency}\n", + })) + app.Use(cors.New()) + + // Health endpoint + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "admin-service", + "version": "1.0.0", + }) + }) + + // API routes + api := app.Group("/api/v1") + api.Get("/admin", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "message": "Admin service endpoint", + "admin": true, + }) + }) + + // Graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("Gracefully shutting down Admin Service...") + _ = app.Shutdown() + }() + + port := os.Getenv("PORT") + if port == "" { + port = "3006" + } + + log.Printf("Admin Service starting on port %s", port) + if err := app.Listen(":" + port); err != nil { + log.Printf("Error starting server: %v", err) + } +} \ No newline at end of file diff --git a/backend/services/admin-service/go.mod b/backend/services/admin-service/go.mod new file mode 100644 index 0000000..5c4168e --- /dev/null +++ b/backend/services/admin-service/go.mod @@ -0,0 +1,21 @@ +module knowfoolery/backend/services/admin-service + +go 1.21 + +require github.com/gofiber/fiber/v2 v2.52.0 + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.15.0 // indirect +) + +replace knowfoolery/backend/shared => ../../shared diff --git a/backend/services/admin-service/go.sum b/backend/services/admin-service/go.sum new file mode 100644 index 0000000..caafe41 --- /dev/null +++ b/backend/services/admin-service/go.sum @@ -0,0 +1,27 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/backend/services/game-service/Dockerfile b/backend/services/game-service/Dockerfile new file mode 100644 index 0000000..f0546cc --- /dev/null +++ b/backend/services/game-service/Dockerfile @@ -0,0 +1,53 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git ca-certificates tzdata + +# Set working directory +WORKDIR /build + +# Copy go mod files +COPY go.mod go.sum ./ +COPY ../../shared /build/shared + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o game-service ./cmd + +# Final stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates curl + +# Create app directory +WORKDIR /app + +# Copy the binary from builder stage +COPY --from=builder /build/game-service . + +# Create non-root user +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +# Change ownership +RUN chown -R appuser:appgroup /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3001/health || exit 1 + +# Run the application +CMD ["./game-service"] \ No newline at end of file diff --git a/backend/services/game-service/cmd/main.go b/backend/services/game-service/cmd/main.go new file mode 100644 index 0000000..06bb6bd --- /dev/null +++ b/backend/services/game-service/cmd/main.go @@ -0,0 +1,217 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" + "knowfoolery/backend/shared/auth" + "knowfoolery/backend/shared/database" + "knowfoolery/backend/shared/utils" + "knowfoolery/backend/services/game-service/internal/services" +) + +func main() { + app := fiber.New(fiber.Config{ + AppName: "Know Foolery Game Service v1.0.0", + ServerHeader: "Game Service", + }) + + // Initialize database connection + databaseURL := utils.GetEnvOrDefault("DATABASE_URL", "sqlite://./game_service.db") + + var dbConfig database.Config + if strings.HasPrefix(databaseURL, "postgres") { + dbConfig = database.Config{ + Driver: "postgres", + Host: utils.GetEnvOrDefault("DB_HOST", "localhost"), + Port: utils.GetEnvOrDefaultInt("DB_PORT", 5432), + User: utils.GetEnvOrDefault("DB_USER", "knowfoolery"), + Password: utils.GetEnvOrDefault("DB_PASSWORD", "dev-password-2024"), + Database: utils.GetEnvOrDefault("DB_NAME", "knowfoolery"), + SSLMode: utils.GetEnvOrDefault("DB_SSLMODE", "disable"), + } + } else { + dbConfig = database.Config{ + Driver: "sqlite3", + Database: utils.GetEnvOrDefault("SQLITE_PATH", "./game_service.db"), + } + } + + db, err := database.NewClient(dbConfig) + if err != nil { + log.Fatal("Failed to connect to database:", err) + } + defer db.Close() + + // Create tables if they don't exist + if err := db.CreateTables(context.Background()); err != nil { + log.Fatal("Failed to create database tables:", err) + } + + log.Println("Database connected successfully") + + // Initialize services + gameService := services.NewGameService(db) + + // Initialize mock auth service + authService, err := auth.NewMockAuthService("knowfoolery-game-service") + if err != nil { + log.Fatal("Failed to create auth service:", err) + } + middleware := auth.NewJWTMiddleware(authService) + + // Create mock tokens for testing + mockTokens := authService.CreateMockUsers() + log.Printf("Mock tokens created:") + for user, token := range mockTokens { + log.Printf(" %s: Bearer %s", user, token) + } + + // Middleware + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path} ${latency}\n", + })) + app.Use(cors.New()) + + // Health endpoint (no auth required) + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "game-service", + "version": "1.0.0", + }) + }) + + // Auth endpoints + app.Post("/auth/tokens", func(c *fiber.Ctx) error { + tokens := authService.CreateMockUsers() + return c.JSON(fiber.Map{ + "message": "Mock tokens for development", + "tokens": tokens, + }) + }) + + // Protected API routes + api := app.Group("/api/v1") + api.Use(middleware.Optional()) // Optional auth for most endpoints + + api.Get("/games", func(c *fiber.Ctx) error { + user, err := auth.GetUserFromContext(c) + if err != nil { + // Not authenticated, return public info + return c.JSON(fiber.Map{ + "message": "Game service endpoint", + "games": []string{}, + "user": "anonymous", + }) + } + + // Authenticated, return user-specific info + return c.JSON(fiber.Map{ + "message": "Game service endpoint", + "games": []string{}, + "user": user.Username, + "roles": user.Roles, + }) + }) + + // Database test endpoints + api.Get("/questions/random", func(c *fiber.Ctx) error { + theme := c.Query("theme") + difficulty := c.Query("difficulty") + + question, err := gameService.GetRandomQuestion(c.Context(), theme, difficulty) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "question": question, + }) + }) + + api.Post("/sessions", func(c *fiber.Ctx) error { + var req struct { + PlayerName string `json:"player_name"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{ + "error": "Invalid request body", + }) + } + + // Get user ID if authenticated + var userID *string + if user, err := auth.GetUserFromContext(c); err == nil { + userID = &user.UserID + } + + session, err := gameService.CreateSession(c.Context(), req.PlayerName, userID) + if err != nil { + return c.Status(500).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "session": session, + }) + }) + + api.Get("/sessions/:id", func(c *fiber.Ctx) error { + sessionID := c.Params("id") + + session, err := gameService.GetSessionByID(c.Context(), sessionID) + if err != nil { + return c.Status(404).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return c.JSON(fiber.Map{ + "session": session, + }) + }) + + // Admin-only endpoints + adminAPI := api.Group("/admin") + adminAPI.Use(middleware.Authenticate()) + adminAPI.Use(middleware.RequireRole("admin")) + + adminAPI.Get("/stats", func(c *fiber.Ctx) error { + user, _ := auth.GetUserFromContext(c) + return c.JSON(fiber.Map{ + "message": "Admin stats endpoint", + "admin_user": user.Username, + "total_games": 42, + "active_users": 15, + }) + }) + + // Graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("Gracefully shutting down Game Service...") + _ = app.Shutdown() + }() + + port := utils.GetEnvOrDefault("PORT", "3001") + + log.Printf("Game Service starting on port %s", port) + if err := app.Listen(":" + port); err != nil { + log.Printf("Error starting server: %v", err) + } +} \ No newline at end of file diff --git a/backend/services/game-service/game-service b/backend/services/game-service/game-service new file mode 100755 index 0000000..c56887a Binary files /dev/null and b/backend/services/game-service/game-service differ diff --git a/backend/services/game-service/game-service-test b/backend/services/game-service/game-service-test new file mode 100755 index 0000000..c56887a Binary files /dev/null and b/backend/services/game-service/game-service-test differ diff --git a/backend/services/game-service/game_service.db b/backend/services/game-service/game_service.db new file mode 100644 index 0000000..b07cb4e Binary files /dev/null and b/backend/services/game-service/game_service.db differ diff --git a/backend/services/game-service/go.mod b/backend/services/game-service/go.mod new file mode 100644 index 0000000..0a8d499 --- /dev/null +++ b/backend/services/game-service/go.mod @@ -0,0 +1,74 @@ +module knowfoolery/backend/services/game-service + +go 1.21 + +require ( + github.com/gofiber/fiber/v2 v2.52.0 + github.com/testcontainers/testcontainers-go v0.28.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.28.0 + knowfoolery/backend/shared v0.0.0 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Microsoft/hcsshim v0.11.4 // indirect + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/containerd/containerd v1.7.12 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/cpuguy83/dockercfg v0.3.1 // indirect + github.com/distribution/reference v0.5.0 // indirect + github.com/docker/docker v25.0.2+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.3 // indirect + github.com/go-logr/logr v1.2.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-sqlite3 v1.14.19 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc5 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/shirou/gopsutil/v3 v3.23.12 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 // indirect + go.opentelemetry.io/otel v1.19.0 // indirect + go.opentelemetry.io/otel/metric v1.19.0 // indirect + go.opentelemetry.io/otel/trace v1.19.0 // indirect + golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect + golang.org/x/mod v0.11.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/tools v0.10.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/grpc v1.58.3 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) + +replace knowfoolery/backend/shared => ../../shared diff --git a/backend/services/game-service/go.sum b/backend/services/game-service/go.sum new file mode 100644 index 0000000..1097c1a --- /dev/null +++ b/backend/services/game-service/go.sum @@ -0,0 +1,226 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= +github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/containerd v1.7.12 h1:+KQsnv4VnzyxWcfO9mlxxELaoztsDEjOuCMPAuPqgU0= +github.com/containerd/containerd v1.7.12/go.mod h1:/5OMpE1p0ylxtEUGY8kuCYkDRzJm9NO1TFMWjUpdevk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= +github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v25.0.2+incompatible h1:/OaKeauroa10K4Nqavw4zlhcDq/WBcPMc5DbjOGgozY= +github.com/docker/docker v25.0.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.3 h1:Ces6/M3wbDXYpM8JyyPD57ivTtJACFZJd885pdIaV2s= +github.com/jackc/pgx/v5 v5.5.3/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI= +github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/testcontainers/testcontainers-go v0.28.0 h1:1HLm9qm+J5VikzFDYhOd+Zw12NtOl+8drH2E8nTY1r8= +github.com/testcontainers/testcontainers-go v0.28.0/go.mod h1:COlDpUXbwW3owtpMkEB1zo9gwb1CoKVKlyrVPejF4AU= +github.com/testcontainers/testcontainers-go/modules/postgres v0.28.0 h1:ff0s4JdYIdNAVSi/SrpN2Pdt1f+IjIw3AKjbHau8Un4= +github.com/testcontainers/testcontainers-go/modules/postgres v0.28.0/go.mod h1:fXgcYpbyrduNdiz2qRZuYkmvqLnEqsjbQiBNYH1ystI= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0 h1:x8Z78aZx8cOF0+Kkazoc7lwUNMGy0LrzEMxTm4BbTxg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.45.0/go.mod h1:62CPTSry9QZtOaSsE3tOzhx6LzDhHnXJ6xHeMNNiM6Q= +go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs= +go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE= +go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8= +go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= +go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= +go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg= +go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= +golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= +golang.org/x/tools v0.10.0/go.mod h1:UJwyiVBsOA2uwvK/e5OY3GTpDUJriEd+/YlqAwLPmyM= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 h1:Z0hjGZePRE0ZBWotvtrwxFNrNE9CUAGtplaDK5NNI/g= +google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 h1:FmF5cCW94Ij59cfpoLiwTgodWmm60eEV0CjlsVg2fuw= +google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ= +google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/backend/services/game-service/internal/services/game.go b/backend/services/game-service/internal/services/game.go new file mode 100644 index 0000000..994630e --- /dev/null +++ b/backend/services/game-service/internal/services/game.go @@ -0,0 +1,100 @@ +package services + +import ( + "context" + "fmt" + "time" + + "knowfoolery/backend/shared/database" +) + +// GameService handles game-related operations +type GameService struct { + db *database.Client + questionRepo *database.QuestionRepository +} + +// NewGameService creates a new game service +func NewGameService(db *database.Client) *GameService { + return &GameService{ + db: db, + questionRepo: database.NewQuestionRepository(db), + } +} + +// GetRandomQuestion returns a random question for the game +func (s *GameService) GetRandomQuestion(ctx context.Context, theme string, difficulty string) (*database.Question, error) { + return s.questionRepo.GetRandom(ctx, theme, difficulty) +} + +// CreateSession creates a new game session +func (s *GameService) CreateSession(ctx context.Context, playerName string, userID *string) (*database.GameSession, error) { + session := &database.GameSession{ + ID: fmt.Sprintf("session-%d", time.Now().UnixNano()), + PlayerName: playerName, + UserID: userID, + TotalScore: 0, + QuestionsAsked: 0, + QuestionsCorrect: 0, + HintsUsed: 0, + StartTime: time.Now(), + Status: "active", + CurrentAttempts: 0, + } + + query := ` + INSERT INTO game_sessions (id, player_name, user_id, total_score, questions_asked, questions_correct, hints_used, start_time, status, current_attempts) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ` + + _, err := s.db.DB().ExecContext(ctx, query, + session.ID, + session.PlayerName, + session.UserID, + session.TotalScore, + session.QuestionsAsked, + session.QuestionsCorrect, + session.HintsUsed, + session.StartTime, + session.Status, + session.CurrentAttempts, + ) + + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + + return session, nil +} + +// GetSessionByID retrieves a game session by ID +func (s *GameService) GetSessionByID(ctx context.Context, sessionID string) (*database.GameSession, error) { + query := ` + SELECT id, player_name, user_id, total_score, questions_asked, questions_correct, hints_used, start_time, end_time, status, current_question_id, current_attempts, session_data + FROM game_sessions + WHERE id = $1 + ` + + var session database.GameSession + err := s.db.DB().QueryRowContext(ctx, query, sessionID).Scan( + &session.ID, + &session.PlayerName, + &session.UserID, + &session.TotalScore, + &session.QuestionsAsked, + &session.QuestionsCorrect, + &session.HintsUsed, + &session.StartTime, + &session.EndTime, + &session.Status, + &session.CurrentQuestionID, + &session.CurrentAttempts, + &session.SessionData, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get session: %w", err) + } + + return &session, nil +} \ No newline at end of file diff --git a/backend/services/game-service/tests/basic_test.go b/backend/services/game-service/tests/basic_test.go new file mode 100644 index 0000000..6a7198a --- /dev/null +++ b/backend/services/game-service/tests/basic_test.go @@ -0,0 +1,217 @@ +package tests + +import ( + "context" + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" + "knowfoolery/backend/shared/auth" + "knowfoolery/backend/shared/database" + "knowfoolery/backend/services/game-service/internal/services" +) + +// TestBasicHealthEndpoint tests the health endpoint without containers +func TestBasicHealthEndpoint(t *testing.T) { + app := fiber.New(fiber.Config{ + DisableStartupMessage: true, + }) + + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "game-service", + }) + }) + + req, err := http.NewRequest("GET", "/health", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Failed to test request: %v", err) + } + + if resp.StatusCode != 200 { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } +} + +// TestSQLiteDatabase tests SQLite database functionality +func TestSQLiteDatabase(t *testing.T) { + // Use in-memory SQLite database + dbConfig := database.Config{ + Driver: "sqlite3", + Database: ":memory:", + } + + db, err := database.NewClient(dbConfig) + if err != nil { + t.Fatalf("Failed to create database client: %v", err) + } + defer db.Close() + + ctx := context.Background() + + // Test database health + if err := db.Health(ctx); err != nil { + t.Errorf("Database health check failed: %v", err) + } + + // Create tables + if err := db.CreateTables(ctx); err != nil { + t.Fatalf("Failed to create tables: %v", err) + } + + t.Log("SQLite database test completed successfully") +} + +// TestMockAuthentication tests the mock authentication system +func TestMockAuthentication(t *testing.T) { + authService, err := auth.NewMockAuthService("test-service") + if err != nil { + t.Fatalf("Failed to create auth service: %v", err) + } + + // Create mock tokens + tokens := authService.CreateMockUsers() + + // Test admin token + ctx := context.Background() + adminClaims, err := authService.ValidateToken(ctx, tokens["admin"]) + if err != nil { + t.Errorf("Failed to validate admin token: %v", err) + } + + if adminClaims.UserID != "admin-1" { + t.Errorf("Expected admin user ID 'admin-1', got '%s'", adminClaims.UserID) + } + + // Verify admin role + hasAdminRole := false + for _, role := range adminClaims.Roles { + if role == "admin" { + hasAdminRole = true + break + } + } + + if !hasAdminRole { + t.Error("Admin token should have admin role") + } + + t.Log("Mock authentication test completed successfully") +} + +// TestGameServiceIntegration tests game service with SQLite +func TestGameServiceIntegration(t *testing.T) { + // Use in-memory SQLite database + dbConfig := database.Config{ + Driver: "sqlite3", + Database: ":memory:", + } + + db, err := database.NewClient(dbConfig) + if err != nil { + t.Fatalf("Failed to create database client: %v", err) + } + defer db.Close() + + ctx := context.Background() + + // Create tables + if err := db.CreateTables(ctx); err != nil { + t.Fatalf("Failed to create tables: %v", err) + } + + // Create game service + gameService := services.NewGameService(db) + + // Test creating a session + userID := "test-user-1" + session, err := gameService.CreateSession(ctx, "Test Player", &userID) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + if session.PlayerName != "Test Player" { + t.Errorf("Expected player name 'Test Player', got '%s'", session.PlayerName) + } + + // Test retrieving the session + retrievedSession, err := gameService.GetSessionByID(ctx, session.ID) + if err != nil { + t.Fatalf("Failed to retrieve session: %v", err) + } + + if retrievedSession.ID != session.ID { + t.Errorf("Retrieved session ID doesn't match") + } + + t.Log("Game service integration test completed successfully") +} + +// TestFiberWithAuth tests Fiber app with authentication middleware +func TestFiberWithAuth(t *testing.T) { + authService, err := auth.NewMockAuthService("test-service") + if err != nil { + t.Fatalf("Failed to create auth service: %v", err) + } + + middleware := auth.NewJWTMiddleware(authService) + tokens := authService.CreateMockUsers() + + app := fiber.New(fiber.Config{ + DisableStartupMessage: true, + }) + + // Add authenticated route + api := app.Group("/api") + api.Use(middleware.Optional()) + + api.Get("/test", func(c *fiber.Ctx) error { + user, err := auth.GetUserFromContext(c) + if err != nil { + return c.JSON(fiber.Map{"authenticated": false}) + } + return c.JSON(fiber.Map{ + "authenticated": true, + "user": user.Username, + }) + }) + + // Test without authentication + req, err := http.NewRequest("GET", "/api/test", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("Failed to test request: %v", err) + } + + if resp.StatusCode != 200 { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + // Test with authentication + authReq, err := http.NewRequest("GET", "/api/test", nil) + if err != nil { + t.Fatalf("Failed to create authenticated request: %v", err) + } + authReq.Header.Set("Authorization", "Bearer "+tokens["player"]) + + authResp, err := app.Test(authReq) + if err != nil { + t.Fatalf("Failed to test authenticated request: %v", err) + } + + if authResp.StatusCode != 200 { + t.Errorf("Expected status 200 for authenticated request, got %d", authResp.StatusCode) + } + + t.Log("Fiber authentication test completed successfully") +} \ No newline at end of file diff --git a/backend/services/game-service/tests/integration_test.go b/backend/services/game-service/tests/integration_test.go new file mode 100644 index 0000000..a6216ba --- /dev/null +++ b/backend/services/game-service/tests/integration_test.go @@ -0,0 +1,306 @@ +package tests + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "knowfoolery/backend/shared/auth" + "knowfoolery/backend/shared/database" + "knowfoolery/backend/services/game-service/internal/services" +) + +// TestEnvironment holds the test environment setup +type TestEnvironment struct { + DB *database.Client + AuthService *auth.MockAuthService + GameService *services.GameService + App *fiber.App + AdminToken string + PlayerToken string + ContainerCleanup func() +} + +// SetupTestEnvironment creates a test environment with PostgreSQL container +func SetupTestEnvironment(t *testing.T) *TestEnvironment { + ctx := context.Background() + + // Try to start PostgreSQL container (fallback to SQLite if fails) + var postgresContainer testcontainers.Container + postgresContainer, err := postgres.RunContainer(ctx, + testcontainers.WithImage("postgres:15-alpine"), + postgres.WithDatabase("knowfoolery_test"), + postgres.WithUsername("test_user"), + postgres.WithPassword("test_password"), + ) + if err != nil { + t.Logf("Failed to start postgres container, using SQLite: %v", err) + } + + // Create database client + dbConfig := database.Config{ + Driver: "postgres", + Host: "localhost", + Port: 5432, // Will be overridden by connection string parsing + User: "test_user", + Password: "test_password", + Database: "knowfoolery_test", + SSLMode: "disable", + } + + // For simplicity, create a simple connection (in real implementation, parse connStr) + db, err := database.NewClient(dbConfig) + if err != nil { + // If postgres fails, fallback to SQLite for testing + t.Logf("PostgreSQL connection failed, using SQLite: %v", err) + dbConfig = database.Config{ + Driver: "sqlite3", + Database: ":memory:", + } + db, err = database.NewClient(dbConfig) + if err != nil { + t.Fatalf("Failed to create database client: %v", err) + } + } + + // Create tables + if err := db.CreateTables(ctx); err != nil { + t.Fatalf("Failed to create tables: %v", err) + } + + // Initialize services + authService, err := auth.NewMockAuthService("test-service") + if err != nil { + t.Fatalf("Failed to create auth service: %v", err) + } + + gameService := services.NewGameService(db) + + // Create test tokens + tokens := authService.CreateMockUsers() + + // Create Fiber app for testing + app := fiber.New(fiber.Config{ + DisableStartupMessage: true, + }) + + // Add basic routes for testing + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "healthy"}) + }) + + // Cleanup function + cleanup := func() { + db.Close() + if postgresContainer != nil { + postgresContainer.Terminate(ctx) + } + } + + return &TestEnvironment{ + DB: db, + AuthService: authService, + GameService: gameService, + App: app, + AdminToken: tokens["admin"], + PlayerToken: tokens["player"], + ContainerCleanup: cleanup, + } +} + +// TestHealthEndpoint tests the health check endpoint +func TestHealthEndpoint(t *testing.T) { + env := SetupTestEnvironment(t) + defer env.ContainerCleanup() + + // Test health endpoint + req, err := http.NewRequest("GET", "/health", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := env.App.Test(req) + if err != nil { + t.Fatalf("Failed to test request: %v", err) + } + + if resp.StatusCode != 200 { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } +} + +// TestDatabaseConnection tests that we can connect to the database +func TestDatabaseConnection(t *testing.T) { + env := SetupTestEnvironment(t) + defer env.ContainerCleanup() + + // Test database health + ctx := context.Background() + if err := env.DB.Health(ctx); err != nil { + t.Errorf("Database health check failed: %v", err) + } +} + +// TestAuthTokenGeneration tests mock authentication token generation +func TestAuthTokenGeneration(t *testing.T) { + env := SetupTestEnvironment(t) + defer env.ContainerCleanup() + + // Validate admin token + ctx := context.Background() + adminClaims, err := env.AuthService.ValidateToken(ctx, env.AdminToken) + if err != nil { + t.Errorf("Failed to validate admin token: %v", err) + } + + if adminClaims.UserID != "admin-1" { + t.Errorf("Expected admin user ID 'admin-1', got '%s'", adminClaims.UserID) + } + + // Check admin has admin role + hasAdminRole := false + for _, role := range adminClaims.Roles { + if role == "admin" { + hasAdminRole = true + break + } + } + + if !hasAdminRole { + t.Error("Admin token should have admin role") + } + + // Validate player token + playerClaims, err := env.AuthService.ValidateToken(ctx, env.PlayerToken) + if err != nil { + t.Errorf("Failed to validate player token: %v", err) + } + + if playerClaims.UserID != "player-1" { + t.Errorf("Expected player user ID 'player-1', got '%s'", playerClaims.UserID) + } +} + +// TestGameSessionCreation tests creating a game session +func TestGameSessionCreation(t *testing.T) { + env := SetupTestEnvironment(t) + defer env.ContainerCleanup() + + ctx := context.Background() + userID := "test-user-1" + + // Create a game session + session, err := env.GameService.CreateSession(ctx, "Test Player", &userID) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + + if session.PlayerName != "Test Player" { + t.Errorf("Expected player name 'Test Player', got '%s'", session.PlayerName) + } + + if session.UserID == nil || *session.UserID != userID { + t.Errorf("Expected user ID '%s', got %v", userID, session.UserID) + } + + if session.Status != "active" { + t.Errorf("Expected status 'active', got '%s'", session.Status) + } + + // Retrieve the session + retrievedSession, err := env.GameService.GetSessionByID(ctx, session.ID) + if err != nil { + t.Fatalf("Failed to retrieve session: %v", err) + } + + if retrievedSession.ID != session.ID { + t.Errorf("Retrieved session ID doesn't match: expected '%s', got '%s'", session.ID, retrievedSession.ID) + } +} + +// TestRandomQuestionRetrieval tests retrieving random questions +func TestRandomQuestionRetrieval(t *testing.T) { + env := SetupTestEnvironment(t) + defer env.ContainerCleanup() + + ctx := context.Background() + + // Note: This test will only work if we have sample data in the database + // For now, it tests that the function doesn't crash + question, err := env.GameService.GetRandomQuestion(ctx, "", "") + + // We expect this to fail since we haven't inserted test data yet + if err == nil { + t.Logf("Got random question: %s", question.Text) + } else { + t.Logf("No questions found (expected for empty database): %v", err) + } +} + +// TestAPIEndpointsWithAuth tests API endpoints with authentication +func TestAPIEndpointsWithAuth(t *testing.T) { + env := SetupTestEnvironment(t) + defer env.ContainerCleanup() + + middleware := auth.NewJWTMiddleware(env.AuthService) + + // Add authenticated route + api := env.App.Group("/api/v1") + api.Use(middleware.Optional()) + + api.Get("/test", func(c *fiber.Ctx) error { + user, err := auth.GetUserFromContext(c) + if err != nil { + return c.JSON(fiber.Map{"authenticated": false}) + } + return c.JSON(fiber.Map{ + "authenticated": true, + "user": user.Username, + "roles": user.Roles, + }) + }) + + // Test without authentication + req, err := http.NewRequest("GET", "/api/v1/test", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + resp, err := env.App.Test(req) + if err != nil { + t.Fatalf("Failed to test request: %v", err) + } + + if resp.StatusCode != 200 { + t.Errorf("Expected status 200, got %d", resp.StatusCode) + } + + // Test with authentication + authReq, err := http.NewRequest("GET", "/api/v1/test", nil) + if err != nil { + t.Fatalf("Failed to create authenticated request: %v", err) + } + authReq.Header.Set("Authorization", "Bearer "+env.PlayerToken) + + authResp, err := env.App.Test(authReq) + if err != nil { + t.Fatalf("Failed to test authenticated request: %v", err) + } + + if authResp.StatusCode != 200 { + t.Errorf("Expected status 200 for authenticated request, got %d", authResp.StatusCode) + } +} + +// Helper function to parse JSON response +func parseJSONResponse(t *testing.T, resp *http.Response) map[string]interface{} { + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("Failed to parse JSON response: %v", err) + } + return result +} \ No newline at end of file diff --git a/backend/services/gateway-service/cmd/main.go b/backend/services/gateway-service/cmd/main.go new file mode 100644 index 0000000..25f7fff --- /dev/null +++ b/backend/services/gateway-service/cmd/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func main() { + app := fiber.New(fiber.Config{ + AppName: "Know Foolery Gateway Service v1.0.0", + ServerHeader: "Gateway Service", + }) + + // Middleware + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path} ${latency}\n", + })) + app.Use(cors.New()) + + // Health endpoint + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "gateway-service", + "version": "1.0.0", + }) + }) + + // API routes + api := app.Group("/api/v1") + api.Get("/gateway", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "message": "Gateway service endpoint", + "gateway": true, + }) + }) + + // Graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("Gracefully shutting down Gateway Service...") + _ = app.Shutdown() + }() + + port := os.Getenv("PORT") + if port == "" { + port = "3000" + } + + log.Printf("Gateway Service starting on port %s", port) + if err := app.Listen(":" + port); err != nil { + log.Printf("Error starting server: %v", err) + } +} \ No newline at end of file diff --git a/backend/services/gateway-service/go.mod b/backend/services/gateway-service/go.mod new file mode 100644 index 0000000..eccb8f8 --- /dev/null +++ b/backend/services/gateway-service/go.mod @@ -0,0 +1,21 @@ +module knowfoolery/backend/services/gateway-service + +go 1.21 + +require github.com/gofiber/fiber/v2 v2.52.0 + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.15.0 // indirect +) + +replace knowfoolery/backend/shared => ../../shared diff --git a/backend/services/gateway-service/go.sum b/backend/services/gateway-service/go.sum new file mode 100644 index 0000000..caafe41 --- /dev/null +++ b/backend/services/gateway-service/go.sum @@ -0,0 +1,27 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/backend/services/leaderboard-service/cmd/main.go b/backend/services/leaderboard-service/cmd/main.go new file mode 100644 index 0000000..349af80 --- /dev/null +++ b/backend/services/leaderboard-service/cmd/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func main() { + app := fiber.New(fiber.Config{ + AppName: "Know Foolery Leaderboard Service v1.0.0", + ServerHeader: "Leaderboard Service", + }) + + // Middleware + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path} ${latency}\n", + })) + app.Use(cors.New()) + + // Health endpoint + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "leaderboard-service", + "version": "1.0.0", + }) + }) + + // API routes + api := app.Group("/api/v1") + api.Get("/leaderboard", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "message": "Leaderboard service endpoint", + "leaderboard": []string{}, + }) + }) + + // Graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("Gracefully shutting down Leaderboard Service...") + _ = app.Shutdown() + }() + + port := os.Getenv("PORT") + if port == "" { + port = "3004" + } + + log.Printf("Leaderboard Service starting on port %s", port) + if err := app.Listen(":" + port); err != nil { + log.Printf("Error starting server: %v", err) + } +} \ No newline at end of file diff --git a/backend/services/leaderboard-service/go.mod b/backend/services/leaderboard-service/go.mod new file mode 100644 index 0000000..0068c3f --- /dev/null +++ b/backend/services/leaderboard-service/go.mod @@ -0,0 +1,21 @@ +module knowfoolery/backend/services/leaderboard-service + +go 1.21 + +require github.com/gofiber/fiber/v2 v2.52.0 + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.15.0 // indirect +) + +replace knowfoolery/backend/shared => ../../shared diff --git a/backend/services/leaderboard-service/go.sum b/backend/services/leaderboard-service/go.sum new file mode 100644 index 0000000..caafe41 --- /dev/null +++ b/backend/services/leaderboard-service/go.sum @@ -0,0 +1,27 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/backend/services/question-service/cmd/main.go b/backend/services/question-service/cmd/main.go new file mode 100644 index 0000000..34eec59 --- /dev/null +++ b/backend/services/question-service/cmd/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func main() { + app := fiber.New(fiber.Config{ + AppName: "Know Foolery Question Service v1.0.0", + ServerHeader: "Question Service", + }) + + // Middleware + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path} ${latency}\n", + })) + app.Use(cors.New()) + + // Health endpoint + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "question-service", + "version": "1.0.0", + }) + }) + + // API routes + api := app.Group("/api/v1") + api.Get("/questions", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "message": "Question service endpoint", + "questions": []string{}, + }) + }) + + // Graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("Gracefully shutting down Question Service...") + _ = app.Shutdown() + }() + + port := os.Getenv("PORT") + if port == "" { + port = "3002" + } + + log.Printf("Question Service starting on port %s", port) + if err := app.Listen(":" + port); err != nil { + log.Printf("Error starting server: %v", err) + } +} \ No newline at end of file diff --git a/backend/services/question-service/go.mod b/backend/services/question-service/go.mod new file mode 100644 index 0000000..a67aee6 --- /dev/null +++ b/backend/services/question-service/go.mod @@ -0,0 +1,21 @@ +module knowfoolery/backend/services/question-service + +go 1.21 + +require github.com/gofiber/fiber/v2 v2.52.0 + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.15.0 // indirect +) + +replace knowfoolery/backend/shared => ../../shared diff --git a/backend/services/question-service/go.sum b/backend/services/question-service/go.sum new file mode 100644 index 0000000..caafe41 --- /dev/null +++ b/backend/services/question-service/go.sum @@ -0,0 +1,27 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/backend/services/session-service/cmd/main.go b/backend/services/session-service/cmd/main.go new file mode 100644 index 0000000..1a643ea --- /dev/null +++ b/backend/services/session-service/cmd/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func main() { + app := fiber.New(fiber.Config{ + AppName: "Know Foolery Session Service v1.0.0", + ServerHeader: "Session Service", + }) + + // Middleware + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path} ${latency}\n", + })) + app.Use(cors.New()) + + // Health endpoint + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "session-service", + "version": "1.0.0", + }) + }) + + // API routes + api := app.Group("/api/v1") + api.Get("/sessions", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "message": "Session service endpoint", + "sessions": []string{}, + }) + }) + + // Graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("Gracefully shutting down Session Service...") + _ = app.Shutdown() + }() + + port := os.Getenv("PORT") + if port == "" { + port = "3005" + } + + log.Printf("Session Service starting on port %s", port) + if err := app.Listen(":" + port); err != nil { + log.Printf("Error starting server: %v", err) + } +} \ No newline at end of file diff --git a/backend/services/session-service/go.mod b/backend/services/session-service/go.mod new file mode 100644 index 0000000..1123385 --- /dev/null +++ b/backend/services/session-service/go.mod @@ -0,0 +1,21 @@ +module knowfoolery/backend/services/session-service + +go 1.21 + +require github.com/gofiber/fiber/v2 v2.52.0 + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.15.0 // indirect +) + +replace knowfoolery/backend/shared => ../../shared diff --git a/backend/services/session-service/go.sum b/backend/services/session-service/go.sum new file mode 100644 index 0000000..caafe41 --- /dev/null +++ b/backend/services/session-service/go.sum @@ -0,0 +1,27 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/backend/services/user-service/cmd/main.go b/backend/services/user-service/cmd/main.go new file mode 100644 index 0000000..6b5ddfc --- /dev/null +++ b/backend/services/user-service/cmd/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" +) + +func main() { + app := fiber.New(fiber.Config{ + AppName: "Know Foolery User Service v1.0.0", + ServerHeader: "User Service", + }) + + // Middleware + app.Use(logger.New(logger.Config{ + Format: "[${time}] ${status} - ${method} ${path} ${latency}\n", + })) + app.Use(cors.New()) + + // Health endpoint + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "healthy", + "service": "user-service", + "version": "1.0.0", + }) + }) + + // API routes + api := app.Group("/api/v1") + api.Get("/users", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "message": "User service endpoint", + "users": []string{}, + }) + }) + + // Graceful shutdown + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + go func() { + <-c + log.Println("Gracefully shutting down User Service...") + _ = app.Shutdown() + }() + + port := os.Getenv("PORT") + if port == "" { + port = "3003" + } + + log.Printf("User Service starting on port %s", port) + if err := app.Listen(":" + port); err != nil { + log.Printf("Error starting server: %v", err) + } +} \ No newline at end of file diff --git a/backend/services/user-service/go.mod b/backend/services/user-service/go.mod new file mode 100644 index 0000000..585dd0e --- /dev/null +++ b/backend/services/user-service/go.mod @@ -0,0 +1,21 @@ +module knowfoolery/backend/services/user-service + +go 1.21 + +require github.com/gofiber/fiber/v2 v2.52.0 + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.15.0 // indirect +) + +replace knowfoolery/backend/shared => ../../shared diff --git a/backend/services/user-service/go.sum b/backend/services/user-service/go.sum new file mode 100644 index 0000000..caafe41 --- /dev/null +++ b/backend/services/user-service/go.sum @@ -0,0 +1,27 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/backend/shared/auth/constants.go b/backend/shared/auth/constants.go new file mode 100644 index 0000000..5cf3915 --- /dev/null +++ b/backend/shared/auth/constants.go @@ -0,0 +1,39 @@ +package auth + +import "github.com/gofiber/fiber/v2" + +// HTTP headers +const ( + AuthorizationHeader = "Authorization" + BearerPrefix = "Bearer " +) + +// Context keys for storing user information in fiber context +const ( + UserIDKey = "user_id" + UsernameKey = "username" + EmailKey = "email" + RolesKey = "roles" +) + +// Common role names +const ( + AdminRole = "admin" + PlayerRole = "player" +) + +// Error messages +const ( + ErrUserNotAuthenticated = "user not authenticated" + ErrAuthHeaderRequired = "authorization header required" + ErrInvalidAuthHeaderFormat = "invalid authorization header format" + ErrInvalidToken = "invalid token" + ErrNoRolesFound = "no roles found in token" + ErrInsufficientPermissions = "insufficient permissions" +) + +// HTTP status codes for auth errors +const ( + StatusUnauthorized = fiber.StatusUnauthorized + StatusForbidden = fiber.StatusForbidden +) diff --git a/backend/shared/auth/context.go b/backend/shared/auth/context.go new file mode 100644 index 0000000..79a3494 --- /dev/null +++ b/backend/shared/auth/context.go @@ -0,0 +1,145 @@ +package auth + +import ( + "errors" + "fmt" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// UserContext holds user information extracted from JWT +type UserContext struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + Roles []string `json:"roles"` +} + +// GetUserFromContext extracts user information from fiber context +func GetUserFromContext(c *fiber.Ctx) (*UserContext, error) { + userID := c.Locals(UserIDKey) + if userID == nil { + return nil, errors.New(ErrUserNotAuthenticated) + } + + username, _ := c.Locals(UsernameKey).(string) + email, _ := c.Locals(EmailKey).(string) + roles, _ := c.Locals(RolesKey).([]string) + + return &UserContext{ + UserID: userID.(string), + Username: username, + Email: email, + Roles: roles, + }, nil +} + +// IsAuthenticated checks if user is authenticated +func IsAuthenticated(c *fiber.Ctx) bool { + return c.Locals(UserIDKey) != nil +} + +// HasRole checks if user has a specific role +func HasRole(c *fiber.Ctx, role string) bool { + return hasRole(c, role) +} + +// hasRole is the internal implementation for role checking +func hasRole(c *fiber.Ctx, role string) bool { + roles, ok := c.Locals(RolesKey).([]string) + if !ok { + return false + } + + for _, r := range roles { + if r == role { + return true + } + } + return false +} + +// hasAnyRole is the internal implementation for checking multiple roles +func HasAnyRole(c *fiber.Ctx, roles ...string) bool { + userRoles, ok := c.Locals(RolesKey).([]string) + if !ok { + return false + } + + for _, userRole := range userRoles { + for _, role := range roles { + if userRole == role { + return true + } + } + } + return false +} + +// IsAdmin checks if user has admin role +func IsAdmin(c *fiber.Ctx) bool { + return HasRole(c, AdminRole) +} + +// Token processing helper functions + +// extractToken extracts Bearer token from Authorization header +func extractToken(c *fiber.Ctx) (string, error) { + authHeader := c.Get(AuthorizationHeader) + if authHeader == "" { + return "", errors.New(ErrAuthHeaderRequired) + } + + tokenString := strings.TrimPrefix(authHeader, BearerPrefix) + if tokenString == authHeader { + return "", errors.New(ErrInvalidAuthHeaderFormat) + } + + return tokenString, nil +} + +// setUserContext sets user information in fiber context from claims +func setUserContext(c *fiber.Ctx, claims *Claims) { + c.Locals(UserIDKey, claims.UserID) + c.Locals(UsernameKey, claims.Username) + c.Locals(EmailKey, claims.Email) + c.Locals(RolesKey, claims.Roles) +} + +// validateAndSetContext validates token and sets user context +func validateAndSetContext(c *fiber.Ctx, authService *MockAuthService, tokenString string) error { + claims, err := authService.ValidateToken(c.Context(), tokenString) + if err != nil { + return fmt.Errorf("%s: %w", ErrInvalidToken, err) + } + + setUserContext(c, claims) + return nil +} + +// Error response helper functions + +// unauthorizedError creates a standardized unauthorized error response +func unauthorizedError(c *fiber.Ctx, message string, details ...string) error { + response := fiber.Map{ + "error": message, + } + if len(details) > 0 { + response["details"] = details[0] + } + return c.Status(StatusUnauthorized).JSON(response) +} + +// forbiddenError creates a standardized forbidden error response +func forbiddenError(c *fiber.Ctx, message string, extraFields ...fiber.Map) error { + response := fiber.Map{ + "error": message, + } + for _, extra := range extraFields { + for k, v := range extra { + response[k] = v + } + } + return c.Status(StatusForbidden).JSON(response) +} diff --git a/backend/shared/auth/context_test.go b/backend/shared/auth/context_test.go new file mode 100644 index 0000000..5332400 --- /dev/null +++ b/backend/shared/auth/context_test.go @@ -0,0 +1,190 @@ +package auth + +import ( + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/valyala/fasthttp" +) + +// setupTestContext creates a proper fiber context for testing +func setupTestContext(app *fiber.App) *fiber.Ctx { + reqCtx := &fasthttp.RequestCtx{} + reqCtx.Request.SetBody([]byte("")) + reqCtx.Request.Header.SetMethod(fiber.MethodGet) + reqCtx.Request.SetRequestURI("/") + c := app.AcquireCtx(reqCtx) + return c +} + +func TestGetUserFromContext(t *testing.T) { + app := fiber.New() + + t.Run("Successfully extracts user context", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + // Set user context + c.Locals(UserIDKey, "user123") + c.Locals(UsernameKey, "testuser") + c.Locals(EmailKey, "test@example.com") + c.Locals(RolesKey, []string{"player", "admin"}) + + user, err := GetUserFromContext(c) + + require.NoError(t, err) + assert.Equal(t, "user123", user.UserID) + assert.Equal(t, "testuser", user.Username) + assert.Equal(t, "test@example.com", user.Email) + assert.Equal(t, []string{"player", "admin"}, user.Roles) + }) + + t.Run("Returns error when user not authenticated", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + user, err := GetUserFromContext(c) + + require.Error(t, err) + assert.Nil(t, user) + assert.Contains(t, err.Error(), ErrUserNotAuthenticated) + }) + + t.Run("Handles missing optional fields gracefully", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + // Only set required user_id + c.Locals(UserIDKey, "user123") + + user, err := GetUserFromContext(c) + + require.NoError(t, err) + assert.Equal(t, "user123", user.UserID) + assert.Equal(t, "", user.Username) + assert.Equal(t, "", user.Email) + assert.Nil(t, user.Roles) + }) +} + +func TestIsAuthenticated(t *testing.T) { + app := fiber.New() + + t.Run("Returns true when user is authenticated", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + c.Locals(UserIDKey, "user123") + + assert.True(t, IsAuthenticated(c)) + }) + + t.Run("Returns false when user is not authenticated", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + assert.False(t, IsAuthenticated(c)) + }) +} + +func TestHasRole(t *testing.T) { + app := fiber.New() + + t.Run("Returns true when user has the role", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + c.Locals(RolesKey, []string{"player", "admin"}) + + assert.True(t, HasRole(c, "admin")) + assert.True(t, HasRole(c, "player")) + }) + + t.Run("Returns false when user doesn't have the role", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + c.Locals(RolesKey, []string{"player"}) + + assert.False(t, HasRole(c, "admin")) + }) + + t.Run("Returns false when no roles are set", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + assert.False(t, HasRole(c, "admin")) + }) + + t.Run("Returns false when roles is not a string slice", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + c.Locals(RolesKey, "not-a-slice") + + assert.False(t, HasRole(c, "admin")) + }) +} + +func TestHasAnyRole(t *testing.T) { + app := fiber.New() + + t.Run("Returns true when user has any of the roles", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + c.Locals(RolesKey, []string{"player"}) + + assert.True(t, HasAnyRole(c, "admin", "player")) + assert.True(t, HasAnyRole(c, "player")) + }) + + t.Run("Returns false when user doesn't have any of the roles", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + c.Locals(RolesKey, []string{"player"}) + + assert.False(t, HasAnyRole(c, "admin", "moderator")) + }) + + t.Run("Returns false when no roles are set", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + assert.False(t, HasAnyRole(c, "admin", "player")) + }) + + t.Run("Returns false when roles is not a string slice", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + c.Locals(RolesKey, 123) + + assert.False(t, HasAnyRole(c, "admin")) + }) +} + +func TestIsAdmin(t *testing.T) { + app := fiber.New() + + t.Run("Returns true when user has admin role", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + c.Locals(RolesKey, []string{"admin", "player"}) + + assert.True(t, IsAdmin(c)) + }) + + t.Run("Returns false when user doesn't have admin role", func(t *testing.T) { + c := setupTestContext(app) + defer app.ReleaseCtx(c) + + c.Locals(RolesKey, []string{"player"}) + + assert.False(t, IsAdmin(c)) + }) +} diff --git a/backend/shared/auth/middleware.go b/backend/shared/auth/middleware.go new file mode 100644 index 0000000..762b609 --- /dev/null +++ b/backend/shared/auth/middleware.go @@ -0,0 +1,93 @@ +package auth + +import ( + "github.com/gofiber/fiber/v2" +) + +// JWTMiddleware provides JWT authentication middleware +type JWTMiddleware struct { + authService *MockAuthService +} + +// NewJWTMiddleware creates a new JWT middleware +func NewJWTMiddleware(authService *MockAuthService) *JWTMiddleware { + return &JWTMiddleware{ + authService: authService, + } +} + +// Authenticate middleware validates JWT tokens +func (m *JWTMiddleware) Authenticate() fiber.Handler { + return func(c *fiber.Ctx) error { + // Extract token from Authorization header + tokenString, err := extractToken(c) + if err != nil { + return unauthorizedError(c, err.Error()) + } + + // Validate token and set user context + if err := validateAndSetContext(c, m.authService, tokenString); err != nil { + return unauthorizedError(c, ErrInvalidToken, err.Error()) + } + + return c.Next() + } +} + +// RequireRole middleware requires specific roles +func (m *JWTMiddleware) RequireRole(requiredRole string) fiber.Handler { + return func(c *fiber.Ctx) error { + userRoles, ok := c.Locals(RolesKey).([]string) + if !ok { + return forbiddenError(c, ErrNoRolesFound) + } + + // Check if user has required role using helper function + if !hasRole(c, requiredRole) { + return forbiddenError(c, ErrInsufficientPermissions, fiber.Map{ + "required_role": requiredRole, + "user_roles": userRoles, + }) + } + + return c.Next() + } +} + +// RequireAnyRole middleware requires any of the specified roles +func (m *JWTMiddleware) RequireAnyRole(requiredRoles ...string) fiber.Handler { + return func(c *fiber.Ctx) error { + userRoles, ok := c.Locals(RolesKey).([]string) + if !ok { + return forbiddenError(c, ErrNoRolesFound) + } + + // Check if user has any of the required roles using helper function + if !HasAnyRole(c, requiredRoles...) { + return forbiddenError(c, ErrInsufficientPermissions, fiber.Map{ + "required_roles": requiredRoles, + "user_roles": userRoles, + }) + } + + return c.Next() + } +} + +// Optional middleware validates token if present, but allows requests without tokens +func (m *JWTMiddleware) Optional() fiber.Handler { + return func(c *fiber.Ctx) error { + // Extract token from Authorization header + tokenString, err := extractToken(c) + if err != nil { + // No token or invalid format, continue without setting user context + return c.Next() + } + + // Try to validate token and set user context + // If validation fails, continue without blocking the request + validateAndSetContext(c, m.authService, tokenString) + + return c.Next() + } +} diff --git a/backend/shared/auth/middleware_test.go b/backend/shared/auth/middleware_test.go new file mode 100644 index 0000000..4adda67 --- /dev/null +++ b/backend/shared/auth/middleware_test.go @@ -0,0 +1,321 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupTestApp() (*fiber.App, *JWTMiddleware, *MockAuthService) { + app := fiber.New() + authService, _ := NewMockAuthService("test-service") + middleware := NewJWTMiddleware(authService) + return app, middleware, authService +} + +func TestJWTMiddleware_Authenticate(t *testing.T) { + app, middleware, authService := setupTestApp() + + // Generate a valid token + validToken, err := authService.GenerateToken("user123", "testuser", "test@example.com", []string{"player"}) + require.NoError(t, err) + + app.Get("/protected", middleware.Authenticate(), func(c *fiber.Ctx) error { + user, _ := GetUserFromContext(c) + return c.JSON(fiber.Map{"user": user.Username}) + }) + + t.Run("Allows request with valid token", func(t *testing.T) { + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set(AuthorizationHeader, "Bearer "+validToken) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, "testuser", response["user"]) + }) + + t.Run("Rejects request without Authorization header", func(t *testing.T) { + req := httptest.NewRequest("GET", "/protected", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, StatusUnauthorized, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, ErrAuthHeaderRequired, response["error"]) + }) + + t.Run("Rejects request with invalid Authorization header format", func(t *testing.T) { + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set(AuthorizationHeader, "Token "+validToken) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, StatusUnauthorized, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, ErrInvalidAuthHeaderFormat, response["error"]) + }) + + t.Run("Rejects request with invalid token", func(t *testing.T) { + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set(AuthorizationHeader, "Bearer invalid-token") + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, StatusUnauthorized, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, ErrInvalidToken, response["error"]) + assert.NotNil(t, response["details"]) + }) +} + +func TestJWTMiddleware_RequireRole(t *testing.T) { + app, middleware, authService := setupTestApp() + + // Generate tokens with different roles + adminToken, err := authService.GenerateToken("admin1", "admin", "admin@test.com", []string{"admin", "player"}) + require.NoError(t, err) + + playerToken, err := authService.GenerateToken("player1", "player", "player@test.com", []string{"player"}) + require.NoError(t, err) + + app.Get("/admin", middleware.Authenticate(), middleware.RequireRole("admin"), func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"message": "admin access"}) + }) + + t.Run("Allows access when user has required role", func(t *testing.T) { + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set(AuthorizationHeader, "Bearer "+adminToken) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + }) + + t.Run("Denies access when user doesn't have required role", func(t *testing.T) { + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set(AuthorizationHeader, "Bearer "+playerToken) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, StatusForbidden, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, ErrInsufficientPermissions, response["error"]) + assert.Equal(t, "admin", response["required_role"]) + assert.Contains(t, response["user_roles"], "player") + }) +} + +func TestJWTMiddleware_RequireAnyRole(t *testing.T) { + app, middleware, authService := setupTestApp() + + // Generate tokens with different roles + adminToken, err := authService.GenerateToken("admin1", "admin", "admin@test.com", []string{"admin"}) + require.NoError(t, err) + + playerToken, err := authService.GenerateToken("player1", "player", "player@test.com", []string{"player"}) + require.NoError(t, err) + + guestToken, err := authService.GenerateToken("guest1", "guest", "guest@test.com", []string{"guest"}) + require.NoError(t, err) + + app.Get("/restricted", middleware.Authenticate(), middleware.RequireAnyRole("admin", "player"), func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"message": "access granted"}) + }) + + t.Run("Allows access when user has admin role", func(t *testing.T) { + req := httptest.NewRequest("GET", "/restricted", nil) + req.Header.Set(AuthorizationHeader, "Bearer "+adminToken) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + }) + + t.Run("Allows access when user has player role", func(t *testing.T) { + req := httptest.NewRequest("GET", "/restricted", nil) + req.Header.Set(AuthorizationHeader, "Bearer "+playerToken) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + }) + + t.Run("Denies access when user doesn't have any required role", func(t *testing.T) { + req := httptest.NewRequest("GET", "/restricted", nil) + req.Header.Set(AuthorizationHeader, "Bearer "+guestToken) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, StatusForbidden, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, ErrInsufficientPermissions, response["error"]) + assert.Contains(t, response["required_roles"], "admin") + assert.Contains(t, response["required_roles"], "player") + assert.Contains(t, response["user_roles"], "guest") + }) +} + +func TestJWTMiddleware_Optional(t *testing.T) { + app, middleware, authService := setupTestApp() + + // Generate a valid token + validToken, err := authService.GenerateToken("user123", "testuser", "test@example.com", []string{"player"}) + require.NoError(t, err) + + app.Get("/public", middleware.Optional(), func(c *fiber.Ctx) error { + if IsAuthenticated(c) { + user, _ := GetUserFromContext(c) + return c.JSON(fiber.Map{"authenticated": true, "user": user.Username}) + } + return c.JSON(fiber.Map{"authenticated": false, "user": "anonymous"}) + }) + + t.Run("Sets user context when valid token is provided", func(t *testing.T) { + req := httptest.NewRequest("GET", "/public", nil) + req.Header.Set(AuthorizationHeader, "Bearer "+validToken) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, true, response["authenticated"]) + assert.Equal(t, "testuser", response["user"]) + }) + + t.Run("Allows request without token", func(t *testing.T) { + req := httptest.NewRequest("GET", "/public", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, false, response["authenticated"]) + assert.Equal(t, "anonymous", response["user"]) + }) + + t.Run("Allows request with invalid token format", func(t *testing.T) { + req := httptest.NewRequest("GET", "/public", nil) + req.Header.Set(AuthorizationHeader, "Token invalid-format") + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, false, response["authenticated"]) + assert.Equal(t, "anonymous", response["user"]) + }) + + t.Run("Allows request with invalid token", func(t *testing.T) { + req := httptest.NewRequest("GET", "/public", nil) + req.Header.Set(AuthorizationHeader, "Bearer invalid-token") + + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var response map[string]interface{} + err = json.Unmarshal(body, &response) + require.NoError(t, err) + + assert.Equal(t, false, response["authenticated"]) + assert.Equal(t, "anonymous", response["user"]) + }) +} + +func TestNewJWTMiddleware(t *testing.T) { + t.Run("Creates new JWT middleware", func(t *testing.T) { + authService, err := NewMockAuthService("test-service") + require.NoError(t, err) + + middleware := NewJWTMiddleware(authService) + + assert.NotNil(t, middleware) + assert.Equal(t, authService, middleware.authService) + }) +} \ No newline at end of file diff --git a/backend/shared/auth/mock.go b/backend/shared/auth/mock.go new file mode 100644 index 0000000..0b7ae20 --- /dev/null +++ b/backend/shared/auth/mock.go @@ -0,0 +1,112 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// MockAuthService provides mock authentication functionality +type MockAuthService struct { + secretKey []byte + issuer string +} + +// Claims represents JWT claims for mock authentication +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + Roles []string `json:"roles"` + jwt.RegisteredClaims +} + +// NewMockAuthService creates a new mock authentication service +func NewMockAuthService(issuer string) (*MockAuthService, error) { + // Generate a random secret key for development + secretKey := make([]byte, 32) + if _, err := rand.Read(secretKey); err != nil { + return nil, fmt.Errorf("failed to generate secret key: %w", err) + } + + return &MockAuthService{ + secretKey: secretKey, + issuer: issuer, + }, nil +} + +// GenerateToken generates a JWT token for a user +func (m *MockAuthService) GenerateToken(userID, username, email string, roles []string) (string, error) { + now := time.Now() + claims := Claims{ + UserID: userID, + Username: username, + Email: email, + Roles: roles, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: m.issuer, + Subject: userID, + Audience: []string{"knowfoolery"}, + ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour * 24)), // 24 hours + NotBefore: jwt.NewNumericDate(now), + IssuedAt: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(m.secretKey) +} + +// ValidateToken validates a JWT token +func (m *MockAuthService) ValidateToken(ctx context.Context, tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // Validate signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return m.secretKey, nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + claims, ok := token.Claims.(*Claims) + if !ok { + return nil, fmt.Errorf("invalid token claims") + } + + return claims, nil +} + +// CreateMockUsers creates some mock users for testing +func (m *MockAuthService) CreateMockUsers() map[string]string { + tokens := make(map[string]string) + + // Create admin user token + adminToken, _ := m.GenerateToken("admin-1", "admin", "admin@knowfoolery.com", []string{"admin", "player"}) + tokens["admin"] = adminToken + + // Create regular player token + playerToken, _ := m.GenerateToken("player-1", "john_doe", "john@example.com", []string{"player"}) + tokens["player"] = playerToken + + // Create another player token + player2Token, _ := m.GenerateToken("player-2", "jane_smith", "jane@example.com", []string{"player"}) + tokens["player2"] = player2Token + + return tokens +} + +// GetSecretHex returns the secret key as hex string (for debugging/testing only) +func (m *MockAuthService) GetSecretHex() string { + return hex.EncodeToString(m.secretKey) +} \ No newline at end of file diff --git a/backend/shared/auth/mock_test.go b/backend/shared/auth/mock_test.go new file mode 100644 index 0000000..490e671 --- /dev/null +++ b/backend/shared/auth/mock_test.go @@ -0,0 +1,275 @@ +package auth + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewMockAuthService(t *testing.T) { + t.Run("Creates new mock auth service", func(t *testing.T) { + service, err := NewMockAuthService("test-service") + + require.NoError(t, err) + assert.NotNil(t, service) + assert.Equal(t, "test-service", service.issuer) + assert.Len(t, service.secretKey, 32) + }) +} + +func TestMockAuthService_GenerateToken(t *testing.T) { + service, err := NewMockAuthService("test-service") + require.NoError(t, err) + + t.Run("Generates valid JWT token", func(t *testing.T) { + userID := "user123" + username := "testuser" + email := "test@example.com" + roles := []string{"player", "admin"} + + tokenString, err := service.GenerateToken(userID, username, email, roles) + + require.NoError(t, err) + assert.NotEmpty(t, tokenString) + + // Verify the token has the expected structure + parts := strings.Split(tokenString, ".") + assert.Len(t, parts, 3) // header.payload.signature + }) + + t.Run("Generated token contains correct claims", func(t *testing.T) { + userID := "user123" + username := "testuser" + email := "test@example.com" + roles := []string{"player"} + + tokenString, err := service.GenerateToken(userID, username, email, roles) + require.NoError(t, err) + + // Parse and verify claims + claims, err := service.ValidateToken(context.Background(), tokenString) + require.NoError(t, err) + + assert.Equal(t, userID, claims.UserID) + assert.Equal(t, username, claims.Username) + assert.Equal(t, email, claims.Email) + assert.Equal(t, roles, claims.Roles) + assert.Equal(t, "test-service", claims.Issuer) + assert.Equal(t, userID, claims.Subject) + assert.Contains(t, claims.Audience, "knowfoolery") + }) + + t.Run("Generated token expires in 24 hours", func(t *testing.T) { + tokenString, err := service.GenerateToken("user1", "test", "test@example.com", []string{"player"}) + require.NoError(t, err) + + claims, err := service.ValidateToken(context.Background(), tokenString) + require.NoError(t, err) + + now := time.Now() + expected := now.Add(24 * time.Hour) + + // Allow for some time drift (within 1 minute) + assert.WithinDuration(t, expected, claims.ExpiresAt.Time, time.Minute) + }) + + t.Run("Generates different tokens for different users", func(t *testing.T) { + token1, err1 := service.GenerateToken("user1", "test1", "test1@example.com", []string{"player"}) + token2, err2 := service.GenerateToken("user2", "test2", "test2@example.com", []string{"admin"}) + + require.NoError(t, err1) + require.NoError(t, err2) + assert.NotEqual(t, token1, token2) + }) +} + +func TestMockAuthService_ValidateToken(t *testing.T) { + service, err := NewMockAuthService("test-service") + require.NoError(t, err) + + t.Run("Validates correct token successfully", func(t *testing.T) { + userID := "user123" + username := "testuser" + email := "test@example.com" + roles := []string{"player", "admin"} + + tokenString, err := service.GenerateToken(userID, username, email, roles) + require.NoError(t, err) + + claims, err := service.ValidateToken(context.Background(), tokenString) + + require.NoError(t, err) + assert.Equal(t, userID, claims.UserID) + assert.Equal(t, username, claims.Username) + assert.Equal(t, email, claims.Email) + assert.Equal(t, roles, claims.Roles) + }) + + t.Run("Rejects malformed token", func(t *testing.T) { + claims, err := service.ValidateToken(context.Background(), "invalid.token.format") + + require.Error(t, err) + assert.Nil(t, claims) + assert.Contains(t, err.Error(), "failed to parse token") + }) + + t.Run("Rejects token with wrong signature", func(t *testing.T) { + // Create token with different service (different secret) + otherService, err := NewMockAuthService("other-service") + require.NoError(t, err) + + tokenString, err := otherService.GenerateToken("user1", "test", "test@example.com", []string{"player"}) + require.NoError(t, err) + + // Try to validate with original service + claims, err := service.ValidateToken(context.Background(), tokenString) + + require.Error(t, err) + assert.Nil(t, claims) + assert.Contains(t, err.Error(), "failed to parse token") + }) + + t.Run("Rejects expired token", func(t *testing.T) { + // Create a token that's already expired + now := time.Now() + pastTime := now.Add(-25 * time.Hour) // Expired 1 hour ago + + claims := Claims{ + UserID: "user123", + Username: "testuser", + Email: "test@example.com", + Roles: []string{"player"}, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: service.issuer, + Subject: "user123", + Audience: []string{"knowfoolery"}, + ExpiresAt: jwt.NewNumericDate(pastTime), + NotBefore: jwt.NewNumericDate(pastTime.Add(-24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(pastTime.Add(-24 * time.Hour)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(service.secretKey) + require.NoError(t, err) + + // Try to validate expired token + validClaims, err := service.ValidateToken(context.Background(), tokenString) + + require.Error(t, err) + assert.Nil(t, validClaims) + assert.Contains(t, err.Error(), "failed to parse token") + }) + + t.Run("Rejects token with wrong signing method", func(t *testing.T) { + // We can't easily create a token with wrong signing method without proper keys, + // so just create a malformed token to test the validation + malformedToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.INVALID" + + validClaims, err := service.ValidateToken(context.Background(), malformedToken) + + require.Error(t, err) + assert.Nil(t, validClaims) + }) +} + +func TestMockAuthService_CreateMockUsers(t *testing.T) { + service, err := NewMockAuthService("test-service") + require.NoError(t, err) + + t.Run("Creates mock user tokens", func(t *testing.T) { + tokens := service.CreateMockUsers() + + assert.Len(t, tokens, 3) + assert.Contains(t, tokens, "admin") + assert.Contains(t, tokens, "player") + assert.Contains(t, tokens, "player2") + + // Verify each token is valid + for userType, tokenString := range tokens { + claims, err := service.ValidateToken(context.Background(), tokenString) + require.NoError(t, err, "Token for %s should be valid", userType) + assert.NotEmpty(t, claims.UserID) + assert.NotEmpty(t, claims.Username) + assert.NotEmpty(t, claims.Email) + assert.NotEmpty(t, claims.Roles) + } + }) + + t.Run("Admin token has admin role", func(t *testing.T) { + tokens := service.CreateMockUsers() + adminToken := tokens["admin"] + + claims, err := service.ValidateToken(context.Background(), adminToken) + require.NoError(t, err) + + assert.Contains(t, claims.Roles, "admin") + assert.Contains(t, claims.Roles, "player") + assert.Equal(t, "admin", claims.Username) + }) + + t.Run("Player tokens have player role", func(t *testing.T) { + tokens := service.CreateMockUsers() + + for _, playerKey := range []string{"player", "player2"} { + playerToken := tokens[playerKey] + claims, err := service.ValidateToken(context.Background(), playerToken) + require.NoError(t, err) + + assert.Contains(t, claims.Roles, "player") + assert.NotContains(t, claims.Roles, "admin") + } + }) + + t.Run("Creates different tokens each time", func(t *testing.T) { + tokens1 := service.CreateMockUsers() + time.Sleep(time.Second + time.Millisecond) // Ensure different issued-at time + tokens2 := service.CreateMockUsers() + + for userType := range tokens1 { + assert.NotEqual(t, tokens1[userType], tokens2[userType], + "Tokens for %s should be different between calls", userType) + } + }) +} + +func TestMockAuthService_GetSecretHex(t *testing.T) { + service, err := NewMockAuthService("test-service") + require.NoError(t, err) + + t.Run("Returns hex encoded secret key", func(t *testing.T) { + hexSecret := service.GetSecretHex() + + assert.NotEmpty(t, hexSecret) + assert.Len(t, hexSecret, 64) // 32 bytes * 2 hex chars per byte + + // Verify it's valid hex + for _, char := range hexSecret { + assert.True(t, + (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F'), + "Character %c should be valid hex", char) + } + }) + + t.Run("Returns consistent secret key", func(t *testing.T) { + hex1 := service.GetSecretHex() + hex2 := service.GetSecretHex() + + assert.Equal(t, hex1, hex2, "Secret key should be consistent") + }) + + t.Run("Different services have different secrets", func(t *testing.T) { + service2, err := NewMockAuthService("other-service") + require.NoError(t, err) + + hex1 := service.GetSecretHex() + hex2 := service2.GetSecretHex() + + assert.NotEqual(t, hex1, hex2, "Different services should have different secrets") + }) +} \ No newline at end of file diff --git a/backend/shared/database/client.go b/backend/shared/database/client.go new file mode 100644 index 0000000..ec77a3b --- /dev/null +++ b/backend/shared/database/client.go @@ -0,0 +1,138 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + + _ "github.com/lib/pq" + _ "github.com/mattn/go-sqlite3" +) + +// Config holds database configuration +type Config struct { + Driver string + Host string + Port int + User string + Password string + Database string + SSLMode string +} + +// Client wraps database connection +type Client struct { + db *sql.DB + config Config +} + +// NewClient creates a new database client +func NewClient(config Config) (*Client, error) { + var dsn string + + switch config.Driver { + case "sqlite3": + dsn = config.Database + case "postgres": + dsn = fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + config.Host, config.Port, config.User, config.Password, config.Database, config.SSLMode) + default: + return nil, fmt.Errorf("unsupported driver: %s", config.Driver) + } + + db, err := sql.Open(config.Driver, dsn) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Test connection + if err := db.PingContext(context.Background()); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &Client{ + db: db, + config: config, + }, nil +} + +// Close closes the database connection +func (c *Client) Close() error { + if c.db != nil { + return c.db.Close() + } + return nil +} + +// DB returns the underlying sql.DB instance +func (c *Client) DB() *sql.DB { + return c.db +} + +// Health checks database connection health +func (c *Client) Health(ctx context.Context) error { + return c.db.PingContext(ctx) +} + +// CreateTables creates the basic table structure +func (c *Client) CreateTables(ctx context.Context) error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS questions ( + id TEXT PRIMARY KEY, + theme TEXT NOT NULL, + text TEXT NOT NULL, + answer TEXT NOT NULL, + hint TEXT, + difficulty TEXT DEFAULT 'medium' CHECK (difficulty IN ('easy', 'medium', 'hard')), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_questions_theme ON questions(theme)`, + `CREATE INDEX IF NOT EXISTS idx_questions_difficulty ON questions(difficulty)`, + `CREATE INDEX IF NOT EXISTS idx_questions_active ON questions(is_active)`, + + `CREATE TABLE IF NOT EXISTS game_sessions ( + id TEXT PRIMARY KEY, + player_name TEXT NOT NULL, + user_id TEXT, + total_score INTEGER DEFAULT 0, + questions_asked INTEGER DEFAULT 0, + questions_correct INTEGER DEFAULT 0, + hints_used INTEGER DEFAULT 0, + start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMP, + status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'timeout', 'abandoned')), + current_question_id TEXT, + current_attempts INTEGER DEFAULT 0, + session_data TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_sessions_user ON game_sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_status ON game_sessions(status)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_score ON game_sessions(total_score)`, + + `CREATE TABLE IF NOT EXISTS question_attempts ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + question_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + submitted_answer TEXT NOT NULL, + is_correct BOOLEAN NOT NULL, + used_hint BOOLEAN DEFAULT FALSE, + points_awarded INTEGER DEFAULT 0, + submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + time_taken_ms INTEGER DEFAULT 0, + metadata TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_attempts_session ON question_attempts(session_id)`, + `CREATE INDEX IF NOT EXISTS idx_attempts_question ON question_attempts(question_id)`, + } + + for _, query := range queries { + if _, err := c.db.ExecContext(ctx, query); err != nil { + return fmt.Errorf("failed to create table: %w", err) + } + } + + return nil +} \ No newline at end of file diff --git a/backend/shared/database/ent/generate.go b/backend/shared/database/ent/generate.go new file mode 100644 index 0000000..c60c6d9 --- /dev/null +++ b/backend/shared/database/ent/generate.go @@ -0,0 +1,3 @@ +//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schema + +package ent \ No newline at end of file diff --git a/backend/shared/database/ent/schema/gamesession.go b/backend/shared/database/ent/schema/gamesession.go new file mode 100644 index 0000000..fc5723b --- /dev/null +++ b/backend/shared/database/ent/schema/gamesession.go @@ -0,0 +1,90 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// GameSession holds the schema definition for the GameSession entity. +type GameSession struct { + ent.Schema +} + +// Fields of the GameSession. +func (GameSession) Fields() []ent.Field { + return []ent.Field{ + field.String("id"). + Unique(). + Immutable(), + field.String("player_name"). + NotEmpty(). + Comment("Name of the player"), + field.String("user_id"). + Optional(). + Comment("Optional authenticated user ID"), + field.Int("total_score"). + Default(0). + NonNegative(). + Comment("Total score accumulated in this session"), + field.Int("questions_asked"). + Default(0). + NonNegative(). + Comment("Number of questions asked in this session"), + field.Int("questions_correct"). + Default(0). + NonNegative(). + Comment("Number of questions answered correctly"), + field.Int("hints_used"). + Default(0). + NonNegative(). + Comment("Number of hints used in this session"), + field.Time("start_time"). + Default(time.Now). + Immutable(). + Comment("When the game session started"), + field.Time("end_time"). + Optional(). + Nillable(). + Comment("When the game session ended"), + field.Enum("status"). + Values("active", "completed", "timeout", "abandoned"). + Default("active"). + Comment("Current status of the game session"), + field.String("current_question_id"). + Optional(). + Comment("ID of the current question being asked"), + field.Int("current_attempts"). + Default(0). + NonNegative(). + Comment("Number of attempts on current question"), + field.JSON("session_data", map[string]interface{}{}). + Optional(). + Comment("Additional session data as JSON"), + } +} + +// Edges of the GameSession. +func (GameSession) Edges() []ent.Edge { + return []ent.Edge{ + edge.To("attempts", QuestionAttempt.Type), + edge.To("current_question", Question.Type). + Unique(). + Field("current_question_id"), + } +} + +// Indexes of the GameSession. +func (GameSession) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("user_id"), + index.Fields("status"), + index.Fields("start_time"), + index.Fields("end_time"), + index.Fields("status", "start_time"), + index.Fields("total_score"), + } +} \ No newline at end of file diff --git a/backend/shared/database/ent/schema/question.go b/backend/shared/database/ent/schema/question.go new file mode 100644 index 0000000..fe394a0 --- /dev/null +++ b/backend/shared/database/ent/schema/question.go @@ -0,0 +1,71 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// Question holds the schema definition for the Question entity. +type Question struct { + ent.Schema +} + +// Fields of the Question. +func (Question) Fields() []ent.Field { + return []ent.Field{ + field.String("id"). + Unique(). + Immutable(), + field.String("theme"). + NotEmpty(). + Comment("The theme category of the question"), + field.Text("text"). + NotEmpty(). + Comment("The actual question text"), + field.String("answer"). + NotEmpty(). + Comment("The correct answer to the question"), + field.Text("hint"). + Optional(). + Comment("Optional hint for the question"), + field.Enum("difficulty"). + Values("easy", "medium", "hard"). + Default("medium"). + Comment("Difficulty level of the question"), + field.Bool("is_active"). + Default(true). + Comment("Whether this question is currently active"), + field.Time("created_at"). + Default(time.Now). + Immutable(). + Comment("When the question was created"), + field.Time("updated_at"). + Default(time.Now). + UpdateDefault(time.Now). + Comment("When the question was last updated"), + } +} + +// Edges of the Question. +func (Question) Edges() []ent.Edge { + return []ent.Edge{ + edge.To("attempts", QuestionAttempt.Type), + edge.From("current_sessions", GameSession.Type). + Ref("current_question"), + } +} + +// Indexes of the Question. +func (Question) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("theme"), + index.Fields("difficulty"), + index.Fields("is_active"), + index.Fields("created_at"), + index.Fields("theme", "difficulty", "is_active"), + } +} \ No newline at end of file diff --git a/backend/shared/database/ent/schema/questionattempt.go b/backend/shared/database/ent/schema/questionattempt.go new file mode 100644 index 0000000..895d787 --- /dev/null +++ b/backend/shared/database/ent/schema/questionattempt.go @@ -0,0 +1,82 @@ +package schema + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// QuestionAttempt holds the schema definition for the QuestionAttempt entity. +type QuestionAttempt struct { + ent.Schema +} + +// Fields of the QuestionAttempt. +func (QuestionAttempt) Fields() []ent.Field { + return []ent.Field{ + field.String("id"). + Unique(). + Immutable(), + field.String("session_id"). + NotEmpty(). + Comment("ID of the game session this attempt belongs to"), + field.String("question_id"). + NotEmpty(). + Comment("ID of the question being attempted"), + field.Int("attempt_number"). + Positive(). + Comment("Which attempt this is (1, 2, or 3)"), + field.String("submitted_answer"). + NotEmpty(). + Comment("The answer submitted by the player"), + field.Bool("is_correct"). + Comment("Whether the submitted answer was correct"), + field.Bool("used_hint"). + Default(false). + Comment("Whether a hint was used for this attempt"), + field.Int("points_awarded"). + Default(0). + NonNegative(). + Comment("Points awarded for this attempt"), + field.Time("submitted_at"). + Default(time.Now). + Immutable(). + Comment("When this attempt was submitted"), + field.Int64("time_taken_ms"). + NonNegative(). + Comment("Time taken for this attempt in milliseconds"), + field.JSON("metadata", map[string]interface{}{}). + Optional(). + Comment("Additional metadata as JSON"), + } +} + +// Edges of the QuestionAttempt. +func (QuestionAttempt) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("session", GameSession.Type). + Ref("attempts"). + Unique(). + Required(). + Field("session_id"), + edge.To("question", Question.Type). + Unique(). + Required(). + Field("question_id"), + } +} + +// Indexes of the QuestionAttempt. +func (QuestionAttempt) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("session_id"), + index.Fields("question_id"), + index.Fields("submitted_at"), + index.Fields("is_correct"), + index.Fields("session_id", "question_id"), + index.Fields("session_id", "attempt_number"), + } +} \ No newline at end of file diff --git a/backend/shared/database/models.go b/backend/shared/database/models.go new file mode 100644 index 0000000..eb05d60 --- /dev/null +++ b/backend/shared/database/models.go @@ -0,0 +1,50 @@ +package database + +import ( + "time" +) + +// Question represents a quiz question +type Question struct { + ID string `json:"id" db:"id"` + Theme string `json:"theme" db:"theme"` + Text string `json:"text" db:"text"` + Answer string `json:"answer" db:"answer"` + Hint *string `json:"hint" db:"hint"` + Difficulty string `json:"difficulty" db:"difficulty"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// GameSession represents a game session +type GameSession struct { + ID string `json:"id" db:"id"` + PlayerName string `json:"player_name" db:"player_name"` + UserID *string `json:"user_id" db:"user_id"` + TotalScore int `json:"total_score" db:"total_score"` + QuestionsAsked int `json:"questions_asked" db:"questions_asked"` + QuestionsCorrect int `json:"questions_correct" db:"questions_correct"` + HintsUsed int `json:"hints_used" db:"hints_used"` + StartTime time.Time `json:"start_time" db:"start_time"` + EndTime *time.Time `json:"end_time" db:"end_time"` + Status string `json:"status" db:"status"` + CurrentQuestionID *string `json:"current_question_id" db:"current_question_id"` + CurrentAttempts int `json:"current_attempts" db:"current_attempts"` + SessionData *string `json:"session_data" db:"session_data"` +} + +// QuestionAttempt represents an attempt at answering a question +type QuestionAttempt struct { + ID string `json:"id" db:"id"` + SessionID string `json:"session_id" db:"session_id"` + QuestionID string `json:"question_id" db:"question_id"` + AttemptNumber int `json:"attempt_number" db:"attempt_number"` + SubmittedAnswer string `json:"submitted_answer" db:"submitted_answer"` + IsCorrect bool `json:"is_correct" db:"is_correct"` + UsedHint bool `json:"used_hint" db:"used_hint"` + PointsAwarded int `json:"points_awarded" db:"points_awarded"` + SubmittedAt time.Time `json:"submitted_at" db:"submitted_at"` + TimeTakenMs int64 `json:"time_taken_ms" db:"time_taken_ms"` + Metadata *string `json:"metadata" db:"metadata"` +} \ No newline at end of file diff --git a/backend/shared/database/questions.go b/backend/shared/database/questions.go new file mode 100644 index 0000000..2f5cba0 --- /dev/null +++ b/backend/shared/database/questions.go @@ -0,0 +1,156 @@ +package database + +import ( + "context" + "database/sql" + "fmt" +) + +// QuestionRepository handles question operations +type QuestionRepository struct { + client *Client +} + +// NewQuestionRepository creates a new question repository +func NewQuestionRepository(client *Client) *QuestionRepository { + return &QuestionRepository{client: client} +} + +// Create creates a new question +func (r *QuestionRepository) Create(ctx context.Context, question *Question) error { + query := ` + INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ` + + _, err := r.client.db.ExecContext(ctx, query, + question.ID, + question.Theme, + question.Text, + question.Answer, + question.Hint, + question.Difficulty, + question.IsActive, + question.CreatedAt, + question.UpdatedAt, + ) + + return err +} + +// GetByID retrieves a question by ID +func (r *QuestionRepository) GetByID(ctx context.Context, id string) (*Question, error) { + query := ` + SELECT id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at + FROM questions + WHERE id = $1 + ` + + var question Question + err := r.client.db.QueryRowContext(ctx, query, id).Scan( + &question.ID, + &question.Theme, + &question.Text, + &question.Answer, + &question.Hint, + &question.Difficulty, + &question.IsActive, + &question.CreatedAt, + &question.UpdatedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("question not found: %s", id) + } + return nil, err + } + + return &question, nil +} + +// GetByTheme retrieves questions by theme +func (r *QuestionRepository) GetByTheme(ctx context.Context, theme string) ([]*Question, error) { + query := ` + SELECT id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at + FROM questions + WHERE theme = $1 AND is_active = true + ORDER BY created_at DESC + ` + + rows, err := r.client.db.QueryContext(ctx, query, theme) + if err != nil { + return nil, err + } + defer rows.Close() + + var questions []*Question + for rows.Next() { + var question Question + err := rows.Scan( + &question.ID, + &question.Theme, + &question.Text, + &question.Answer, + &question.Hint, + &question.Difficulty, + &question.IsActive, + &question.CreatedAt, + &question.UpdatedAt, + ) + if err != nil { + return nil, err + } + questions = append(questions, &question) + } + + return questions, rows.Err() +} + +// GetRandom retrieves a random question +func (r *QuestionRepository) GetRandom(ctx context.Context, theme string, difficulty string) (*Question, error) { + query := ` + SELECT id, theme, text, answer, hint, difficulty, is_active, created_at, updated_at + FROM questions + WHERE is_active = true + ` + + args := []interface{}{} + argIndex := 1 + + if theme != "" { + query += fmt.Sprintf(" AND theme = $%d", argIndex) + args = append(args, theme) + argIndex++ + } + + if difficulty != "" { + query += fmt.Sprintf(" AND difficulty = $%d", argIndex) + args = append(args, difficulty) + argIndex++ + } + + query += " ORDER BY RANDOM() LIMIT 1" + + var question Question + err := r.client.db.QueryRowContext(ctx, query, args...).Scan( + &question.ID, + &question.Theme, + &question.Text, + &question.Answer, + &question.Hint, + &question.Difficulty, + &question.IsActive, + &question.CreatedAt, + &question.UpdatedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("no questions found for theme: %s, difficulty: %s", theme, difficulty) + } + return nil, err + } + + return &question, nil +} \ No newline at end of file diff --git a/backend/shared/go.mod b/backend/shared/go.mod new file mode 100644 index 0000000..21c4cad --- /dev/null +++ b/backend/shared/go.mod @@ -0,0 +1,29 @@ +module knowfoolery/backend/shared + +go 1.21 + +require ( + entgo.io/ent v0.13.1 + github.com/gofiber/fiber/v2 v2.52.0 + github.com/golang-jwt/jwt/v5 v5.2.0 + github.com/lib/pq v1.10.9 + github.com/mattn/go-sqlite3 v1.14.19 + github.com/stretchr/testify v1.8.2 + github.com/valyala/fasthttp v1.51.0 +) + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/shared/go.sum b/backend/shared/go.sum new file mode 100644 index 0000000..d919131 --- /dev/null +++ b/backend/shared/go.sum @@ -0,0 +1,52 @@ +entgo.io/ent v0.13.1 h1:uD8QwN1h6SNphdCCzmkMN3feSUzNnVvV/WIkHKMbzOE= +entgo.io/ent v0.13.1/go.mod h1:qCEmo+biw3ccBn9OyL4ZK5dfpwg++l1Gxwac5B1206A= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI= +github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/shared/utils/config.go b/backend/shared/utils/config.go new file mode 100644 index 0000000..4837c5d --- /dev/null +++ b/backend/shared/utils/config.go @@ -0,0 +1,151 @@ +package utils + +import ( + "fmt" + "strings" + "time" +) + +// DatabaseConfig represents database configuration from environment +type DatabaseConfig struct { + URL string + Driver string + Host string + Port int + User string + Password string + Database string + SSLMode string +} + +// ServerConfig represents server configuration from environment +type ServerConfig struct { + Port string + Host string + ReadTimeout time.Duration + WriteTimeout time.Duration + ShutdownTimeout time.Duration + LogLevel string +} + +// AuthConfig represents authentication configuration from environment +type AuthConfig struct { + JWTSecret string + TokenExpiration time.Duration + Issuer string + RequireMFA bool +} + +// GetDatabaseConfig loads database configuration from environment variables +func GetDatabaseConfig() DatabaseConfig { + config := DatabaseConfig{ + URL: GetEnvOrDefault("DATABASE_URL", "sqlite://./app.db"), + Host: GetEnvOrDefault("DB_HOST", "localhost"), + Port: GetEnvOrDefaultInt("DB_PORT", 5432), + User: GetEnvOrDefault("DB_USER", "app"), + Password: GetEnvOrDefault("DB_PASSWORD", "password"), + Database: GetEnvOrDefault("DB_NAME", "app"), + SSLMode: GetEnvOrDefault("DB_SSLMODE", "disable"), + } + + // Determine driver from URL + if strings.HasPrefix(config.URL, "postgres") { + config.Driver = "postgres" + } else { + config.Driver = "sqlite3" + } + + return config +} + +// GetServerConfig loads server configuration from environment variables +func GetServerConfig(defaultPort string) ServerConfig { + return ServerConfig{ + Port: GetEnvOrDefault("PORT", defaultPort), + Host: GetEnvOrDefault("HOST", ""), + ReadTimeout: GetEnvOrDefaultDuration("READ_TIMEOUT", 30*time.Second), + WriteTimeout: GetEnvOrDefaultDuration("WRITE_TIMEOUT", 30*time.Second), + ShutdownTimeout: GetEnvOrDefaultDuration("SHUTDOWN_TIMEOUT", 30*time.Second), + LogLevel: GetEnvOrDefault("LOG_LEVEL", "info"), + } +} + +// GetAuthConfig loads authentication configuration from environment variables +func GetAuthConfig(serviceName string) AuthConfig { + return AuthConfig{ + JWTSecret: GetEnvOrDefault("JWT_SECRET", "dev-secret-"+serviceName), + TokenExpiration: GetEnvOrDefaultDuration("TOKEN_EXPIRATION", 24*time.Hour), + Issuer: GetEnvOrDefault("JWT_ISSUER", "knowfoolery-"+serviceName), + RequireMFA: GetEnvOrDefaultBool("REQUIRE_MFA", false), + } +} + +// GetListenAddress returns the address to listen on +func (c ServerConfig) GetListenAddress() string { + if c.Host != "" { + return fmt.Sprintf("%s:%s", c.Host, c.Port) + } + return ":" + c.Port +} + +// IsProduction checks if the environment is production +func IsProduction() bool { + env := GetEnvOrDefault("ENVIRONMENT", "development") + return strings.ToLower(env) == "production" || strings.ToLower(env) == "prod" +} + +// IsDevelopment checks if the environment is development +func IsDevelopment() bool { + env := GetEnvOrDefault("ENVIRONMENT", "development") + return strings.ToLower(env) == "development" || strings.ToLower(env) == "dev" +} + +// GetEnvironment returns the current environment +func GetEnvironment() string { + return GetEnvOrDefault("ENVIRONMENT", "development") +} + +// GetServiceName returns the service name from environment or default +func GetServiceName(defaultName string) string { + return GetEnvOrDefault("SERVICE_NAME", defaultName) +} + +// GetVersion returns the service version from environment or default +func GetVersion() string { + return GetEnvOrDefault("VERSION", "development") +} + +// LogConfig logs the configuration (without sensitive data) +func (c DatabaseConfig) LogSafe() map[string]interface{} { + return map[string]interface{}{ + "driver": c.Driver, + "host": c.Host, + "port": c.Port, + "database": c.Database, + "ssl_mode": c.SSLMode, + "user": c.User, + // Note: Never log passwords! + } +} + +// LogConfig logs the server configuration +func (c ServerConfig) LogSafe() map[string]interface{} { + return map[string]interface{}{ + "port": c.Port, + "host": c.Host, + "read_timeout": c.ReadTimeout.String(), + "write_timeout": c.WriteTimeout.String(), + "shutdown_timeout": c.ShutdownTimeout.String(), + "log_level": c.LogLevel, + } +} + +// LogConfig logs the auth configuration (without sensitive data) +func (c AuthConfig) LogSafe() map[string]interface{} { + return map[string]interface{}{ + "issuer": c.Issuer, + "token_expiration": c.TokenExpiration.String(), + "require_mfa": c.RequireMFA, + // Note: Never log JWT secrets! + } +} \ No newline at end of file diff --git a/backend/shared/utils/config_test.go b/backend/shared/utils/config_test.go new file mode 100644 index 0000000..96da636 --- /dev/null +++ b/backend/shared/utils/config_test.go @@ -0,0 +1,484 @@ +package utils + +import ( + "os" + "testing" + "time" +) + +func TestGetDatabaseConfig(t *testing.T) { + // Clean up environment before test + cleanupDBEnv := func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("DB_HOST") + os.Unsetenv("DB_PORT") + os.Unsetenv("DB_USER") + os.Unsetenv("DB_PASSWORD") + os.Unsetenv("DB_NAME") + os.Unsetenv("DB_SSLMODE") + } + + t.Run("Default configuration", func(t *testing.T) { + cleanupDBEnv() + + config := GetDatabaseConfig() + + if config.URL != "sqlite://./app.db" { + t.Errorf("Expected default URL 'sqlite://./app.db', got '%s'", config.URL) + } + + if config.Driver != "sqlite3" { + t.Errorf("Expected driver 'sqlite3', got '%s'", config.Driver) + } + + if config.Host != "localhost" { + t.Errorf("Expected host 'localhost', got '%s'", config.Host) + } + + if config.Port != 5432 { + t.Errorf("Expected port 5432, got %d", config.Port) + } + }) + + t.Run("PostgreSQL configuration", func(t *testing.T) { + cleanupDBEnv() + + os.Setenv("DATABASE_URL", "postgres://user:pass@host:5433/dbname") + os.Setenv("DB_HOST", "custom-host") + os.Setenv("DB_PORT", "5433") + os.Setenv("DB_USER", "custom-user") + os.Setenv("DB_PASSWORD", "custom-pass") + os.Setenv("DB_NAME", "custom-db") + os.Setenv("DB_SSLMODE", "require") + + defer cleanupDBEnv() + + config := GetDatabaseConfig() + + if config.Driver != "postgres" { + t.Errorf("Expected driver 'postgres', got '%s'", config.Driver) + } + + if config.Host != "custom-host" { + t.Errorf("Expected host 'custom-host', got '%s'", config.Host) + } + + if config.Port != 5433 { + t.Errorf("Expected port 5433, got %d", config.Port) + } + + if config.User != "custom-user" { + t.Errorf("Expected user 'custom-user', got '%s'", config.User) + } + + if config.SSLMode != "require" { + t.Errorf("Expected sslmode 'require', got '%s'", config.SSLMode) + } + }) + + t.Run("Unknown database URL defaults to sqlite3", func(t *testing.T) { + cleanupDBEnv() + + os.Setenv("DATABASE_URL", "unknown://user:pass@host:1234/dbname") + defer cleanupDBEnv() + + config := GetDatabaseConfig() + + if config.Driver != "sqlite3" { + t.Errorf("Expected driver 'sqlite3' for unknown URL, got '%s'", config.Driver) + } + + if config.URL != "unknown://user:pass@host:1234/dbname" { + t.Errorf("URL should be preserved as-is, got '%s'", config.URL) + } + }) + +} + +func TestGetServerConfig(t *testing.T) { + cleanupServerEnv := func() { + os.Unsetenv("PORT") + os.Unsetenv("HOST") + os.Unsetenv("READ_TIMEOUT") + os.Unsetenv("WRITE_TIMEOUT") + os.Unsetenv("SHUTDOWN_TIMEOUT") + os.Unsetenv("LOG_LEVEL") + } + + t.Run("Default configuration", func(t *testing.T) { + cleanupServerEnv() + + config := GetServerConfig("8080") + + if config.Port != "8080" { + t.Errorf("Expected port '8080', got '%s'", config.Port) + } + + if config.Host != "" { + t.Errorf("Expected empty host, got '%s'", config.Host) + } + + if config.ReadTimeout != 30*time.Second { + t.Errorf("Expected read timeout 30s, got %v", config.ReadTimeout) + } + + if config.LogLevel != "info" { + t.Errorf("Expected log level 'info', got '%s'", config.LogLevel) + } + }) + + t.Run("Custom configuration", func(t *testing.T) { + cleanupServerEnv() + + os.Setenv("PORT", "9000") + os.Setenv("HOST", "127.0.0.1") + os.Setenv("READ_TIMEOUT", "45s") + os.Setenv("WRITE_TIMEOUT", "60s") + os.Setenv("SHUTDOWN_TIMEOUT", "10s") + os.Setenv("LOG_LEVEL", "debug") + + defer cleanupServerEnv() + + config := GetServerConfig("8080") + + if config.Port != "9000" { + t.Errorf("Expected port '9000', got '%s'", config.Port) + } + + if config.Host != "127.0.0.1" { + t.Errorf("Expected host '127.0.0.1', got '%s'", config.Host) + } + + if config.ReadTimeout != 45*time.Second { + t.Errorf("Expected read timeout 45s, got %v", config.ReadTimeout) + } + + if config.WriteTimeout != 60*time.Second { + t.Errorf("Expected write timeout 60s, got %v", config.WriteTimeout) + } + + if config.ShutdownTimeout != 10*time.Second { + t.Errorf("Expected shutdown timeout 10s, got %v", config.ShutdownTimeout) + } + + if config.LogLevel != "debug" { + t.Errorf("Expected log level 'debug', got '%s'", config.LogLevel) + } + }) +} + +func TestServerConfigGetListenAddress(t *testing.T) { + tests := []struct { + name string + host string + port string + expected string + }{ + { + name: "With host", + host: "127.0.0.1", + port: "8080", + expected: "127.0.0.1:8080", + }, + { + name: "Without host", + host: "", + port: "8080", + expected: ":8080", + }, + { + name: "With localhost", + host: "localhost", + port: "3000", + expected: "localhost:3000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := ServerConfig{ + Host: tt.host, + Port: tt.port, + } + + result := config.GetListenAddress() + + if result != tt.expected { + t.Errorf("GetListenAddress() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestGetAuthConfig(t *testing.T) { + cleanupAuthEnv := func() { + os.Unsetenv("JWT_SECRET") + os.Unsetenv("TOKEN_EXPIRATION") + os.Unsetenv("JWT_ISSUER") + os.Unsetenv("REQUIRE_MFA") + } + + t.Run("Default configuration", func(t *testing.T) { + cleanupAuthEnv() + + config := GetAuthConfig("test-service") + + if config.JWTSecret != "dev-secret-test-service" { + t.Errorf("Expected JWT secret 'dev-secret-test-service', got '%s'", config.JWTSecret) + } + + if config.TokenExpiration != 24*time.Hour { + t.Errorf("Expected token expiration 24h, got %v", config.TokenExpiration) + } + + if config.Issuer != "knowfoolery-test-service" { + t.Errorf("Expected issuer 'knowfoolery-test-service', got '%s'", config.Issuer) + } + + if config.RequireMFA != false { + t.Errorf("Expected require MFA false, got %v", config.RequireMFA) + } + }) + + t.Run("Custom configuration", func(t *testing.T) { + cleanupAuthEnv() + + os.Setenv("JWT_SECRET", "custom-secret") + os.Setenv("TOKEN_EXPIRATION", "2h") + os.Setenv("JWT_ISSUER", "custom-issuer") + os.Setenv("REQUIRE_MFA", "true") + + defer cleanupAuthEnv() + + config := GetAuthConfig("test-service") + + if config.JWTSecret != "custom-secret" { + t.Errorf("Expected JWT secret 'custom-secret', got '%s'", config.JWTSecret) + } + + if config.TokenExpiration != 2*time.Hour { + t.Errorf("Expected token expiration 2h, got %v", config.TokenExpiration) + } + + if config.Issuer != "custom-issuer" { + t.Errorf("Expected issuer 'custom-issuer', got '%s'", config.Issuer) + } + + if config.RequireMFA != true { + t.Errorf("Expected require MFA true, got %v", config.RequireMFA) + } + }) +} + +func TestEnvironmentChecks(t *testing.T) { + cleanupEnv := func() { + os.Unsetenv("ENVIRONMENT") + } + + tests := []struct { + name string + envValue string + isProd bool + isDev bool + getEnv string + }{ + { + name: "Production environment", + envValue: "production", + isProd: true, + isDev: false, + getEnv: "production", + }, + { + name: "Prod environment", + envValue: "prod", + isProd: true, + isDev: false, + getEnv: "prod", + }, + { + name: "Development environment", + envValue: "development", + isProd: false, + isDev: true, + getEnv: "development", + }, + { + name: "Dev environment", + envValue: "dev", + isProd: false, + isDev: true, + getEnv: "dev", + }, + { + name: "Default environment", + envValue: "", + isProd: false, + isDev: true, + getEnv: "development", + }, + { + name: "Custom environment", + envValue: "staging", + isProd: false, + isDev: false, + getEnv: "staging", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanupEnv() + + if tt.envValue != "" { + os.Setenv("ENVIRONMENT", tt.envValue) + defer cleanupEnv() + } + + if IsProduction() != tt.isProd { + t.Errorf("IsProduction() = %v, want %v", IsProduction(), tt.isProd) + } + + if IsDevelopment() != tt.isDev { + t.Errorf("IsDevelopment() = %v, want %v", IsDevelopment(), tt.isDev) + } + + if GetEnvironment() != tt.getEnv { + t.Errorf("GetEnvironment() = %v, want %v", GetEnvironment(), tt.getEnv) + } + }) + } +} + +func TestGetServiceName(t *testing.T) { + cleanupEnv := func() { + os.Unsetenv("SERVICE_NAME") + } + + t.Run("Default service name", func(t *testing.T) { + cleanupEnv() + + result := GetServiceName("default-service") + + if result != "default-service" { + t.Errorf("Expected 'default-service', got '%s'", result) + } + }) + + t.Run("Custom service name", func(t *testing.T) { + cleanupEnv() + + os.Setenv("SERVICE_NAME", "custom-service") + defer cleanupEnv() + + result := GetServiceName("default-service") + + if result != "custom-service" { + t.Errorf("Expected 'custom-service', got '%s'", result) + } + }) +} + +func TestGetVersion(t *testing.T) { + cleanupEnv := func() { + os.Unsetenv("VERSION") + } + + t.Run("Default version", func(t *testing.T) { + cleanupEnv() + + result := GetVersion() + + if result != "development" { + t.Errorf("Expected 'development', got '%s'", result) + } + }) + + t.Run("Custom version", func(t *testing.T) { + cleanupEnv() + + os.Setenv("VERSION", "1.2.3") + defer cleanupEnv() + + result := GetVersion() + + if result != "1.2.3" { + t.Errorf("Expected '1.2.3', got '%s'", result) + } + }) +} + +func TestLogSafeMethods(t *testing.T) { + t.Run("DatabaseConfig LogSafe", func(t *testing.T) { + config := DatabaseConfig{ + Driver: "postgres", + Host: "localhost", + Port: 5432, + User: "testuser", + Password: "secret-password", + Database: "testdb", + SSLMode: "disable", + } + + safe := config.LogSafe() + + // Check that all non-sensitive fields are present + if safe["driver"] != "postgres" { + t.Error("Driver not present in safe log") + } + if safe["host"] != "localhost" { + t.Error("Host not present in safe log") + } + if safe["port"] != 5432 { + t.Error("Port not present in safe log") + } + + // Check that password is NOT present + if _, exists := safe["password"]; exists { + t.Error("Password should not be present in safe log") + } + }) + + t.Run("ServerConfig LogSafe", func(t *testing.T) { + config := ServerConfig{ + Port: "8080", + Host: "localhost", + ReadTimeout: 30 * time.Second, + WriteTimeout: 45 * time.Second, + LogLevel: "debug", + } + + safe := config.LogSafe() + + if safe["port"] != "8080" { + t.Error("Port not present in safe log") + } + if safe["log_level"] != "debug" { + t.Error("Log level not present in safe log") + } + }) + + t.Run("AuthConfig LogSafe", func(t *testing.T) { + config := AuthConfig{ + JWTSecret: "secret-jwt-key", + TokenExpiration: 24 * time.Hour, + Issuer: "test-issuer", + RequireMFA: true, + } + + safe := config.LogSafe() + + // Check that non-sensitive fields are present + if safe["issuer"] != "test-issuer" { + t.Error("Issuer not present in safe log") + } + if safe["require_mfa"] != true { + t.Error("RequireMFA not present in safe log") + } + + // Check that JWT secret is NOT present + if _, exists := safe["jwt_secret"]; exists { + t.Error("JWT secret should not be present in safe log") + } + }) +} \ No newline at end of file diff --git a/backend/shared/utils/utils.go b/backend/shared/utils/utils.go new file mode 100644 index 0000000..f67052c --- /dev/null +++ b/backend/shared/utils/utils.go @@ -0,0 +1,64 @@ +package utils + +import ( + "os" + "strconv" + "time" +) + +// FormatTime formats time in a standard format +func FormatTime(t time.Time) string { + return t.Format(time.RFC3339) +} + +// GetEnvOrDefault returns environment variable value or default if not set +func GetEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// GetEnvOrDefaultInt returns environment variable as int or default if not set/invalid +func GetEnvOrDefaultInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} + +// GetEnvOrDefaultBool returns environment variable as bool or default if not set/invalid +func GetEnvOrDefaultBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + if boolValue, err := strconv.ParseBool(value); err == nil { + return boolValue + } + } + return defaultValue +} + +// GetEnvOrDefaultDuration returns environment variable as duration or default if not set/invalid +func GetEnvOrDefaultDuration(key string, defaultValue time.Duration) time.Duration { + if value := os.Getenv(key); value != "" { + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + } + return defaultValue +} + +// MustGetEnv returns environment variable value or panics if not set +func MustGetEnv(key string) string { + if value := os.Getenv(key); value != "" { + return value + } + panic("Required environment variable not set: " + key) +} + +// IsEnvSet checks if an environment variable is set (even if empty) +func IsEnvSet(key string) bool { + _, exists := os.LookupEnv(key) + return exists +} \ No newline at end of file diff --git a/backend/shared/utils/utils_test.go b/backend/shared/utils/utils_test.go new file mode 100644 index 0000000..b12326e --- /dev/null +++ b/backend/shared/utils/utils_test.go @@ -0,0 +1,437 @@ +package utils + +import ( + "os" + "testing" + "time" +) + +func TestFormatTime(t *testing.T) { + testTime := time.Date(2024, 8, 20, 15, 30, 45, 0, time.UTC) + expected := "2024-08-20T15:30:45Z" + + result := FormatTime(testTime) + + if result != expected { + t.Errorf("FormatTime() = %v, want %v", result, expected) + } +} + +func TestGetEnvOrDefault(t *testing.T) { + tests := []struct { + name string + key string + envValue string + defaultValue string + expected string + setEnv bool + }{ + { + name: "Environment variable exists", + key: "TEST_VAR_EXISTS", + envValue: "env_value", + defaultValue: "default_value", + expected: "env_value", + setEnv: true, + }, + { + name: "Environment variable doesn't exist", + key: "TEST_VAR_NOT_EXISTS", + defaultValue: "default_value", + expected: "default_value", + setEnv: false, + }, + { + name: "Environment variable is empty string", + key: "TEST_VAR_EMPTY", + envValue: "", + defaultValue: "default_value", + expected: "default_value", + setEnv: true, + }, + { + name: "Environment variable has whitespace", + key: "TEST_VAR_WHITESPACE", + envValue: " value ", + defaultValue: "default_value", + expected: " value ", + setEnv: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clean up environment + os.Unsetenv(tt.key) + + // Set environment variable if needed + if tt.setEnv { + os.Setenv(tt.key, tt.envValue) + defer os.Unsetenv(tt.key) + } + + result := GetEnvOrDefault(tt.key, tt.defaultValue) + + if result != tt.expected { + t.Errorf("GetEnvOrDefault(%q, %q) = %v, want %v", tt.key, tt.defaultValue, result, tt.expected) + } + }) + } +} + +func TestGetEnvOrDefaultInt(t *testing.T) { + tests := []struct { + name string + key string + envValue string + defaultValue int + expected int + setEnv bool + }{ + { + name: "Valid integer environment variable", + key: "TEST_INT_VALID", + envValue: "42", + defaultValue: 10, + expected: 42, + setEnv: true, + }, + { + name: "Invalid integer environment variable", + key: "TEST_INT_INVALID", + envValue: "not_a_number", + defaultValue: 10, + expected: 10, + setEnv: true, + }, + { + name: "Environment variable doesn't exist", + key: "TEST_INT_NOT_EXISTS", + defaultValue: 10, + expected: 10, + setEnv: false, + }, + { + name: "Negative integer", + key: "TEST_INT_NEGATIVE", + envValue: "-123", + defaultValue: 10, + expected: -123, + setEnv: true, + }, + { + name: "Zero value", + key: "TEST_INT_ZERO", + envValue: "0", + defaultValue: 10, + expected: 0, + setEnv: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clean up environment + os.Unsetenv(tt.key) + + // Set environment variable if needed + if tt.setEnv { + os.Setenv(tt.key, tt.envValue) + defer os.Unsetenv(tt.key) + } + + result := GetEnvOrDefaultInt(tt.key, tt.defaultValue) + + if result != tt.expected { + t.Errorf("GetEnvOrDefaultInt(%q, %d) = %v, want %v", tt.key, tt.defaultValue, result, tt.expected) + } + }) + } +} + +func TestGetEnvOrDefaultBool(t *testing.T) { + tests := []struct { + name string + key string + envValue string + defaultValue bool + expected bool + setEnv bool + }{ + { + name: "True boolean", + key: "TEST_BOOL_TRUE", + envValue: "true", + defaultValue: false, + expected: true, + setEnv: true, + }, + { + name: "False boolean", + key: "TEST_BOOL_FALSE", + envValue: "false", + defaultValue: true, + expected: false, + setEnv: true, + }, + { + name: "1 as true", + key: "TEST_BOOL_ONE", + envValue: "1", + defaultValue: false, + expected: true, + setEnv: true, + }, + { + name: "0 as false", + key: "TEST_BOOL_ZERO", + envValue: "0", + defaultValue: true, + expected: false, + setEnv: true, + }, + { + name: "Invalid boolean", + key: "TEST_BOOL_INVALID", + envValue: "maybe", + defaultValue: true, + expected: true, + setEnv: true, + }, + { + name: "Environment variable doesn't exist", + key: "TEST_BOOL_NOT_EXISTS", + defaultValue: false, + expected: false, + setEnv: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clean up environment + os.Unsetenv(tt.key) + + // Set environment variable if needed + if tt.setEnv { + os.Setenv(tt.key, tt.envValue) + defer os.Unsetenv(tt.key) + } + + result := GetEnvOrDefaultBool(tt.key, tt.defaultValue) + + if result != tt.expected { + t.Errorf("GetEnvOrDefaultBool(%q, %v) = %v, want %v", tt.key, tt.defaultValue, result, tt.expected) + } + }) + } +} + +func TestGetEnvOrDefaultDuration(t *testing.T) { + tests := []struct { + name string + key string + envValue string + defaultValue time.Duration + expected time.Duration + setEnv bool + }{ + { + name: "Valid duration in seconds", + key: "TEST_DURATION_SECONDS", + envValue: "30s", + defaultValue: 10 * time.Second, + expected: 30 * time.Second, + setEnv: true, + }, + { + name: "Valid duration in minutes", + key: "TEST_DURATION_MINUTES", + envValue: "5m", + defaultValue: 1 * time.Minute, + expected: 5 * time.Minute, + setEnv: true, + }, + { + name: "Valid duration in hours", + key: "TEST_DURATION_HOURS", + envValue: "2h", + defaultValue: 1 * time.Hour, + expected: 2 * time.Hour, + setEnv: true, + }, + { + name: "Invalid duration format", + key: "TEST_DURATION_INVALID", + envValue: "not_a_duration", + defaultValue: 10 * time.Second, + expected: 10 * time.Second, + setEnv: true, + }, + { + name: "Environment variable doesn't exist", + key: "TEST_DURATION_NOT_EXISTS", + defaultValue: 5 * time.Minute, + expected: 5 * time.Minute, + setEnv: false, + }, + { + name: "Complex duration", + key: "TEST_DURATION_COMPLEX", + envValue: "1h30m45s", + defaultValue: 1 * time.Second, + expected: 1*time.Hour + 30*time.Minute + 45*time.Second, + setEnv: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clean up environment + os.Unsetenv(tt.key) + + // Set environment variable if needed + if tt.setEnv { + os.Setenv(tt.key, tt.envValue) + defer os.Unsetenv(tt.key) + } + + result := GetEnvOrDefaultDuration(tt.key, tt.defaultValue) + + if result != tt.expected { + t.Errorf("GetEnvOrDefaultDuration(%q, %v) = %v, want %v", tt.key, tt.defaultValue, result, tt.expected) + } + }) + } +} + +func TestMustGetEnv(t *testing.T) { + t.Run("Environment variable exists", func(t *testing.T) { + key := "TEST_MUST_EXISTS" + value := "required_value" + + os.Setenv(key, value) + defer os.Unsetenv(key) + + result := MustGetEnv(key) + + if result != value { + t.Errorf("MustGetEnv(%q) = %v, want %v", key, result, value) + } + }) + + t.Run("Environment variable doesn't exist - should panic", func(t *testing.T) { + key := "TEST_MUST_NOT_EXISTS" + + // Ensure the environment variable doesn't exist + os.Unsetenv(key) + + defer func() { + if r := recover(); r == nil { + t.Errorf("MustGetEnv(%q) should have panicked but didn't", key) + } else { + expectedMsg := "Required environment variable not set: " + key + if r != expectedMsg { + t.Errorf("MustGetEnv(%q) panic message = %v, want %v", key, r, expectedMsg) + } + } + }() + + MustGetEnv(key) + }) + + t.Run("Environment variable is empty - should panic", func(t *testing.T) { + key := "TEST_MUST_EMPTY" + + os.Setenv(key, "") + defer os.Unsetenv(key) + + defer func() { + if r := recover(); r == nil { + t.Errorf("MustGetEnv(%q) should have panicked for empty value but didn't", key) + } + }() + + MustGetEnv(key) + }) +} + +func TestIsEnvSet(t *testing.T) { + tests := []struct { + name string + key string + envValue string + expected bool + setEnv bool + }{ + { + name: "Environment variable exists with value", + key: "TEST_IS_SET_VALUE", + envValue: "some_value", + expected: true, + setEnv: true, + }, + { + name: "Environment variable exists but empty", + key: "TEST_IS_SET_EMPTY", + envValue: "", + expected: true, + setEnv: true, + }, + { + name: "Environment variable doesn't exist", + key: "TEST_IS_SET_NOT_EXISTS", + expected: false, + setEnv: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clean up environment + os.Unsetenv(tt.key) + + // Set environment variable if needed + if tt.setEnv { + os.Setenv(tt.key, tt.envValue) + defer os.Unsetenv(tt.key) + } + + result := IsEnvSet(tt.key) + + if result != tt.expected { + t.Errorf("IsEnvSet(%q) = %v, want %v", tt.key, result, tt.expected) + } + }) + } +} + +// BenchmarkGetEnvOrDefault benchmarks the GetEnvOrDefault function +func BenchmarkGetEnvOrDefault(b *testing.B) { + key := "BENCHMARK_TEST_VAR" + value := "benchmark_value" + defaultValue := "default_value" + + os.Setenv(key, value) + defer os.Unsetenv(key) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + GetEnvOrDefault(key, defaultValue) + } +} + +// BenchmarkGetEnvOrDefaultInt benchmarks the GetEnvOrDefaultInt function +func BenchmarkGetEnvOrDefaultInt(b *testing.B) { + key := "BENCHMARK_TEST_INT_VAR" + value := "42" + defaultValue := 10 + + os.Setenv(key, value) + defer os.Unsetenv(key) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + GetEnvOrDefaultInt(key, defaultValue) + } +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e5e190f --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module knowfoolery + +go 1.25.0 + +require github.com/stretchr/testify v1.10.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7bfdabe --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= diff --git a/infrastructure/docker/docker-compose.basic.yml b/infrastructure/docker/docker-compose.basic.yml new file mode 100644 index 0000000..2502bc1 --- /dev/null +++ b/infrastructure/docker/docker-compose.basic.yml @@ -0,0 +1,106 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: knowfoolery-postgres + environment: + POSTGRES_DB: knowfoolery + POSTGRES_USER: knowfoolery + POSTGRES_PASSWORD: dev-password-2024 + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U knowfoolery -d knowfoolery"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + networks: + - knowfoolery + + # Game Service + game-service: + build: + context: ../../backend/services/game-service + dockerfile: Dockerfile + container_name: knowfoolery-game-service + environment: + - PORT=3001 + - DATABASE_URL=postgres://knowfoolery:dev-password-2024@postgres:5432/knowfoolery?sslmode=disable + ports: + - "3001:3001" + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped + networks: + - knowfoolery + + # Question Service + question-service: + build: + context: ../../backend/services/question-service + dockerfile: Dockerfile + container_name: knowfoolery-question-service + environment: + - PORT=3002 + - DATABASE_URL=postgres://knowfoolery:dev-password-2024@postgres:5432/knowfoolery?sslmode=disable + ports: + - "3002:3002" + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3002/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped + networks: + - knowfoolery + + # Gateway Service + gateway-service: + build: + context: ../../backend/services/gateway-service + dockerfile: Dockerfile + container_name: knowfoolery-gateway + environment: + - PORT=3000 + - GAME_SERVICE_URL=http://game-service:3001 + - QUESTION_SERVICE_URL=http://question-service:3002 + ports: + - "3000:3000" + depends_on: + - game-service + - question-service + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + restart: unless-stopped + networks: + - knowfoolery + +volumes: + postgres_data: + driver: local + +networks: + knowfoolery: + driver: bridge \ No newline at end of file diff --git a/infrastructure/docker/init-db.sql b/infrastructure/docker/init-db.sql new file mode 100644 index 0000000..b5ae3d8 --- /dev/null +++ b/infrastructure/docker/init-db.sql @@ -0,0 +1,112 @@ +-- Know Foolery Database Initialization +-- This script sets up the basic database structure for Phase 1A + +-- Create database (if not exists - handled by POSTGRES_DB env var) +\c knowfoolery; + +-- Create tables +CREATE TABLE IF NOT EXISTS questions ( + id TEXT PRIMARY KEY, + theme TEXT NOT NULL, + text TEXT NOT NULL, + answer TEXT NOT NULL, + hint TEXT, + difficulty TEXT DEFAULT 'medium' CHECK (difficulty IN ('easy', 'medium', 'hard')), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS game_sessions ( + id TEXT PRIMARY KEY, + player_name TEXT NOT NULL, + user_id TEXT, + total_score INTEGER DEFAULT 0, + questions_asked INTEGER DEFAULT 0, + questions_correct INTEGER DEFAULT 0, + hints_used INTEGER DEFAULT 0, + start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMP, + status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'timeout', 'abandoned')), + current_question_id TEXT, + current_attempts INTEGER DEFAULT 0, + session_data TEXT +); + +CREATE TABLE IF NOT EXISTS question_attempts ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + question_id TEXT NOT NULL, + attempt_number INTEGER NOT NULL, + submitted_answer TEXT NOT NULL, + is_correct BOOLEAN NOT NULL, + used_hint BOOLEAN DEFAULT FALSE, + points_awarded INTEGER DEFAULT 0, + submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + time_taken_ms BIGINT DEFAULT 0, + metadata TEXT +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_questions_theme ON questions(theme); +CREATE INDEX IF NOT EXISTS idx_questions_difficulty ON questions(difficulty); +CREATE INDEX IF NOT EXISTS idx_questions_active ON questions(is_active); +CREATE INDEX IF NOT EXISTS idx_questions_theme_active ON questions(theme, is_active); + +CREATE INDEX IF NOT EXISTS idx_sessions_user ON game_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_status ON game_sessions(status); +CREATE INDEX IF NOT EXISTS idx_sessions_score ON game_sessions(total_score DESC); +CREATE INDEX IF NOT EXISTS idx_sessions_start_time ON game_sessions(start_time); + +CREATE INDEX IF NOT EXISTS idx_attempts_session ON question_attempts(session_id); +CREATE INDEX IF NOT EXISTS idx_attempts_question ON question_attempts(question_id); +CREATE INDEX IF NOT EXISTS idx_attempts_session_question ON question_attempts(session_id, question_id); + +-- Insert sample data for testing +INSERT INTO questions (id, theme, text, answer, hint, difficulty, is_active) VALUES +('q1', 'Geography', 'What is the capital of France?', 'Paris', 'It''s also known as the City of Light', 'easy', true), +('q2', 'Geography', 'Which country has the most time zones?', 'France', 'This country has territories across the globe', 'medium', true), +('q3', 'Science', 'What is the chemical symbol for gold?', 'Au', 'It comes from the Latin word aurum', 'medium', true), +('q4', 'History', 'In which year did World War II end?', '1945', 'It was in the mid-1940s', 'easy', true), +('q5', 'Geography', 'What is the smallest country in the world?', 'Vatican City', 'It''s located within Rome', 'hard', true), +('q6', 'Science', 'What is the speed of light in vacuum?', '299792458', 'About 300,000 km/s', 'hard', true), +('q7', 'Literature', 'Who wrote "Romeo and Juliet"?', 'Shakespeare', 'An English playwright from the 16th century', 'easy', true), +('q8', 'Mathematics', 'What is the value of Pi to 2 decimal places?', '3.14', 'The ratio of circumference to diameter', 'easy', true), +('q9', 'Sports', 'How many players are on a basketball team on court?', '5', 'Same as fingers on one hand', 'easy', true), +('q10', 'Technology', 'What does HTTP stand for?', 'HyperText Transfer Protocol', 'It''s about transferring hypertext', 'medium', true); + +-- Insert a sample game session for testing +INSERT INTO game_sessions (id, player_name, user_id, total_score, questions_asked, questions_correct, hints_used, status) VALUES +('session-1', 'Test Player', 'player-1', 150, 3, 2, 1, 'active'); + +-- Insert sample attempts for testing +INSERT INTO question_attempts (id, session_id, question_id, attempt_number, submitted_answer, is_correct, used_hint, points_awarded, time_taken_ms) VALUES +('attempt-1', 'session-1', 'q1', 1, 'Paris', true, false, 100, 5000), +('attempt-2', 'session-1', 'q2', 1, 'Russia', false, true, 0, 8000), +('attempt-3', 'session-1', 'q2', 2, 'France', true, true, 50, 3000); + +-- Create a function to update the updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- Create trigger to automatically update updated_at +CREATE TRIGGER update_questions_updated_at + BEFORE UPDATE ON questions + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Grant permissions +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO knowfoolery; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO knowfoolery; + +-- Print success message +\echo 'Database initialized successfully with sample data!' +\echo 'Tables created: questions, game_sessions, question_attempts' +\echo 'Sample questions inserted: 10' +\echo 'Sample game session inserted: 1' +\echo 'Sample attempts inserted: 3' \ No newline at end of file diff --git a/scripts/start-postgres.sh b/scripts/start-postgres.sh new file mode 100755 index 0000000..35bea2a --- /dev/null +++ b/scripts/start-postgres.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +# Start PostgreSQL container for development +echo "Starting PostgreSQL container for Know Foolery development..." + +# Stop and remove existing container if it exists +docker stop knowfoolery-postgres 2>/dev/null || true +docker rm knowfoolery-postgres 2>/dev/null || true + +# Start PostgreSQL container +docker run -d \ + --name knowfoolery-postgres \ + -e POSTGRES_DB=knowfoolery \ + -e POSTGRES_USER=knowfoolery \ + -e POSTGRES_PASSWORD=dev-password-2024 \ + -p 5432:5432 \ + -v knowfoolery_postgres_data:/var/lib/postgresql/data \ + postgres:15-alpine + +echo "Waiting for PostgreSQL to be ready..." +sleep 10 + +# Check if PostgreSQL is ready +until docker exec knowfoolery-postgres pg_isready -U knowfoolery -d knowfoolery; do + echo "PostgreSQL is not ready yet. Waiting..." + sleep 2 +done + +echo "PostgreSQL is ready!" + +# Initialize database with our schema +echo "Initializing database schema..." +docker exec -i knowfoolery-postgres psql -U knowfoolery -d knowfoolery < ../infrastructure/docker/init-db.sql + +echo "PostgreSQL setup complete!" +echo "Database URL: postgres://knowfoolery:dev-password-2024@localhost:5432/knowfoolery?sslmode=disable" +echo "" +echo "To connect to the database:" +echo " docker exec -it knowfoolery-postgres psql -U knowfoolery -d knowfoolery" +echo "" +echo "To stop the database:" +echo " docker stop knowfoolery-postgres" \ No newline at end of file