init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,586 @@
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()
}