topfans/backend/gateway/router/router_test.go
zerosaturation e1326acaf9 fix(backend): service stability — bcrypt off-txn / login anti-enum / MQ stub / aiChat / event reliability / gateway aggregate (batch 3)
- 3.1 bcrypt 移出事务 (Register): repository.HashPassword 前移到 db.Transaction 之前,消除连接池占用。
- 3.2 Login 消除用户枚举 + 限流 + timing 抹平: pkg/errors 加 ErrInvalidCredential
  /ErrTooManyLoginAttempts; 用户不存在/密码错/密码空 三路径统一返回同一错误;
  mobile 5次/ip 20次 per 15min 限流 (Redis, fail-open 降级); user-not-found 走
  dummy bcrypt 抹平 ~100ms 时序差,完全消除枚举侧信道;空密码分支已核实无时序 leak。
- 3.3 MQ streams adapter 停用 → stub: 0 业务调用方, 新 stub EventProducer.Publish no-op;
  pkg/mq/mq.go Init 不再装配 streams; 全仓 grep 验证 11 处硬编码
  'gallery'/'default' 集中到 pkg/queue/consts (值不变, 仅消漂移)。
- 3.5 JWT 密钥治理: pkg/jwt MustInit fail-fast + atomic.Value (见上一个 commit 293c7b1)。
- 3.6 aiChat 健壮性: SaveContext 用 persona.ID(非 req.PersonaId); Redis/memory 错误
  记 WARN 不静默; Dify err 映射稳定用户文案,原始 err 仅服务端日志。
- 3.7 statistic.Client 重构: TrackEvent 改 buffered channel (cap 1024) + dispatchLoop
  worker; 失败 ERROR 日志带字段; drop 记 WARN; Close 可重复调用。
- 3.8 网关聚合: StarCache (60s TTL, singleflight) 替换 5+ 处 GetFanIdentities 链式调用;
  DeleteAccount 改网关直调 userService.DeleteAccount(避免改 hand-written triple.go
  风险,见报告 §5 proto 风险复盘); 铸造双写改异步 channel+consumer (3 retry)。
- 大量单测: 各子项 TDD (RED→GREEN), 关键并发 race_test (50 goroutine)。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-23 18:50:40 +08:00

169 lines
6.0 KiB
Go
Raw 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 router
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/topfans/backend/gateway/middleware"
"github.com/topfans/backend/pkg/jwt"
"github.com/topfans/backend/pkg/logger"
)
// TestMain 初始化 logger(AuthMiddleware 内部会调 logger.Warn)。
// 使用 zap.NewNop() 让测试输出干净,只靠断言判定。
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
logger.Logger = zap.NewNop()
logger.Sugar = logger.Logger.Sugar()
m.Run()
}
// TestAuthValidateRoute_RequiresAuth 验证 /api/v1/auth/validate 路由受 AuthMiddleware 保护
// 审计 §四 P2ValidateToken 不应公开。
//
// 这是 router.go 中 auth 分组的回归测试。
// router.go 现在把 /validate 注册在 authProtected 组(挂 AuthMiddleware),
// 而不是公开组。下面的 testLocalAuthGroups 把 router.go 中的两组注册复刻到本测试,
// 当 router.go 后续又把 /validate 误移回公开组时,我们再扩展此测试,
// 让 setup 指向真实分组定义(从 router.go 抽出来)以验证之。
func TestAuthValidateRoute_RequiresAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
// 复刻 router.go:144-181 的 /auth 分组结构
r := testLocalAuthGroups()
// POST /api/v1/auth/validate 不带 token → AuthMiddleware 必须拦截 → 401
req := httptest.NewRequest("POST", "/api/v1/auth/validate", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("POST /api/v1/auth/validate without token: got status %d, want %d (AuthMiddleware should reject). body=%s",
w.Code, http.StatusUnauthorized, w.Body.String())
}
}
// TestAuthValidateRoute_WithValidToken_OK 验证带有效 JWT 时 /validate 能命中 handler
// 这进一步证明 AuthMiddleware 在 /validate 路由上正常工作:
// 1. 解析 JWT → 通过
// 2. Redis 黑名单检查 → Redis 不可用时降级放行 → 通过
// 3. 设置 c.Set("user_id", ...) → c.Next() → handler 被调用
func TestAuthValidateRoute_WithValidToken_OK(t *testing.T) {
gin.SetMode(gin.TestMode)
// 设置 JWT 密钥(AuthMiddleware 内部要验签)
// 32+ 字节合法密钥,匹配 pkg/jwt.MinSecretLen。
if err := jwt.MustInit("test-secret-key-must-be-at-least-32-bytes"); err != nil {
t.Fatalf("jwt.MustInit failed: %v", err)
}
r := testLocalAuthGroups()
// 生成有效 JWT
token, err := jwt.GenerateToken(10000001, 123, time.Now().UnixMilli())
if err != nil {
t.Fatalf("GenerateToken failed: %v", err)
}
req := httptest.NewRequest("POST", "/api/v1/auth/validate", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("POST /api/v1/auth/validate with valid token: got status %d, want %d (AuthMiddleware should pass through to handler). body=%s",
w.Code, http.StatusOK, w.Body.String())
}
}
// TestAuthLoginRoute_PublicOK 验证 /auth/login 保持公开(注册/登录前置流程)
// 确保我们没有误把整个 /auth 组都搬到保护下。
func TestAuthLoginRoute_PublicOK(t *testing.T) {
gin.SetMode(gin.TestMode)
r := testLocalAuthGroups()
// POST /api/v1/auth/login 不带 token → 应到达 handler(公开组)
req := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("POST /api/v1/auth/login without token: got status %d, want %d (公开接口应无需 token). body=%s",
w.Code, http.StatusOK, w.Body.String())
}
}
// TestAuthValidateRoute_NegativeControl_PublicWouldFail 负面对照:
// 如果 /validate 被错误地放在公开组,无 token 请求应当能到达 handler(返回 200),
// 这就证明 TestAuthValidateRoute_RequiresAuth 真的能拦截"漏放公开"的情况。
//
// 这个测试确保我们的正向测试不是空断言:它真的会因为 /validate
// 被错误地移回公开组而失败。
func TestAuthValidateRoute_NegativeControl_PublicWouldFail(t *testing.T) {
gin.SetMode(gin.TestMode)
// 故意把 /validate 放在公开组(模拟修复前的"漏放"状态)
r := gin.New()
authPublic := r.Group("/api/v1/auth")
{
authPublic.POST("/validate", stubOKHandler) // 错误地放在公开组
}
req := httptest.NewRequest("POST", "/api/v1/auth/validate", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// 在"漏放公开"配置下,无 token 请求能到达 handler → 200
if w.Code != http.StatusOK {
t.Fatalf("负面对照: /validate 在公开组时,无 token 应能命中 handler。got status %d, want %d",
w.Code, http.StatusOK)
}
// 如果此对照通过 → 证明把 /validate 放进 protected 是必须的(否则会跟此处一样)
}
// testLocalAuthGroups 复刻 router.go 中 /auth 的两个分组结构。
// 用 stub handler 代替真实 controller(不需要 Dubbo / 业务)。
//
// 关键点:/validate 必须挂在 authProtected(挂 AuthMiddleware)
// 与 router.go:144-181 保持一致。
func testLocalAuthGroups() *gin.Engine {
r := gin.New()
// 公开 /auth 组(注册/登录前置)
authPublic := r.Group("/api/v1/auth")
{
authPublic.POST("/register", stubOKHandler)
authPublic.POST("/login", stubOKHandler)
authPublic.POST("/check-nickname", stubOKHandler)
authPublic.POST("/check-mobile", stubOKHandler)
authPublic.POST("/send-code", stubOKHandler)
authPublic.POST("/verify-code", stubOKHandler)
authPublic.POST("/reset-password", stubOKHandler)
// 注意:/validate 不在公开组(修复后)
}
// 受保护 /auth 组(AuthMiddleware 保护)
authProtected := r.Group("/api/v1/auth")
authProtected.Use(middleware.AuthMiddleware())
{
authProtected.GET("/me", stubOKHandler)
authProtected.POST("/refresh", stubOKHandler)
authProtected.POST("/logout", stubOKHandler)
authProtected.POST("/validate", stubOKHandler) // ★ 修复后移到此处
}
return r
}
// stubOKHandler 桩 handler不真调业务模拟"到达 handler"。
// 我们要测的是"路由是否被 AuthMiddleware 拦截",而不是业务逻辑。
func stubOKHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}