package handler import ( "encoding/json" "net/http" "net/http/httptest" "os" "regexp" "strings" "testing" "perms-system-server/internal/handler/pub" "perms-system-server/internal/middleware" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" ) func init() { response.Setup() } // --------------------------------------------------------------------------- // 覆盖目标: // M-B (audit):HTTP /api/auth/refreshToken 路由必须挂载 RefreshTokenRateLimit // 中间件(IP 维度),配额用尽后对同 IP 请求必须返回 429 "请求过于频繁"。 // // 本文件从两个独立角度交叉验证: // 1. 静态 wiring:读取 routes.go 源码,断言 /auth/refreshToken 路由块内出现 // serverCtx.RefreshTokenRateLimit(防止有人误删中间件却骗过运行时); // 2. 行为验证:用同一条中间件组合链路(= 生产代码的 rest.WithMiddlewares 展开) // 直接发 HTTP 请求,达到 quota 后必须 429。 // --------------------------------------------------------------------------- // TC-0832: 静态 wiring 检查 —— routes.go 中 /auth/refreshToken 必须显式绑定 // RefreshTokenRateLimit 中间件。任何人无意中删掉这一行,本用例即红。 func TestRoutes_RefreshTokenRateLimitWired(t *testing.T) { // 读 routes.go 源码 raw, err := os.ReadFile("./routes.go") require.NoError(t, err, "必须能读到 internal/handler/routes.go") src := string(raw) // 先定位 /auth/refreshToken 的路由块,再在块内检查中间件引用 // 语义等价于:rest.WithMiddlewares([]rest.Middleware{serverCtx.RefreshTokenRateLimit}, ... "/auth/refreshToken" ...) re := regexp.MustCompile(`(?s)rest\.WithMiddlewares\(\s*\[\]rest\.Middleware\{([^}]*)\}[^)]*?"/auth/refreshToken"`) m := re.FindStringSubmatch(src) require.NotEmpty(t, m, "routes.go 里 /auth/refreshToken 必须位于 rest.WithMiddlewares(...) 包裹块中;未匹配说明中间件被剥离") assert.Contains(t, m[1], "serverCtx.RefreshTokenRateLimit", "M-B:/auth/refreshToken 路由的中间件列表必须包含 RefreshTokenRateLimit") } // TC-0833: 行为验证 —— 复用生产中间件定义,quota=1 的窗口内同 IP 第 2 次必须 429。 func TestRefreshTokenRoute_RateLimit_EnforcedOnSameIP(t *testing.T) { cfg := testutil.GetTestConfig() svcCtx := svc.NewServiceContext(cfg) rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) // 构造与 routes.go 等价的中间件链:RefreshTokenRateLimit → RefreshTokenHandler // 这里故意使用 quota=1 的新实例,避免污染生产 limiter,同时保持行为完全一致。 rl := middleware.NewRateLimitMiddleware( rds, 60, 1, cfg.CacheRedis.KeyPrefix+":rl:refresh:wiring:"+testutil.UniqueId(), false, /* behindProxy: 与默认配置一致,本测试用 RemoteAddr */ ) inner := pub.RefreshTokenHandler(svcCtx) wrapped := rl.Handle(func(w http.ResponseWriter, r *http.Request) { inner.ServeHTTP(w, r) }) // 固定 RemoteAddr,两次请求同 IP 不同端口,必须共享同一限流桶。 doRequest := func(remoteAddr string) (*httptest.ResponseRecorder, response.Body) { req := httptest.NewRequest(http.MethodPost, "/api/auth/refreshToken", strings.NewReader("{}")) req.Header.Set("Content-Type", "application/json") req.RemoteAddr = remoteAddr rr := httptest.NewRecorder() wrapped(rr, req) var body response.Body _ = json.Unmarshal(rr.Body.Bytes(), &body) return rr, body } // 第 1 次:放行,进入 RefreshTokenHandler 后因缺 Authorization 返回 401(业务层) _, body1 := doRequest("198.51.100.7:40001") assert.NotEqual(t, 429, body1.Code, "首次请求必须放行,由业务层决定返回码;实际 code=%d msg=%q", body1.Code, body1.Msg) // 第 2 次:同 IP 不同端口,必须被限流拦截,返回 429 "请求过于频繁..." _, body2 := doRequest("198.51.100.7:40002") assert.Equal(t, 429, body2.Code, "M-B:/api/auth/refreshToken 必须受 IP 维度限流保护;quota=1 时第 2 次必须 429。实际 code=%d msg=%q", body2.Code, body2.Msg) assert.Contains(t, body2.Msg, "过于频繁", "429 的业务文案必须是用户可读的限流提示,而不是原始 limiter 错误") // 不同 IP 必须不受影响,证明限流是 per-IP 而不是全局。 _, body3 := doRequest("203.0.113.9:55555") assert.NotEqual(t, 429, body3.Code, "不同 IP 必须独立计数;不应被前一 IP 的 burst 牵连,实际 code=%d", body3.Code) }