server_timing_test.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. package middleware
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "strings"
  6. "testing"
  7. "time"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
  9. "github.com/gin-gonic/gin"
  10. )
  11. func runServerTimingRequest(
  12. t *testing.T,
  13. enabled bool,
  14. path string,
  15. adminMarker string,
  16. userMarker string,
  17. role string,
  18. handler gin.HandlerFunc,
  19. ) *httptest.ResponseRecorder {
  20. t.Helper()
  21. gin.SetMode(gin.TestMode)
  22. engine := gin.New()
  23. engine.Use(ServerTiming(enabled))
  24. engine.Any("/*path", func(c *gin.Context) {
  25. if role != "" {
  26. c.Set(string(ContextKeyUserRole), role)
  27. }
  28. handler(c)
  29. })
  30. recorder := httptest.NewRecorder()
  31. request := httptest.NewRequest(http.MethodGet, path, nil)
  32. if adminMarker != "" {
  33. request.Header.Set(servertiming.AdminUIHeader, adminMarker)
  34. }
  35. if userMarker != "" {
  36. request.Header.Set(servertiming.UserUIHeader, userMarker)
  37. }
  38. engine.ServeHTTP(recorder, request)
  39. return recorder
  40. }
  41. func TestServerTimingScopesAndRoleGate(t *testing.T) {
  42. tests := []struct {
  43. name string
  44. enabled bool
  45. path string
  46. adminMarker string
  47. userMarker string
  48. role string
  49. wantHeader bool
  50. }{
  51. {name: "disabled", enabled: false, path: "/api/v1/admin/users", role: "admin"},
  52. {name: "admin API path", enabled: true, path: "/api/v1/admin/users", role: "admin", wantHeader: true},
  53. {name: "shared API marked by admin UI", enabled: true, path: "/api/v1/groups/available", adminMarker: "1", role: "admin", wantHeader: true},
  54. {name: "user role on allowlisted path", enabled: true, path: "/api/v1/groups/available", role: "user", wantHeader: true},
  55. {name: "user role with user UI marker on allowlisted path", enabled: true, path: "/api/v1/keys", userMarker: "1", role: "user", wantHeader: true},
  56. {name: "user role cannot use admin marker on non-user path", enabled: true, path: "/api/v1/settings/public", adminMarker: "1", role: "user"},
  57. {name: "user marker alone does not authorize non-user path", enabled: true, path: "/api/v1/settings/public", userMarker: "1", role: "user"},
  58. {name: "unauthenticated public request", enabled: true, path: "/api/v1/settings/public", adminMarker: "1"},
  59. {name: "unauthenticated user path", enabled: true, path: "/api/v1/keys"},
  60. {name: "unmarked shared API still scopes by path for admin", enabled: true, path: "/api/v1/groups/available", role: "admin", wantHeader: true},
  61. {name: "invalid admin marker on non-scoped path", enabled: true, path: "/api/v1/settings/public", adminMarker: "true", role: "admin"},
  62. {name: "admin prefix boundary", enabled: true, path: "/api/v1/administrator", role: "admin"},
  63. {name: "auth me path", enabled: true, path: "/api/v1/auth/me", role: "user", wantHeader: true},
  64. {name: "payment user path", enabled: true, path: "/api/v1/payment/plans", role: "user", wantHeader: true},
  65. {name: "payment public excluded", enabled: true, path: "/api/v1/payment/public/orders/verify", userMarker: "1", role: "user"},
  66. {name: "payment webhook excluded", enabled: true, path: "/api/v1/payment/webhook/stripe", userMarker: "1", role: "user"},
  67. {name: "channel monitors path", enabled: true, path: "/api/v1/channel-monitors/1/status", role: "user", wantHeader: true},
  68. }
  69. for _, tt := range tests {
  70. t.Run(tt.name, func(t *testing.T) {
  71. recorder := runServerTimingRequest(t, tt.enabled, tt.path, tt.adminMarker, tt.userMarker, tt.role, func(c *gin.Context) {
  72. c.JSON(http.StatusOK, gin.H{"ok": true})
  73. })
  74. header := recorder.Header().Get(servertiming.HeaderName)
  75. if tt.wantHeader && header == "" {
  76. t.Fatalf("%s header missing", servertiming.HeaderName)
  77. }
  78. if !tt.wantHeader && header != "" {
  79. t.Fatalf("unexpected %s header: %q", servertiming.HeaderName, header)
  80. }
  81. if header != "" && (!strings.Contains(header, "total;dur=") || !strings.Contains(header, `cache;desc="bypass"`)) {
  82. t.Fatalf("incomplete timing header: %q", header)
  83. }
  84. })
  85. }
  86. }
  87. func TestIsUserTimingPath(t *testing.T) {
  88. tests := []struct {
  89. path string
  90. want bool
  91. }{
  92. {"/api/v1/auth/me", true},
  93. {"/api/v1/auth/revoke-all-sessions", true},
  94. {"/api/v1/auth/oauth/bind-token", true},
  95. {"/api/v1/auth/login", false},
  96. {"/api/v1/user", true},
  97. {"/api/v1/user/profile", true},
  98. {"/api/v1/user/totp/status", true},
  99. {"/api/v1/keys", true},
  100. {"/api/v1/keys/12", true},
  101. {"/api/v1/groups/available", true},
  102. {"/api/v1/groups/rates", true},
  103. {"/api/v1/groups", false},
  104. {"/api/v1/channels/available", true},
  105. {"/api/v1/channels", false},
  106. {"/api/v1/usage/stats", true},
  107. {"/api/v1/announcements", true},
  108. {"/api/v1/redeem/history", true},
  109. {"/api/v1/subscriptions/active", true},
  110. {"/api/v1/channel-monitors", true},
  111. {"/api/v1/payment/config", true},
  112. {"/api/v1/payment/orders/my", true},
  113. {"/api/v1/payment/public/orders/verify", false},
  114. {"/api/v1/payment/webhook/easypay", false},
  115. {"/api/v1/admin/users", false},
  116. {"/api/v1/settings/public", false},
  117. }
  118. for _, tt := range tests {
  119. t.Run(tt.path, func(t *testing.T) {
  120. if got := isUserTimingPath(tt.path); got != tt.want {
  121. t.Fatalf("isUserTimingPath(%q) = %v, want %v", tt.path, got, tt.want)
  122. }
  123. })
  124. }
  125. }
  126. func TestServerTimingCollectorIsRequestScoped(t *testing.T) {
  127. active := false
  128. recorder := runServerTimingRequest(t, true, "/api/v1/keys", "1", "", "admin", func(c *gin.Context) {
  129. active = servertiming.Active(c.Request.Context())
  130. c.Status(http.StatusNoContent)
  131. })
  132. if !active {
  133. t.Fatal("collector was not attached to marked request context")
  134. }
  135. if recorder.Header().Get(servertiming.HeaderName) == "" {
  136. t.Fatal("timing header missing from status-only response")
  137. }
  138. }
  139. func TestServerTimingCollectorForUserUIMarker(t *testing.T) {
  140. active := false
  141. // Use a non-allowlisted path so collection depends on the user UI marker.
  142. recorder := runServerTimingRequest(t, true, "/api/v1/settings/public", "", "1", "admin", func(c *gin.Context) {
  143. active = servertiming.Active(c.Request.Context())
  144. c.JSON(http.StatusOK, gin.H{"ok": true})
  145. })
  146. if !active {
  147. t.Fatal("collector was not attached for user UI marker")
  148. }
  149. // Admin role may emit even when the path is not user-allowlisted.
  150. if recorder.Header().Get(servertiming.HeaderName) == "" {
  151. t.Fatal("admin timing header missing for user-UI-marked request")
  152. }
  153. }
  154. func TestServerTimingFinalizesBeforeEarlyCommit(t *testing.T) {
  155. recorder := runServerTimingRequest(t, true, "/api/v1/admin/stream", "", "", "admin", func(c *gin.Context) {
  156. c.Status(http.StatusAccepted)
  157. c.Writer.WriteHeaderNow()
  158. })
  159. if got := recorder.Header().Get(servertiming.HeaderName); got == "" {
  160. t.Fatal("timing header was not written before response commit")
  161. }
  162. }
  163. func TestServerTimingFinalizesOnFlush(t *testing.T) {
  164. recorder := runServerTimingRequest(t, true, "/api/v1/admin/export", "", "", "admin", func(c *gin.Context) {
  165. c.Writer.Flush()
  166. })
  167. if got := recorder.Header().Get(servertiming.HeaderName); got == "" {
  168. t.Fatal("timing header was not written before stream flush")
  169. }
  170. }
  171. func TestServerTimingStatusResponses(t *testing.T) {
  172. tests := []struct {
  173. name string
  174. status int
  175. }{
  176. {name: "not modified", status: http.StatusNotModified},
  177. {name: "internal error", status: http.StatusInternalServerError},
  178. }
  179. for _, tt := range tests {
  180. t.Run(tt.name, func(t *testing.T) {
  181. recorder := runServerTimingRequest(t, true, "/api/v1/admin/test", "", "", "admin", func(c *gin.Context) {
  182. c.Status(tt.status)
  183. })
  184. if recorder.Code != tt.status {
  185. t.Fatalf("status = %d, want %d", recorder.Code, tt.status)
  186. }
  187. if got := recorder.Header().Get(servertiming.HeaderName); got == "" {
  188. t.Fatalf("timing header missing from status %d response", tt.status)
  189. }
  190. })
  191. }
  192. }
  193. func TestServerTimingResponseWriterUnwraps(t *testing.T) {
  194. recorder := httptest.NewRecorder()
  195. c, _ := gin.CreateTestContext(recorder)
  196. baseWriter := c.Writer
  197. writer := &serverTimingResponseWriter{ResponseWriter: baseWriter}
  198. if got := writer.Unwrap(); got != baseWriter {
  199. t.Fatalf("Unwrap() = %T, want original Gin writer", got)
  200. }
  201. }
  202. func TestServerTimingCacheOutcome(t *testing.T) {
  203. tests := []struct {
  204. name string
  205. headerName string
  206. value string
  207. want string
  208. }{
  209. {name: "snapshot hit", headerName: snapshotCacheHeader, value: "hit", want: "hit"},
  210. {name: "usage miss", headerName: usageCacheHeader, value: "MISS", want: "miss"},
  211. {name: "invalid", headerName: snapshotCacheHeader, value: "stale", want: "bypass"},
  212. }
  213. for _, tt := range tests {
  214. t.Run(tt.name, func(t *testing.T) {
  215. recorder := runServerTimingRequest(t, true, "/api/v1/admin/dashboard", "", "", "admin", func(c *gin.Context) {
  216. c.Header(tt.headerName, tt.value)
  217. c.JSON(http.StatusOK, gin.H{"ok": true})
  218. })
  219. want := `cache;desc="` + tt.want + `"`
  220. if got := recorder.Header().Get(servertiming.HeaderName); !strings.Contains(got, want) {
  221. t.Fatalf("timing header %q does not contain %q", got, want)
  222. }
  223. })
  224. }
  225. }
  226. func TestServerTimingResponseHeaderForWebSocket(t *testing.T) {
  227. recorder := httptest.NewRecorder()
  228. c, _ := gin.CreateTestContext(recorder)
  229. c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/ops/ws/qps", nil)
  230. collector := servertiming.New(time.Now())
  231. c.Request = c.Request.WithContext(servertiming.WithCollector(c.Request.Context(), collector))
  232. c.Set(string(ContextKeyUserRole), "admin")
  233. header := ServerTimingResponseHeader(c)
  234. if header.Get(servertiming.HeaderName) == "" {
  235. t.Fatal("WebSocket response header missing timing value")
  236. }
  237. c.Set(string(ContextKeyUserRole), "user")
  238. if got := ServerTimingResponseHeader(c); got != nil {
  239. t.Fatalf("non-admin WebSocket received timing header: %#v", got)
  240. }
  241. }