diff --git a/internal/auditlog/middleware.go b/internal/auditlog/middleware.go index 6322a8de..cdb94f27 100644 --- a/internal/auditlog/middleware.go +++ b/internal/auditlog/middleware.go @@ -45,17 +45,8 @@ func Middleware(logger LoggerInterface) echo.MiddlewareFunc { start := time.Now() req := c.Request() - // Generate request ID if not present + // Read request ID (always set by the request ID middleware in http.go) requestID := req.Header.Get("X-Request-ID") - if requestID == "" { - requestID = uuid.NewString() - } - - // Set request ID in both request and response headers - // Request header: for internal components to read consistently - // Response header: for clients to receive in the response - req.Header.Set("X-Request-ID", requestID) - c.Response().Header().Set("X-Request-ID", requestID) // Create initial log entry entry := &LogEntry{ diff --git a/internal/server/http.go b/internal/server/http.go index f361db0a..6fc43a51 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -7,6 +7,7 @@ import ( "path" "strings" + "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -132,6 +133,20 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { } e.Use(middleware.BodyLimit(bodySizeLimit)) + // Request ID middleware (always active — ensures every request has a unique ID + // for usage tracking, audit logging, and response correlation) + e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + id := c.Request().Header.Get("X-Request-ID") + if id == "" { + id = uuid.NewString() + c.Request().Header.Set("X-Request-ID", id) + } + c.Response().Header().Set("X-Request-ID", id) + return next(c) + } + }) + // Audit logging middleware (before authentication to capture all requests) if cfg != nil && cfg.AuditLogger != nil && cfg.AuditLogger.Config().Enabled { e.Use(auditlog.Middleware(cfg.AuditLogger)) diff --git a/internal/server/http_test.go b/internal/server/http_test.go index 1eb6826e..1f0ae46c 100644 --- a/internal/server/http_test.go +++ b/internal/server/http_test.go @@ -12,6 +12,47 @@ import ( _ "gomodel/cmd/gomodel/docs" ) +func TestRequestIDMiddleware(t *testing.T) { + mock := &mockProvider{} + srv := New(mock, nil) + + t.Run("generates request ID when missing", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + got := rec.Header().Get("X-Request-ID") + if got == "" { + t.Fatal("expected X-Request-ID in response header, got empty") + } + // Validate UUID format (8-4-4-4-12 hex digits) + if len(got) != 36 { + t.Errorf("expected UUID (36 chars), got %q (%d chars)", got, len(got)) + } + }) + + t.Run("preserves existing request ID", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("X-Request-ID", "my-custom-id") + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + // Request header must not be overwritten + got := req.Header.Get("X-Request-ID") + if got != "my-custom-id" { + t.Errorf("expected request header to be preserved as %q, got %q", "my-custom-id", got) + } + + // Response header must echo the client-provided ID back + respID := rec.Header().Get("X-Request-ID") + if respID != "my-custom-id" { + t.Errorf("expected response header X-Request-ID to be %q, got %q", "my-custom-id", respID) + } + }) +} + func TestMetricsEndpoint(t *testing.T) { tests := []struct { name string