| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311 |
- package middleware
- import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/gin-gonic/gin"
- )
- type testLogSink struct {
- mu sync.Mutex
- events []*logger.LogEvent
- }
- func (s *testLogSink) WriteLogEvent(event *logger.LogEvent) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.events = append(s.events, event)
- }
- func (s *testLogSink) list() []*logger.LogEvent {
- s.mu.Lock()
- defer s.mu.Unlock()
- out := make([]*logger.LogEvent, len(s.events))
- copy(out, s.events)
- return out
- }
- func initMiddlewareTestLogger(t *testing.T) *testLogSink {
- return initMiddlewareTestLoggerWithLevel(t, "debug")
- }
- func initMiddlewareTestLoggerWithLevel(t *testing.T, level string) *testLogSink {
- t.Helper()
- level = strings.TrimSpace(level)
- if level == "" {
- level = "debug"
- }
- if err := logger.Init(logger.InitOptions{
- Level: level,
- Format: "json",
- ServiceName: "sub2api",
- Environment: "test",
- Output: logger.OutputOptions{
- ToStdout: false,
- ToFile: false,
- },
- }); err != nil {
- t.Fatalf("init logger: %v", err)
- }
- sink := &testLogSink{}
- logger.SetSink(sink)
- t.Cleanup(func() {
- logger.SetSink(nil)
- })
- return sink
- }
- func TestRequestLogger_GenerateAndPropagateRequestID(t *testing.T) {
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(RequestLogger())
- r.GET("/t", func(c *gin.Context) {
- reqID, ok := c.Request.Context().Value(ctxkey.RequestID).(string)
- if !ok || reqID == "" {
- t.Fatalf("request_id missing in context")
- }
- if got := c.Writer.Header().Get(requestIDHeader); got != reqID {
- t.Fatalf("response header request_id mismatch, header=%q ctx=%q", got, reqID)
- }
- c.Status(http.StatusOK)
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/t", nil)
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("status=%d", w.Code)
- }
- if w.Header().Get(requestIDHeader) == "" {
- t.Fatalf("X-Request-ID should be set")
- }
- }
- func TestRequestLogger_KeepIncomingRequestID(t *testing.T) {
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(RequestLogger())
- r.GET("/t", func(c *gin.Context) {
- reqID, _ := c.Request.Context().Value(ctxkey.RequestID).(string)
- if reqID != "rid-fixed" {
- t.Fatalf("request_id=%q, want rid-fixed", reqID)
- }
- c.Status(http.StatusOK)
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/t", nil)
- req.Header.Set(requestIDHeader, "rid-fixed")
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("status=%d", w.Code)
- }
- if got := w.Header().Get(requestIDHeader); got != "rid-fixed" {
- t.Fatalf("header=%q, want rid-fixed", got)
- }
- }
- func TestRequestLoggerBoundsIncomingRequestID(t *testing.T) {
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(RequestLogger())
- r.GET("/t", func(c *gin.Context) {
- reqID, _ := c.Request.Context().Value(ctxkey.RequestID).(string)
- if len(reqID) != 36 {
- t.Fatalf("request_id length=%d", len(reqID))
- }
- c.Status(http.StatusOK)
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/t", nil)
- req.Header.Set(requestIDHeader, strings.Repeat("r", 1024))
- r.ServeHTTP(w, req)
- if got := len(w.Header().Get(requestIDHeader)); got != 36 {
- t.Fatalf("response request_id length=%d", got)
- }
- }
- func TestLogger_AccessLogIncludesCoreFields(t *testing.T) {
- gin.SetMode(gin.TestMode)
- sink := initMiddlewareTestLogger(t)
- r := gin.New()
- r.Use(Logger())
- r.Use(func(c *gin.Context) {
- ctx := c.Request.Context()
- ctx = context.WithValue(ctx, ctxkey.AccountID, int64(101))
- ctx = context.WithValue(ctx, ctxkey.Platform, "openai")
- ctx = context.WithValue(ctx, ctxkey.Model, "gpt-5")
- c.Request = c.Request.WithContext(ctx)
- c.Next()
- })
- r.GET("/api/test", func(c *gin.Context) {
- c.Status(http.StatusCreated)
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
- r.ServeHTTP(w, req)
- if w.Code != http.StatusCreated {
- t.Fatalf("status=%d", w.Code)
- }
- events := sink.list()
- if len(events) == 0 {
- t.Fatalf("expected at least one log event")
- }
- found := false
- for _, event := range events {
- if event == nil || event.Message != "http request completed" {
- continue
- }
- found = true
- switch v := event.Fields["status_code"].(type) {
- case int:
- if v != http.StatusCreated {
- t.Fatalf("status_code field mismatch: %v", v)
- }
- case int64:
- if v != int64(http.StatusCreated) {
- t.Fatalf("status_code field mismatch: %v", v)
- }
- default:
- t.Fatalf("status_code type mismatch: %T", v)
- }
- switch v := event.Fields["account_id"].(type) {
- case int64:
- if v != 101 {
- t.Fatalf("account_id field mismatch: %v", v)
- }
- case int:
- if v != 101 {
- t.Fatalf("account_id field mismatch: %v", v)
- }
- default:
- t.Fatalf("account_id type mismatch: %T", v)
- }
- if event.Fields["platform"] != "openai" || event.Fields["model"] != "gpt-5" {
- t.Fatalf("platform/model mismatch: %+v", event.Fields)
- }
- }
- if !found {
- t.Fatalf("access log event not found")
- }
- }
- func TestLogger_IngressRejectRemainsInStandardAccessLog(t *testing.T) {
- gin.SetMode(gin.TestMode)
- sink := initMiddlewareTestLogger(t)
- r := gin.New()
- r.Use(Logger())
- r.GET("/v1/messages", func(c *gin.Context) {
- MarkIngressRejected(c, IngressRejectInvalidAPIKey)
- c.Status(http.StatusUnauthorized)
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil)
- r.ServeHTTP(w, req)
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("status=%d", w.Code)
- }
- events := sink.list()
- if len(events) != 1 {
- t.Fatalf("events=%d, want 1", len(events))
- }
- if got := events[0].Fields["ingress_reject_reason"]; got != string(IngressRejectInvalidAPIKey) {
- t.Fatalf("ingress_reject_reason=%v", got)
- }
- if got, _ := events[0].Fields[logger.OpsSystemLogSkipField].(bool); !got {
- t.Fatalf("%s must be true", logger.OpsSystemLogSkipField)
- }
- }
- func TestLogger_AccessLogUsesForwardedClientIPFromTrustedProxy(t *testing.T) {
- gin.SetMode(gin.TestMode)
- sink := initMiddlewareTestLogger(t)
- r := gin.New()
- if err := r.SetTrustedProxies([]string{"104.23.251.120"}); err != nil {
- t.Fatalf("set trusted proxies: %v", err)
- }
- r.Use(Logger())
- r.GET("/api/test", func(c *gin.Context) {
- c.Status(http.StatusOK)
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
- req.RemoteAddr = "104.23.251.120:443"
- req.Header.Set("X-Forwarded-For", "203.0.113.42")
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("status=%d", w.Code)
- }
- for _, event := range sink.list() {
- if event == nil || event.Message != "http request completed" {
- continue
- }
- if got := event.Fields["client_ip"]; got != "203.0.113.42" {
- t.Fatalf("client_ip=%q, want real forwarded ip", got)
- }
- return
- }
- t.Fatalf("access log event not found")
- }
- func TestLogger_HealthPathSkipped(t *testing.T) {
- gin.SetMode(gin.TestMode)
- sink := initMiddlewareTestLogger(t)
- r := gin.New()
- r.Use(Logger())
- r.GET("/health", func(c *gin.Context) {
- c.Status(http.StatusOK)
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/health", nil)
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("status=%d", w.Code)
- }
- if len(sink.list()) != 0 {
- t.Fatalf("health endpoint should not write access log")
- }
- }
- func TestLogger_AccessLogDroppedWhenLevelWarn(t *testing.T) {
- gin.SetMode(gin.TestMode)
- sink := initMiddlewareTestLoggerWithLevel(t, "warn")
- r := gin.New()
- r.Use(RequestLogger())
- r.Use(Logger())
- r.GET("/api/test", func(c *gin.Context) {
- c.Status(http.StatusCreated)
- })
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
- r.ServeHTTP(w, req)
- if w.Code != http.StatusCreated {
- t.Fatalf("status=%d", w.Code)
- }
- events := sink.list()
- for _, event := range events {
- if event != nil && event.Message == "http request completed" {
- t.Fatalf("access log should not be indexed when level=warn: %+v", event)
- }
- }
- }
|