request_access_logger_test.go 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. package middleware
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "sync"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  11. "github.com/gin-gonic/gin"
  12. )
  13. type testLogSink struct {
  14. mu sync.Mutex
  15. events []*logger.LogEvent
  16. }
  17. func (s *testLogSink) WriteLogEvent(event *logger.LogEvent) {
  18. s.mu.Lock()
  19. defer s.mu.Unlock()
  20. s.events = append(s.events, event)
  21. }
  22. func (s *testLogSink) list() []*logger.LogEvent {
  23. s.mu.Lock()
  24. defer s.mu.Unlock()
  25. out := make([]*logger.LogEvent, len(s.events))
  26. copy(out, s.events)
  27. return out
  28. }
  29. func initMiddlewareTestLogger(t *testing.T) *testLogSink {
  30. return initMiddlewareTestLoggerWithLevel(t, "debug")
  31. }
  32. func initMiddlewareTestLoggerWithLevel(t *testing.T, level string) *testLogSink {
  33. t.Helper()
  34. level = strings.TrimSpace(level)
  35. if level == "" {
  36. level = "debug"
  37. }
  38. if err := logger.Init(logger.InitOptions{
  39. Level: level,
  40. Format: "json",
  41. ServiceName: "sub2api",
  42. Environment: "test",
  43. Output: logger.OutputOptions{
  44. ToStdout: false,
  45. ToFile: false,
  46. },
  47. }); err != nil {
  48. t.Fatalf("init logger: %v", err)
  49. }
  50. sink := &testLogSink{}
  51. logger.SetSink(sink)
  52. t.Cleanup(func() {
  53. logger.SetSink(nil)
  54. })
  55. return sink
  56. }
  57. func TestRequestLogger_GenerateAndPropagateRequestID(t *testing.T) {
  58. gin.SetMode(gin.TestMode)
  59. r := gin.New()
  60. r.Use(RequestLogger())
  61. r.GET("/t", func(c *gin.Context) {
  62. reqID, ok := c.Request.Context().Value(ctxkey.RequestID).(string)
  63. if !ok || reqID == "" {
  64. t.Fatalf("request_id missing in context")
  65. }
  66. if got := c.Writer.Header().Get(requestIDHeader); got != reqID {
  67. t.Fatalf("response header request_id mismatch, header=%q ctx=%q", got, reqID)
  68. }
  69. c.Status(http.StatusOK)
  70. })
  71. w := httptest.NewRecorder()
  72. req := httptest.NewRequest(http.MethodGet, "/t", nil)
  73. r.ServeHTTP(w, req)
  74. if w.Code != http.StatusOK {
  75. t.Fatalf("status=%d", w.Code)
  76. }
  77. if w.Header().Get(requestIDHeader) == "" {
  78. t.Fatalf("X-Request-ID should be set")
  79. }
  80. }
  81. func TestRequestLogger_KeepIncomingRequestID(t *testing.T) {
  82. gin.SetMode(gin.TestMode)
  83. r := gin.New()
  84. r.Use(RequestLogger())
  85. r.GET("/t", func(c *gin.Context) {
  86. reqID, _ := c.Request.Context().Value(ctxkey.RequestID).(string)
  87. if reqID != "rid-fixed" {
  88. t.Fatalf("request_id=%q, want rid-fixed", reqID)
  89. }
  90. c.Status(http.StatusOK)
  91. })
  92. w := httptest.NewRecorder()
  93. req := httptest.NewRequest(http.MethodGet, "/t", nil)
  94. req.Header.Set(requestIDHeader, "rid-fixed")
  95. r.ServeHTTP(w, req)
  96. if w.Code != http.StatusOK {
  97. t.Fatalf("status=%d", w.Code)
  98. }
  99. if got := w.Header().Get(requestIDHeader); got != "rid-fixed" {
  100. t.Fatalf("header=%q, want rid-fixed", got)
  101. }
  102. }
  103. func TestRequestLoggerBoundsIncomingRequestID(t *testing.T) {
  104. gin.SetMode(gin.TestMode)
  105. r := gin.New()
  106. r.Use(RequestLogger())
  107. r.GET("/t", func(c *gin.Context) {
  108. reqID, _ := c.Request.Context().Value(ctxkey.RequestID).(string)
  109. if len(reqID) != 36 {
  110. t.Fatalf("request_id length=%d", len(reqID))
  111. }
  112. c.Status(http.StatusOK)
  113. })
  114. w := httptest.NewRecorder()
  115. req := httptest.NewRequest(http.MethodGet, "/t", nil)
  116. req.Header.Set(requestIDHeader, strings.Repeat("r", 1024))
  117. r.ServeHTTP(w, req)
  118. if got := len(w.Header().Get(requestIDHeader)); got != 36 {
  119. t.Fatalf("response request_id length=%d", got)
  120. }
  121. }
  122. func TestLogger_AccessLogIncludesCoreFields(t *testing.T) {
  123. gin.SetMode(gin.TestMode)
  124. sink := initMiddlewareTestLogger(t)
  125. r := gin.New()
  126. r.Use(Logger())
  127. r.Use(func(c *gin.Context) {
  128. ctx := c.Request.Context()
  129. ctx = context.WithValue(ctx, ctxkey.AccountID, int64(101))
  130. ctx = context.WithValue(ctx, ctxkey.Platform, "openai")
  131. ctx = context.WithValue(ctx, ctxkey.Model, "gpt-5")
  132. c.Request = c.Request.WithContext(ctx)
  133. c.Next()
  134. })
  135. r.GET("/api/test", func(c *gin.Context) {
  136. c.Status(http.StatusCreated)
  137. })
  138. w := httptest.NewRecorder()
  139. req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
  140. r.ServeHTTP(w, req)
  141. if w.Code != http.StatusCreated {
  142. t.Fatalf("status=%d", w.Code)
  143. }
  144. events := sink.list()
  145. if len(events) == 0 {
  146. t.Fatalf("expected at least one log event")
  147. }
  148. found := false
  149. for _, event := range events {
  150. if event == nil || event.Message != "http request completed" {
  151. continue
  152. }
  153. found = true
  154. switch v := event.Fields["status_code"].(type) {
  155. case int:
  156. if v != http.StatusCreated {
  157. t.Fatalf("status_code field mismatch: %v", v)
  158. }
  159. case int64:
  160. if v != int64(http.StatusCreated) {
  161. t.Fatalf("status_code field mismatch: %v", v)
  162. }
  163. default:
  164. t.Fatalf("status_code type mismatch: %T", v)
  165. }
  166. switch v := event.Fields["account_id"].(type) {
  167. case int64:
  168. if v != 101 {
  169. t.Fatalf("account_id field mismatch: %v", v)
  170. }
  171. case int:
  172. if v != 101 {
  173. t.Fatalf("account_id field mismatch: %v", v)
  174. }
  175. default:
  176. t.Fatalf("account_id type mismatch: %T", v)
  177. }
  178. if event.Fields["platform"] != "openai" || event.Fields["model"] != "gpt-5" {
  179. t.Fatalf("platform/model mismatch: %+v", event.Fields)
  180. }
  181. }
  182. if !found {
  183. t.Fatalf("access log event not found")
  184. }
  185. }
  186. func TestLogger_IngressRejectRemainsInStandardAccessLog(t *testing.T) {
  187. gin.SetMode(gin.TestMode)
  188. sink := initMiddlewareTestLogger(t)
  189. r := gin.New()
  190. r.Use(Logger())
  191. r.GET("/v1/messages", func(c *gin.Context) {
  192. MarkIngressRejected(c, IngressRejectInvalidAPIKey)
  193. c.Status(http.StatusUnauthorized)
  194. })
  195. w := httptest.NewRecorder()
  196. req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil)
  197. r.ServeHTTP(w, req)
  198. if w.Code != http.StatusUnauthorized {
  199. t.Fatalf("status=%d", w.Code)
  200. }
  201. events := sink.list()
  202. if len(events) != 1 {
  203. t.Fatalf("events=%d, want 1", len(events))
  204. }
  205. if got := events[0].Fields["ingress_reject_reason"]; got != string(IngressRejectInvalidAPIKey) {
  206. t.Fatalf("ingress_reject_reason=%v", got)
  207. }
  208. if got, _ := events[0].Fields[logger.OpsSystemLogSkipField].(bool); !got {
  209. t.Fatalf("%s must be true", logger.OpsSystemLogSkipField)
  210. }
  211. }
  212. func TestLogger_AccessLogUsesForwardedClientIPFromTrustedProxy(t *testing.T) {
  213. gin.SetMode(gin.TestMode)
  214. sink := initMiddlewareTestLogger(t)
  215. r := gin.New()
  216. if err := r.SetTrustedProxies([]string{"104.23.251.120"}); err != nil {
  217. t.Fatalf("set trusted proxies: %v", err)
  218. }
  219. r.Use(Logger())
  220. r.GET("/api/test", func(c *gin.Context) {
  221. c.Status(http.StatusOK)
  222. })
  223. w := httptest.NewRecorder()
  224. req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
  225. req.RemoteAddr = "104.23.251.120:443"
  226. req.Header.Set("X-Forwarded-For", "203.0.113.42")
  227. r.ServeHTTP(w, req)
  228. if w.Code != http.StatusOK {
  229. t.Fatalf("status=%d", w.Code)
  230. }
  231. for _, event := range sink.list() {
  232. if event == nil || event.Message != "http request completed" {
  233. continue
  234. }
  235. if got := event.Fields["client_ip"]; got != "203.0.113.42" {
  236. t.Fatalf("client_ip=%q, want real forwarded ip", got)
  237. }
  238. return
  239. }
  240. t.Fatalf("access log event not found")
  241. }
  242. func TestLogger_HealthPathSkipped(t *testing.T) {
  243. gin.SetMode(gin.TestMode)
  244. sink := initMiddlewareTestLogger(t)
  245. r := gin.New()
  246. r.Use(Logger())
  247. r.GET("/health", func(c *gin.Context) {
  248. c.Status(http.StatusOK)
  249. })
  250. w := httptest.NewRecorder()
  251. req := httptest.NewRequest(http.MethodGet, "/health", nil)
  252. r.ServeHTTP(w, req)
  253. if w.Code != http.StatusOK {
  254. t.Fatalf("status=%d", w.Code)
  255. }
  256. if len(sink.list()) != 0 {
  257. t.Fatalf("health endpoint should not write access log")
  258. }
  259. }
  260. func TestLogger_AccessLogDroppedWhenLevelWarn(t *testing.T) {
  261. gin.SetMode(gin.TestMode)
  262. sink := initMiddlewareTestLoggerWithLevel(t, "warn")
  263. r := gin.New()
  264. r.Use(RequestLogger())
  265. r.Use(Logger())
  266. r.GET("/api/test", func(c *gin.Context) {
  267. c.Status(http.StatusCreated)
  268. })
  269. w := httptest.NewRecorder()
  270. req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
  271. r.ServeHTTP(w, req)
  272. if w.Code != http.StatusCreated {
  273. t.Fatalf("status=%d", w.Code)
  274. }
  275. events := sink.list()
  276. for _, event := range events {
  277. if event != nil && event.Message == "http request completed" {
  278. t.Fatalf("access log should not be indexed when level=warn: %+v", event)
  279. }
  280. }
  281. }