package api import ( "bytes" "compress/gzip" "encoding/json" "io" "log/slog" "net/http" "strings" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" "wucher/internal/config" "wucher/internal/shared/pkg/apperrorsx" "wucher/internal/transport/http/jsonapi" "wucher/internal/transport/http/response" ) func testHTTPConfig() config.HTTPConfig { return config.HTTPConfig{ CORSAllowOrigins: "http://localhost:3000", CORSAllowMethods: "GET,POST,PUT,PATCH,DELETE,OPTIONS", CORSAllowHeaders: "Origin,Content-Type,Accept,Authorization", CORSExposeHeaders: "X-Request-ID", CORSAllowCredentials: true, CORSMaxAge: 300, RateLimitMax: 100, RateLimitWindow: time.Minute, RequestTimeout: 0, RequestTimeoutByPath: map[string]time.Duration{}, HSTSMaxAge: 0, } } func TestUseDefaultMiddlewares_CORSHeaders(t *testing.T) { cfg := testHTTPConfig() app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Get("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/ping", nil) req.Header.Set("Origin", "http://localhost:3000") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" { t.Fatalf("expected allow-origin http://localhost:3000, got %q", got) } if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" { t.Fatalf("expected allow-credentials true, got %q", got) } if got := resp.Header.Get("X-Frame-Options"); got != "DENY" { t.Fatalf("expected X-Frame-Options DENY, got %q", got) } if got := resp.Header.Get("X-Request-Id"); got == "" { t.Fatalf("expected request id header to be set") } } func TestUseDefaultMiddlewares_BlockTrace(t *testing.T) { cfg := testHTTPConfig() app := fiber.New() UseDefaultMiddlewares(app, cfg) app.All("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodTrace, "/ping", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusMethodNotAllowed { t.Fatalf("expected 405, got %d", resp.StatusCode) } } func TestUseDefaultMiddlewares_RateLimiter(t *testing.T) { cfg := testHTTPConfig() cfg.RateLimitMax = 1 cfg.RateLimitWindow = time.Minute app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Get("/limited", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) firstReq, _ := http.NewRequest(http.MethodGet, "/limited", nil) firstResp, err := app.Test(firstReq) if err != nil { t.Fatalf("first request app.Test: %v", err) } if firstResp.StatusCode != http.StatusOK { t.Fatalf("expected first request 200, got %d", firstResp.StatusCode) } secondReq, _ := http.NewRequest(http.MethodGet, "/limited", nil) secondResp, err := app.Test(secondReq) if err != nil { t.Fatalf("second request app.Test: %v", err) } if secondResp.StatusCode != http.StatusTooManyRequests { t.Fatalf("expected second request 429, got %d", secondResp.StatusCode) } if got := secondResp.Header.Get("Retry-After"); got == "" { t.Fatalf("expected Retry-After header on 429 response") } } func TestUseDefaultMiddlewares_EndpointRateLimiter(t *testing.T) { cfg := testHTTPConfig() cfg.RateLimitMax = 100 cfg.RateLimitByEndpoint = map[string]config.RateLimitRule{ "POST /auth/login": { Max: 1, Window: time.Minute, }, } app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Post("/auth/login", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) app.Post("/auth/refresh", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) req1, _ := http.NewRequest(http.MethodPost, "/auth/login", nil) resp1, err := app.Test(req1) if err != nil { t.Fatalf("first login request app.Test: %v", err) } if resp1.StatusCode != http.StatusOK { t.Fatalf("expected first login request 200, got %d", resp1.StatusCode) } req2, _ := http.NewRequest(http.MethodPost, "/auth/login", nil) resp2, err := app.Test(req2) if err != nil { t.Fatalf("second login request app.Test: %v", err) } if resp2.StatusCode != http.StatusTooManyRequests { t.Fatalf("expected second login request 429, got %d", resp2.StatusCode) } otherReq, _ := http.NewRequest(http.MethodPost, "/auth/refresh", nil) otherResp, err := app.Test(otherReq) if err != nil { t.Fatalf("other endpoint request app.Test: %v", err) } if otherResp.StatusCode != http.StatusOK { t.Fatalf("expected other endpoint request 200, got %d", otherResp.StatusCode) } } func TestUseDefaultMiddlewares_WildcardOriginDisablesCredentials(t *testing.T) { cfg := testHTTPConfig() cfg.CORSAllowOrigins = "*" cfg.CORSAllowCredentials = true app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Get("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/ping", nil) req.Header.Set("Origin", "http://localhost:3000") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { t.Fatalf("expected allow-origin *, got %q", got) } if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" { t.Fatalf("expected allow-credentials to be omitted, got %q", got) } } func TestUseDefaultMiddlewares_RequestContextDetachedFromShutdown(t *testing.T) { cfg := testHTTPConfig() app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Get("/ctx", func(c *fiber.Ctx) error { if c.UserContext() == nil { return c.SendStatus(http.StatusInternalServerError) } if c.UserContext().Done() != nil { return c.SendStatus(http.StatusInternalServerError) } return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/ctx", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func TestUseDefaultMiddlewares_CompressesLargeJSONWhenGzipAccepted(t *testing.T) { cfg := testHTTPConfig() app := fiber.New() UseDefaultMiddlewares(app, cfg) largePayload := map[string]string{ "data": strings.Repeat("x", 8*1024), } app.Get("/large-json", func(c *fiber.Ctx) error { return c.JSON(largePayload) }) req, _ := http.NewRequest(http.MethodGet, "/large-json", nil) req.Header.Set("Accept-Encoding", "gzip") resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } if got := resp.Header.Get("Content-Encoding"); got != "gzip" { t.Fatalf("expected Content-Encoding gzip, got %q", got) } gz, err := gzip.NewReader(resp.Body) if err != nil { t.Fatalf("gzip reader: %v", err) } defer gz.Close() var payload map[string]string if err := json.NewDecoder(gz).Decode(&payload); err != nil { t.Fatalf("decode json: %v", err) } if payload["data"] != largePayload["data"] { t.Fatalf("unexpected payload body") } } func TestUseDefaultMiddlewares_RequestTimeoutExceeded(t *testing.T) { cfg := testHTTPConfig() cfg.RequestTimeout = 10 * time.Millisecond app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Get("/slow", func(c *fiber.Ctx) error { select { case <-time.After(50 * time.Millisecond): return c.SendStatus(http.StatusOK) case <-c.UserContext().Done(): return c.SendStatus(http.StatusRequestTimeout) } }) req, _ := http.NewRequest(http.MethodGet, "/slow", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusRequestTimeout { t.Fatalf("expected 408, got %d", resp.StatusCode) } } func TestUseDefaultMiddlewares_RequestTimeoutNormalRequestUnaffected(t *testing.T) { cfg := testHTTPConfig() cfg.RequestTimeout = 200 * time.Millisecond app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Get("/fast", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) }) req, _ := http.NewRequest(http.MethodGet, "/fast", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } } func TestUseDefaultMiddlewares_RequestTimeoutPerEndpointOverride(t *testing.T) { cfg := testHTTPConfig() cfg.RequestTimeout = 10 * time.Millisecond cfg.RequestTimeoutByPath = map[string]time.Duration{ "GET /slow": 120 * time.Millisecond, } app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Get("/slow", func(c *fiber.Ctx) error { select { case <-time.After(40 * time.Millisecond): return c.SendStatus(http.StatusOK) case <-c.UserContext().Done(): return c.SendStatus(http.StatusRequestTimeout) } }) req, _ := http.NewRequest(http.MethodGet, "/slow", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 with override timeout, got %d", resp.StatusCode) } } func TestUseDefaultMiddlewares_RequestTimeoutPerEndpointOverrideDynamicRoute(t *testing.T) { cfg := testHTTPConfig() cfg.RequestTimeout = 10 * time.Millisecond cfg.RequestTimeoutByPath = map[string]time.Duration{ "GET /users/:id": 120 * time.Millisecond, } app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Get("/users/:id", func(c *fiber.Ctx) error { select { case <-time.After(40 * time.Millisecond): return c.SendStatus(http.StatusOK) case <-c.UserContext().Done(): return c.SendStatus(http.StatusRequestTimeout) } }) req, _ := http.NewRequest(http.MethodGet, "/users/123", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200 with dynamic route override timeout, got %d", resp.StatusCode) } } func TestHTTPAccessLogMiddlewareIncludesErrorDetail(t *testing.T) { var logBuf bytes.Buffer prev := slog.Default() slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{}))) t.Cleanup(func() { slog.SetDefault(prev) }) app := fiber.New() app.Use(httpAccessLogMiddleware()) app.Get("/bad", func(c *fiber.Ctx) error { return response.WriteErrors(c, http.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Title: "Validation error", Detail: "name is required", }}) }) req, _ := http.NewRequest(http.MethodGet, "/bad", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("expected 422, got %d", resp.StatusCode) } body, _ := io.ReadAll(resp.Body) if strings.Contains(string(body), "name is required") { t.Fatalf("expected sanitized response detail, got %s", string(body)) } if !strings.Contains(string(body), `"detail":"validation failed"`) { t.Fatalf("expected generic response detail, got %s", string(body)) } if !strings.Contains(logBuf.String(), "name is required") { t.Fatalf("expected access log to include original detail, got %s", logBuf.String()) } } func TestHTTPAccessLogMiddlewareIncludesResponseBodyOnServerError(t *testing.T) { var logBuf bytes.Buffer prev := slog.Default() slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{}))) t.Cleanup(func() { slog.SetDefault(prev) }) app := fiber.New() app.Use(httpAccessLogMiddleware()) app.Get("/boom", func(c *fiber.Ctx) error { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "errors": []fiber.Map{{ "status": "500", "title": "Update failed", "detail": "db deadlock on takeover update", }}, }) }) req, _ := http.NewRequest(http.MethodGet, "/boom", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", resp.StatusCode) } if !strings.Contains(logBuf.String(), "db deadlock on takeover update") { t.Fatalf("expected access log to include response body detail, got %s", logBuf.String()) } if !strings.Contains(logBuf.String(), "response_body") { t.Fatalf("expected access log to include response_body field, got %s", logBuf.String()) } } func TestHTTPAccessLogMiddlewareIncludesInternalErrorDetail(t *testing.T) { var logBuf bytes.Buffer prev := slog.Default() slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{}))) t.Cleanup(func() { slog.SetDefault(prev) }) app := fiber.New() app.Use(httpAccessLogMiddleware()) app.Get("/boom", func(c *fiber.Ctx) error { c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail, "action=UpdateTakeover | error=reserve update failed | chain=*errors.errorString: reserve update failed") return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "errors": []fiber.Map{{ "status": "500", "title": "Update failed", "detail": "internal server error", }}, }) }) req, _ := http.NewRequest(http.MethodGet, "/boom", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", resp.StatusCode) } if !strings.Contains(logBuf.String(), "internal_detail") { t.Fatalf("expected access log to include internal_detail field, got %s", logBuf.String()) } if !strings.Contains(logBuf.String(), "reserve update failed") { t.Fatalf("expected access log to include internal error detail, got %s", logBuf.String()) } } func TestUseDefaultMiddlewares_HTTPMetricsUsesRoutePatternLabels(t *testing.T) { cfg := testHTTPConfig() app := fiber.New() UseDefaultMiddlewares(app, cfg) app.Get("/users/:id", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusCreated) }) app.Get("/metrics", func(c *fiber.Ctx) error { metricsHandler(c.Context()) return nil }) beforeCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/users/:id", "201") beforeHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/users/:id", "201") req, _ := http.NewRequest(http.MethodGet, "/users/123", nil) resp, err := app.Test(req) if err != nil { t.Fatalf("app.Test: %v", err) } if resp.StatusCode != http.StatusCreated { t.Fatalf("expected 201, got %d", resp.StatusCode) } afterCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/users/:id", "201") afterHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/users/:id", "201") if afterCounter <= beforeCounter { t.Fatalf("expected counter to increase, before=%v after=%v", beforeCounter, afterCounter) } if afterHistogram <= beforeHistogram { t.Fatalf("expected histogram count to increase, before=%v after=%v", beforeHistogram, afterHistogram) } metricsReq, _ := http.NewRequest(http.MethodGet, "/metrics", nil) metricsResp, err := app.Test(metricsReq) if err != nil { t.Fatalf("metrics app.Test: %v", err) } if metricsResp.StatusCode != http.StatusOK { t.Fatalf("expected /metrics status 200, got %d", metricsResp.StatusCode) } body, err := io.ReadAll(metricsResp.Body) if err != nil { t.Fatalf("read /metrics body: %v", err) } metricsBody := string(body) if !strings.Contains(metricsBody, "http_requests_total") { t.Fatalf("expected http_requests_total in /metrics output") } if !strings.Contains(metricsBody, "http_request_duration_seconds") { t.Fatalf("expected http_request_duration_seconds in /metrics output") } if !strings.Contains(metricsBody, "path=\"/users/:id\"") { t.Fatalf("expected route-pattern path label in /metrics output") } beforeMetricsCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/metrics", "200") beforeMetricsHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/metrics", "200") metricsReq2, _ := http.NewRequest(http.MethodGet, "/metrics", nil) metricsResp2, err := app.Test(metricsReq2) if err != nil { t.Fatalf("metrics second app.Test: %v", err) } if metricsResp2.StatusCode != http.StatusOK { t.Fatalf("expected second /metrics status 200, got %d", metricsResp2.StatusCode) } afterMetricsCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/metrics", "200") afterMetricsHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/metrics", "200") if afterMetricsCounter != beforeMetricsCounter { t.Fatalf("expected /metrics to be excluded from counter, before=%v after=%v", beforeMetricsCounter, afterMetricsCounter) } if afterMetricsHistogram != beforeMetricsHistogram { t.Fatalf("expected /metrics to be excluded from histogram, before=%v after=%v", beforeMetricsHistogram, afterMetricsHistogram) } } func TestNormalizeHTTPMethod(t *testing.T) { tests := []struct { name string input string output string }{ {name: "get", input: "GET", output: "GET"}, {name: "lowercase", input: "post", output: "POST"}, {name: "trimmed", input: " put ", output: "PUT"}, {name: "empty", input: "", output: "UNKNOWN"}, {name: "custom", input: "GETT", output: "OTHER"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := normalizeHTTPMethod(tt.input); got != tt.output { t.Fatalf("expected %q, got %q", tt.output, got) } }) } } func metricCounterValue(t *testing.T, vec *prometheus.CounterVec, labels ...string) float64 { t.Helper() m := &dto.Metric{} if err := vec.WithLabelValues(labels...).Write(m); err != nil { t.Fatalf("read counter metric: %v", err) } return m.GetCounter().GetValue() } func metricHistogramCount(t *testing.T, vec *prometheus.HistogramVec, labels ...string) uint64 { t.Helper() observer, err := vec.GetMetricWithLabelValues(labels...) if err != nil { t.Fatalf("load histogram metric: %v", err) } metric, ok := observer.(prometheus.Metric) if !ok { t.Fatalf("histogram observer does not implement prometheus.Metric") } m := &dto.Metric{} if err := metric.Write(m); err != nil { t.Fatalf("read histogram metric: %v", err) } if m.GetHistogram() == nil { t.Fatalf("histogram metric not available for labels %v", labels) } return m.GetHistogram().GetSampleCount() }