routes_test.go 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. package handler
  2. import (
  3. "encoding/json"
  4. "net/http"
  5. "net/http/httptest"
  6. "os"
  7. "regexp"
  8. "strings"
  9. "testing"
  10. "perms-system-server/internal/handler/pub"
  11. "perms-system-server/internal/middleware"
  12. "perms-system-server/internal/response"
  13. "perms-system-server/internal/svc"
  14. "perms-system-server/internal/testutil"
  15. "github.com/stretchr/testify/assert"
  16. "github.com/stretchr/testify/require"
  17. "github.com/zeromicro/go-zero/core/stores/redis"
  18. )
  19. func init() { response.Setup() }
  20. // ---------------------------------------------------------------------------
  21. // 覆盖目标:
  22. // /api/auth/refreshToken 路由必须挂载 RefreshTokenRateLimit 中间件(IP 维度),
  23. // 配额用尽后对同 IP 请求必须返回 429 "请求过于频繁"。
  24. //
  25. // 本组用例从两个独立角度交叉验证:
  26. // 1. 静态 wiring:读取 routes.go 源码,断言 /auth/refreshToken 路由块内出现
  27. // serverCtx.RefreshTokenRateLimit(防止有人误删中间件却骗过运行时);
  28. // 2. 行为验证:用同一条中间件组合链路(= 生产代码的 rest.WithMiddlewares 展开)
  29. // 直接发 HTTP 请求,达到 quota 后必须 429。
  30. // ---------------------------------------------------------------------------
  31. // TC-0832: 静态 wiring 检查 —— routes.go 中 /auth/refreshToken 必须显式绑定
  32. // RefreshTokenRateLimit 中间件。任何人无意中删掉这一行,本用例即红。
  33. func TestRoutes_RefreshTokenRateLimitWired(t *testing.T) {
  34. raw, err := os.ReadFile("./routes.go")
  35. require.NoError(t, err, "必须能读到 internal/handler/routes.go")
  36. src := string(raw)
  37. // 先定位 /auth/refreshToken 的路由块,再在块内检查中间件引用
  38. // 语义等价于:rest.WithMiddlewares([]rest.Middleware{serverCtx.RefreshTokenRateLimit}, ... "/auth/refreshToken" ...)
  39. re := regexp.MustCompile(`(?s)rest\.WithMiddlewares\(\s*\[\]rest\.Middleware\{([^}]*)\}[^)]*?"/auth/refreshToken"`)
  40. m := re.FindStringSubmatch(src)
  41. require.NotEmpty(t, m, "routes.go 里 /auth/refreshToken 必须位于 rest.WithMiddlewares(...) 包裹块中;未匹配说明中间件被剥离")
  42. assert.Contains(t, m[1], "serverCtx.RefreshTokenRateLimit",
  43. "/auth/refreshToken 路由的中间件列表必须包含 RefreshTokenRateLimit")
  44. }
  45. // TC-0833: 行为验证 —— 复用生产中间件定义,quota=1 的窗口内同 IP 第 2 次必须 429。
  46. func TestRefreshTokenRoute_RateLimit_EnforcedOnSameIP(t *testing.T) {
  47. cfg := testutil.GetTestConfig()
  48. svcCtx := svc.NewServiceContext(cfg)
  49. rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf)
  50. // 构造与 routes.go 等价的中间件链:RefreshTokenRateLimit → RefreshTokenHandler
  51. // 这里故意使用 quota=1 的新实例,避免污染生产 limiter,同时保持行为完全一致。
  52. rl := middleware.NewRateLimitMiddleware(
  53. rds, 60, 1,
  54. cfg.CacheRedis.KeyPrefix+":rl:refresh:wiring:"+testutil.UniqueId(),
  55. false, /* behindProxy: 与默认配置一致,本测试用 RemoteAddr */
  56. )
  57. inner := pub.RefreshTokenHandler(svcCtx)
  58. wrapped := rl.Handle(func(w http.ResponseWriter, r *http.Request) {
  59. inner.ServeHTTP(w, r)
  60. })
  61. // 固定 RemoteAddr,两次请求同 IP 不同端口,必须共享同一限流桶。
  62. doRequest := func(remoteAddr string) (*httptest.ResponseRecorder, response.Body) {
  63. req := httptest.NewRequest(http.MethodPost, "/api/auth/refreshToken", strings.NewReader("{}"))
  64. req.Header.Set("Content-Type", "application/json")
  65. req.RemoteAddr = remoteAddr
  66. rr := httptest.NewRecorder()
  67. wrapped(rr, req)
  68. var body response.Body
  69. _ = json.Unmarshal(rr.Body.Bytes(), &body)
  70. return rr, body
  71. }
  72. // 第 1 次:放行,进入 RefreshTokenHandler 后因缺 Authorization 返回 401(业务层)
  73. _, body1 := doRequest("198.51.100.7:40001")
  74. assert.NotEqual(t, 429, body1.ErrorCode, "首次请求必须放行,由业务层决定返回码;实际 code=%d msg=%q", body1.ErrorCode, body1.ErrorMessage)
  75. // 第 2 次:同 IP 不同端口,必须被限流拦截,返回 429 "请求过于频繁..."
  76. _, body2 := doRequest("198.51.100.7:40002")
  77. assert.False(t, body2.Success)
  78. assert.Equal(t, 429, body2.ErrorCode,
  79. "/api/auth/refreshToken 必须受 IP 维度限流保护;quota=1 时第 2 次必须 429。实际 code=%d msg=%q", body2.ErrorCode, body2.ErrorMessage)
  80. assert.Contains(t, body2.ErrorMessage, "过于频繁",
  81. "429 的业务文案必须是用户可读的限流提示,而不是原始 limiter 错误")
  82. // 不同 IP 必须不受影响,证明限流是 per-IP 而不是全局。
  83. _, body3 := doRequest("203.0.113.9:55555")
  84. assert.NotEqual(t, 429, body3.ErrorCode, "不同 IP 必须独立计数;不应被前一 IP 的 burst 牵连,实际 code=%d", body3.ErrorCode)
  85. }
  86. // ---------------------------------------------------------------------------
  87. // 覆盖目标:
  88. // `/api/product/fetchInitialCredentials` 必须挂载 `serverCtx.JwtAuth` 中间件,
  89. // 且必须位于 /api/product 前缀组内,不能被错放到其他无鉴权/错前缀块中。
  90. //
  91. // 为什么这条 wiring 需要独立钉死:
  92. // 1. routes.go 由 goctl 生成,回归时若有人用 `goctl api go -api ... -dir .`
  93. // 覆写此文件,可能把新路由吞掉或挪到无 JwtAuth 包裹的块(例如误标
  94. // `@handler` 在 pub 组)。静态检查能最早拦截。
  95. // 2. 安全假设是"只有超级管理员能消费 ticket"。RequireSuperAdmin 依赖 JwtAuth
  96. // 中间件写入 UserDetails;若 JwtAuth 被去掉,handler 自身仅能回 401(无 ctx),
  97. // **但任何持有前端伪造 UserDetails 注入方式**的攻击者都会直接绕过
  98. // wiring 锚点确保这条防线永远在最外层。
  99. // ---------------------------------------------------------------------------
  100. // TC-0967: routes.go 必须把 /product/fetchInitialCredentials 挂到 JwtAuth 包裹块,
  101. // 并且处于 /api/product 前缀下(而不是 /api 或其他无超管审查的位置)。
  102. func TestRoutes_FetchInitialCredentialsJwtAuthWired(t *testing.T) {
  103. raw, err := os.ReadFile("./routes.go")
  104. require.NoError(t, err, "必须能读到 internal/handler/routes.go")
  105. src := string(raw)
  106. // go-zero 生成的 AddRoutes 块结构:
  107. // server.AddRoutes(
  108. // rest.WithMiddlewares([]rest.Middleware{serverCtx.XxxMiddleware}, []rest.Route{
  109. // {...Path: "/a"...},
  110. // {...Path: "/fetchInitialCredentials"...},
  111. // ...
  112. // }...),
  113. // rest.WithPrefix("/api/product"),
  114. // )
  115. // 我们需要:
  116. // 1. 定位到 /fetchInitialCredentials 所在的 AddRoutes 块整段;
  117. // 2. 从块里摘出 rest.Middleware{...} 列表做字符串断言;
  118. // 3. 从块里摘出 rest.WithPrefix("...") 的 prefix 做断言。
  119. // 简单起见,按"向上/向下扩展"的方式提取:以 "server.AddRoutes(" 为起点、往下到首个
  120. // "rest.WithPrefix(\"...\")" 为止的整段。
  121. addRoutesBlockRe := regexp.MustCompile(
  122. `(?s)server\.AddRoutes\(\s*rest\.WithMiddlewares\(\s*\[\]rest\.Middleware\{([^}]*)\}[\s\S]*?"/fetchInitialCredentials"[\s\S]*?rest\.WithPrefix\("([^"]+)"\)`,
  123. )
  124. m := addRoutesBlockRe.FindStringSubmatch(src)
  125. require.NotEmpty(t, m,
  126. "routes.go 中 /fetchInitialCredentials 必须位于 server.AddRoutes(rest.WithMiddlewares(...), rest.WithPrefix(...)) 结构块里;未匹配说明路由被剥离或迁移到其他结构")
  127. middlewaresList := m[1]
  128. prefix := m[2]
  129. assert.Contains(t, middlewaresList, "serverCtx.JwtAuth",
  130. "/product/fetchInitialCredentials 必须挂载 JwtAuth 中间件,否则 RequireSuperAdmin 的上下文前置条件不成立;实际中间件列表=%q", middlewaresList)
  131. assert.Equal(t, "/api/product", prefix,
  132. "/fetchInitialCredentials 必须位于 /api/product 前缀组下;实际 prefix=%q", prefix)
  133. }
  134. // TC-0968: 防御性 wiring 检查 —— 绝不允许把 fetchInitialCredentials 挂到任何
  135. // *非 JwtAuth* 的中间件块中。此用例是 TC-0967 的"反证":哪怕有人把 JwtAuth 改名
  136. // 成另一条鉴权中间件但语义错位,也会被这里拦住。
  137. func TestRoutes_FetchInitialCredentialsNotInRateLimitGroup(t *testing.T) {
  138. raw, err := os.ReadFile("./routes.go")
  139. require.NoError(t, err)
  140. src := string(raw)
  141. // 检查"限流中间件包裹块内"是否误引入了 fetchInitialCredentials
  142. for _, name := range []string{"AdminLoginRateLimit", "ProductLoginRateLimit", "RefreshTokenRateLimit", "SyncRateLimit"} {
  143. re := regexp.MustCompile(`(?s)rest\.WithMiddlewares\(\s*\[\]rest\.Middleware\{[^}]*?` + name + `[^}]*?\}[^)]*?"/fetchInitialCredentials"`)
  144. assert.False(t, re.MatchString(src),
  145. "/fetchInitialCredentials 绝不能被挂到 %s 中间件组(会绕过 JwtAuth / RequireSuperAdmin)", name)
  146. }
  147. }