diff --git a/backend/services/question-bank-service/cmd/main.go b/backend/services/question-bank-service/cmd/main.go index 1580c5b..bad3d0e 100644 --- a/backend/services/question-bank-service/cmd/main.go +++ b/backend/services/question-bank-service/cmd/main.go @@ -14,6 +14,7 @@ import ( qent "knowfoolery/backend/services/question-bank-service/internal/infra/persistence/ent" httpapi "knowfoolery/backend/services/question-bank-service/internal/interfaces/http" "knowfoolery/backend/shared/infra/auth/zitadel" + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" sharedredis "knowfoolery/backend/shared/infra/database/redis" "knowfoolery/backend/shared/infra/observability/logging" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" @@ -39,7 +40,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - persistence, err := qent.NewClient(ctx, cfg.Postgres) + persistence, err := sharedpostgres.NewClient(ctx, cfg.Postgres) if err != nil { logger.WithError(err).Fatal("failed to initialize postgres client") } @@ -72,7 +73,25 @@ func main() { } app := serviceboot.NewFiberApp(bootCfg) serviceboot.RegisterHealth(app, bootCfg.ServiceSlug) - registerReadiness(app, persistence, redisClient) + serviceboot.RegisterReadiness( + app, + 2*time.Second, + serviceboot.ReadyCheck{ + Name: "postgres", + Required: true, + Probe: persistence.Pool.Ping, + }, + serviceboot.ReadyCheck{ + Name: "redis", + Required: false, + Probe: func(ctx context.Context) error { + if redisClient == nil { + return nil + } + return redisClient.HealthCheck(ctx) + }, + }, + ) app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) adminMiddleware := buildAdminMiddleware(cfg) @@ -83,50 +102,16 @@ func main() { } func buildAdminMiddleware(cfg qbconfig.Config) fiber.Handler { - if cfg.ZitadelBaseURL == "" { - return nil - } - - client := zitadel.NewClient(zitadel.Config{ + return zitadel.BuildJWTMiddleware(zitadel.MiddlewareFactoryConfig{ BaseURL: cfg.ZitadelBaseURL, ClientID: cfg.ZitadelClientID, ClientSecret: cfg.ZitadelSecret, Issuer: cfg.ZitadelIssuer, Audience: cfg.ZitadelAudience, - Timeout: 10 * time.Second, - }) - - return zitadel.JWTMiddleware(zitadel.JWTMiddlewareConfig{ - Client: client, - Issuer: cfg.ZitadelIssuer, - Audience: cfg.ZitadelAudience, - RequiredClaims: []string{"sub"}, + RequiredClaims: []string{ + "sub", + }, AdminEndpoints: []string{"/admin"}, - }) -} - -func registerReadiness(app *fiber.App, persistence *qent.Client, redisClient *sharedredis.Client) { - app.Get("/ready", func(c fiber.Ctx) error { - ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second) - defer cancel() - - checks := map[string]string{"postgres": "ok", "redis": "ok"} - if err := persistence.Pool.Ping(ctx); err != nil { - checks["postgres"] = "down" - } - if redisClient != nil { - if err := redisClient.HealthCheck(ctx); err != nil { - checks["redis"] = "down" - } - } - - status := fiber.StatusOK - if checks["postgres"] != "ok" { - status = fiber.StatusServiceUnavailable - } - return c.Status(status).JSON(fiber.Map{ - "status": "ready", - "checks": checks, - }) + Timeout: 10 * time.Second, }) } diff --git a/backend/services/question-bank-service/internal/infra/persistence/ent/client.go b/backend/services/question-bank-service/internal/infra/persistence/ent/client.go deleted file mode 100644 index 4fbfabf..0000000 --- a/backend/services/question-bank-service/internal/infra/persistence/ent/client.go +++ /dev/null @@ -1,57 +0,0 @@ -package ent - -import ( - "context" - "fmt" - - "github.com/jackc/pgx/v5/pgxpool" - - sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" -) - -// Client wraps the pgx pool used by repository implementations. -type Client struct { - Pool *pgxpool.Pool -} - -// NewClient creates a persistence client and verifies connectivity. -func NewClient(ctx context.Context, cfg sharedpostgres.Config) (*Client, error) { - poolCfg, err := pgxpool.ParseConfig(cfg.URL()) - if err != nil { - return nil, fmt.Errorf("parse postgres config: %w", err) - } - poolCfg.MaxConns = clampIntToInt32(cfg.MaxOpenConns) - poolCfg.MinConns = clampIntToInt32(cfg.MaxIdleConns) - poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime - poolCfg.MaxConnIdleTime = cfg.ConnMaxIdleTime - - pool, err := pgxpool.NewWithConfig(ctx, poolCfg) - if err != nil { - return nil, fmt.Errorf("create postgres pool: %w", err) - } - - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, fmt.Errorf("ping postgres: %w", err) - } - - return &Client{Pool: pool}, nil -} - -// Close closes the underlying pool. -func (c *Client) Close() { - if c != nil && c.Pool != nil { - c.Pool.Close() - } -} - -func clampIntToInt32(v int) int32 { - const maxInt32 = int(^uint32(0) >> 1) - if v <= 0 { - return 0 - } - if v > maxInt32 { - return int32(maxInt32) - } - return int32(v) -} diff --git a/backend/services/question-bank-service/internal/infra/persistence/ent/question_repo.go b/backend/services/question-bank-service/internal/infra/persistence/ent/question_repo.go index 3b51623..3016aaa 100644 --- a/backend/services/question-bank-service/internal/infra/persistence/ent/question_repo.go +++ b/backend/services/question-bank-service/internal/infra/persistence/ent/question_repo.go @@ -12,15 +12,16 @@ import ( domain "knowfoolery/backend/services/question-bank-service/internal/domain/question" sharederrors "knowfoolery/backend/shared/domain/errors" + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" ) // QuestionRepository implements question storage on PostgreSQL. type QuestionRepository struct { - client *Client + client *sharedpostgres.Client } // NewQuestionRepository creates a new question repository. -func NewQuestionRepository(client *Client) *QuestionRepository { +func NewQuestionRepository(client *sharedpostgres.Client) *QuestionRepository { return &QuestionRepository{client: client} } 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 37cacb9..91baf20 100644 --- a/backend/services/question-bank-service/tests/integration_http_test.go +++ b/backend/services/question-bank-service/tests/integration_http_test.go @@ -21,6 +21,7 @@ import ( httpapi "knowfoolery/backend/services/question-bank-service/internal/interfaces/http" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" "knowfoolery/backend/shared/infra/utils/validation" + sharedhttpx "knowfoolery/backend/shared/testutil/httpx" ) // inMemoryRepo is an in-memory repository used for HTTP integration tests. @@ -145,16 +146,12 @@ func TestHTTPRandomAndGet(t *testing.T) { bytes.NewReader(payload), ) req.Header.Set("Content-Type", "application/json") - resp, err := app.Test(req) - if err != nil || resp.StatusCode != http.StatusOK { - t.Fatalf("random failed: err=%v status=%d", err, resp.StatusCode) - } + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "random failed") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/questions/q1", nil) - resp, err = app.Test(req) - if err != nil || resp.StatusCode != http.StatusOK { - t.Fatalf("get failed: err=%v status=%d", err, resp.StatusCode) - } + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "get failed") defer resp.Body.Close() } @@ -176,10 +173,8 @@ func TestHTTPAdminAuthAndMetrics(t *testing.T) { bytes.NewReader(createPayload), ) req.Header.Set("Content-Type", "application/json") - resp, err := app.Test(req) - if err != nil || resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("expected unauthorized: err=%v status=%d", err, resp.StatusCode) - } + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized") defer resp.Body.Close() req = httptest.NewRequest( http.MethodPost, @@ -188,16 +183,12 @@ func TestHTTPAdminAuthAndMetrics(t *testing.T) { ) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer admin") - resp, err = app.Test(req) - if err != nil || resp.StatusCode != http.StatusCreated { - t.Fatalf("expected created: err=%v status=%d", err, resp.StatusCode) - } + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusCreated, "expected created") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/metrics", nil) - resp, err = app.Test(req) - if err != nil || resp.StatusCode != http.StatusOK { - t.Fatalf("metrics failed: err=%v status=%d", err, resp.StatusCode) - } + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed") defer resp.Body.Close() } @@ -212,10 +203,8 @@ func TestHTTPValidateAnswer(t *testing.T) { bytes.NewReader(matchedPayload), ) req.Header.Set("Content-Type", "application/json") - resp, err := app.Test(req) - if err != nil || resp.StatusCode != http.StatusOK { - t.Fatalf("expected matched 200: err=%v status=%d", err, resp.StatusCode) - } + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected matched 200") defer resp.Body.Close() unmatchedPayload, _ := json.Marshal(map[string]any{"answer": "Mars"}) req = httptest.NewRequest( @@ -224,10 +213,8 @@ func TestHTTPValidateAnswer(t *testing.T) { bytes.NewReader(unmatchedPayload), ) req.Header.Set("Content-Type", "application/json") - resp, err = app.Test(req) - if err != nil || resp.StatusCode != http.StatusOK { - t.Fatalf("expected unmatched 200: err=%v status=%d", err, resp.StatusCode) - } + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected unmatched 200") defer resp.Body.Close() req = httptest.NewRequest( http.MethodPost, @@ -235,10 +222,8 @@ func TestHTTPValidateAnswer(t *testing.T) { bytes.NewReader(matchedPayload), ) req.Header.Set("Content-Type", "application/json") - resp, err = app.Test(req) - if err != nil || resp.StatusCode != http.StatusNotFound { - t.Fatalf("expected 404 for missing question: err=%v status=%d", err, resp.StatusCode) - } + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNotFound, "expected 404 for missing question") defer resp.Body.Close() badPayload, _ := json.Marshal(map[string]any{"answer": ""}) req = httptest.NewRequest( @@ -247,9 +232,7 @@ func TestHTTPValidateAnswer(t *testing.T) { bytes.NewReader(badPayload), ) req.Header.Set("Content-Type", "application/json") - resp, err = app.Test(req) - if err != nil || resp.StatusCode != http.StatusBadRequest { - t.Fatalf("expected 400 for empty answer: err=%v status=%d", err, resp.StatusCode) - } + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusBadRequest, "expected 400 for empty answer") defer resp.Body.Close() } diff --git a/backend/services/question-bank-service/tests/integration_repo_test.go b/backend/services/question-bank-service/tests/integration_repo_test.go index 743ae6d..c11cdc7 100644 --- a/backend/services/question-bank-service/tests/integration_repo_test.go +++ b/backend/services/question-bank-service/tests/integration_repo_test.go @@ -42,7 +42,7 @@ func TestRepositoryLifecycle(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - client, err := qent.NewClient(ctx, cfg) + client, err := sharedpostgres.NewClient(ctx, cfg) if err != nil { t.Fatalf("new client: %v", err) } diff --git a/backend/services/user-service/cmd/main.go b/backend/services/user-service/cmd/main.go index dd7bb8f..b352a63 100644 --- a/backend/services/user-service/cmd/main.go +++ b/backend/services/user-service/cmd/main.go @@ -13,6 +13,7 @@ import ( uent "knowfoolery/backend/services/user-service/internal/infra/persistence/ent" httpapi "knowfoolery/backend/services/user-service/internal/interfaces/http" "knowfoolery/backend/shared/infra/auth/zitadel" + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" "knowfoolery/backend/shared/infra/observability/logging" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" "knowfoolery/backend/shared/infra/observability/tracing" @@ -37,7 +38,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - persistence, err := uent.NewClient(ctx, cfg.Postgres) + persistence, err := sharedpostgres.NewClient(ctx, cfg.Postgres) if err != nil { logger.WithError(err).Fatal("failed to initialize postgres client") } @@ -66,7 +67,15 @@ func main() { } app := serviceboot.NewFiberApp(bootCfg) serviceboot.RegisterHealth(app, bootCfg.ServiceSlug) - registerReadiness(app, persistence) + serviceboot.RegisterReadiness( + app, + 2*time.Second, + serviceboot.ReadyCheck{ + Name: "postgres", + Required: true, + Probe: persistence.Pool.Ping, + }, + ) app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler())) authMiddleware, adminMiddleware := buildAuthMiddleware(cfg) @@ -77,47 +86,18 @@ func main() { } func buildAuthMiddleware(cfg uconfig.Config) (fiber.Handler, fiber.Handler) { - if cfg.ZitadelBaseURL == "" { - return nil, nil - } - - client := zitadel.NewClient(zitadel.Config{ + auth := zitadel.BuildJWTMiddleware(zitadel.MiddlewareFactoryConfig{ BaseURL: cfg.ZitadelBaseURL, ClientID: cfg.ZitadelClientID, ClientSecret: cfg.ZitadelSecret, Issuer: cfg.ZitadelIssuer, Audience: cfg.ZitadelAudience, - Timeout: 10 * time.Second, - }) - - auth := zitadel.JWTMiddleware(zitadel.JWTMiddlewareConfig{ - Client: client, - Issuer: cfg.ZitadelIssuer, - Audience: cfg.ZitadelAudience, - RequiredClaims: []string{"sub", "email"}, + RequiredClaims: []string{ + "sub", + "email", + }, AdminEndpoints: []string{"/admin"}, + Timeout: 10 * time.Second, }) return auth, nil } - -func registerReadiness(app *fiber.App, persistence *uent.Client) { - app.Get("/ready", func(c fiber.Ctx) error { - ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second) - defer cancel() - - checks := map[string]string{"postgres": "ok"} - if err := persistence.Pool.Ping(ctx); err != nil { - checks["postgres"] = "down" - } - - status := fiber.StatusOK - if checks["postgres"] != "ok" { - status = fiber.StatusServiceUnavailable - } - - return c.Status(status).JSON(fiber.Map{ - "status": "ready", - "checks": checks, - }) - }) -} diff --git a/backend/services/user-service/internal/infra/persistence/ent/client.go b/backend/services/user-service/internal/infra/persistence/ent/client.go deleted file mode 100644 index 4fbfabf..0000000 --- a/backend/services/user-service/internal/infra/persistence/ent/client.go +++ /dev/null @@ -1,57 +0,0 @@ -package ent - -import ( - "context" - "fmt" - - "github.com/jackc/pgx/v5/pgxpool" - - sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" -) - -// Client wraps the pgx pool used by repository implementations. -type Client struct { - Pool *pgxpool.Pool -} - -// NewClient creates a persistence client and verifies connectivity. -func NewClient(ctx context.Context, cfg sharedpostgres.Config) (*Client, error) { - poolCfg, err := pgxpool.ParseConfig(cfg.URL()) - if err != nil { - return nil, fmt.Errorf("parse postgres config: %w", err) - } - poolCfg.MaxConns = clampIntToInt32(cfg.MaxOpenConns) - poolCfg.MinConns = clampIntToInt32(cfg.MaxIdleConns) - poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime - poolCfg.MaxConnIdleTime = cfg.ConnMaxIdleTime - - pool, err := pgxpool.NewWithConfig(ctx, poolCfg) - if err != nil { - return nil, fmt.Errorf("create postgres pool: %w", err) - } - - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, fmt.Errorf("ping postgres: %w", err) - } - - return &Client{Pool: pool}, nil -} - -// Close closes the underlying pool. -func (c *Client) Close() { - if c != nil && c.Pool != nil { - c.Pool.Close() - } -} - -func clampIntToInt32(v int) int32 { - const maxInt32 = int(^uint32(0) >> 1) - if v <= 0 { - return 0 - } - if v > maxInt32 { - return int32(maxInt32) - } - return int32(v) -} diff --git a/backend/services/user-service/internal/infra/persistence/ent/user_repo.go b/backend/services/user-service/internal/infra/persistence/ent/user_repo.go index 1f0c5de..e52a139 100644 --- a/backend/services/user-service/internal/infra/persistence/ent/user_repo.go +++ b/backend/services/user-service/internal/infra/persistence/ent/user_repo.go @@ -14,15 +14,16 @@ import ( domain "knowfoolery/backend/services/user-service/internal/domain/user" sharederrors "knowfoolery/backend/shared/domain/errors" sharedtypes "knowfoolery/backend/shared/domain/types" + sharedpostgres "knowfoolery/backend/shared/infra/database/postgres" ) // UserRepository implements user storage on PostgreSQL. type UserRepository struct { - client *Client + client *sharedpostgres.Client } // NewUserRepository creates a new user repository. -func NewUserRepository(client *Client) *UserRepository { +func NewUserRepository(client *sharedpostgres.Client) *UserRepository { return &UserRepository{client: client} } diff --git a/backend/services/user-service/tests/integration_http_test.go b/backend/services/user-service/tests/integration_http_test.go index a9a74c5..a67d0b0 100644 --- a/backend/services/user-service/tests/integration_http_test.go +++ b/backend/services/user-service/tests/integration_http_test.go @@ -21,6 +21,7 @@ import ( sharedtypes "knowfoolery/backend/shared/domain/types" sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics" "knowfoolery/backend/shared/infra/utils/validation" + sharedhttpx "knowfoolery/backend/shared/testutil/httpx" ) // inMemoryRepo is a lightweight repository double for HTTP integration tests. @@ -230,13 +231,13 @@ func TestRegisterAndCRUD(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewReader(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer user-1") - resp := mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusOK, "register failed") + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "register failed") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/users/user-1", nil) req.Header.Set("Authorization", "Bearer user-1") - resp = mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusOK, "get failed") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "get failed") defer resp.Body.Close() updatePayload, _ := json.Marshal(map[string]any{ @@ -247,13 +248,13 @@ func TestRegisterAndCRUD(t *testing.T) { req = httptest.NewRequest(http.MethodPut, "/users/user-1", bytes.NewReader(updatePayload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer user-1") - resp = mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusOK, "update failed") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "update failed") defer resp.Body.Close() req = httptest.NewRequest(http.MethodDelete, "/users/user-1", nil) req.Header.Set("Authorization", "Bearer user-1") - resp = mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusNoContent, "delete failed") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNoContent, "delete failed") defer resp.Body.Close() } @@ -262,32 +263,32 @@ func TestAuthGuardsAndAdminRoutes(t *testing.T) { app, _ := setupApp(t) req := httptest.NewRequest(http.MethodGet, "/users/user-1", nil) - resp := mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized") + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusUnauthorized, "expected unauthorized") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/users/user-2", nil) req.Header.Set("Authorization", "Bearer user-1") - resp = mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusForbidden, "expected forbidden") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected forbidden") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) req.Header.Set("Authorization", "Bearer user-1") - resp = mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusForbidden, "expected admin forbidden") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected admin forbidden") defer resp.Body.Close() req = httptest.NewRequest(http.MethodGet, "/admin/users", nil) req.Header.Set("Authorization", "Bearer admin") - resp = mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusOK, "expected admin list ok") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected admin list ok") defer resp.Body.Close() req = httptest.NewRequest(http.MethodPost, "/admin/users/user-2/export", nil) req.Header.Set("Authorization", "Bearer admin") - resp = mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusOK, "expected export ok") + resp = sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "expected export ok") defer resp.Body.Close() } @@ -296,26 +297,7 @@ func TestMetricsEndpoint(t *testing.T) { app, _ := setupApp(t) req := httptest.NewRequest(http.MethodGet, "/metrics", nil) - resp := mustTest(t, app, req) - assertStatusAndClose(t, resp, http.StatusOK, "metrics failed") + resp := sharedhttpx.MustTest(t, app, req) + sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed") defer resp.Body.Close() } - -// mustTest executes a test request and fails immediately on transport errors. -func mustTest(t *testing.T, app *fiber.App, req *http.Request) *http.Response { - t.Helper() - resp, err := app.Test(req) - if err != nil { - t.Fatalf("app.Test failed: %v", err) - } - return resp -} - -// assertStatusAndClose checks the HTTP status and always closes the response body. -func assertStatusAndClose(t *testing.T, resp *http.Response, want int, msg string) { - t.Helper() - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != want { - t.Fatalf("%s: status=%d want=%d", msg, resp.StatusCode, want) - } -} diff --git a/backend/shared/infra/auth/zitadel/factory.go b/backend/shared/infra/auth/zitadel/factory.go new file mode 100644 index 0000000..999b310 --- /dev/null +++ b/backend/shared/infra/auth/zitadel/factory.go @@ -0,0 +1,51 @@ +package zitadel + +import ( + "time" + + "github.com/gofiber/fiber/v3" +) + +// MiddlewareFactoryConfig configures a JWT middleware created from env-backed settings. +type MiddlewareFactoryConfig struct { + BaseURL string + Issuer string + Audience string + ClientID string + ClientSecret string + + RequiredClaims []string + AdminEndpoints []string + SkipPaths []string + Timeout time.Duration +} + +// BuildJWTMiddleware builds a JWT middleware or returns nil when auth is disabled. +func BuildJWTMiddleware(cfg MiddlewareFactoryConfig) fiber.Handler { + if cfg.BaseURL == "" { + return nil + } + + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 10 * time.Second + } + + client := NewClient(Config{ + BaseURL: cfg.BaseURL, + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Issuer: cfg.Issuer, + Audience: cfg.Audience, + Timeout: timeout, + }) + + return JWTMiddleware(JWTMiddlewareConfig{ + Client: client, + Issuer: cfg.Issuer, + Audience: cfg.Audience, + RequiredClaims: cfg.RequiredClaims, + AdminEndpoints: cfg.AdminEndpoints, + SkipPaths: cfg.SkipPaths, + }) +} diff --git a/backend/shared/infra/database/postgres/client.go b/backend/shared/infra/database/postgres/client.go index c96c573..4038a9e 100644 --- a/backend/shared/infra/database/postgres/client.go +++ b/backend/shared/infra/database/postgres/client.go @@ -123,3 +123,50 @@ func validateConfig(c Config) error { } return nil } + +// Client wraps a pgx connection pool for service repositories. +type Client struct { + Pool *pgxpool.Pool +} + +// NewClient creates a pooled postgres client and verifies connectivity. +func NewClient(ctx context.Context, cfg Config) (*Client, error) { + poolCfg, err := pgxpool.ParseConfig(cfg.URL()) + if err != nil { + return nil, fmt.Errorf("parse postgres config: %w", err) + } + poolCfg.MaxConns = clampIntToInt32(cfg.MaxOpenConns) + poolCfg.MinConns = clampIntToInt32(cfg.MaxIdleConns) + poolCfg.MaxConnLifetime = cfg.ConnMaxLifetime + poolCfg.MaxConnIdleTime = cfg.ConnMaxIdleTime + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, fmt.Errorf("create postgres pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + + return &Client{Pool: pool}, nil +} + +// Close closes the underlying pool. +func (c *Client) Close() { + if c != nil && c.Pool != nil { + c.Pool.Close() + } +} + +func clampIntToInt32(v int) int32 { + const maxInt32 = int(^uint32(0) >> 1) + if v <= 0 { + return 0 + } + if v > maxInt32 { + return int32(maxInt32) + } + return int32(v) +} diff --git a/backend/shared/infra/utils/serviceboot/readiness.go b/backend/shared/infra/utils/serviceboot/readiness.go new file mode 100644 index 0000000..e27b537 --- /dev/null +++ b/backend/shared/infra/utils/serviceboot/readiness.go @@ -0,0 +1,49 @@ +package serviceboot + +import ( + "context" + "time" + + "github.com/gofiber/fiber/v3" +) + +// ReadyCheck defines a readiness probe function. +type ReadyCheck struct { + Name string + Required bool + Probe func(ctx context.Context) error +} + +// RegisterReadiness registers a standard /ready endpoint with named checks. +func RegisterReadiness(app *fiber.App, timeout time.Duration, checks ...ReadyCheck) { + if timeout <= 0 { + timeout = 2 * time.Second + } + + app.Get("/ready", func(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(c.Context(), timeout) + defer cancel() + + statusMap := make(map[string]string, len(checks)) + httpStatus := fiber.StatusOK + + for _, check := range checks { + if check.Name == "" || check.Probe == nil { + continue + } + if err := check.Probe(ctx); err != nil { + statusMap[check.Name] = "down" + if check.Required { + httpStatus = fiber.StatusServiceUnavailable + } + continue + } + statusMap[check.Name] = "ok" + } + + return c.Status(httpStatus).JSON(fiber.Map{ + "status": "ready", + "checks": statusMap, + }) + }) +} diff --git a/backend/shared/testutil/httpx/httpx.go b/backend/shared/testutil/httpx/httpx.go new file mode 100644 index 0000000..08fa049 --- /dev/null +++ b/backend/shared/testutil/httpx/httpx.go @@ -0,0 +1,27 @@ +package httpx + +import ( + "net/http" + "testing" + + "github.com/gofiber/fiber/v3" +) + +// MustTest executes a Fiber test request and fails on transport errors. +func MustTest(t testing.TB, app *fiber.App, req *http.Request) *http.Response { + t.Helper() + resp, err := app.Test(req) + if err != nil { + t.Fatalf("app.Test failed: %v", err) + } + return resp +} + +// AssertStatusAndClose validates status code and closes response body. +func AssertStatusAndClose(t testing.TB, resp *http.Response, want int, msg string) { + t.Helper() + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != want { + t.Fatalf("%s: status=%d want=%d", msg, resp.StatusCode, want) + } +}