Files
2026-07-14 10:24:10 +08:00

1549 lines
48 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}
// 回归 C8RecoverWithDetail 同病同治——真实 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 模式 CSRFmap 遮蔽修复 + 单次消费 + 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)
}
}
// 回归 C6cDoubleSubmitCookie 的 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 下发 cookiePOST 携带匹配的 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()
}
// 回归 H4bCustomRateLimit 创建的限流器登记入表,
// 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)
}
}
// 回归 H4bInitRateLimiters 重新初始化时也停止旧的自定义限流器(防 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),每次间隔 12ms8×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-1cRedis 故障(关闭 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-1dfail-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-1efail-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-2comma-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-2SetFailClosed 切换策略——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)
}
}