cors_test.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. package middleware
  2. import (
  3. "net/http"
  4. "net/http/httptest"
  5. "testing"
  6. "github.com/Wei-Shaw/sub2api/internal/config"
  7. "github.com/gin-gonic/gin"
  8. "github.com/stretchr/testify/assert"
  9. )
  10. func init() {
  11. // cors_test 与 security_headers_test 在同一个包,但 init 是幂等的
  12. gin.SetMode(gin.TestMode)
  13. }
  14. // --- Task 8.2: 验证 CORS 条件化头部 ---
  15. func TestCORS_DisallowedOrigin_NoAllowHeaders(t *testing.T) {
  16. cfg := config.CORSConfig{
  17. AllowedOrigins: []string{"https://allowed.example.com"},
  18. AllowCredentials: false,
  19. }
  20. middleware := CORS(cfg)
  21. tests := []struct {
  22. name string
  23. method string
  24. origin string
  25. }{
  26. {
  27. name: "preflight_disallowed_origin",
  28. method: http.MethodOptions,
  29. origin: "https://evil.example.com",
  30. },
  31. {
  32. name: "get_disallowed_origin",
  33. method: http.MethodGet,
  34. origin: "https://evil.example.com",
  35. },
  36. {
  37. name: "post_disallowed_origin",
  38. method: http.MethodPost,
  39. origin: "https://attacker.example.com",
  40. },
  41. {
  42. name: "preflight_no_origin",
  43. method: http.MethodOptions,
  44. origin: "",
  45. },
  46. }
  47. for _, tt := range tests {
  48. t.Run(tt.name, func(t *testing.T) {
  49. w := httptest.NewRecorder()
  50. c, _ := gin.CreateTestContext(w)
  51. c.Request = httptest.NewRequest(tt.method, "/", nil)
  52. if tt.origin != "" {
  53. c.Request.Header.Set("Origin", tt.origin)
  54. }
  55. middleware(c)
  56. // 不应设置 Allow-Headers、Allow-Methods 和 Max-Age
  57. assert.Empty(t, w.Header().Get("Access-Control-Allow-Headers"),
  58. "不允许的 origin 不应收到 Allow-Headers")
  59. assert.Empty(t, w.Header().Get("Access-Control-Allow-Methods"),
  60. "不允许的 origin 不应收到 Allow-Methods")
  61. assert.Empty(t, w.Header().Get("Access-Control-Max-Age"),
  62. "不允许的 origin 不应收到 Max-Age")
  63. assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"),
  64. "不允许的 origin 不应收到 Allow-Origin")
  65. })
  66. }
  67. }
  68. func TestCORS_AllowedOrigin_HasAllowHeaders(t *testing.T) {
  69. cfg := config.CORSConfig{
  70. AllowedOrigins: []string{"https://allowed.example.com"},
  71. AllowCredentials: false,
  72. }
  73. middleware := CORS(cfg)
  74. tests := []struct {
  75. name string
  76. method string
  77. }{
  78. {name: "preflight_OPTIONS", method: http.MethodOptions},
  79. {name: "normal_GET", method: http.MethodGet},
  80. {name: "normal_POST", method: http.MethodPost},
  81. }
  82. for _, tt := range tests {
  83. t.Run(tt.name, func(t *testing.T) {
  84. w := httptest.NewRecorder()
  85. c, _ := gin.CreateTestContext(w)
  86. c.Request = httptest.NewRequest(tt.method, "/", nil)
  87. c.Request.Header.Set("Origin", "https://allowed.example.com")
  88. middleware(c)
  89. // 应设置 Allow-Headers、Allow-Methods 和 Max-Age
  90. assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Headers"),
  91. "允许的 origin 应收到 Allow-Headers")
  92. assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "X-Admin-UI-Request")
  93. assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "X-User-UI-Request")
  94. assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Methods"),
  95. "允许的 origin 应收到 Allow-Methods")
  96. assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "Server-Timing")
  97. assert.Equal(t, "86400", w.Header().Get("Access-Control-Max-Age"),
  98. "允许的 origin 应收到 Max-Age=86400")
  99. assert.Equal(t, "https://allowed.example.com", w.Header().Get("Access-Control-Allow-Origin"),
  100. "允许的 origin 应收到 Allow-Origin")
  101. })
  102. }
  103. }
  104. func TestCORS_PreflightDisallowedOrigin_ReturnsForbidden(t *testing.T) {
  105. cfg := config.CORSConfig{
  106. AllowedOrigins: []string{"https://allowed.example.com"},
  107. AllowCredentials: false,
  108. }
  109. middleware := CORS(cfg)
  110. w := httptest.NewRecorder()
  111. c, _ := gin.CreateTestContext(w)
  112. c.Request = httptest.NewRequest(http.MethodOptions, "/", nil)
  113. c.Request.Header.Set("Origin", "https://evil.example.com")
  114. middleware(c)
  115. assert.Equal(t, http.StatusForbidden, w.Code,
  116. "不允许的 origin 的 preflight 请求应返回 403")
  117. }
  118. func TestCORS_PreflightAllowedOrigin_ReturnsNoContent(t *testing.T) {
  119. cfg := config.CORSConfig{
  120. AllowedOrigins: []string{"https://allowed.example.com"},
  121. AllowCredentials: false,
  122. }
  123. middleware := CORS(cfg)
  124. w := httptest.NewRecorder()
  125. c, _ := gin.CreateTestContext(w)
  126. c.Request = httptest.NewRequest(http.MethodOptions, "/", nil)
  127. c.Request.Header.Set("Origin", "https://allowed.example.com")
  128. middleware(c)
  129. assert.Equal(t, http.StatusNoContent, w.Code,
  130. "允许的 origin 的 preflight 请求应返回 204")
  131. }
  132. func TestCORS_WildcardOrigin_AllowsAny(t *testing.T) {
  133. cfg := config.CORSConfig{
  134. AllowedOrigins: []string{"*"},
  135. AllowCredentials: false,
  136. }
  137. middleware := CORS(cfg)
  138. w := httptest.NewRecorder()
  139. c, _ := gin.CreateTestContext(w)
  140. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  141. c.Request.Header.Set("Origin", "https://any-origin.example.com")
  142. middleware(c)
  143. assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin"),
  144. "通配符配置应返回 *")
  145. assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Headers"),
  146. "通配符 origin 应设置 Allow-Headers")
  147. assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Methods"),
  148. "通配符 origin 应设置 Allow-Methods")
  149. }
  150. func TestCORS_AllowCredentials_SetCorrectly(t *testing.T) {
  151. cfg := config.CORSConfig{
  152. AllowedOrigins: []string{"https://allowed.example.com"},
  153. AllowCredentials: true,
  154. }
  155. middleware := CORS(cfg)
  156. t.Run("allowed_origin_gets_credentials", func(t *testing.T) {
  157. w := httptest.NewRecorder()
  158. c, _ := gin.CreateTestContext(w)
  159. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  160. c.Request.Header.Set("Origin", "https://allowed.example.com")
  161. middleware(c)
  162. assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials"),
  163. "允许的 origin 且开启 credentials 应设置 Allow-Credentials")
  164. })
  165. t.Run("disallowed_origin_no_credentials", func(t *testing.T) {
  166. w := httptest.NewRecorder()
  167. c, _ := gin.CreateTestContext(w)
  168. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  169. c.Request.Header.Set("Origin", "https://evil.example.com")
  170. middleware(c)
  171. assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials"),
  172. "不允许的 origin 不应收到 Allow-Credentials")
  173. })
  174. }
  175. func TestCORS_WildcardWithCredentials_DisablesCredentials(t *testing.T) {
  176. cfg := config.CORSConfig{
  177. AllowedOrigins: []string{"*"},
  178. AllowCredentials: true,
  179. }
  180. middleware := CORS(cfg)
  181. w := httptest.NewRecorder()
  182. c, _ := gin.CreateTestContext(w)
  183. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  184. c.Request.Header.Set("Origin", "https://any.example.com")
  185. middleware(c)
  186. // 通配符 + credentials 不兼容,credentials 应被禁用
  187. assert.Empty(t, w.Header().Get("Access-Control-Allow-Credentials"),
  188. "通配符 origin 应禁用 Allow-Credentials")
  189. }
  190. func TestCORS_MultipleAllowedOrigins(t *testing.T) {
  191. cfg := config.CORSConfig{
  192. AllowedOrigins: []string{
  193. "https://app1.example.com",
  194. "https://app2.example.com",
  195. },
  196. AllowCredentials: false,
  197. }
  198. middleware := CORS(cfg)
  199. t.Run("first_origin_allowed", func(t *testing.T) {
  200. w := httptest.NewRecorder()
  201. c, _ := gin.CreateTestContext(w)
  202. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  203. c.Request.Header.Set("Origin", "https://app1.example.com")
  204. middleware(c)
  205. assert.Equal(t, "https://app1.example.com", w.Header().Get("Access-Control-Allow-Origin"))
  206. assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Headers"))
  207. })
  208. t.Run("second_origin_allowed", func(t *testing.T) {
  209. w := httptest.NewRecorder()
  210. c, _ := gin.CreateTestContext(w)
  211. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  212. c.Request.Header.Set("Origin", "https://app2.example.com")
  213. middleware(c)
  214. assert.Equal(t, "https://app2.example.com", w.Header().Get("Access-Control-Allow-Origin"))
  215. assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Headers"))
  216. })
  217. t.Run("unlisted_origin_rejected", func(t *testing.T) {
  218. w := httptest.NewRecorder()
  219. c, _ := gin.CreateTestContext(w)
  220. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  221. c.Request.Header.Set("Origin", "https://app3.example.com")
  222. middleware(c)
  223. assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
  224. assert.Empty(t, w.Header().Get("Access-Control-Allow-Headers"))
  225. })
  226. }
  227. func TestCORS_VaryHeader_SetForSpecificOrigin(t *testing.T) {
  228. cfg := config.CORSConfig{
  229. AllowedOrigins: []string{"https://allowed.example.com"},
  230. AllowCredentials: false,
  231. }
  232. middleware := CORS(cfg)
  233. w := httptest.NewRecorder()
  234. c, _ := gin.CreateTestContext(w)
  235. c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
  236. c.Request.Header.Set("Origin", "https://allowed.example.com")
  237. middleware(c)
  238. assert.Contains(t, w.Header().Values("Vary"), "Origin",
  239. "非通配符允许的 origin 应设置 Vary: Origin")
  240. }
  241. func TestNormalizeOrigins(t *testing.T) {
  242. tests := []struct {
  243. name string
  244. input []string
  245. expect []string
  246. }{
  247. {name: "nil_input", input: nil, expect: nil},
  248. {name: "empty_input", input: []string{}, expect: nil},
  249. {name: "trims_whitespace", input: []string{" https://a.com ", " https://b.com"}, expect: []string{"https://a.com", "https://b.com"}},
  250. {name: "removes_empty_strings", input: []string{"", " ", "https://a.com"}, expect: []string{"https://a.com"}},
  251. }
  252. for _, tt := range tests {
  253. t.Run(tt.name, func(t *testing.T) {
  254. result := normalizeOrigins(tt.input)
  255. assert.Equal(t, tt.expect, result)
  256. })
  257. }
  258. }