package pub import ( "context" "errors" "sync" "sync/atomic" "testing" authHelper "perms-system-server/internal/logic/auth" "perms-system-server/internal/response" "perms-system-server/internal/testutil" "perms-system-server/internal/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 H-1 修复的 logic 层回归 —— 在 logic 里用 CAS 递增 tokenVersion。 // 该文件聚焦"并发 refresh 同一旧令牌时的行为": // 1) N 个并发 RefreshToken 共用同一把 claims.TokenVersion=0 的 refreshToken, // 必须恰好 1 个返回成功;其余 N-1 个被 401 拒绝(字样必为"登录状态已失效")。 // 2) DB 的 tokenVersion 最终只能递增 1; // 3) 明确 CAS 失败时返回的 401 错误是通过 ErrTokenVersionMismatch 路径产出, // 与"账号冻结"等 403 分支互不混用。 // --------------------------------------------------------------------------- // TC-0812: H-1 logic 并发回归 —— 并发重放同一个旧 refreshToken,只允许一位胜出。 func TestRefreshToken_ConcurrentSameToken_SingleWinner(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() username := "rt_cas_" + testutil.UniqueId() userId, cleanUser := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2) t.Cleanup(cleanUser) // 禁用 TokenOpLimiter,以让本测试的变量只剩"并发 CAS 胜负"。 svcCtx.TokenOpLimiter = nil rt, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 0, ) require.NoError(t, err) // 限制在 6 并发以避免触发 go-zero sqlx breaker(单机 MySQL + breaker 对同批次突发 // 的并发 UPDATE 容易误伤,生产里 refreshToken 也是 per-user 限频 + CAS 双层保护, // 没机会打成这么高的并发)。CAS "唯一胜出" 的契约在 N=6 时已足以钉死。 const N = 6 var ( wg sync.WaitGroup okCount int32 authFailCnt int32 otherErr atomic.Value ) start := make(chan struct{}) for i := 0; i < N; i++ { wg.Add(1) go func() { defer wg.Done() <-start resp, e := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + rt, }) switch { case e == nil && resp != nil: atomic.AddInt32(&okCount, 1) case e != nil: var ce *response.CodeError if errors.As(e, &ce) && ce.Code() == 401 && ce.Error() == "登录状态已失效,请重新登录" { atomic.AddInt32(&authFailCnt, 1) } else { otherErr.Store(e) } } }() } close(start) wg.Wait() if v := otherErr.Load(); v != nil { t.Fatalf("并发 RefreshToken 出现非预期错误:%v", v) } assert.Equal(t, int32(1), atomic.LoadInt32(&okCount), "H-1 会话劫持防线:重放同一旧 refreshToken 的 N 个并发请求必须只有 1 个成功") assert.Equal(t, int32(N-1), atomic.LoadInt32(&authFailCnt), "其他并发者必须返回 401 '登录状态已失效'") // DB 必然只递增 1。 u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion, "DB tokenVersion 递增幅度就是 CAS 成功次数 → 只能是 1") }