diff --git a/README.md b/README.md index 97adc11..ab9687a 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ Make wrappers: - `make infra-up-prod` ### Backend Testing Strategy Implementation (6.1) -- Backend coverage gate: set by `BACKEND_COVERAGE_THRESHOLD` (default `80`), summary written to `reports/tests/backend-coverage-summary.txt` +- Backend coverage gate uses stepwise thresholds from `BACKEND_COVERAGE_THRESHOLDS` (default: `60 70 80`), summary written to `reports/tests/backend-coverage-summary.txt` +- Coverage gate aggregation targets backend service `internal/application`, `internal/domain`, and `internal/interfaces/http` packages from service profiles (`services-*`) with deduplicated unit/integration blocks - Coverage profiles: generated in `reports/tests/coverage/*.out` and merged into `reports/tests/coverage/backend-combined.out` - DB integration tests: question-bank repository integration now uses a disposable Postgres Testcontainers instance - API route coverage: admin service integration tests now validate `/admin/auth`, `/admin/dashboard`, `/admin/audit` success and error paths diff --git a/backend/services/game-session-service/internal/interfaces/http/handler_unit_test.go b/backend/services/game-session-service/internal/interfaces/http/handler_unit_test.go new file mode 100644 index 0000000..791e47c --- /dev/null +++ b/backend/services/game-session-service/internal/interfaces/http/handler_unit_test.go @@ -0,0 +1,134 @@ +package http + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + + "knowfoolery/backend/shared/infra/utils/validation" +) + +func TestUnauthorizedBranches(t *testing.T) { + h := NewHandler(nil, validation.NewValidator(), nil, nil) + app := fiber.New() + app.Post("/sessions/start", h.StartSession) + app.Post("/sessions/end", h.EndSession) + app.Post("/sessions/:id/hint", h.RequestHint) + app.Get("/sessions/:id", h.GetSession) + app.Get("/sessions/:id/question", h.GetCurrentQuestion) + + cases := []struct { + method string + path string + }{ + {http.MethodPost, "/sessions/start"}, + {http.MethodPost, "/sessions/end"}, + {http.MethodPost, "/sessions/s1/hint"}, + {http.MethodGet, "/sessions/s1"}, + {http.MethodGet, "/sessions/s1/question"}, + } + + for _, tc := range cases { + req := httptest.NewRequest(tc.method, tc.path, nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test(%s %s): %v", tc.method, tc.path, err) + } + if res.StatusCode != http.StatusUnauthorized { + _ = res.Body.Close() + t.Fatalf("expected unauthorized for %s %s, got %d", tc.method, tc.path, res.StatusCode) + } + _ = res.Body.Close() + } +} + +func TestStartAndEndValidationBranches(t *testing.T) { + h := NewHandler(nil, validation.NewValidator(), nil, nil) + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals("user_id", "user-1") + c.Locals("user_roles", []string{"player"}) + return c.Next() + }) + app.Post("/sessions/start", h.StartSession) + app.Post("/sessions/end", h.EndSession) + app.Post("/sessions/:id/answer", h.SubmitAnswer) + + req := httptest.NewRequest(http.MethodPost, "/sessions/start", bytes.NewReader([]byte("{"))) + req.Header.Set("Content-Type", "application/json") + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test start malformed: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected bad request for malformed start body, got %d", res.StatusCode) + } + + req = httptest.NewRequest(http.MethodPost, "/sessions/start", bytes.NewReader([]byte(`{"preferred_theme":"a"}`))) + req.Header.Set("Content-Type", "application/json") + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test start validation: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected bad request for start validation, got %d", res.StatusCode) + } + + req = httptest.NewRequest(http.MethodPost, "/sessions/end", bytes.NewReader([]byte("{"))) + req.Header.Set("Content-Type", "application/json") + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test end malformed: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected bad request for malformed end body, got %d", res.StatusCode) + } + + req = httptest.NewRequest(http.MethodPost, "/sessions/end", bytes.NewReader([]byte(`{"session_id":""}`))) + req.Header.Set("Content-Type", "application/json") + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test end validation: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected bad request for end validation, got %d", res.StatusCode) + } + +} + +func TestBearerTokenAndClaimsHelpers(t *testing.T) { + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals("user_id", "admin-1") + c.Locals("user_roles", []string{"admin"}) + return c.Next() + }) + app.Get("/token", func(c fiber.Ctx) error { + if got := bearerToken(c); got != "abc" { + return c.SendStatus(http.StatusInternalServerError) + } + claims := authClaimsFromContext(c) + if !claims.IsAdmin || claims.UserID != "admin-1" { + return c.SendStatus(http.StatusInternalServerError) + } + return c.SendStatus(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/token", nil) + req.Header.Set("Authorization", "Bearer abc") + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test helper route: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected helper route success, got %d", res.StatusCode) + } +} diff --git a/backend/services/gateway-service/go.mod b/backend/services/gateway-service/go.mod index e181332..58ca31d 100644 --- a/backend/services/gateway-service/go.mod +++ b/backend/services/gateway-service/go.mod @@ -3,6 +3,7 @@ module knowfoolery/backend/services/gateway-service go 1.25.5 require ( + github.com/alicebob/miniredis/v2 v2.33.0 github.com/gofiber/fiber/v3 v3.0.0-beta.3 github.com/google/uuid v1.6.0 github.com/redis/go-redis/v9 v9.7.0 @@ -13,6 +14,7 @@ require ( require ( github.com/MicahParks/jwkset v0.11.0 // indirect github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect + github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -37,6 +39,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.55.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect diff --git a/backend/services/gateway-service/internal/interfaces/http/middleware/middleware_test.go b/backend/services/gateway-service/internal/interfaces/http/middleware/middleware_test.go new file mode 100644 index 0000000..7101810 --- /dev/null +++ b/backend/services/gateway-service/internal/interfaces/http/middleware/middleware_test.go @@ -0,0 +1,417 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/gofiber/fiber/v3" + redisv9 "github.com/redis/go-redis/v9" + + gconfig "knowfoolery/backend/services/gateway-service/internal/infra/config" + "knowfoolery/backend/shared/infra/auth/zitadel" + "knowfoolery/backend/shared/infra/observability/logging" +) + +func TestCORSMiddleware(t *testing.T) { + app := fiber.New() + app.Use(CORS(gconfig.CORSConfig{ + AllowedOrigins: []string{"http://localhost:5173"}, + AllowedMethods: "GET,POST,OPTIONS", + AllowedHeaders: "Content-Type,Authorization", + AllowCredentials: true, + MaxAgeSeconds: 120, + })) + app.Get("/ok", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/ok", nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.Header.Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("expected no CORS header without origin") + } + + req = httptest.NewRequest(http.MethodOptions, "/ok", nil) + req.Header.Set("Origin", "http://evil.local") + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusForbidden { + t.Fatalf("expected forbidden preflight for disallowed origin, got %d", res.StatusCode) + } + + req = httptest.NewRequest(http.MethodOptions, "/ok", nil) + req.Header.Set("Origin", "http://localhost:5173") + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + t.Fatalf("expected no content preflight, got %d", res.StatusCode) + } + if res.Header.Get("Access-Control-Allow-Origin") != "http://localhost:5173" { + t.Fatalf("unexpected allow origin: %s", res.Header.Get("Access-Control-Allow-Origin")) + } + if res.Header.Get("Access-Control-Allow-Credentials") != "true" { + t.Fatalf("missing allow credentials") + } +} + +func TestCORSAllowAll(t *testing.T) { + app := fiber.New() + app.Use(CORS(gconfig.CORSConfig{AllowedOrigins: []string{"*"}})) + app.Get("/ok", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/ok", nil) + req.Header.Set("Origin", "http://anything.local") + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.Header.Get("Access-Control-Allow-Origin") != "*" { + t.Fatalf("expected wildcard origin header") + } +} + +func TestSecurityHeadersMiddleware(t *testing.T) { + app := fiber.New() + app.Use(SecurityHeaders(gconfig.SecurityHeadersConfig{ + ContentSecurityPolicy: "default-src 'self'", + EnableHSTS: true, + HSTSMaxAge: 31536000, + FrameOptions: "DENY", + ContentTypeOptions: true, + ReferrerPolicy: "strict-origin-when-cross-origin", + PermissionsPolicy: "geolocation=()", + })) + app.Get("/ok", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/ok", nil) + req.Header.Set("X-Forwarded-Proto", "https") + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + + if res.Header.Get("Content-Security-Policy") == "" { + t.Fatalf("missing CSP header") + } + if res.Header.Get("X-Frame-Options") != "DENY" { + t.Fatalf("missing frame options") + } + if res.Header.Get("X-Content-Type-Options") != "nosniff" { + t.Fatalf("missing nosniff") + } + if res.Header.Get("Strict-Transport-Security") == "" { + t.Fatalf("expected HSTS header for https request") + } +} + +func TestSecurityHeadersNoHSTSOnHTTP(t *testing.T) { + app := fiber.New() + app.Use(SecurityHeaders(gconfig.SecurityHeadersConfig{ + EnableHSTS: true, + HSTSMaxAge: 31536000, + })) + app.Get("/ok", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/ok", nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.Header.Get("Strict-Transport-Security") != "" { + t.Fatalf("did not expect HSTS header on non-https request") + } +} + +func TestRequestContextMiddlewareAndRequestID(t *testing.T) { + app := fiber.New() + app.Use(RequestContext(nil)) + app.Get("/id", func(c fiber.Ctx) error { + if RequestID(c) == "" { + return c.SendStatus(http.StatusInternalServerError) + } + return c.SendStatus(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/id", nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + if res.Header.Get("X-Request-ID") == "" { + t.Fatalf("expected generated request id") + } + + req = httptest.NewRequest(http.MethodGet, "/id", nil) + req.Header.Set("X-Request-ID", "req-123") + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.Header.Get("X-Request-ID") != "req-123" { + t.Fatalf("expected propagated request id") + } +} + +func TestRequestContextWithLoggerAndInvalidRequestIDLocal(t *testing.T) { + logger := logging.NewLogger(logging.DefaultConfig()) + app := fiber.New() + app.Use(RequestContext(logger)) + app.Get("/id", func(c fiber.Ctx) error { + c.Locals(requestIDKey, 123) + if RequestID(c) != "" { + return c.SendStatus(http.StatusInternalServerError) + } + return c.SendStatus(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/id", nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } +} + +func TestRateLimitMiddlewareDegradedModeAndHelpers(t *testing.T) { + app := fiber.New() + mw := RateLimitMiddleware(nil, gconfig.RateLimitConfig{ + GeneralRequests: 7, + APIRequests: 7, + }, "/api/v1", nil) + app.Use(mw) + app.Get("/api/v1/x", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + if res.Header.Get("X-RateLimit-Policy") != "degraded" { + t.Fatalf("expected degraded policy") + } + if res.Header.Get("X-RateLimit-Limit") != "7" { + t.Fatalf("expected limit header") + } + + app = fiber.New() + app.Use(mw) + app.Options("/api/v1/x", func(c fiber.Ctx) error { return c.SendStatus(http.StatusNoContent) }) + req = httptest.NewRequest(http.MethodOptions, "/api/v1/x", nil) + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + t.Fatalf("expected options pass-through, got %d", res.StatusCode) + } + + tiers := map[string]rateTier{ + "general": {Name: "general"}, + "auth": {Name: "auth"}, + "api": {Name: "api"}, + "admin": {Name: "admin"}, + } + if got := selectTier("/api/v1/admin/auth", "/api/v1", tiers); got.Name != "auth" { + t.Fatalf("expected auth tier, got %s", got.Name) + } + if got := selectTier("/api/v1/admin/x", "/api/v1", tiers); got.Name != "admin" { + t.Fatalf("expected admin tier, got %s", got.Name) + } + if got := selectTier("/api/v1/x", "/api/v1", tiers); got.Name != "api" { + t.Fatalf("expected api tier, got %s", got.Name) + } + if got := selectTier("/x", "/api/v1", tiers); got.Name != "general" { + t.Fatalf("expected general tier, got %s", got.Name) + } + + if v, ok := toInt64(int64(1)); !ok || v != 1 { + t.Fatalf("toInt64 int64 failed") + } + if v, ok := toInt64(int(2)); !ok || v != 2 { + t.Fatalf("toInt64 int failed") + } + if v, ok := toInt64("3"); !ok || v != 3 { + t.Fatalf("toInt64 string failed") + } + if _, ok := toInt64("bad"); ok { + t.Fatalf("expected invalid string parse failure") + } + if _, ok := toInt64(struct{}{}); ok { + t.Fatalf("expected unsupported type failure") + } + + if allow, count, reset, ok := parseLimiterResult([]interface{}{int64(1), int64(2), + int64(3)}); !ok || !allow || count != 2 || reset != 3 { + t.Fatalf("unexpected parseLimiterResult output") + } + if _, _, _, ok := parseLimiterResult("bad"); ok { + t.Fatalf("expected parse failure for non-slice") + } + if _, _, _, ok := parseLimiterResult([]interface{}{1, 2}); ok { + t.Fatalf("expected parse failure for short slice") + } + if _, _, _, ok := parseLimiterResult([]interface{}{1, 2, struct{}{}}); ok { + t.Fatalf("expected parse failure for invalid reset") + } + + if d := positiveDuration(0, time.Second); d != time.Second { + t.Fatalf("expected fallback duration") + } + if d := positiveDuration(2*time.Second, time.Second); d != 2*time.Second { + t.Fatalf("expected provided duration") + } + if m := maxInt(0, 9); m != 9 { + t.Fatalf("expected fallback int") + } + if m := maxInt(10, 9); m != 10 { + t.Fatalf("expected provided int") + } +} + +func TestRateLimitMiddlewareRedisAllowedAndBlocked(t *testing.T) { + mr, err := miniredis.Run() + if err != nil { + t.Skipf("miniredis unavailable in this environment: %v", err) + } + t.Cleanup(func() { mr.Close() }) + client := redisv9.NewClient(&redisv9.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals(string(zitadel.ContextKeyUserID), "user-1") + return c.Next() + }) + app.Use(RateLimitMiddleware( + client, + gconfig.RateLimitConfig{ + APIRequests: 1, + Window: time.Minute, + }, + "/api/v1", + nil, + )) + app.Get("/api/v1/x", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test first request: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected first request to pass, got %d", res.StatusCode) + } + if res.Header.Get("X-RateLimit-Limit") != "1" { + t.Fatalf("expected rate-limit headers") + } + + req = httptest.NewRequest(http.MethodGet, "/api/v1/x", nil) + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test second request: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusTooManyRequests { + t.Fatalf("expected second request to be throttled, got %d", res.StatusCode) + } + if res.Header.Get("X-RateLimit-Remaining") != "0" { + t.Fatalf("expected remaining=0 when throttled") + } +} + +func TestRateLimitMiddlewareRedisErrorDegrades(t *testing.T) { + // Use a client pointing to an unreachable endpoint to force script.Run error. + client := redisv9.NewClient(&redisv9.Options{Addr: "127.0.0.1:1"}) + t.Cleanup(func() { _ = client.Close() }) + + app := fiber.New() + app.Use(RateLimitMiddleware( + client, + gconfig.RateLimitConfig{ + APIRequests: 5, + Window: time.Minute, + }, + "/api/v1", + nil, + )) + app.Get("/api/v1/x", func(c fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected degraded mode to fail-open with 200, got %d", res.StatusCode) + } + if res.Header.Get("X-RateLimit-Policy") != "degraded" { + t.Fatalf("expected degraded policy header") + } +} + +func TestIdentifyRequester(t *testing.T) { + app := fiber.New() + app.Get("/id", func(c fiber.Ctx) error { + if c.Query("u") == "1" { + c.Locals(string(zitadel.ContextKeyUserID), "user-1") + } + id := identifyRequester(c) + if c.Query("u") == "1" && id != "user:user-1" { + return c.Status(http.StatusInternalServerError).SendString(id) + } + if c.Query("u") != "1" && !strings.HasPrefix(id, "ip:") { + return c.Status(http.StatusInternalServerError).SendString(id) + } + return c.SendStatus(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodGet, "/id?u=1", nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for user requester") + } + + req = httptest.NewRequest(http.MethodGet, "/id", nil) + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for ip requester") + } +} diff --git a/backend/services/leaderboard-service/internal/application/leaderboard/service_test.go b/backend/services/leaderboard-service/internal/application/leaderboard/service_test.go index 325ef08..183899d 100644 --- a/backend/services/leaderboard-service/internal/application/leaderboard/service_test.go +++ b/backend/services/leaderboard-service/internal/application/leaderboard/service_test.go @@ -2,7 +2,9 @@ package leaderboard import ( "context" + "encoding/json" "errors" + "strings" "testing" "time" @@ -11,8 +13,14 @@ import ( ) type fakeRepo struct { - entries []*domain.LeaderboardEntry - stats map[string]*domain.PlayerStats + entries []*domain.LeaderboardEntry + stats map[string]*domain.PlayerStats + ingestErr error + topErr error + statsErr error + rankErr error + historyErr error + globalErr error } func newFakeRepo() *fakeRepo { @@ -28,6 +36,9 @@ func (r *fakeRepo) IngestEntry( ctx context.Context, entry *domain.LeaderboardEntry, ) (*domain.LeaderboardEntry, bool, error) { + if r.ingestErr != nil { + return nil, false, r.ingestErr + } for _, e := range r.entries { if e.SessionID == entry.SessionID { return e, true, nil @@ -82,6 +93,9 @@ func (r *fakeRepo) ListTop( filter domain.TopFilter, limit int, ) ([]*domain.LeaderboardEntry, error) { + if r.topErr != nil { + return nil, r.topErr + } if len(r.entries) < limit { limit = len(r.entries) } @@ -93,6 +107,9 @@ func (r *fakeRepo) ListTop( } func (r *fakeRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) { + if r.statsErr != nil { + return nil, r.statsErr + } stats := r.stats[playerID] if stats == nil { return nil, domain.ErrPlayerNotFound @@ -102,6 +119,9 @@ func (r *fakeRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain } func (r *fakeRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) { + if r.rankErr != nil { + return 0, r.rankErr + } if _, ok := r.stats[playerID]; !ok { return 0, domain.ErrPlayerNotFound } @@ -113,6 +133,9 @@ func (r *fakeRepo) ListPlayerHistory( playerID string, pagination sharedtypes.Pagination, ) ([]*domain.LeaderboardEntry, int64, error) { + if r.historyErr != nil { + return nil, 0, r.historyErr + } out := make([]*domain.LeaderboardEntry, 0) for _, entry := range r.entries { if entry.PlayerID == playerID { @@ -123,11 +146,16 @@ func (r *fakeRepo) ListPlayerHistory( } func (r *fakeRepo) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) { + if r.globalErr != nil { + return nil, r.globalErr + } return &domain.GlobalStats{TotalGames: int64(len(r.entries)), UpdatedAt: time.Now().UTC()}, nil } type fakeState struct { - data map[string]string + data map[string]string + setErr error + deleteErr error } func newFakeState() *fakeState { @@ -138,10 +166,16 @@ func (s *fakeState) Get(ctx context.Context, key string) (string, bool) { return v, ok } func (s *fakeState) Set(ctx context.Context, key, value string, ttl time.Duration) error { + if s.setErr != nil { + return s.setErr + } s.data[key] = value return nil } func (s *fakeState) Delete(ctx context.Context, keys ...string) error { + if s.deleteErr != nil { + return s.deleteErr + } for _, key := range keys { delete(s.data, key) } @@ -229,3 +263,157 @@ func TestGetPlayerRanking(t *testing.T) { t.Fatalf("history len=%d want=1", len(result.History)) } } + +func TestUpdateScoreValidationAndErrorPaths(t *testing.T) { + svc := NewService(newFakeRepo(), newFakeState(), Config{}) + cases := []UpdateScoreInput{ + {SessionID: "", PlayerID: "u1", CompletionType: "completed"}, + {SessionID: "s1", PlayerID: "", CompletionType: "completed"}, + {SessionID: "s1", PlayerID: "u1", TotalScore: -1, CompletionType: "completed"}, + {SessionID: "s1", PlayerID: "u1", QuestionsAsked: 1, QuestionsCorrect: 2, CompletionType: "completed"}, + {SessionID: "s1", PlayerID: "u1", CompletionType: "invalid"}, + } + for i, in := range cases { + _, err := svc.UpdateScore(context.Background(), in) + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("case %d: expected invalid input, got %v", i, err) + } + } + + repo := newFakeRepo() + repo.ingestErr = errors.New("ingest boom") + svc = NewService(repo, newFakeState(), Config{}) + _, err := svc.UpdateScore(context.Background(), UpdateScoreInput{ + SessionID: "s1", PlayerID: "u1", PlayerName: "Alice", CompletionType: "completed", + }) + if err == nil || !strings.Contains(err.Error(), "ingest boom") { + t.Fatalf("expected ingest error, got %v", err) + } +} + +func TestTopAndStatsCachePaths(t *testing.T) { + repo := newFakeRepo() + state := newFakeState() + svc := NewService(repo, state, Config{}) + _, _ = svc.UpdateScore(context.Background(), UpdateScoreInput{ + SessionID: "s1", + PlayerID: "u1", + PlayerName: "Alice", + TotalScore: 8, + QuestionsAsked: 10, + QuestionsCorrect: 7, + DurationSeconds: 100, + CompletedAt: time.Now().UTC(), + CompletionType: "completed", + }) + + top, err := svc.GetTop10(context.Background(), GetTopInput{Window: domain.Window7d}) + if err != nil { + t.Fatalf("GetTop10 failed: %v", err) + } + if len(top.Items) == 0 || top.Items[0].Rank != 1 { + t.Fatalf("expected ranked top item") + } + + cacheKey := "lb:top10:v1::7d" + payload, _ := json.Marshal(Top10Result{ + Items: []RankedEntry{{Rank: 1, Entry: domain.LeaderboardEntry{SessionID: "cached"}}}, + }) + state.data[cacheKey] = string(payload) + cached, err := svc.GetTop10(context.Background(), GetTopInput{Window: domain.Window7d}) + if err != nil { + t.Fatalf("GetTop10 cached failed: %v", err) + } + if cached.Items[0].Entry.SessionID != "cached" { + t.Fatalf("expected cached top payload") + } + + stats, err := svc.GetGlobalStats(context.Background(), GetStatsInput{Window: domain.WindowAll}) + if err != nil { + t.Fatalf("GetGlobalStats failed: %v", err) + } + if stats.TotalGames <= 0 { + t.Fatalf("expected total games > 0") + } + + state.data["lb:stats:global:v1::all"] = `{"TotalGames":42}` + cachedStats, err := svc.GetGlobalStats(context.Background(), GetStatsInput{Window: domain.WindowAll}) + if err != nil { + t.Fatalf("cached stats failed: %v", err) + } + if cachedStats.TotalGames != 42 { + t.Fatalf("expected cached total games") + } +} + +func TestRankingValidationAndErrorPaths(t *testing.T) { + repo := newFakeRepo() + state := newFakeState() + svc := NewService(repo, state, Config{}) + + _, err := svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: ""}) + if !errors.Is(err, domain.ErrInvalidInput) { + t.Fatalf("expected invalid input for empty player id, got %v", err) + } + + _, _ = svc.UpdateScore(context.Background(), UpdateScoreInput{ + SessionID: "s1", + PlayerID: "u1", + PlayerName: "Alice", + TotalScore: 3, + QuestionsAsked: 4, + QuestionsCorrect: 2, + DurationSeconds: 50, + CompletedAt: time.Now().UTC(), + CompletionType: "completed", + }) + + state.data["lb:rank:u1"] = "not-a-number" + _, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{ + PlayerID: "u1", + Pagination: sharedtypes.Pagination{ + Page: 1, + PageSize: 1, + }, + }) + if err != nil { + t.Fatalf("expected fallback to repo rank when cache parse fails: %v", err) + } + + repo.statsErr = errors.New("stats boom") + _, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: "u1"}) + if err == nil || !strings.Contains(err.Error(), "stats boom") { + t.Fatalf("expected stats error, got %v", err) + } + repo.statsErr = nil + repo.rankErr = errors.New("rank boom") + state.data["lb:rank:u1"] = "0" + _, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: "u1"}) + if err == nil || !strings.Contains(err.Error(), "rank boom") { + t.Fatalf("expected rank error, got %v", err) + } + repo.rankErr = nil + repo.historyErr = errors.New("history boom") + _, err = svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{PlayerID: "u1"}) + if err == nil || !strings.Contains(err.Error(), "history boom") { + t.Fatalf("expected history error, got %v", err) + } +} + +func TestGlobalStatsAndTopErrors(t *testing.T) { + repo := newFakeRepo() + repo.topErr = errors.New("top boom") + svc := NewService(repo, newFakeState(), Config{}) + _, err := svc.GetTop10(context.Background(), GetTopInput{Window: domain.Window("bogus")}) + if err == nil || !strings.Contains(err.Error(), "top boom") { + t.Fatalf("expected top error, got %v", err) + } + + repo = newFakeRepo() + repo.globalErr = errors.New("global boom") + svc = NewService(repo, newFakeState(), Config{}) + _, err = svc.GetGlobalStats(context.Background(), GetStatsInput{Window: domain.Window("bogus")}) + if err == nil || !strings.Contains(err.Error(), "global boom") { + t.Fatalf("expected global error, got %v", err) + } +} diff --git a/backend/services/leaderboard-service/internal/interfaces/http/handler_unit_test.go b/backend/services/leaderboard-service/internal/interfaces/http/handler_unit_test.go new file mode 100644 index 0000000..ed181fb --- /dev/null +++ b/backend/services/leaderboard-service/internal/interfaces/http/handler_unit_test.go @@ -0,0 +1,120 @@ +package http + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + + "knowfoolery/backend/shared/infra/utils/validation" +) + +func TestUpdateAuthAndValidationBranches(t *testing.T) { + h := NewHandler(nil, validation.NewValidator(), nil, nil, true, 20, 100) + app := fiber.New() + app.Post("/leaderboard/update", h.Update) + + req := httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader([]byte(`{}`))) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusForbidden { + t.Fatalf("expected forbidden without auth claims, got %d", res.StatusCode) + } + + app = fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals("user_id", "user-1") + c.Locals("user_roles", []string{"player"}) + return c.Next() + }) + app.Post("/leaderboard/update", h.Update) + req = httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader([]byte(`{}`))) + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusForbidden { + t.Fatalf("expected forbidden for non-service/non-admin, got %d", res.StatusCode) + } + + app = fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals("user_id", "svc-1") + c.Locals("user_roles", []string{"service"}) + return c.Next() + }) + app.Post("/leaderboard/update", h.Update) + + req = httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader([]byte("{"))) + req.Header.Set("Content-Type", "application/json") + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected bad request for malformed json, got %d", res.StatusCode) + } + + validShape := `{ + "session_id":"s1", + "player_id":"u1", + "player_name":"P", + "total_score":1, + "questions_asked":1, + "questions_correct":1, + "hints_used":0, + "duration_seconds":10, + "completed_at":"not-rfc3339", + "completion_type":"completed" + }` + req = httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader([]byte(validShape))) + req.Header.Set("Content-Type", "application/json") + res, err = app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("expected bad request for completed_at format, got %d", res.StatusCode) + } +} + +func TestGetPlayerRankingForbiddenBranch(t *testing.T) { + h := NewHandler(nil, validation.NewValidator(), nil, nil, false, 20, 100) + app := fiber.New() + app.Use(func(c fiber.Ctx) error { + c.Locals("user_id", "user-2") + c.Locals("user_roles", []string{"player"}) + return c.Next() + }) + app.Get("/leaderboard/players/:id", h.GetPlayerRanking) + + req := httptest.NewRequest(http.MethodGet, "/leaderboard/players/user-1?page=oops&page_size=-1", nil) + res, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test: %v", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusForbidden { + t.Fatalf("expected forbidden for non-owner non-admin, got %d", res.StatusCode) + } +} + +func TestHelperFunctions(t *testing.T) { + if got := atoiWithDefault("", 3); got != 3 { + t.Fatalf("expected default for empty input") + } + if got := atoiWithDefault("bad", 4); got != 4 { + t.Fatalf("expected default for invalid input") + } + if got := atoiWithDefault("7", 4); got != 7 { + t.Fatalf("expected parsed int") + } +} diff --git a/backend/services/question-bank-service/internal/application/question/service_test.go b/backend/services/question-bank-service/internal/application/question/service_test.go index 20340d0..c6e5f8c 100644 --- a/backend/services/question-bank-service/internal/application/question/service_test.go +++ b/backend/services/question-bank-service/internal/application/question/service_test.go @@ -5,6 +5,7 @@ package question import ( "context" "errors" + "strings" "testing" "time" @@ -13,7 +14,16 @@ import ( // fakeRepo is an in-memory repository double for service tests. type fakeRepo struct { - items []*domain.Question + items []*domain.Question + createErr error + updateErr error + deleteErr error + listErr error + countErr error + randomErr error + bulkErr error + bulkErrors []domain.BulkError + bulkCount int } // GetByID returns a fake question by ID. @@ -28,6 +38,9 @@ func (f *fakeRepo) GetByID(ctx context.Context, id string) (*domain.Question, er // Create appends a fake question and assigns a deterministic ID. func (f *fakeRepo) Create(ctx context.Context, q *domain.Question) (*domain.Question, error) { + if f.createErr != nil { + return nil, f.createErr + } q.ID = "id-1" f.items = append(f.items, q) return q, nil @@ -35,19 +48,30 @@ func (f *fakeRepo) Create(ctx context.Context, q *domain.Question) (*domain.Ques // Update returns the provided fake question as updated. func (f *fakeRepo) Update(ctx context.Context, id string, q *domain.Question) (*domain.Question, error) { + if f.updateErr != nil { + return nil, f.updateErr + } return q, nil } // SoftDelete is a no-op fake delete implementation. -func (f *fakeRepo) SoftDelete(ctx context.Context, id string) error { return nil } +func (f *fakeRepo) SoftDelete(ctx context.Context, id string) error { + return f.deleteErr +} // ListThemes returns a fixed fake theme set. func (f *fakeRepo) ListThemes(ctx context.Context) ([]string, error) { + if f.listErr != nil { + return nil, f.listErr + } return []string{"Science"}, nil } // CountRandomCandidates returns fake candidate count from in-memory state. func (f *fakeRepo) CountRandomCandidates(ctx context.Context, filter domain.RandomFilter) (int, error) { + if f.countErr != nil { + return 0, f.countErr + } if len(f.items) == 0 { return 0, nil } @@ -60,6 +84,9 @@ func (f *fakeRepo) RandomByOffset( filter domain.RandomFilter, offset int, ) (*domain.Question, error) { + if f.randomErr != nil { + return nil, f.randomErr + } if len(f.items) == 0 { return nil, domain.ErrNoQuestionsAvailable } @@ -74,13 +101,20 @@ func (f *fakeRepo) BulkCreate( ctx context.Context, questions []*domain.Question, ) (int, []domain.BulkError, error) { + if f.bulkErr != nil { + return 0, nil, f.bulkErr + } f.items = append(f.items, questions...) - return len(questions), nil, nil + if f.bulkCount > 0 { + return f.bulkCount, f.bulkErrors, nil + } + return len(questions), f.bulkErrors, nil } // fakeCache is a simple in-memory cache double. type fakeCache struct { - store map[string]*domain.Question + store map[string]*domain.Question + invalidated bool } // Get returns a cached fake question when present. @@ -97,8 +131,8 @@ func (f *fakeCache) Set(ctx context.Context, key string, q *domain.Question, ttl f.store[key] = q } -// Invalidate is a no-op for tests. -func (f *fakeCache) Invalidate(ctx context.Context) {} +// Invalidate marks cache invalidation in tests. +func (f *fakeCache) Invalidate(ctx context.Context) { f.invalidated = true } // TestGetRandomQuestion_NoQuestions returns ErrNoQuestionsAvailable when no active candidates exist. func TestGetRandomQuestion_NoQuestions(t *testing.T) { @@ -131,6 +165,14 @@ func TestGetRandomQuestion_WithCache(t *testing.T) { if q.ID == "" { t.Fatal("expected id") } + + q2, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) + if err != nil { + t.Fatal(err) + } + if q2.ID != q.ID { + t.Fatalf("expected cached question id %s, got %s", q.ID, q2.ID) + } } // TestValidateAnswerByQuestionID returns a matched result and expected threshold for equivalent answers. @@ -176,3 +218,177 @@ func TestValidateAnswerByQuestionID_ValidationError(t *testing.T) { t.Fatalf("expected validation error, got %v", err) } } + +func TestGetRandomQuestion_InvalidDifficulty(t *testing.T) { + svc := NewService(&fakeRepo{}, &fakeCache{}, time.Minute, 200) + _, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{ + Difficulty: domain.Difficulty("legendary"), + }) + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation error, got %v", err) + } +} + +func TestGetRandomQuestion_RepoErrors(t *testing.T) { + repo := &fakeRepo{countErr: errors.New("count boom")} + svc := NewService(repo, &fakeCache{}, time.Minute, 200) + _, err := svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) + if err == nil || !strings.Contains(err.Error(), "count boom") { + t.Fatalf("expected count error, got %v", err) + } + + repo = &fakeRepo{ + items: []*domain.Question{{ID: "q1", Theme: "Science", Text: "Q", Answer: "A", + Difficulty: domain.DifficultyEasy, IsActive: true}}, + randomErr: errors.New("random boom"), + } + svc = NewService(repo, &fakeCache{}, time.Minute, 200) + _, err = svc.GetRandomQuestion(context.Background(), RandomQuestionRequest{}) + if err == nil || !strings.Contains(err.Error(), "random boom") { + t.Fatalf("expected random error, got %v", err) + } +} + +func TestQuestionCRUDAndThemes(t *testing.T) { + repo := &fakeRepo{} + cache := &fakeCache{} + svc := NewService(repo, cache, time.Minute, 200) + + created, err := svc.CreateQuestion(context.Background(), CreateQuestionInput{ + Theme: " Science ", + Text: "What is H2O?", + Answer: " Water ", + Hint: " liquid ", + Difficulty: domain.DifficultyEasy, + }) + if err != nil { + t.Fatalf("create failed: %v", err) + } + if created.ID == "" { + t.Fatalf("expected created id") + } + if !cache.invalidated { + t.Fatalf("expected cache invalidation after create") + } + + cache.invalidated = false + _, err = svc.UpdateQuestion(context.Background(), "id-1", UpdateQuestionInput{ + Theme: "Science", + Text: "Updated", + Answer: "Water", + Hint: "hint", + Difficulty: domain.DifficultyMedium, + IsActive: false, + }) + if err != nil { + t.Fatalf("update failed: %v", err) + } + if !cache.invalidated { + t.Fatalf("expected cache invalidation after update") + } + + cache.invalidated = false + if err := svc.DeleteQuestion(context.Background(), "id-1"); err != nil { + t.Fatalf("delete failed: %v", err) + } + if !cache.invalidated { + t.Fatalf("expected cache invalidation after delete") + } + + themes, err := svc.ListThemes(context.Background()) + if err != nil { + t.Fatalf("list themes failed: %v", err) + } + if len(themes) != 1 || themes[0] != "Science" { + t.Fatalf("unexpected themes: %#v", themes) + } +} + +func TestQuestionCRUDValidationAndErrors(t *testing.T) { + repo := &fakeRepo{createErr: errors.New("create boom")} + svc := NewService(repo, &fakeCache{}, time.Minute, 200) + + _, err := svc.CreateQuestion(context.Background(), CreateQuestionInput{ + Theme: "Science", + Text: "x", + Answer: "a", + Difficulty: domain.Difficulty("bad"), + }) + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation error, got %v", err) + } + + _, err = svc.CreateQuestion(context.Background(), CreateQuestionInput{ + Theme: "Science", + Text: "x", + Answer: "a", + Difficulty: domain.DifficultyEasy, + }) + if err == nil || !strings.Contains(err.Error(), "create boom") { + t.Fatalf("expected repo create error, got %v", err) + } + + repo = &fakeRepo{updateErr: errors.New("update boom")} + svc = NewService(repo, &fakeCache{}, time.Minute, 200) + _, err = svc.UpdateQuestion(context.Background(), "id-1", UpdateQuestionInput{ + Theme: "Science", + Text: "x", + Answer: "a", + Difficulty: domain.DifficultyEasy, + IsActive: true, + }) + if err == nil || !strings.Contains(err.Error(), "update boom") { + t.Fatalf("expected repo update error, got %v", err) + } + + repo = &fakeRepo{deleteErr: errors.New("delete boom"), listErr: errors.New("list boom")} + svc = NewService(repo, &fakeCache{}, time.Minute, 200) + err = svc.DeleteQuestion(context.Background(), "id-1") + if err == nil || !strings.Contains(err.Error(), "delete boom") { + t.Fatalf("expected repo delete error, got %v", err) + } + _, err = svc.ListThemes(context.Background()) + if err == nil || !strings.Contains(err.Error(), "list boom") { + t.Fatalf("expected repo list error, got %v", err) + } +} + +func TestBulkImportScenarios(t *testing.T) { + cache := &fakeCache{} + repo := &fakeRepo{bulkCount: 1, bulkErrors: []domain.BulkError{{Index: 0, Reason: "row"}}} + svc := NewService(repo, cache, time.Minute, 200) + + _, err := svc.BulkImport(context.Background(), []BulkImportItem{ + {Theme: "A"}, + {Theme: "B"}, + }, 1) + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected max-items validation error, got %v", err) + } + + result, err := svc.BulkImport(context.Background(), []BulkImportItem{ + {Theme: "Science", Text: "What is H2O?", Answer: "water", Hint: "liquid", Difficulty: domain.DifficultyEasy}, + {Theme: "", Text: "bad", Answer: "bad", Hint: "bad", Difficulty: domain.DifficultyEasy}, + }, 10) + if err != nil { + t.Fatalf("bulk import failed: %v", err) + } + if result.CreatedCount != 1 { + t.Fatalf("expected one created, got %d", result.CreatedCount) + } + if len(result.Errors) == 0 { + t.Fatalf("expected error list for invalid rows") + } + if !cache.invalidated { + t.Fatalf("expected cache invalidation after bulk import") + } + + repo = &fakeRepo{bulkErr: errors.New("bulk boom")} + svc = NewService(repo, &fakeCache{}, time.Minute, 200) + _, err = svc.BulkImport(context.Background(), []BulkImportItem{ + {Theme: "Science", Text: "What is H2O?", Answer: "water", Hint: "liquid", Difficulty: domain.DifficultyEasy}, + }, 10) + if err == nil || !strings.Contains(err.Error(), "bulk boom") { + t.Fatalf("expected repo bulk error, got %v", err) + } +} diff --git a/backend/services/question-bank-service/tests/integration_http_test.go b/backend/services/question-bank-service/tests/integration_http_test.go index 91baf20..7ee98c1 100644 --- a/backend/services/question-bank-service/tests/integration_http_test.go +++ b/backend/services/question-bank-service/tests/integration_http_test.go @@ -236,3 +236,85 @@ func TestHTTPValidateAnswer(t *testing.T) { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected 400 for empty answer") defer resp.Body.Close() } + +// TestHTTPAdminCRUDThemesAndBulk validates admin-only CRUD, themes, and bulk import flows. +func TestHTTPAdminCRUDThemesAndBulk(t *testing.T) { + app := setupApp(t) + + updatePayload, _ := json.Marshal(map[string]any{ + "theme": "Science", + "text": "Updated question text", + "answer": "jupiter", + "hint": "planet", + "difficulty": "medium", + "is_active": true, + }) + req := httptest.NewRequest(http.MethodPut, "/admin/questions/q1", bytes.NewReader(updatePayload)) + req.Header.Set("Content-Type", "application/json") + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized update") + defer resp.Body.Close() + + req = httptest.NewRequest(http.MethodPut, "/admin/questions/q1", bytes.NewReader(updatePayload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer admin") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected update success") + defer resp.Body.Close() + + req = httptest.NewRequest(http.MethodDelete, "/admin/questions/q1", nil) + req.Header.Set("Authorization", "Bearer admin") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNoContent, "expected delete success") + defer resp.Body.Close() + + req = httptest.NewRequest(http.MethodGet, "/admin/themes", nil) + req.Header.Set("Authorization", "Bearer admin") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected themes success") + defer resp.Body.Close() + + emptyBulkPayload, _ := json.Marshal(map[string]any{"questions": []any{}}) + req = httptest.NewRequest(http.MethodPost, "/admin/questions/bulk", bytes.NewReader(emptyBulkPayload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer admin") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bulk validation failure") + defer resp.Body.Close() + + bulkPayload, _ := json.Marshal(map[string]any{ + "questions": []map[string]any{ + { + "theme": "History", + "text": "Who discovered America?", + "answer": "Columbus", + "hint": "1492", + "difficulty": "easy", + }, + }, + }) + req = httptest.NewRequest(http.MethodPost, "/admin/questions/bulk", bytes.NewReader(bulkPayload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer admin") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected bulk success") + defer resp.Body.Close() +} + +// TestHTTPRandomQuestionInputValidation validates malformed and invalid random question inputs. +func TestHTTPRandomQuestionInputValidation(t *testing.T) { + app := setupApp(t) + + req := httptest.NewRequest(http.MethodPost, "/questions/random", bytes.NewReader([]byte("{"))) + req.Header.Set("Content-Type", "application/json") + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bad json failure") + defer resp.Body.Close() + + payload, _ := json.Marshal(map[string]any{"difficulty": "legendary"}) + req = httptest.NewRequest(http.MethodPost, "/questions/random", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected invalid difficulty failure") + defer resp.Body.Close() +} diff --git a/backend/services/user-service/internal/application/user/service_test.go b/backend/services/user-service/internal/application/user/service_test.go index 6249fce..5fba8fd 100644 --- a/backend/services/user-service/internal/application/user/service_test.go +++ b/backend/services/user-service/internal/application/user/service_test.go @@ -2,6 +2,8 @@ package user import ( "context" + "errors" + "strings" "testing" "time" @@ -16,6 +18,16 @@ type fakeRepo struct { usersByEmail map[string]*domain.User usersByZitadel map[string]*domain.User audit []domain.AuditLogEntry + createErr error + getByIDErr error + getByEmailErr error + getByZidErr error + updateErr error + verifyErr error + deleteErr error + listErr error + logsErr error + writeAuditErr error } // newFakeRepo creates a fake repository with empty stores. @@ -32,6 +44,9 @@ func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil } // Create stores a user and assigns deterministic timestamps and ID for tests. func (r *fakeRepo) Create(ctx context.Context, user *domain.User) (*domain.User, error) { + if r.createErr != nil { + return nil, r.createErr + } user.ID = "u-1" now := time.Now().UTC() user.CreatedAt = now @@ -44,6 +59,9 @@ func (r *fakeRepo) Create(ctx context.Context, user *domain.User) (*domain.User, // GetByID returns a non-deleted user by ID. func (r *fakeRepo) GetByID(ctx context.Context, id string) (*domain.User, error) { + if r.getByIDErr != nil { + return nil, r.getByIDErr + } if u, ok := r.usersByID[id]; ok && !u.IsDeleted() { return u, nil } @@ -52,6 +70,9 @@ func (r *fakeRepo) GetByID(ctx context.Context, id string) (*domain.User, error) // GetByEmail returns a non-deleted user by email. func (r *fakeRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) { + if r.getByEmailErr != nil { + return nil, r.getByEmailErr + } if u, ok := r.usersByEmail[email]; ok && !u.IsDeleted() { return u, nil } @@ -60,6 +81,9 @@ func (r *fakeRepo) GetByEmail(ctx context.Context, email string) (*domain.User, // GetByZitadelUserID returns a non-deleted user by Zitadel subject. func (r *fakeRepo) GetByZitadelUserID(ctx context.Context, zid string) (*domain.User, error) { + if r.getByZidErr != nil { + return nil, r.getByZidErr + } if u, ok := r.usersByZitadel[zid]; ok && !u.IsDeleted() { return u, nil } @@ -72,6 +96,9 @@ func (r *fakeRepo) UpdateProfile( id, displayName string, consent domain.ConsentRecord, ) (*domain.User, error) { + if r.updateErr != nil { + return nil, r.updateErr + } u, err := r.GetByID(ctx, id) if err != nil { return nil, err @@ -86,6 +113,9 @@ func (r *fakeRepo) UpdateProfile( // MarkEmailVerified marks a user as verified in the fake store. func (r *fakeRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.User, error) { + if r.verifyErr != nil { + return nil, r.verifyErr + } u, err := r.GetByID(ctx, id) if err != nil { return nil, err @@ -97,6 +127,9 @@ func (r *fakeRepo) MarkEmailVerified(ctx context.Context, id string) (*domain.Us // SoftDelete marks a user as deleted and records an audit entry. func (r *fakeRepo) SoftDelete(ctx context.Context, id string, actorUserID string) error { + if r.deleteErr != nil { + return r.deleteErr + } u, err := r.GetByID(ctx, id) if err != nil { return err @@ -120,6 +153,9 @@ func (r *fakeRepo) List( pagination sharedtypes.Pagination, filter domain.ListFilter, ) ([]*domain.User, int64, error) { + if r.listErr != nil { + return nil, 0, r.listErr + } items := make([]*domain.User, 0, len(r.usersByID)) for _, u := range r.usersByID { if !filter.IncludeDeleted && u.IsDeleted() { @@ -132,6 +168,9 @@ func (r *fakeRepo) List( // AuditLogsByUserID returns audit entries associated with a target user. func (r *fakeRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.AuditLogEntry, error) { + if r.logsErr != nil { + return nil, r.logsErr + } out := make([]domain.AuditLogEntry, 0) for _, a := range r.audit { if a.TargetUserID == id { @@ -143,6 +182,9 @@ func (r *fakeRepo) AuditLogsByUserID(ctx context.Context, id string) ([]domain.A // WriteAuditLog appends an audit entry to the fake repository. func (r *fakeRepo) WriteAuditLog(ctx context.Context, entry domain.AuditLogEntry) error { + if r.writeAuditErr != nil { + return r.writeAuditErr + } r.audit = append(r.audit, entry) return nil } @@ -204,3 +246,237 @@ func TestDeleteAndExport(t *testing.T) { t.Fatal("expected deleted user to be unavailable") } } + +func TestRegisterValidationAndRepoErrors(t *testing.T) { + svc := NewService(newFakeRepo()) + _, err := svc.Register(context.Background(), RegisterInput{}) + if !errors.Is(err, domain.ErrUnauthorized) { + t.Fatalf("expected unauthorized, got %v", err) + } + + _, err = svc.Register(context.Background(), RegisterInput{ + ActorZitadelUserID: "zid-1", + ActorEmail: "bad-email", + DisplayName: "Player", + ConsentVersion: "v1", + ConsentSource: "web", + }) + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation for bad email, got %v", err) + } + + _, err = svc.Register(context.Background(), RegisterInput{ + ActorZitadelUserID: "zid-1", + ActorEmail: "a@b.com", + DisplayName: "", + ConsentVersion: "v1", + ConsentSource: "web", + }) + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation for display name, got %v", err) + } + + repo := newFakeRepo() + repo.getByZidErr = errors.New("zid boom") + svc = NewService(repo) + _, err = svc.Register(context.Background(), RegisterInput{ + ActorZitadelUserID: "zid-1", + ActorEmail: "p@example.com", + DisplayName: "Player", + ConsentVersion: "v1", + ConsentSource: "web", + }) + if err == nil || !strings.Contains(err.Error(), "zid boom") { + t.Fatalf("expected zid lookup error, got %v", err) + } + + repo = newFakeRepo() + repo.createErr = errors.New("create boom") + svc = NewService(repo) + _, err = svc.Register(context.Background(), RegisterInput{ + ActorZitadelUserID: "zid-1", + ActorEmail: "p@example.com", + DisplayName: "Player", + ConsentVersion: "v1", + ConsentSource: "web", + }) + if err == nil || !strings.Contains(err.Error(), "create boom") { + t.Fatalf("expected create error, got %v", err) + } +} + +func TestProfileAndEmailFlows(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo) + user, err := svc.Register(context.Background(), RegisterInput{ + ActorZitadelUserID: "zid-1", + ActorEmail: "player@example.com", + DisplayName: "Player One", + ConsentVersion: "v1", + ConsentSource: "web", + }) + if err != nil { + t.Fatalf("register failed: %v", err) + } + + _, err = svc.GetProfile(context.Background(), "") + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation on empty id, got %v", err) + } + + updated, err := svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{ + DisplayName: "Player Renamed", + ConsentVersion: "v2", + ConsentSource: "", + }) + if err != nil { + t.Fatalf("update profile failed: %v", err) + } + if updated.DisplayName != "Player Renamed" { + t.Fatalf("unexpected updated name: %s", updated.DisplayName) + } + + verified, err := svc.VerifyEmail(context.Background(), user.ID) + if err != nil { + t.Fatalf("verify email failed: %v", err) + } + if !verified.EmailVerified { + t.Fatalf("expected email verified") + } +} + +func TestProfileValidationAndRepoErrors(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo) + user, _ := svc.Register(context.Background(), RegisterInput{ + ActorZitadelUserID: "zid-1", + ActorEmail: "player@example.com", + DisplayName: "Player One", + ConsentVersion: "v1", + ConsentSource: "web", + }) + + _, err := svc.UpdateProfile(context.Background(), "", UpdateProfileInput{DisplayName: "x", ConsentVersion: "v1"}) + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation on empty user id, got %v", err) + } + _, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{DisplayName: "", ConsentVersion: "v1"}) + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation on display name, got %v", err) + } + _, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{DisplayName: "ok", + ConsentVersion: strings.Repeat("a", 40)}) + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation on consent version, got %v", err) + } + _, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{ + DisplayName: "ok", + ConsentVersion: "v1", + ConsentSource: strings.Repeat("a", 40), + }) + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected validation on consent source, got %v", err) + } + + repo.updateErr = errors.New("update boom") + _, err = svc.UpdateProfile(context.Background(), user.ID, UpdateProfileInput{ + DisplayName: "ok", + ConsentVersion: "v1", + ConsentSource: "web", + }) + if err == nil || !strings.Contains(err.Error(), "update boom") { + t.Fatalf("expected update repo error, got %v", err) + } + + repo.verifyErr = errors.New("verify boom") + _, err = svc.VerifyEmail(context.Background(), user.ID) + if err == nil || !strings.Contains(err.Error(), "verify boom") { + t.Fatalf("expected verify repo error, got %v", err) + } +} + +func TestAdminListAndExport(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo) + user, _ := svc.Register(context.Background(), RegisterInput{ + ActorZitadelUserID: "zid-1", + ActorEmail: "player@example.com", + DisplayName: "Player One", + ConsentVersion: "v1", + ConsentSource: "web", + }) + repo.audit = append(repo.audit, domain.AuditLogEntry{TargetUserID: user.ID, Action: domain.AuditActionGDPRExport}) + + items, total, pagination, err := svc.AdminListUsers(context.Background(), ListUsersInput{ + Page: 0, PageSize: 0, Email: "PLAYER@EXAMPLE.COM", DisplayName: " Player ", + }) + if err != nil { + t.Fatalf("admin list failed: %v", err) + } + if total == 0 || len(items) == 0 { + t.Fatalf("expected listed users") + } + if pagination.Page <= 0 || pagination.PageSize <= 0 { + t.Fatalf("expected normalized pagination, got %+v", pagination) + } + + bundle, err := svc.AdminExportUser(context.Background(), user.ID, "admin-1") + if err != nil { + t.Fatalf("admin export failed: %v", err) + } + if bundle.User.ID != user.ID { + t.Fatalf("unexpected export user id: %s", bundle.User.ID) + } + if len(repo.audit) == 0 { + t.Fatalf("expected audit writes during export") + } +} + +func TestDeleteListExportErrors(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo) + err := svc.DeleteUser(context.Background(), "", "admin") + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected delete validation error, got %v", err) + } + + repo.deleteErr = errors.New("delete boom") + err = svc.DeleteUser(context.Background(), "u-1", "admin") + if err == nil || !strings.Contains(err.Error(), "delete boom") { + t.Fatalf("expected delete repo error, got %v", err) + } + + repo.listErr = errors.New("list boom") + _, _, _, err = svc.AdminListUsers(context.Background(), ListUsersInput{Page: 1, PageSize: 10}) + if err == nil || !strings.Contains(err.Error(), "list boom") { + t.Fatalf("expected list repo error, got %v", err) + } + + _, err = svc.AdminExportUser(context.Background(), "", "admin") + if !errors.Is(err, domain.ErrValidationFailed) { + t.Fatalf("expected export validation error, got %v", err) + } + + repo = newFakeRepo() + svc = NewService(repo) + repo.getByIDErr = errors.New("profile boom") + _, err = svc.AdminExportUser(context.Background(), "u-1", "admin") + if err == nil || !strings.Contains(err.Error(), "profile boom") { + t.Fatalf("expected get profile error, got %v", err) + } + + repo = newFakeRepo() + svc = NewService(repo) + user, _ := svc.Register(context.Background(), RegisterInput{ + ActorZitadelUserID: "zid-1", + ActorEmail: "player@example.com", + DisplayName: "Player One", + ConsentVersion: "v1", + ConsentSource: "web", + }) + repo.logsErr = errors.New("logs boom") + _, err = svc.AdminExportUser(context.Background(), user.ID, "admin") + if err == nil || !strings.Contains(err.Error(), "logs boom") { + t.Fatalf("expected logs error, got %v", err) + } +} diff --git a/backend/services/user-service/tests/integration_http_test.go b/backend/services/user-service/tests/integration_http_test.go index a67d0b0..df8fd7a 100644 --- a/backend/services/user-service/tests/integration_http_test.go +++ b/backend/services/user-service/tests/integration_http_test.go @@ -301,3 +301,62 @@ func TestMetricsEndpoint(t *testing.T) { sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed") defer resp.Body.Close() } + +// TestVerifyEmailAndValidationPaths covers verify-email success/unauthorized and admin query validation errors. +func TestVerifyEmailAndValidationPaths(t *testing.T) { + app, _ := setupApp(t) + + req := httptest.NewRequest(http.MethodPost, "/users/verify-email", nil) + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized verify-email") + defer resp.Body.Close() + + req = httptest.NewRequest(http.MethodPost, "/users/verify-email", nil) + req.Header.Set("Authorization", "Bearer user-1") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected verify-email success") + defer resp.Body.Close() + + req = httptest.NewRequest(http.MethodGet, "/admin/users?created_after=not-rfc3339", nil) + req.Header.Set("Authorization", "Bearer admin") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected created_after validation failure") + defer resp.Body.Close() + + req = httptest.NewRequest(http.MethodGet, "/admin/users?created_before=not-rfc3339", nil) + req.Header.Set("Authorization", "Bearer admin") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected created_before validation failure") + defer resp.Body.Close() +} + +// TestRegisterAndUpdateInvalidPayloads covers invalid request-body and validation paths. +func TestRegisterAndUpdateInvalidPayloads(t *testing.T) { + app, _ := setupApp(t) + + req := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader([]byte("{"))) + req.Header.Set("Authorization", "Bearer user-1") + req.Header.Set("Content-Type", "application/json") + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bad json for register") + defer resp.Body.Close() + + invalidRegister, _ := json.Marshal(map[string]any{ + "display_name": "", + "consent_version": "v1", + "consent_source": "web", + }) + req = httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader(invalidRegister)) + req.Header.Set("Authorization", "Bearer user-1") + req.Header.Set("Content-Type", "application/json") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected validation error for register") + defer resp.Body.Close() + + req = httptest.NewRequest(http.MethodPut, "/users/user-1", bytes.NewReader([]byte("{"))) + req.Header.Set("Authorization", "Bearer user-1") + req.Header.Set("Content-Type", "application/json") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected bad json for update") + defer resp.Body.Close() +} diff --git a/tasks/security-scan.yml b/tasks/security-scan.yml index 9a04f2f..4784172 100644 --- a/tasks/security-scan.yml +++ b/tasks/security-scan.yml @@ -104,7 +104,7 @@ tasks: ' enforce-backend-coverage: - desc: Aggregate backend coverage and fail if below threshold + desc: Aggregate backend service coverage and enforce stepwise thresholds up to 80% cmds: - | bash -o pipefail -c ' @@ -113,27 +113,72 @@ tasks: COVERAGE_DIR="reports/tests/coverage" COMBINED="${COVERAGE_DIR}/backend-combined.out" SUMMARY="reports/tests/backend-coverage-summary.txt" - THRESHOLD="${BACKEND_COVERAGE_THRESHOLD:-80}" + THRESHOLDS="${BACKEND_COVERAGE_THRESHOLDS:-60 70 80}" rm -f "${COMBINED}" echo "mode: atomic" > "${COMBINED}" - found=0 + merged_input="" for profile in "${COVERAGE_DIR}"/*.out; do [ -f "${profile}" ] || continue - tail -n +2 "${profile}" >> "${COMBINED}" - found=1 + [ "${profile}" = "${COMBINED}" ] && continue + case "${profile}" in + *shared-unit.out) continue ;; + esac + merged_input="${merged_input} ${profile}" done - if [ "${found}" -eq 0 ]; then + if [ -z "${merged_input}" ]; then echo "No backend coverage profiles were generated." | tee "${SUMMARY}" exit 1 fi + # Deduplicate statement blocks across unit/integration profiles by keeping max count per block. + # shellcheck disable=SC2086 + tail -n +2 ${merged_input} | sort -k1,1 | awk " + { + if (NR == 1) { + prev = \$1 + stmt = \$2 + max = \$3 + next + } + if (\$1 != prev) { + print prev, stmt, max + prev = \$1 + stmt = \$2 + max = \$3 + next + } + if ((\$3 + 0) > (max + 0)) { + max = \$3 + } + } + END { + if (NR > 0) { + print prev, stmt, max + } + } + " >> "${COMBINED}" + total_stats="$(awk " NR > 1 { + split(\$1, loc, \":\") + path = loc[1] + pkg = path + sub(/\\/[^\\/]*$/, \"\", pkg) + + # Focus on service application/domain and HTTP interface logic. + if (pkg !~ /^knowfoolery\\/backend\\/services\\//) { + next + } + if (pkg !~ /\\/internal\\/(application|domain)\\// && + pkg !~ /\\/internal\\/interfaces\\/http($|\\/)/) { + next + } + total += \$2 - if (\$3 > 0) { + if ((\$3 + 0) > 0) { covered += \$2 } } @@ -150,12 +195,14 @@ tasks: total_stmt="$(echo "${total_stats}" | awk "{print \$3}")" { - echo "Backend total coverage: ${total_pct}%" + echo "Backend service (application/domain/interfaces-http) coverage: ${total_pct}%" echo "Covered statements: ${covered_stmt}/${total_stmt}" - echo "Coverage threshold: ${THRESHOLD}%" + echo "Threshold progression: ${THRESHOLDS}%" } | tee "${SUMMARY}" - awk -v coverage="${total_pct}" -v threshold="${THRESHOLD}" "BEGIN { exit((coverage+0) < (threshold+0)) }" + for threshold in ${THRESHOLDS}; do + awk -v coverage="${total_pct}" -v threshold="${threshold}" "BEGIN { exit((coverage+0) < (threshold+0)) }" + done ' docker-build-validate: