| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166 |
- 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() }
- // ---------------------------------------------------------------------------
- // 覆盖目标:
- // /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) {
- 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",
- "/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,
- "/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)
- }
- // ---------------------------------------------------------------------------
- // 覆盖目标:
- // `/api/product/fetchInitialCredentials` 必须挂载 `serverCtx.JwtAuth` 中间件,
- // 且必须位于 /api/product 前缀组内,不能被错放到其他无鉴权/错前缀块中。
- //
- // 为什么这条 wiring 需要独立钉死:
- // 1. routes.go 由 goctl 生成,回归时若有人用 `goctl api go -api ... -dir .`
- // 覆写此文件,可能把新路由吞掉或挪到无 JwtAuth 包裹的块(例如误标
- // `@handler` 在 pub 组)。静态检查能最早拦截。
- // 2. 安全假设是"只有超级管理员能消费 ticket"。RequireSuperAdmin 依赖 JwtAuth
- // 中间件写入 UserDetails;若 JwtAuth 被去掉,handler 自身仅能回 401(无 ctx),
- // **但任何持有前端伪造 UserDetails 注入方式**的攻击者都会直接绕过
- // wiring 锚点确保这条防线永远在最外层。
- // ---------------------------------------------------------------------------
- // TC-0967: routes.go 必须把 /product/fetchInitialCredentials 挂到 JwtAuth 包裹块,
- // 并且处于 /api/product 前缀下(而不是 /api 或其他无超管审查的位置)。
- func TestRoutes_FetchInitialCredentialsJwtAuthWired(t *testing.T) {
- raw, err := os.ReadFile("./routes.go")
- require.NoError(t, err, "必须能读到 internal/handler/routes.go")
- src := string(raw)
- // go-zero 生成的 AddRoutes 块结构:
- // server.AddRoutes(
- // rest.WithMiddlewares([]rest.Middleware{serverCtx.XxxMiddleware}, []rest.Route{
- // {...Path: "/a"...},
- // {...Path: "/fetchInitialCredentials"...},
- // ...
- // }...),
- // rest.WithPrefix("/api/product"),
- // )
- // 我们需要:
- // 1. 定位到 /fetchInitialCredentials 所在的 AddRoutes 块整段;
- // 2. 从块里摘出 rest.Middleware{...} 列表做字符串断言;
- // 3. 从块里摘出 rest.WithPrefix("...") 的 prefix 做断言。
- // 简单起见,按"向上/向下扩展"的方式提取:以 "server.AddRoutes(" 为起点、往下到首个
- // "rest.WithPrefix(\"...\")" 为止的整段。
- addRoutesBlockRe := regexp.MustCompile(
- `(?s)server\.AddRoutes\(\s*rest\.WithMiddlewares\(\s*\[\]rest\.Middleware\{([^}]*)\}[\s\S]*?"/fetchInitialCredentials"[\s\S]*?rest\.WithPrefix\("([^"]+)"\)`,
- )
- m := addRoutesBlockRe.FindStringSubmatch(src)
- require.NotEmpty(t, m,
- "routes.go 中 /fetchInitialCredentials 必须位于 server.AddRoutes(rest.WithMiddlewares(...), rest.WithPrefix(...)) 结构块里;未匹配说明路由被剥离或迁移到其他结构")
- middlewaresList := m[1]
- prefix := m[2]
- assert.Contains(t, middlewaresList, "serverCtx.JwtAuth",
- "/product/fetchInitialCredentials 必须挂载 JwtAuth 中间件,否则 RequireSuperAdmin 的上下文前置条件不成立;实际中间件列表=%q", middlewaresList)
- assert.Equal(t, "/api/product", prefix,
- "/fetchInitialCredentials 必须位于 /api/product 前缀组下;实际 prefix=%q", prefix)
- }
- // TC-0968: 防御性 wiring 检查 —— 绝不允许把 fetchInitialCredentials 挂到任何
- // *非 JwtAuth* 的中间件块中。此用例是 TC-0967 的"反证":哪怕有人把 JwtAuth 改名
- // 成另一条鉴权中间件但语义错位,也会被这里拦住。
- func TestRoutes_FetchInitialCredentialsNotInRateLimitGroup(t *testing.T) {
- raw, err := os.ReadFile("./routes.go")
- require.NoError(t, err)
- src := string(raw)
- // 检查"限流中间件包裹块内"是否误引入了 fetchInitialCredentials
- for _, name := range []string{"AdminLoginRateLimit", "ProductLoginRateLimit", "RefreshTokenRateLimit", "SyncRateLimit"} {
- re := regexp.MustCompile(`(?s)rest\.WithMiddlewares\(\s*\[\]rest\.Middleware\{[^}]*?` + name + `[^}]*?\}[^)]*?"/fetchInitialCredentials"`)
- assert.False(t, re.MatchString(src),
- "/fetchInitialCredentials 绝不能被挂到 %s 中间件组(会绕过 JwtAuth / RequireSuperAdmin)", name)
- }
- }
|