Production hardening: security, resilience, observability, and compliance
Password complexity: custom validator requiring uppercase, lowercase, digit (min 8 chars)
Token expiry: 90-day token lifetime with refresh endpoint (60-90 day renewal window)
Health check: /api/health/ now pings Postgres + Redis, returns 503 on failure
Audit logging: async audit_log table for auth events (login, register, delete, etc.)
Circuit breaker: APNs/FCM push sends wrapped with 5-failure threshold, 30s recovery
FK indexes: 27 missing foreign key indexes across all tables (migration 017)
CSP header: default-src 'none'; frame-ancestors 'none'
Gzip compression: level 5 with media endpoint skipper
Prometheus metrics: /metrics endpoint using existing monitoring service
External timeouts: 15s push, 30s SMTP, context timeouts on all external calls
Migrations: 016 (token created_at), 017 (FK indexes), 018 (audit_log)
Tests: circuit breaker (15), audit service (8), token refresh (7), health (4),
middleware expiry (5), validator (new)
This commit is contained in:
136
internal/router/health_test.go
Normal file
136
internal/router/health_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// setupTestDB creates an in-memory SQLite database for health check tests
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestLiveCheck_Returns200(t *testing.T) {
|
||||
e := echo.New()
|
||||
e.GET("/api/health/live", liveCheck)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health/live", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "alive", resp["status"])
|
||||
assert.Equal(t, Version, resp["version"])
|
||||
assert.Contains(t, resp, "timestamp")
|
||||
}
|
||||
|
||||
func TestReadinessCheck_HealthyDB_NilCache_Returns200(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
deps := &Dependencies{
|
||||
DB: db,
|
||||
Cache: nil, // No Redis configured
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
e.GET("/api/health/", readinessCheck(deps))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "healthy", resp["status"])
|
||||
assert.Equal(t, Version, resp["version"])
|
||||
|
||||
checks := resp["checks"].(map[string]interface{})
|
||||
assert.Equal(t, "ok", checks["postgres"])
|
||||
assert.Equal(t, "not configured", checks["redis"])
|
||||
}
|
||||
|
||||
func TestReadinessCheck_DBDown_Returns503(t *testing.T) {
|
||||
// Open an in-memory SQLite DB, then close the underlying sql.DB to simulate failure
|
||||
db := setupTestDB(t)
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err)
|
||||
sqlDB.Close()
|
||||
|
||||
deps := &Dependencies{
|
||||
DB: db,
|
||||
Cache: nil,
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
e.GET("/api/health/", readinessCheck(deps))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
err = json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "unhealthy", resp["status"])
|
||||
|
||||
checks := resp["checks"].(map[string]interface{})
|
||||
assert.Contains(t, checks["postgres"], "ping failed")
|
||||
}
|
||||
|
||||
func TestReadinessCheck_ResponseFormat(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
|
||||
deps := &Dependencies{
|
||||
DB: db,
|
||||
Cache: nil,
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
e.GET("/api/health/", readinessCheck(deps))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/health/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify all expected fields are present
|
||||
assert.Contains(t, resp, "status")
|
||||
assert.Contains(t, resp, "version")
|
||||
assert.Contains(t, resp, "checks")
|
||||
assert.Contains(t, resp, "timestamp")
|
||||
|
||||
// Verify checks is a map with expected keys
|
||||
checks, ok := resp["checks"].(map[string]interface{})
|
||||
require.True(t, ok, "checks should be a map")
|
||||
assert.Contains(t, checks, "postgres")
|
||||
assert.Contains(t, checks, "redis")
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -62,11 +63,12 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
|
||||
// Security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, etc.)
|
||||
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
|
||||
XSSProtection: "1; mode=block",
|
||||
ContentTypeNosniff: "nosniff",
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
HSTSMaxAge: 31536000, // 1 year in seconds
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
XSSProtection: "1; mode=block",
|
||||
ContentTypeNosniff: "nosniff",
|
||||
XFrameOptions: "SAMEORIGIN",
|
||||
HSTSMaxAge: 31536000, // 1 year in seconds
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
ContentSecurityPolicy: "default-src 'none'; frame-ancestors 'none'",
|
||||
}))
|
||||
e.Use(middleware.BodyLimitWithConfig(middleware.BodyLimitConfig{
|
||||
Limit: "1M", // 1MB default for JSON payloads
|
||||
@@ -93,6 +95,14 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
e.Use(corsMiddleware(cfg))
|
||||
e.Use(i18n.Middleware())
|
||||
|
||||
// Gzip compression (skip media endpoints since they serve binary files)
|
||||
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
|
||||
Level: 5,
|
||||
Skipper: func(c echo.Context) bool {
|
||||
return strings.HasPrefix(c.Request().URL.Path, "/api/media/")
|
||||
},
|
||||
}))
|
||||
|
||||
// Monitoring metrics middleware (if monitoring is enabled)
|
||||
if deps.MonitoringService != nil {
|
||||
if metricsMiddleware := deps.MonitoringService.MetricsMiddleware(); metricsMiddleware != nil {
|
||||
@@ -114,8 +124,9 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
})
|
||||
}
|
||||
|
||||
// Health check endpoint (no auth required)
|
||||
e.GET("/api/health/", healthCheck)
|
||||
// Health check endpoints (no auth required)
|
||||
e.GET("/api/health/", readinessCheck(deps))
|
||||
e.GET("/api/health/live", liveCheck)
|
||||
|
||||
// Initialize onboarding email service for tracking handler
|
||||
onboardingService := services.NewOnboardingEmailService(deps.DB, deps.EmailService, cfg.Server.BaseURL)
|
||||
@@ -172,17 +183,21 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
subscriptionWebhookHandler.SetStripeService(stripeService)
|
||||
|
||||
// Initialize middleware
|
||||
authMiddleware := custommiddleware.NewAuthMiddleware(deps.DB, deps.Cache)
|
||||
authMiddleware := custommiddleware.NewAuthMiddlewareWithConfig(deps.DB, deps.Cache, cfg)
|
||||
|
||||
// Initialize Apple auth service
|
||||
appleAuthService := services.NewAppleAuthService(deps.Cache, cfg)
|
||||
googleAuthService := services.NewGoogleAuthService(deps.Cache, cfg)
|
||||
|
||||
// Initialize audit service for security event logging
|
||||
auditService := services.NewAuditService(deps.DB)
|
||||
|
||||
// Initialize handlers
|
||||
authHandler := handlers.NewAuthHandler(authService, deps.EmailService, deps.Cache)
|
||||
authHandler.SetAppleAuthService(appleAuthService)
|
||||
authHandler.SetGoogleAuthService(googleAuthService)
|
||||
authHandler.SetStorageService(deps.StorageService)
|
||||
authHandler.SetAuditService(auditService)
|
||||
userHandler := handlers.NewUserHandler(userService)
|
||||
residenceHandler := handlers.NewResidenceHandler(residenceService, deps.PDFService, deps.EmailService, cfg.Features.PDFReportsEnabled)
|
||||
taskHandler := handlers.NewTaskHandler(taskService, deps.StorageService)
|
||||
@@ -201,6 +216,11 @@ func SetupRouter(deps *Dependencies) *echo.Echo {
|
||||
mediaHandler = handlers.NewMediaHandler(documentRepo, taskRepo, residenceRepo, deps.StorageService)
|
||||
}
|
||||
|
||||
// Prometheus metrics endpoint (no auth required, for scraping)
|
||||
if deps.MonitoringService != nil {
|
||||
e.GET("/metrics", prometheusMetrics(deps.MonitoringService))
|
||||
}
|
||||
|
||||
// Set up admin routes with monitoring handler (if available)
|
||||
var monitoringHandler *monitoring.Handler
|
||||
if deps.MonitoringService != nil {
|
||||
@@ -295,16 +315,126 @@ func corsMiddleware(cfg *config.Config) echo.MiddlewareFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// healthCheck returns API health status
|
||||
func healthCheck(c echo.Context) error {
|
||||
// liveCheck returns a simple 200 for Kubernetes liveness probes
|
||||
func liveCheck(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"status": "alive",
|
||||
"version": Version,
|
||||
"framework": "Echo",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// readinessCheck returns 200 if PostgreSQL and Redis are reachable, 503 otherwise.
|
||||
// This is used by Kubernetes readiness probes and load balancers.
|
||||
func readinessCheck(deps *Dependencies) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status := "healthy"
|
||||
httpStatus := http.StatusOK
|
||||
checks := make(map[string]string)
|
||||
|
||||
// Check PostgreSQL
|
||||
sqlDB, err := deps.DB.DB()
|
||||
if err != nil {
|
||||
checks["postgres"] = fmt.Sprintf("failed to get sql.DB: %v", err)
|
||||
status = "unhealthy"
|
||||
httpStatus = http.StatusServiceUnavailable
|
||||
} else if err := sqlDB.PingContext(ctx); err != nil {
|
||||
checks["postgres"] = fmt.Sprintf("ping failed: %v", err)
|
||||
status = "unhealthy"
|
||||
httpStatus = http.StatusServiceUnavailable
|
||||
} else {
|
||||
checks["postgres"] = "ok"
|
||||
}
|
||||
|
||||
// Check Redis (if cache service is available)
|
||||
if deps.Cache != nil {
|
||||
if err := deps.Cache.Client().Ping(ctx).Err(); err != nil {
|
||||
checks["redis"] = fmt.Sprintf("ping failed: %v", err)
|
||||
status = "unhealthy"
|
||||
httpStatus = http.StatusServiceUnavailable
|
||||
} else {
|
||||
checks["redis"] = "ok"
|
||||
}
|
||||
} else {
|
||||
checks["redis"] = "not configured"
|
||||
}
|
||||
|
||||
return c.JSON(httpStatus, map[string]interface{}{
|
||||
"status": status,
|
||||
"version": Version,
|
||||
"checks": checks,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// prometheusMetrics returns an Echo handler that outputs metrics in Prometheus text format.
|
||||
// It uses the existing monitoring service's HTTP stats collector to avoid adding external dependencies.
|
||||
func prometheusMetrics(monSvc *monitoring.Service) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
httpCollector := monSvc.HTTPCollector()
|
||||
if httpCollector == nil {
|
||||
return c.String(http.StatusOK, "# No HTTP metrics available (collector not initialized)\n")
|
||||
}
|
||||
|
||||
stats := httpCollector.GetStats()
|
||||
var b strings.Builder
|
||||
|
||||
// Request count by method+path+status
|
||||
b.WriteString("# HELP http_requests_total Total number of HTTP requests.\n")
|
||||
b.WriteString("# TYPE http_requests_total counter\n")
|
||||
for statusCode, count := range stats.ByStatusCode {
|
||||
fmt.Fprintf(&b, "http_requests_total{status_code=\"%d\"} %d\n", statusCode, count)
|
||||
}
|
||||
|
||||
// Per-endpoint request count
|
||||
b.WriteString("# HELP http_endpoint_requests_total Total requests per endpoint.\n")
|
||||
b.WriteString("# TYPE http_endpoint_requests_total counter\n")
|
||||
for endpoint, epStats := range stats.ByEndpoint {
|
||||
// endpoint is "METHOD /path"
|
||||
parts := strings.SplitN(endpoint, " ", 2)
|
||||
method := endpoint
|
||||
path := ""
|
||||
if len(parts) == 2 {
|
||||
method = parts[0]
|
||||
path = parts[1]
|
||||
}
|
||||
fmt.Fprintf(&b, "http_endpoint_requests_total{method=\"%s\",path=\"%s\"} %d\n", method, path, epStats.Count)
|
||||
}
|
||||
|
||||
// Request duration (avg latency as a gauge, since we don't have raw histogram buckets)
|
||||
b.WriteString("# HELP http_request_duration_ms Average request duration in milliseconds per endpoint.\n")
|
||||
b.WriteString("# TYPE http_request_duration_ms gauge\n")
|
||||
for endpoint, epStats := range stats.ByEndpoint {
|
||||
parts := strings.SplitN(endpoint, " ", 2)
|
||||
method := endpoint
|
||||
path := ""
|
||||
if len(parts) == 2 {
|
||||
method = parts[0]
|
||||
path = parts[1]
|
||||
}
|
||||
fmt.Fprintf(&b, "http_request_duration_ms{method=\"%s\",path=\"%s\",quantile=\"avg\"} %.2f\n", method, path, epStats.AvgLatencyMs)
|
||||
fmt.Fprintf(&b, "http_request_duration_ms{method=\"%s\",path=\"%s\",quantile=\"p95\"} %.2f\n", method, path, epStats.P95LatencyMs)
|
||||
}
|
||||
|
||||
// Error rate
|
||||
b.WriteString("# HELP http_error_rate Overall error rate (4xx+5xx / total).\n")
|
||||
b.WriteString("# TYPE http_error_rate gauge\n")
|
||||
fmt.Fprintf(&b, "http_error_rate %.4f\n", stats.ErrorRate)
|
||||
|
||||
// Requests per minute
|
||||
b.WriteString("# HELP http_requests_per_minute Current request rate.\n")
|
||||
b.WriteString("# TYPE http_requests_per_minute gauge\n")
|
||||
fmt.Fprintf(&b, "http_requests_per_minute %.2f\n", stats.RequestsPerMinute)
|
||||
|
||||
c.Response().Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
return c.String(http.StatusOK, b.String())
|
||||
}
|
||||
}
|
||||
|
||||
// setupPublicAuthRoutes configures public authentication routes with
|
||||
// per-endpoint rate limiters to mitigate brute-force and credential-stuffing.
|
||||
// Rate limiters are disabled in debug mode to allow UI test suites to run
|
||||
@@ -342,6 +472,7 @@ func setupProtectedAuthRoutes(api *echo.Group, authHandler *handlers.AuthHandler
|
||||
auth := api.Group("/auth")
|
||||
{
|
||||
auth.POST("/logout/", authHandler.Logout)
|
||||
auth.POST("/refresh/", authHandler.RefreshToken)
|
||||
auth.GET("/me/", authHandler.CurrentUser)
|
||||
auth.PUT("/profile/", authHandler.UpdateProfile)
|
||||
auth.PATCH("/profile/", authHandler.UpdateProfile)
|
||||
|
||||
Reference in New Issue
Block a user