1549 lines
48 KiB
Go
1549 lines
48 KiB
Go
package middleware_test
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"runtime"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||
"github.com/EthanCodeCraft/xlgo-core/middleware"
|
||
"github.com/EthanCodeCraft/xlgo-core/response"
|
||
"github.com/alicebob/miniredis/v2"
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/redis/go-redis/v9"
|
||
)
|
||
|
||
// respCode 解析统一响应体中的业务 code 字段。
|
||
func respCode(t *testing.T, w *httptest.ResponseRecorder) int {
|
||
t.Helper()
|
||
var r response.Response
|
||
if err := json.Unmarshal(w.Body.Bytes(), &r); err != nil {
|
||
t.Fatalf("unmarshal response %q: %v", w.Body.String(), err)
|
||
}
|
||
return r.Code
|
||
}
|
||
|
||
func setupTestRouter() *gin.Engine {
|
||
gin.SetMode(gin.TestMode)
|
||
r := gin.New()
|
||
return r
|
||
}
|
||
|
||
// ===== RequestID Tests =====
|
||
|
||
func TestRequestID(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RequestID())
|
||
r.GET("/test", func(c *gin.Context) {
|
||
id := middleware.GetRequestID(c)
|
||
c.JSON(200, gin.H{"request_id": id})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
// 验证响应头有 X-Request-ID
|
||
headerID := w.Header().Get("X-Request-ID")
|
||
if headerID == "" {
|
||
t.Error("X-Request-ID header should be set")
|
||
}
|
||
|
||
// 验证响应体中的 request_id
|
||
if w.Code != 200 {
|
||
t.Errorf("RequestID status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestRequestIDWithExisting(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RequestID())
|
||
r.GET("/test", func(c *gin.Context) {
|
||
id := middleware.GetRequestID(c)
|
||
c.JSON(200, gin.H{"request_id": id})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("X-Request-ID", "custom-id-123")
|
||
r.ServeHTTP(w, req)
|
||
|
||
// 验证使用了传入的 ID
|
||
headerID := w.Header().Get("X-Request-ID")
|
||
if headerID != "custom-id-123" {
|
||
t.Errorf("X-Request-ID = %s, want custom-id-123", headerID)
|
||
}
|
||
}
|
||
|
||
func TestGetRequestIDEmpty(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.GET("/test", func(c *gin.Context) {
|
||
id := middleware.GetRequestID(c)
|
||
c.JSON(200, gin.H{"request_id": id})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
// 没有 RequestID 中间件时,返回空
|
||
if w.Code != 200 {
|
||
t.Errorf("status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
// TestRequestIDSanitizesClientHeader_M15:含换行/超长的客户端 X-Request-ID 应被忽略并重新生成,
|
||
// 防头注入与日志伪造。合法 ASCII ID 仍沿用。
|
||
func TestRequestIDSanitizesClientHeader_M15(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RequestID())
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"request_id": middleware.GetRequestID(c)})
|
||
})
|
||
|
||
// 含 CRLF 的非法 ID 应被忽略、重新生成(非空、无换行)。
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("X-Request-ID", "evil\r\nX-Forged: 1")
|
||
r.ServeHTTP(w, req)
|
||
got := w.Header().Get("X-Request-ID")
|
||
if strings.Contains(got, "\n") || strings.Contains(got, "\r") || got == "" {
|
||
t.Errorf("CRLF-injected request id not sanitized, got %q", got)
|
||
}
|
||
|
||
// 超长 ID 应被忽略、重新生成。
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("GET", "/test", nil)
|
||
req2.Header.Set("X-Request-ID", strings.Repeat("a", 200))
|
||
r.ServeHTTP(w2, req2)
|
||
if len(w2.Header().Get("X-Request-ID")) > 128 {
|
||
t.Errorf("overlong request id not regenerated, got len %d", len(w2.Header().Get("X-Request-ID")))
|
||
}
|
||
|
||
// 合法 ASCII ID 仍沿用客户端值。
|
||
w3 := httptest.NewRecorder()
|
||
req3 := httptest.NewRequest("GET", "/test", nil)
|
||
req3.Header.Set("X-Request-ID", "trace-abc-123")
|
||
r.ServeHTTP(w3, req3)
|
||
if w3.Header().Get("X-Request-ID") != "trace-abc-123" {
|
||
t.Errorf("legit request id should be preserved, got %q", w3.Header().Get("X-Request-ID"))
|
||
}
|
||
}
|
||
|
||
// ===== Recover Tests =====
|
||
|
||
func TestRecover(t *testing.T) {
|
||
// 需要初始化 logger,否则 Recover 会 panic
|
||
// 这里跳过完整测试,仅验证中间件可以正常注册
|
||
r := setupTestRouter()
|
||
r.Use(middleware.Recover())
|
||
r.GET("/normal", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/normal", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("Normal status = %d, want 200", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestRecoverNoPanic(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.Recover())
|
||
r.GET("/normal", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/normal", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("Normal status = %d, want 200", w.Code)
|
||
}
|
||
}
|
||
|
||
// ensureNopLogger 把全局 logger 重置为 Nop,避免 Recover 内 logger.Error
|
||
// 在 logger 未初始化时 nil deref 二次 panic 干扰断言。
|
||
func ensureNopLogger() {
|
||
_ = logger.Close() // Close 后 Logger/apiLog/dbLog 均为 zap.NewNop(),写日志安全。
|
||
}
|
||
|
||
// 回归 C8:默认 ModeBusiness 下,真实触发 panic 必须返回 HTTP 500(而非 200)。
|
||
// 修复前:FailWithCode 经 writeResp 在 ModeBusiness 下写 200 并 flush,
|
||
// 随后 AbortWithStatus(500) 因 w.Written()==true 沦为 no-op,客户端收 200 + body code:500。
|
||
func TestRecoverPanicReturns500(t *testing.T) {
|
||
ensureNopLogger()
|
||
response.SetMode(response.ModeBusiness) // 默认模式,复现 bug 的模式
|
||
defer response.SetMode(response.ModeBusiness)
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RequestID(), middleware.Recover())
|
||
r.GET("/panic", func(c *gin.Context) { panic("boom") })
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/panic", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 500 {
|
||
t.Errorf("panic status = %d, want 500 (C8: ModeBusiness must not swallow 500 into 200)", w.Code)
|
||
}
|
||
if got := respCode(t, w); got != response.CodeServerError {
|
||
t.Errorf("panic body code = %d, want %d", got, response.CodeServerError)
|
||
}
|
||
// Custom 保留 RequestID,便于链路追踪。
|
||
var r2 response.Response
|
||
if err := json.Unmarshal(w.Body.Bytes(), &r2); err != nil {
|
||
t.Fatalf("unmarshal response: %v", err)
|
||
}
|
||
if r2.RequestID == "" {
|
||
t.Error("panic response must carry request_id for tracing")
|
||
}
|
||
}
|
||
|
||
// 回归 C8:RecoverWithDetail 同病同治——真实 panic 必须返回 500。
|
||
func TestRecoverWithDetailPanicReturns500(t *testing.T) {
|
||
ensureNopLogger()
|
||
response.SetMode(response.ModeBusiness)
|
||
defer response.SetMode(response.ModeBusiness)
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RequestID(), middleware.RecoverWithDetail())
|
||
r.GET("/panic", func(c *gin.Context) { panic("boom") })
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/panic", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 500 {
|
||
t.Errorf("RecoverWithDetail panic status = %d, want 500", w.Code)
|
||
}
|
||
if got := respCode(t, w); got != response.CodeServerError {
|
||
t.Errorf("RecoverWithDetail body code = %d, want %d", got, response.CodeServerError)
|
||
}
|
||
}
|
||
|
||
// C8 跨模式一致性:ModeREST 下 panic 同样必须 500(修复前 REST 模式本就 500,
|
||
// 此用例锁定两模式行为一致,防止后续回归)。
|
||
func TestRecoverPanicRESTMode500(t *testing.T) {
|
||
ensureNopLogger()
|
||
response.SetMode(response.ModeREST)
|
||
defer response.SetMode(response.ModeBusiness)
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RequestID(), middleware.Recover())
|
||
r.GET("/panic", func(c *gin.Context) { panic("boom") })
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/panic", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 500 {
|
||
t.Errorf("REST mode panic status = %d, want 500", w.Code)
|
||
}
|
||
}
|
||
|
||
// ===== CSRF Tests =====
|
||
|
||
func TestCSRF(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CSRF())
|
||
r.GET("/form", func(c *gin.Context) {
|
||
token := middleware.GetCSRFToken(c)
|
||
c.JSON(200, gin.H{"csrf_token": token})
|
||
})
|
||
r.POST("/submit", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
// GET 请求应该成功,并设置 cookie
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/form", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("CSRF GET status = %d", w.Code)
|
||
}
|
||
|
||
// 获取 cookie 中的 token
|
||
cookies := w.Result().Cookies()
|
||
var csrfToken string
|
||
for _, c := range cookies {
|
||
if c.Name == "csrf_token" {
|
||
csrfToken = c.Value
|
||
break
|
||
}
|
||
}
|
||
|
||
// POST 无 token 应该失败
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("POST", "/submit", nil)
|
||
r.ServeHTTP(w2, req2)
|
||
|
||
if w2.Code != 200 {
|
||
// 成功捕获错误,返回 200 但 code != 1
|
||
}
|
||
|
||
// POST 带 token 应该成功
|
||
w3 := httptest.NewRecorder()
|
||
req3 := httptest.NewRequest("POST", "/submit", nil)
|
||
req3.Header.Set("X-CSRF-Token", csrfToken)
|
||
r.ServeHTTP(w3, req3)
|
||
|
||
// 注意:由于 cookie 需要从上一个请求传递,这里可能需要手动设置
|
||
}
|
||
|
||
func TestGetCSRFToken(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CSRF())
|
||
r.GET("/token", func(c *gin.Context) {
|
||
token := middleware.GetCSRFToken(c)
|
||
c.JSON(200, gin.H{"token": token})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/token", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("GetCSRFToken status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestDoubleSubmitCookie(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.DoubleSubmitCookie())
|
||
r.GET("/get", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
r.POST("/post", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
// GET 应该成功
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/get", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("DoubleSubmit GET status = %d", w.Code)
|
||
}
|
||
|
||
// POST 无 token 应该失败
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("POST", "/post", nil)
|
||
r.ServeHTTP(w2, req2)
|
||
|
||
// 应返回错误
|
||
if w2.Code != 200 && w2.Code != 400 {
|
||
t.Errorf("DoubleSubmit POST without token status = %d", w2.Code)
|
||
}
|
||
}
|
||
|
||
// ===== C6 回归:API 模式 CSRF(map 遮蔽修复 + 单次消费 + TTL) =====
|
||
|
||
// apiCSRFToken 从 GenerateAPIToken 响应体中提取颁发的 token。
|
||
func apiCSRFToken(t *testing.T, w *httptest.ResponseRecorder) string {
|
||
t.Helper()
|
||
var r response.Response
|
||
if err := json.Unmarshal(w.Body.Bytes(), &r); err != nil {
|
||
t.Fatalf("unmarshal response %q: %v", w.Body.String(), err)
|
||
}
|
||
data, ok := r.Data.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("response data not an object: %v", r.Data)
|
||
}
|
||
tok, ok := data["csrf_token"].(string)
|
||
if !ok || tok == "" {
|
||
t.Fatalf("missing csrf_token in response: %v", r.Data)
|
||
}
|
||
return tok
|
||
}
|
||
|
||
func setupAPICSRFRouter() *gin.Engine {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CSRFForAPI())
|
||
r.GET("/csrf-token", middleware.GenerateAPIToken)
|
||
r.POST("/action", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
return r
|
||
}
|
||
|
||
// 回归 C6a:颁发 → 校验闭环。修复前颁发的 token 永不在校验 map 里,
|
||
// 所有非安全方法请求被判“CSRF Token 无效”拒绝,API CSRF 模式整体不可用。
|
||
func TestCSRFForAPIIssueValidateCycle(t *testing.T) {
|
||
r := setupAPICSRFRouter()
|
||
|
||
// 颁发 token
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/csrf-token", nil)
|
||
r.ServeHTTP(w, req)
|
||
if respCode(t, w) != response.CodeSuccess {
|
||
t.Fatalf("issue token code = %d, want success", respCode(t, w))
|
||
}
|
||
token := apiCSRFToken(t, w)
|
||
|
||
// 携带 token 的 POST 必须通过(修复前这里恒失败)
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("POST", "/action", nil)
|
||
req2.Header.Set("X-CSRF-Token", token)
|
||
r.ServeHTTP(w2, req2)
|
||
if respCode(t, w2) != response.CodeSuccess {
|
||
t.Errorf("POST with valid token code = %d, want success (issue→validate cycle broken)", respCode(t, w2))
|
||
}
|
||
}
|
||
|
||
// 回归 C6b:单次消费——同一 token 第二次使用必须被拒绝,防止重放。
|
||
func TestCSRFForAPISingleUseConsumption(t *testing.T) {
|
||
r := setupAPICSRFRouter()
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest("GET", "/csrf-token", nil))
|
||
token := apiCSRFToken(t, w)
|
||
|
||
// 首次使用:通过
|
||
w1 := httptest.NewRecorder()
|
||
req1 := httptest.NewRequest("POST", "/action", nil)
|
||
req1.Header.Set("X-CSRF-Token", token)
|
||
r.ServeHTTP(w1, req1)
|
||
if respCode(t, w1) != response.CodeSuccess {
|
||
t.Fatalf("first use code = %d, want success", respCode(t, w1))
|
||
}
|
||
|
||
// 重放:必须拒绝
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("POST", "/action", nil)
|
||
req2.Header.Set("X-CSRF-Token", token)
|
||
r.ServeHTTP(w2, req2)
|
||
if respCode(t, w2) == response.CodeSuccess {
|
||
t.Error("replayed token must be rejected (single-use consumption broken)")
|
||
}
|
||
}
|
||
|
||
// 回归 C6:缺失 / 伪造 token 必须被拒绝。
|
||
func TestCSRFForAPIInvalidAndMissing(t *testing.T) {
|
||
r := setupAPICSRFRouter()
|
||
|
||
// 无 token
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest("POST", "/action", nil))
|
||
if respCode(t, w) == response.CodeSuccess {
|
||
t.Error("POST without token must be rejected")
|
||
}
|
||
|
||
// 伪造 token
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("POST", "/action", nil)
|
||
req2.Header.Set("X-CSRF-Token", "not-a-real-token")
|
||
r.ServeHTTP(w2, req2)
|
||
if respCode(t, w2) == response.CodeSuccess {
|
||
t.Error("POST with forged token must be rejected")
|
||
}
|
||
}
|
||
|
||
// 回归 C6:安全方法不校验,直接放行。
|
||
func TestCSRFForAPISafeMethodPasses(t *testing.T) {
|
||
r := setupAPICSRFRouter()
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest("GET", "/csrf-token", nil))
|
||
if w.Code != 200 {
|
||
t.Errorf("safe method status = %d, want 200", w.Code)
|
||
}
|
||
}
|
||
|
||
// 回归 C6c:DoubleSubmitCookie 的 cookie 必须 HttpOnly=false,
|
||
// 否则前端 JS 读不到 cookie、无法回填 X-CSRF-Token 头,双重提交对真实前端不可用。
|
||
func TestDoubleSubmitCookieHttpOnlyFalse(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.DoubleSubmitCookie())
|
||
r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest("GET", "/get", nil))
|
||
|
||
for _, c := range w.Result().Cookies() {
|
||
if c.Name == "csrf_token" {
|
||
if c.HttpOnly {
|
||
t.Errorf("DoubleSubmit cookie HttpOnly = true, want false (JS must read it to refill header)")
|
||
}
|
||
return
|
||
}
|
||
}
|
||
t.Fatal("csrf_token cookie not set on GET")
|
||
}
|
||
|
||
// 回归 C6c:前端回填闭环——GET 下发 cookie,POST 携带匹配的 X-CSRF-Token 通过,不匹配拒绝。
|
||
func TestDoubleSubmitCookieFrontendRefill(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.DoubleSubmitCookie())
|
||
r.GET("/get", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||
r.POST("/post", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||
|
||
// GET 下发 cookie
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest("GET", "/get", nil))
|
||
var cookieToken string
|
||
for _, c := range w.Result().Cookies() {
|
||
if c.Name == "csrf_token" {
|
||
cookieToken = c.Value
|
||
}
|
||
}
|
||
if cookieToken == "" {
|
||
t.Fatal("no csrf_token cookie issued")
|
||
}
|
||
|
||
// 携带匹配 token 的 cookie + header:通过
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("POST", "/post", nil)
|
||
req2.Header.Set("Cookie", "csrf_token="+cookieToken)
|
||
req2.Header.Set("X-CSRF-Token", cookieToken)
|
||
r.ServeHTTP(w2, req2)
|
||
if w2.Code != 200 {
|
||
t.Errorf("POST with matching token status = %d, want 200", w2.Code)
|
||
}
|
||
|
||
// 不匹配:拒绝
|
||
w3 := httptest.NewRecorder()
|
||
req3 := httptest.NewRequest("POST", "/post", nil)
|
||
req3.Header.Set("Cookie", "csrf_token="+cookieToken)
|
||
req3.Header.Set("X-CSRF-Token", "mismatched")
|
||
r.ServeHTTP(w3, req3)
|
||
if w3.Code == 200 && respCode(t, w3) == response.CodeSuccess {
|
||
t.Error("POST with mismatched token must be rejected")
|
||
}
|
||
}
|
||
|
||
// ===== CORS Tests =====
|
||
|
||
func TestCORS(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORS())
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
// 使用 localhost origin(开发环境默认允许)
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "http://localhost:3000")
|
||
r.ServeHTTP(w, req)
|
||
|
||
// 验证请求成功
|
||
if w.Code != 200 {
|
||
t.Errorf("CORS status = %d", w.Code)
|
||
}
|
||
|
||
// 验证其他 CORS 头始终设置
|
||
allowMethods := w.Header().Get("Access-Control-Allow-Methods")
|
||
if allowMethods == "" {
|
||
t.Error("Access-Control-Allow-Methods should be set")
|
||
}
|
||
}
|
||
|
||
func TestCORSOptions(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORS())
|
||
r.POST("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
// OPTIONS 预检请求
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("OPTIONS", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
// OPTIONS 应返回 204
|
||
if w.Code != 204 {
|
||
t.Errorf("CORS OPTIONS status = %d, want 204", w.Code)
|
||
}
|
||
}
|
||
|
||
// CORS 规范要求:AllowCredentials=false 时不应发送 Access-Control-Allow-Credentials 头。
|
||
// 历史 bug:旧实现在 if/else 两个分支都设了 "true",相当于永远允许凭证。
|
||
func TestCORSAllowCredentialsDefault(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORSWithConfig(&config.CORSConfig{
|
||
AllowedOrigins: []string{"https://example.com"},
|
||
AllowCredentials: false,
|
||
}))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "https://example.com")
|
||
r.ServeHTTP(w, req)
|
||
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" {
|
||
t.Errorf("Access-Control-Allow-Credentials = %q, want empty when AllowCredentials=false", got)
|
||
}
|
||
}
|
||
|
||
// AllowCredentials=true 且 Origin 在白名单内:发送凭证头并回显具体 Origin。
|
||
func TestCORSAllowCredentialsExplicitOrigin(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORSWithConfig(&config.CORSConfig{
|
||
AllowedOrigins: []string{"https://example.com"},
|
||
AllowCredentials: true,
|
||
}))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "https://example.com")
|
||
r.ServeHTTP(w, req)
|
||
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||
t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\"", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
|
||
t.Errorf("Access-Control-Allow-Origin = %q, want \"https://example.com\"", got)
|
||
}
|
||
if got := w.Header().Get("Vary"); got != "Origin" {
|
||
t.Errorf("Vary = %q, want \"Origin\"", got)
|
||
}
|
||
}
|
||
|
||
// CORS 规范:AllowCredentials=true 时禁止使用 "*",必须回显具体 Origin。
|
||
func TestCORSWildcardWithCredentials(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORSWithConfig(&config.CORSConfig{
|
||
AllowedOrigins: []string{"*"},
|
||
AllowCredentials: true,
|
||
}))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "https://anywhere.example")
|
||
r.ServeHTTP(w, req)
|
||
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got == "*" {
|
||
t.Errorf("Access-Control-Allow-Origin must NOT be \"*\" when credentials are allowed, got %q", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://anywhere.example" {
|
||
t.Errorf("Access-Control-Allow-Origin = %q, want echoed origin", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
||
t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\"", got)
|
||
}
|
||
}
|
||
|
||
// AllowedOrigins=["*"] 且 AllowCredentials=false:保持 "*" 通配符语义。
|
||
func TestCORSWildcardWithoutCredentials(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORSWithConfig(&config.CORSConfig{
|
||
AllowedOrigins: []string{"*"},
|
||
AllowCredentials: false,
|
||
}))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "https://anywhere.example")
|
||
r.ServeHTTP(w, req)
|
||
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
|
||
t.Errorf("Access-Control-Allow-Origin = %q, want \"*\"", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" {
|
||
t.Errorf("Access-Control-Allow-Credentials = %q, want empty (AllowCredentials=false)", got)
|
||
}
|
||
}
|
||
|
||
// Origin 不在白名单时不应回显,避免反射型 CORS 漏洞。
|
||
func TestCORSOriginNotAllowed(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORSWithConfig(&config.CORSConfig{
|
||
AllowedOrigins: []string{"https://allowed.example"},
|
||
AllowCredentials: true,
|
||
}))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "https://evil.example")
|
||
r.ServeHTTP(w, req)
|
||
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("Access-Control-Allow-Origin = %q, want empty for non-whitelisted origin", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" {
|
||
t.Errorf("Access-Control-Allow-Credentials = %q, want empty for non-whitelisted origin", got)
|
||
}
|
||
}
|
||
|
||
// ===== C7 回归:CORS 通配后缀绕过 + 开发态任意 Origin 回显 =====
|
||
|
||
// 回归 C7a:通配符 *.example.com 必须拒绝 notexample.com(后缀相同但非真实子域)。
|
||
// 旧实现 strings.HasSuffix(origin, "example.com") 接受此类绕过。
|
||
func TestCORSWildcardSuffixBypassRejected(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORSWithConfig(&config.CORSConfig{
|
||
AllowedOrigins: []string{"*.example.com"},
|
||
AllowCredentials: true,
|
||
}))
|
||
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||
|
||
for _, evil := range []string{"https://notexample.com", "https://evil-example.com"} {
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", evil)
|
||
r.ServeHTTP(w, req)
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("evil origin %q got Allow-Origin %q, want empty (suffix bypass)", evil, got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "" {
|
||
t.Errorf("evil origin %q got Allow-Credentials %q, want empty", evil, got)
|
||
}
|
||
}
|
||
|
||
// 真实子域仍应通过。
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "https://app.example.com")
|
||
r.ServeHTTP(w, req)
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
|
||
t.Errorf("real subdomain got Allow-Origin %q, want echoed", got)
|
||
}
|
||
}
|
||
|
||
// 回归 C7a:通配符不匹配 apex 自身(example.com 不由 *.example.com 覆盖,需显式配置)。
|
||
func TestCORSWildcardDoesNotMatchApex(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORSWithConfig(&config.CORSConfig{
|
||
AllowedOrigins: []string{"*.example.com"},
|
||
}))
|
||
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "https://example.com")
|
||
r.ServeHTTP(w, req)
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("apex origin matched by wildcard got %q, want empty", got)
|
||
}
|
||
}
|
||
|
||
// 回归 C7b:开发态兜底仅对 localhost 回显,不回显任意 Origin(防凭据型反射)。
|
||
// 旧实现 cfg.IsDevelopment() && origin != "" 无条件回显任意 Origin。
|
||
func TestCORSDevModeRejectsArbitraryOrigin(t *testing.T) {
|
||
// 注入开发态全局配置(无 CORS 白名单 → 走开发态兜底分支)。
|
||
old := config.Get()
|
||
config.Set(&config.Config{
|
||
App: config.AppConfig{Env: "development"},
|
||
Server: config.ServerConfig{Mode: "development"},
|
||
})
|
||
t.Cleanup(func() {
|
||
if old != nil {
|
||
config.Set(old)
|
||
} else {
|
||
config.Set(&config.Config{})
|
||
}
|
||
})
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORS())
|
||
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||
|
||
// 非 localhost 的任意 Origin 不应被回显。
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "https://evil.com")
|
||
r.ServeHTTP(w, req)
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("arbitrary origin echoed in dev mode: %q, want empty", got)
|
||
}
|
||
|
||
// localhost 仍应被回显(开发态正常用法)。
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("GET", "/test", nil)
|
||
req2.Header.Set("Origin", "http://localhost:3000")
|
||
r.ServeHTTP(w2, req2)
|
||
if got := w2.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
|
||
t.Errorf("localhost origin in dev mode got %q, want echoed", got)
|
||
}
|
||
}
|
||
|
||
// 回归 C7 收尾:未匹配 origin 不发 Allow-Methods/Headers(收敛信息泄露)。
|
||
// 旧实现无论 origin 是否匹配都无条件发送,向未授权 origin 暴露 API 允许的方法/头清单。
|
||
func TestCORSUnmatchedOriginNoMethodHeaders(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CORSWithConfig(&config.CORSConfig{
|
||
AllowedOrigins: []string{"https://example.com"},
|
||
}))
|
||
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) })
|
||
|
||
// 未匹配 origin:不应发任何 CORS 头(含 Allow-Methods/Headers)。
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
req.Header.Set("Origin", "https://evil.com")
|
||
r.ServeHTTP(w, req)
|
||
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||
t.Errorf("unmatched origin got Allow-Origin %q, want empty", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Methods"); got != "" {
|
||
t.Errorf("unmatched origin got Allow-Methods %q, want empty (info leak)", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Allow-Headers"); got != "" {
|
||
t.Errorf("unmatched origin got Allow-Headers %q, want empty (info leak)", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Expose-Headers"); got != "" {
|
||
t.Errorf("unmatched origin got Expose-Headers %q, want empty", got)
|
||
}
|
||
if got := w.Header().Get("Access-Control-Max-Age"); got != "" {
|
||
t.Errorf("unmatched origin got Max-Age %q, want empty", got)
|
||
}
|
||
|
||
// 匹配 origin:正常发 Allow-Methods/Headers。
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("GET", "/test", nil)
|
||
req2.Header.Set("Origin", "https://example.com")
|
||
r.ServeHTTP(w2, req2)
|
||
if got := w2.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
|
||
t.Errorf("matched origin got Allow-Origin %q, want echoed", got)
|
||
}
|
||
if got := w2.Header().Get("Access-Control-Allow-Methods"); got == "" {
|
||
t.Error("matched origin should have Allow-Methods set")
|
||
}
|
||
if got := w2.Header().Get("Access-Control-Allow-Headers"); got == "" {
|
||
t.Error("matched origin should have Allow-Headers set")
|
||
}
|
||
}
|
||
|
||
// ===== RateLimit Tests =====
|
||
|
||
func TestRateLimit(t *testing.T) {
|
||
r := setupTestRouter()
|
||
// 使用自定义限流器
|
||
limiter := middleware.NewRateLimiter(10, time.Minute) // 每分钟10次
|
||
defer limiter.Stop()
|
||
r.Use(middleware.RateLimit(limiter))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
// 正常请求应该成功
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
// 单次请求应该成功
|
||
if w.Code != 200 {
|
||
t.Errorf("RateLimit status = %d, want 200", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestRateLimiterAllow(t *testing.T) {
|
||
limiter := middleware.NewRateLimiter(3, time.Minute) // 每分钟3次
|
||
defer limiter.Stop()
|
||
|
||
// 前3次允许
|
||
for i := 0; i < 3; i++ {
|
||
if !limiter.Allow("192.168.1.1") {
|
||
t.Errorf("Request %d should be allowed", i+1)
|
||
}
|
||
}
|
||
|
||
// 第4次拒绝
|
||
if limiter.Allow("192.168.1.1") {
|
||
t.Error("Request 4 should be denied")
|
||
}
|
||
|
||
// 不同 IP 应该独立计数
|
||
if !limiter.Allow("192.168.1.2") {
|
||
t.Error("Different IP should be allowed")
|
||
}
|
||
}
|
||
|
||
func TestCustomRateLimit(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CustomRateLimit(5, time.Minute))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("CustomRateLimit status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
// ===== H4b 回归:CustomRateLimit goroutine 泄漏 =====
|
||
|
||
// goroutineCount 返回当前 goroutine 数(经短暂 GC + 让出以稳定读数)。
|
||
func goroutineCount() int {
|
||
runtime.GC()
|
||
// 给 cleanup goroutine 退出的时间窗口一点余量。
|
||
for i := 0; i < 20; i++ {
|
||
n := runtime.NumGoroutine()
|
||
_ = n
|
||
runtime.Gosched()
|
||
}
|
||
return runtime.NumGoroutine()
|
||
}
|
||
|
||
// 回归 H4b:CustomRateLimit 创建的限流器登记入表,
|
||
// StopRateLimiters 停止其 cleanup goroutine,无泄漏。
|
||
// 修复前 CustomRateLimit 创建的 limiter 无句柄,StopRateLimiters 不感知 → cleanup goroutine 永久泄漏。
|
||
func TestCustomRateLimitNoGoroutineLeak(t *testing.T) {
|
||
// 先清空全局状态(其他测试可能残留)。
|
||
middleware.StopRateLimiters()
|
||
|
||
before := goroutineCount()
|
||
|
||
// 创建多个自定义限流器(每个启动一个 cleanup goroutine)。
|
||
const n = 5
|
||
r := setupTestRouter()
|
||
for i := 0; i < n; i++ {
|
||
r.Use(middleware.CustomRateLimit(100, time.Minute))
|
||
}
|
||
r.GET("/test", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||
|
||
// 触发一次请求使中间件生效(limiter 已在构造时创建)。
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||
if w.Code != 200 {
|
||
t.Fatalf("status = %d, want 200", w.Code)
|
||
}
|
||
|
||
created := goroutineCount()
|
||
// 创建 n 个限流器后 goroutine 数应明显增加(至少 n 个 cleanup goroutine)。
|
||
if created < before+n {
|
||
t.Errorf("after creating %d custom limiters: goroutines = %d, before = %d, want >= before+%d", n, created, before, n)
|
||
}
|
||
|
||
// StopRateLimiters 须停止登记的自定义限流器 → cleanup goroutine 退出。
|
||
middleware.StopRateLimiters()
|
||
|
||
// 等待 cleanup goroutine 退出(Stop 内部 wg.Wait 已保证,但 NumGoroutine 读数有调度延迟)。
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
var after int
|
||
for time.Now().Before(deadline) {
|
||
after = goroutineCount()
|
||
if after <= before+2 { // 允许少量调度噪声
|
||
break
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
|
||
if after > before+2 {
|
||
t.Errorf("after StopRateLimiters: goroutines = %d, before = %d, expected custom cleanup goroutines released (H4b leak)", after, before)
|
||
}
|
||
}
|
||
|
||
// 回归 H4b:InitRateLimiters 重新初始化时也停止旧的自定义限流器(防 re-init 泄漏)。
|
||
func TestCustomRateLimitReinitStopsOldCustoms(t *testing.T) {
|
||
middleware.StopRateLimiters()
|
||
|
||
// 基线:InitRateLimiters 只建 3 个命名限流器(3 个 cleanup goroutine),无自定义。
|
||
middleware.InitRateLimiters()
|
||
baseline := goroutineCount()
|
||
|
||
// 创建 2 个自定义限流器(修复后登记入表)。
|
||
_ = middleware.CustomRateLimit(100, time.Minute)
|
||
_ = middleware.CustomRateLimit(100, time.Minute)
|
||
|
||
// InitRateLimiters 重建命名限流器时也应停止已登记的自定义限流器。
|
||
// 修复后:自定义 2 个被停止,仅剩 3 个命名 cleanup goroutine → goroutine 数 ≈ baseline。
|
||
// 修复前(不登记):2 个自定义 cleanup goroutine 泄漏 → goroutine 数 ≈ baseline+2。
|
||
middleware.InitRateLimiters()
|
||
|
||
deadline := time.Now().Add(2 * time.Second)
|
||
var after int
|
||
for time.Now().Before(deadline) {
|
||
after = goroutineCount()
|
||
if after <= baseline+1 { // 允许少量调度噪声
|
||
break
|
||
}
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
|
||
if after > baseline+1 {
|
||
t.Errorf("after InitRateLimiters: goroutines = %d, baseline = %d, expected old custom limiters stopped (H4b re-init leak)", after, baseline)
|
||
}
|
||
|
||
middleware.StopRateLimiters()
|
||
}
|
||
|
||
// ===== H4a 回归:限流窗口语义 =====
|
||
|
||
// fakeClock 可控时钟,供 RateLimiter 测试注入 nowFunc(避免真实 Sleep flaky)。
|
||
type fakeClock struct {
|
||
mu sync.Mutex
|
||
now time.Time
|
||
}
|
||
|
||
func newFakeClock() *fakeClock {
|
||
return &fakeClock{now: time.Now()}
|
||
}
|
||
|
||
func (fc *fakeClock) Now() time.Time {
|
||
fc.mu.Lock()
|
||
defer fc.mu.Unlock()
|
||
return fc.now
|
||
}
|
||
|
||
func (fc *fakeClock) Advance(d time.Duration) {
|
||
fc.mu.Lock()
|
||
defer fc.mu.Unlock()
|
||
fc.now = fc.now.Add(d)
|
||
}
|
||
|
||
// 回归 H4a:稳态客户端跨窗口持续低于 rate,不被误限。
|
||
// rate=10/100ms。每窗口 8 次(< rate),分散在窗口内。
|
||
// 旧实现放行每次更新 windowStart,致重置分支永不成立、count 跨窗口累加,
|
||
// 第 2 个窗口内 count 累加超过 10 被误限——尽管每窗口请求数(8)低于 rate。
|
||
// 修复后 windowStart 仅窗口起点设置,跨窗口重置、稳态低于 rate 永不被限。
|
||
func TestRateLimiterSteadyClientNotBlocked(t *testing.T) {
|
||
clock := newFakeClock()
|
||
limiter := middleware.NewRateLimiter(10, 100*time.Millisecond)
|
||
limiter.SetNowFunc(clock.Now)
|
||
defer limiter.Stop()
|
||
|
||
// 3 个窗口,每窗口 8 次(< rate=10),每次间隔 12ms(8×12=96ms < 100ms 在窗口内)。
|
||
// 窗口间靠最后一次 Advance 推进到 >100ms 触发跨窗口。
|
||
for w := 0; w < 3; w++ {
|
||
for i := 0; i < 8; i++ {
|
||
if !limiter.Allow("1.2.3.4") {
|
||
t.Fatalf("window %d request %d should be allowed (steady below rate, H4a: count must reset per window)", w+1, i+1)
|
||
}
|
||
clock.Advance(12 * time.Millisecond) // 窗口内推进
|
||
}
|
||
// 8×12=96ms 已过,再推进 8ms 到 104ms > 100ms,进入下一窗口。
|
||
clock.Advance(8 * time.Millisecond)
|
||
}
|
||
}
|
||
|
||
// 回归 H4a:窗口过后 count 重置,可再次放行(固定窗口语义)。
|
||
// 旧实现 lastSeen 每次放行更新,窗口过期分支对持续客户端永不触发。
|
||
func TestRateLimiterWindowReset(t *testing.T) {
|
||
clock := newFakeClock()
|
||
limiter := middleware.NewRateLimiter(3, time.Minute)
|
||
limiter.SetNowFunc(clock.Now)
|
||
defer limiter.Stop()
|
||
|
||
for i := 0; i < 3; i++ {
|
||
if !limiter.Allow("1.2.3.4") {
|
||
t.Fatalf("request %d should be allowed", i+1)
|
||
}
|
||
}
|
||
if limiter.Allow("1.2.3.4") {
|
||
t.Error("request 4 should be denied (rate exceeded)")
|
||
}
|
||
clock.Advance(time.Minute + time.Second)
|
||
if !limiter.Allow("1.2.3.4") {
|
||
t.Error("request after window should be allowed (window reset)")
|
||
}
|
||
}
|
||
|
||
// 回归 H4a:超限被拦(基本限流仍生效,修复不是放宽到无限)。
|
||
func TestRateLimiterBlocksOverRate(t *testing.T) {
|
||
limiter := middleware.NewRateLimiter(3, time.Minute)
|
||
defer limiter.Stop()
|
||
|
||
for i := 0; i < 3; i++ {
|
||
if !limiter.Allow("1.2.3.4") {
|
||
t.Fatalf("request %d should be allowed", i+1)
|
||
}
|
||
}
|
||
if limiter.Allow("1.2.3.4") {
|
||
t.Error("request 4 should be denied")
|
||
}
|
||
}
|
||
|
||
// 回归 H4a:窗口内突发(瞬时集中)达 rate 后拒绝——固定窗口允许突发但封顶 rate。
|
||
func TestRateLimiterBurstCappedAtRate(t *testing.T) {
|
||
clock := newFakeClock()
|
||
limiter := middleware.NewRateLimiter(5, time.Minute)
|
||
limiter.SetNowFunc(clock.Now)
|
||
defer limiter.Stop()
|
||
|
||
for i := 0; i < 5; i++ {
|
||
if !limiter.Allow("1.2.3.4") {
|
||
t.Errorf("burst request %d should be allowed", i+1)
|
||
}
|
||
}
|
||
if limiter.Allow("1.2.3.4") {
|
||
t.Error("request 6 in same window should be denied")
|
||
}
|
||
}
|
||
|
||
func assertPanic(t *testing.T, name string, fn func()) {
|
||
t.Helper()
|
||
defer func() {
|
||
if r := recover(); r == nil {
|
||
t.Fatalf("%s did not panic", name)
|
||
}
|
||
}()
|
||
fn()
|
||
}
|
||
|
||
func TestRateLimiterRejectsInvalidConfig(t *testing.T) {
|
||
assertPanic(t, "memory limiter zero rate", func() {
|
||
_ = middleware.NewRateLimiter(0, time.Minute)
|
||
})
|
||
assertPanic(t, "memory limiter zero window", func() {
|
||
_ = middleware.NewRateLimiter(1, 0)
|
||
})
|
||
assertPanic(t, "redis limiter zero rate", func() {
|
||
_ = middleware.NewRedisRateLimiter("bad", 0, time.Minute)
|
||
})
|
||
assertPanic(t, "redis fail-closed limiter zero window", func() {
|
||
_ = middleware.NewRedisRateLimiter("bad", 1, 0, middleware.WithFailClosed(true))
|
||
})
|
||
}
|
||
|
||
func TestRateLimiterStopIdempotent(t *testing.T) {
|
||
limiter := middleware.NewRateLimiter(1, time.Minute)
|
||
limiter.Stop()
|
||
limiter.Stop()
|
||
}
|
||
|
||
func TestRateLimitNilLimiterFailsClosed(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RateLimit(nil))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest("GET", "/test", nil))
|
||
|
||
if w.Code != http.StatusServiceUnavailable {
|
||
t.Fatalf("RateLimit(nil) status = %d, want 503; body=%s", w.Code, w.Body.String())
|
||
}
|
||
if got := respCode(t, w); got != response.CodeServiceUnavailable {
|
||
t.Fatalf("RateLimit(nil) code = %d, want %d", got, response.CodeServiceUnavailable)
|
||
}
|
||
}
|
||
|
||
func TestLoginRateLimit(t *testing.T) {
|
||
middleware.InitRateLimiters()
|
||
defer middleware.StopRateLimiters()
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.LoginRateLimit())
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("LoginRateLimit status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestAPIRateLimit(t *testing.T) {
|
||
middleware.InitRateLimiters()
|
||
defer middleware.StopRateLimiters()
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.APIRateLimit())
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("APIRateLimit status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestUploadRateLimit(t *testing.T) {
|
||
middleware.InitRateLimiters()
|
||
defer middleware.StopRateLimiters()
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.UploadRateLimit())
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("UploadRateLimit status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
// ===== RedisRateLimiter Tests =====
|
||
|
||
func TestRedisRateLimiter(t *testing.T) {
|
||
limiter := middleware.NewRedisRateLimiter("test_limit", 10, time.Minute)
|
||
|
||
// Without Redis, should always allow
|
||
ctx := context.Background()
|
||
allowed, err := limiter.Allow(ctx, "192.168.1.1")
|
||
if err != nil {
|
||
t.Errorf("RedisRateLimiter error: %v", err)
|
||
}
|
||
if !allowed {
|
||
t.Error("RedisRateLimiter should allow without Redis")
|
||
}
|
||
}
|
||
|
||
func TestRedisRateLimitMiddleware(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RedisRateLimit("test_api", 100))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
// Without Redis, should pass
|
||
if w.Code != 200 {
|
||
t.Errorf("RedisRateLimit status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestLoginRedisRateLimit(t *testing.T) {
|
||
// H4c: LoginRedisRateLimit 改 fail-closed——无 Redis 时拒绝(503),
|
||
// 防爆破场景下 Redis 故障不能静默放行(原 fail-open 致限流失效)。
|
||
// 测试环境无 Redis,故预期 503。
|
||
prev := database.SetTestRedisClient(nil)
|
||
defer func() { database.SetTestRedisClient(prev) }()
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.LoginRedisRateLimit())
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusServiceUnavailable {
|
||
t.Errorf("LoginRedisRateLimit (no Redis, fail-closed) status = %d, want 503", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestAPIRedisRateLimit(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.APIRedisRateLimit())
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("APIRedisRateLimit status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestCustomRedisRateLimit(t *testing.T) {
|
||
r := setupTestRouter()
|
||
r.Use(middleware.CustomRedisRateLimit("custom", 50, time.Minute))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest("GET", "/test", nil)
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("CustomRedisRateLimit status = %d", w.Code)
|
||
}
|
||
}
|
||
|
||
func TestRedisRateLimitWithIdentifierNilFuncUsesClientIP(t *testing.T) {
|
||
setupMiddlewareMiniRedis(t)
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RedisRateLimitWithIdentifier("nil_identifier", 1, nil))
|
||
r.GET("/test", func(c *gin.Context) {
|
||
c.JSON(200, gin.H{"status": "ok"})
|
||
})
|
||
|
||
w1 := httptest.NewRecorder()
|
||
req1 := httptest.NewRequest("GET", "/test", nil)
|
||
req1.RemoteAddr = "192.0.2.10:12345"
|
||
r.ServeHTTP(w1, req1)
|
||
if w1.Code != http.StatusOK {
|
||
t.Fatalf("first request status = %d, want 200; body=%s", w1.Code, w1.Body.String())
|
||
}
|
||
|
||
w2 := httptest.NewRecorder()
|
||
req2 := httptest.NewRequest("GET", "/test", nil)
|
||
req2.RemoteAddr = "192.0.2.10:12345"
|
||
r.ServeHTTP(w2, req2)
|
||
if got := respCode(t, w2); got != response.CodeRateLimit {
|
||
t.Fatalf("second request code = %d, want %d; status=%d body=%s", got, response.CodeRateLimit, w2.Code, w2.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestRedisRateLimiterGetCount(t *testing.T) {
|
||
limiter := middleware.NewRedisRateLimiter("test_count", 10, time.Minute)
|
||
|
||
ctx := context.Background()
|
||
count, err := limiter.GetCount(ctx, "192.168.1.1")
|
||
if err != nil {
|
||
t.Errorf("GetCount error: %v", err)
|
||
}
|
||
// Without Redis, count should be 0
|
||
if count != 0 {
|
||
t.Errorf("GetCount = %d, want 0 without Redis", count)
|
||
}
|
||
}
|
||
|
||
func TestRedisRateLimiterReset(t *testing.T) {
|
||
limiter := middleware.NewRedisRateLimiter("test_reset", 10, time.Minute)
|
||
|
||
ctx := context.Background()
|
||
err := limiter.Reset(ctx, "192.168.1.1")
|
||
if err != nil {
|
||
t.Errorf("Reset error: %v", err)
|
||
}
|
||
}
|
||
|
||
// ===== H4c 回归:RedisRateLimiter fail-open/fail-closed + 裸断言 =====
|
||
|
||
// setupMiddlewareMiniRedis 启动 miniredis 并注入 database 内部 redisClient,返回 mr 与清理。
|
||
func setupMiddlewareMiniRedis(t *testing.T) *miniredis.Miniredis {
|
||
t.Helper()
|
||
mr := miniredis.RunT(t)
|
||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||
t.Cleanup(func() { _ = client.Close() })
|
||
old := database.SetTestRedisClient(client)
|
||
t.Cleanup(func() { database.SetTestRedisClient(old) })
|
||
return mr
|
||
}
|
||
|
||
// 回归 H4c-1a:无 Redis 时 fail-open 放行(兼容旧行为)。
|
||
func TestRedisRateLimiterFailOpenNoRedis(t *testing.T) {
|
||
prev := database.SetTestRedisClient(nil)
|
||
defer func() { database.SetTestRedisClient(prev) }()
|
||
|
||
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute)
|
||
allowed, err := limiter.Allow(context.Background(), "1.2.3.4")
|
||
if err != nil {
|
||
t.Errorf("fail-open no-redis err = %v, want nil", err)
|
||
}
|
||
if !allowed {
|
||
t.Error("fail-open no-redis should allow (兼容旧行为)")
|
||
}
|
||
}
|
||
|
||
// 回归 H4c-1b:无 Redis 时 fail-closed 拒绝 + 返 ErrRedisRateLimiterUnavailable。
|
||
// 修复前无此选项;fail-closed 是 H4c 新增的安全语义。
|
||
func TestRedisRateLimiterFailClosedNoRedis(t *testing.T) {
|
||
prev := database.SetTestRedisClient(nil)
|
||
defer func() { database.SetTestRedisClient(prev) }()
|
||
|
||
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute, middleware.WithFailClosed(true))
|
||
allowed, err := limiter.Allow(context.Background(), "1.2.3.4")
|
||
if allowed {
|
||
t.Error("fail-closed no-redis should deny")
|
||
}
|
||
if !errors.Is(err, middleware.ErrRedisRateLimiterUnavailable) {
|
||
t.Errorf("fail-closed no-redis err = %v, want ErrRedisRateLimiterUnavailable", err)
|
||
}
|
||
}
|
||
|
||
// 回归 H4c-1c:Redis 故障(关闭 miniredis)时 fail-open 放行、fail-closed 拒绝。
|
||
// 这是 H4c 核心——登录防爆破场景下 fail-closed 防限流静默失效。
|
||
func TestRedisRateLimiterFailClosedOnRedisError(t *testing.T) {
|
||
mr := setupMiddlewareMiniRedis(t)
|
||
|
||
// fail-open:正常时放行。
|
||
openLimiter := middleware.NewRedisRateLimiter("test_open", 10, time.Minute)
|
||
allowed, err := openLimiter.Allow(context.Background(), "1.2.3.4")
|
||
if err != nil {
|
||
t.Fatalf("fail-open normal err = %v", err)
|
||
}
|
||
if !allowed {
|
||
t.Error("fail-open normal should allow")
|
||
}
|
||
|
||
// fail-closed:正常时放行。
|
||
closedLimiter := middleware.NewRedisRateLimiter("test_closed", 10, time.Minute, middleware.WithFailClosed(true))
|
||
allowed, err = closedLimiter.Allow(context.Background(), "1.2.3.4")
|
||
if err != nil {
|
||
t.Fatalf("fail-closed normal err = %v", err)
|
||
}
|
||
if !allowed {
|
||
t.Error("fail-closed normal should allow")
|
||
}
|
||
|
||
// 关闭 miniredis 模拟 Redis 故障。
|
||
mr.Close()
|
||
|
||
// fail-open:故障时放行(兼容旧行为)。
|
||
allowed, _ = openLimiter.Allow(context.Background(), "1.2.3.4")
|
||
if !allowed {
|
||
t.Error("fail-open on redis error should allow (兼容旧行为)")
|
||
}
|
||
|
||
// fail-closed:故障时拒绝(H4c 安全语义)。
|
||
allowed, err = closedLimiter.Allow(context.Background(), "1.2.3.4")
|
||
if allowed {
|
||
t.Error("fail-closed on redis error should deny (H4c: 防限流静默失效)")
|
||
}
|
||
if err == nil {
|
||
t.Error("fail-closed on redis error should return non-nil err")
|
||
}
|
||
}
|
||
|
||
// 回归 H4c-1d:fail-closed 中间件在 Redis 故障时返 503(区别于真实超限的 429)。
|
||
func TestRedisRateLimitFailClosedMiddlewareReturns503(t *testing.T) {
|
||
prev := database.SetTestRedisClient(nil)
|
||
defer func() { database.SetTestRedisClient(prev) }()
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RedisRateLimit("login_limit", 10, middleware.WithFailClosed(true)))
|
||
r.GET("/login", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest("POST", "/login", nil))
|
||
|
||
if w.Code != http.StatusServiceUnavailable {
|
||
t.Errorf("fail-closed middleware (no redis) status = %d, want 503", w.Code)
|
||
}
|
||
var body response.Response
|
||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||
t.Fatalf("decode body: %v", err)
|
||
}
|
||
if body.Code != response.CodeServiceUnavailable {
|
||
t.Errorf("fail-closed middleware code = %d, want %d", body.Code, response.CodeServiceUnavailable)
|
||
}
|
||
}
|
||
|
||
// 回归 H4c-1e:fail-open 中间件在 Redis 故障时放行(兼容旧行为)。
|
||
func TestRedisRateLimitFailOpenMiddlewareAllowsOnError(t *testing.T) {
|
||
prev := database.SetTestRedisClient(nil)
|
||
defer func() { database.SetTestRedisClient(prev) }()
|
||
|
||
r := setupTestRouter()
|
||
r.Use(middleware.RedisRateLimit("api_limit", 100))
|
||
r.GET("/api", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest("GET", "/api", nil))
|
||
|
||
if w.Code != 200 {
|
||
t.Errorf("fail-open middleware (no redis) status = %d, want 200", w.Code)
|
||
}
|
||
}
|
||
|
||
// 回归 H4c-2:真实 Redis 闭环——超限被拦(429),未超限放行。
|
||
// 用 miniredis 验证滑动窗口 Lua 脚本与 comma-ok 断言路径在真实 Redis 下正常。
|
||
func TestRedisRateLimiterRealRedisLimitCycle(t *testing.T) {
|
||
setupMiddlewareMiniRedis(t)
|
||
|
||
limiter := middleware.NewRedisRateLimiter("cycle_limit", 3, time.Minute)
|
||
ctx := context.Background()
|
||
|
||
// 前 3 次放行。
|
||
for i := 0; i < 3; i++ {
|
||
allowed, err := limiter.Allow(ctx, "1.2.3.4")
|
||
if err != nil {
|
||
t.Fatalf("request %d err = %v", i, err)
|
||
}
|
||
if !allowed {
|
||
t.Errorf("request %d should be allowed", i)
|
||
}
|
||
}
|
||
// 第 4 次拒绝(超限)。
|
||
allowed, err := limiter.Allow(ctx, "1.2.3.4")
|
||
if err != nil {
|
||
t.Fatalf("over-limit err = %v", err)
|
||
}
|
||
if allowed {
|
||
t.Error("request 4 should be denied (over limit)")
|
||
}
|
||
}
|
||
|
||
// 回归 H4c-2:comma-ok 断言防护——Redis 返回非 int64 时不 panic 而返错。
|
||
// miniredis 的 slidingWindowLua 恒返 int64,无法经 Allow 触发断言失败路径;
|
||
// 此处直接用返回字符串的 Lua 脚本验证 result.(int64) 的 comma-ok 行为,
|
||
// 并对照证明裸断言会 panic(H4c-2 缺陷根因)。
|
||
func TestRedisRateLimiterCommaOkAssertion(t *testing.T) {
|
||
setupMiddlewareMiniRedis(t)
|
||
ctx := context.Background()
|
||
|
||
// miniredis 执行返回字符串的脚本——模拟 Redis 返回非 int64 结果。
|
||
res, err := database.GetRedis().Eval(ctx, `return 'not-an-int'`, nil).Result()
|
||
if err != nil {
|
||
t.Fatalf("eval err = %v", err)
|
||
}
|
||
|
||
// 对照:裸断言在非 int64 时 panic。
|
||
var barePanicked bool
|
||
func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
barePanicked = true
|
||
}
|
||
}()
|
||
_ = res.(int64) // 旧实现行:裸断言
|
||
}()
|
||
if !barePanicked {
|
||
t.Error("bare assertion res.(int64) should panic on non-int64 (H4c-2 缺陷根因)")
|
||
}
|
||
|
||
// 修复:comma-ok 不 panic,返 ok=false。
|
||
if _, ok := res.(int64); ok {
|
||
t.Error("comma-ok should return ok=false for non-int64 result")
|
||
}
|
||
}
|
||
|
||
// 回归 H4c-2:SetFailClosed 切换策略——fail-open 限流器切 fail-closed 后无 Redis 拒绝。
|
||
func TestRedisRateLimiterSetFailClosed(t *testing.T) {
|
||
prev := database.SetTestRedisClient(nil)
|
||
defer func() { database.SetTestRedisClient(prev) }()
|
||
|
||
limiter := middleware.NewRedisRateLimiter("test", 10, time.Minute)
|
||
// 初始 fail-open:放行。
|
||
if allowed, _ := limiter.Allow(context.Background(), "1.2.3.4"); !allowed {
|
||
t.Error("initial fail-open should allow")
|
||
}
|
||
// 切换 fail-closed:拒绝。
|
||
limiter.SetFailClosed(true)
|
||
if allowed, err := limiter.Allow(context.Background(), "1.2.3.4"); allowed {
|
||
t.Error("after SetFailClosed(true) should deny")
|
||
} else if !errors.Is(err, middleware.ErrRedisRateLimiterUnavailable) {
|
||
t.Errorf("after SetFailClosed err = %v, want ErrRedisRateLimiterUnavailable", err)
|
||
}
|
||
}
|