package pub import ( "context" "database/sql" "errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/limit" "github.com/zeromicro/go-zero/core/stores/redis" authHelper "perms-system-server/internal/logic/auth" "perms-system-server/internal/middleware" permModel "perms-system-server/internal/model/perm" productmemberModel "perms-system-server/internal/model/productmember" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/testutil" "perms-system-server/internal/types" "sync" "sync/atomic" "testing" "time" ) func insertRefreshTestUser(t *testing.T, ctx context.Context, username, password string, status, isSuperAdmin int64) (int64, func()) { t.Helper() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() now := time.Now().Unix() hashed := testutil.HashPassword(password) res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: hashed, Nickname: username, Avatar: sql.NullString{}, Email: username + "@test.com", Phone: "13800000000", Remark: "", DeptId: 0, IsSuperAdmin: isSuperAdmin, MustChangePassword: 2, Status: status, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() cleanup := func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) } return id, cleanup } // TC-0026: 正常刷新(refreshToken从header获取,原样返回不重新生成) func TestRefreshToken_Normal(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() username := testutil.UniqueId() password := "TestPass123" userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2) t.Cleanup(cleanUser) refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 0, ) require.NoError(t, err) logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + refreshToken, }) require.NoError(t, err) require.NotNil(t, resp) assert.NotEmpty(t, resp.AccessToken) assert.NotEmpty(t, resp.RefreshToken, "应返回新的refreshToken") assert.NotEqual(t, resp.AccessToken, resp.RefreshToken, "accessToken和refreshToken应不同") assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳") assert.Equal(t, userId, resp.UserInfo.UserId) assert.Equal(t, username, resp.UserInfo.Username) } // TC-0027: 不带productCode(回退) func TestRefreshToken_FallbackToClaimsProductCode(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() username := testutil.UniqueId() password := "TestPass123" pc := testutil.UniqueId() now := time.Now().Unix() userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2) t.Cleanup(cleanUser) _, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret") t.Cleanup(cleanProduct) pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{ ProductCode: pc, UserId: userId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pmId, _ := pmRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) }) permCode := testutil.UniqueId() permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: pc, Name: "refresh_perm", Code: permCode, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permId, _ := permRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) }) refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, pc, 0, ) require.NoError(t, err) logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + refreshToken, }) require.NoError(t, err) require.NotNil(t, resp) assert.Equal(t, "ADMIN", resp.UserInfo.MemberType) assert.Contains(t, resp.UserInfo.Perms, permCode) } // TC-0028: token无效 func TestRefreshToken_InvalidToken(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer invalid.token.string", }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "refreshToken无效或已过期", codeErr.Error()) } // TC-0029: 用户已被删除 —— 修复后必须区分"不存在"(401) 与"冻结"(403)。 // // 修复前:Loader 对不存在用户返回空壳 UserDetails(Status=0),RefreshToken 走到"账号已被冻结"分支 (403), // // 将"用户不存在"与"账号冻结"两个语义混淆,监控告警与运维处置策略无法区分。 // // 修复后:Loader 返回 (ud, nil) 且 ud.Username == "",RefreshToken 显式回 401 "用户不存在或已被删除"。 // // 这样客户端/前端才能走"注销本地会话 + 返回登录页"的终态流程,而不是提示"账号已冻结请联系管理员"。 func TestRefreshToken_UserDeleted(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() nonExistentUserId := int64(999999999) refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, nonExistentUserId, "", 0, ) require.NoError(t, err) logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + refreshToken, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code(), "用户不存在必须走 401,不得与冻结态 (403) 混淆") assert.Equal(t, "用户不存在或已被删除", codeErr.Error()) } // TC-0030: 账号冻结 func TestRefreshToken_AccountFrozen(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() username := testutil.UniqueId() password := "TestPass123" userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 2, 2) t.Cleanup(cleanUser) refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 0, ) require.NoError(t, err) logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + refreshToken, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 403, codeErr.Code()) assert.Equal(t, "账号已被冻结", codeErr.Error()) } // TC-0032: 尝试切换产品被拒绝 func TestRefreshToken_ProductCodeSwitchRejected(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() username := testutil.UniqueId() password := "TestPass123" userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2) t.Cleanup(cleanUser) refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "product_a", 0, ) require.NoError(t, err) logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + refreshToken, ProductCode: "product_b", }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "刷新令牌不允许切换产品", codeErr.Error()) } // TC-0033: TokenVersion不匹配时拒绝刷新 func TestRefreshToken_TokenVersionMismatch(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() username := testutil.UniqueId() password := "TestPass123" userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2) t.Cleanup(cleanUser) refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 999, ) require.NoError(t, err) logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + refreshToken, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "登录状态已失效,请重新登录", codeErr.Error()) } // TC-0034: 使用accessToken作为refreshToken被拒绝 func TestRefreshToken_AccessTokenRejected(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() username := testutil.UniqueId() password := "TestPass123" userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2) t.Cleanup(cleanUser) accessToken, err := authHelper.GenerateAccessToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.AccessExpire, userId, username, "", "", 0, ) require.NoError(t, err) logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + accessToken, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 401, codeErr.Code()) assert.Equal(t, "refreshToken无效或已过期", codeErr.Error()) } // TC-0035: 产品成员已移除时拒绝刷新 func TestRefreshToken_MemberRemovedRejected(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() username := testutil.UniqueId() password := "TestPass123" pc := testutil.UniqueId() now := time.Now().Unix() userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2) t.Cleanup(cleanUser) _, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret") t.Cleanup(cleanProduct) pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productmemberModel.SysProductMember{ ProductCode: pc, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pmId, _ := pmRes.LastInsertId() refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, pc, 0, ) require.NoError(t, err) testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + refreshToken, }) require.Nil(t, resp) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 403, codeErr.Code()) assert.Equal(t, "您已不是该产品的成员", codeErr.Error()) } // TC-0031: 超管+productCode(refreshToken原样返回) func TestRefreshToken_SuperAdminWithProductCode(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() conn := testutil.GetTestSqlConn() username := testutil.UniqueId() password := "TestPass123" pc := testutil.UniqueId() now := time.Now().Unix() userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 1) t.Cleanup(cleanUser) _, cleanProduct := insertTestProduct(t, ctx, svcCtx, pc, testutil.UniqueId(), "secret") t.Cleanup(cleanProduct) permCode := testutil.UniqueId() permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: pc, Name: "sa_refresh_perm", Code: permCode, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permId, _ := permRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) }) refreshToken, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, pc, 0, ) require.NoError(t, err) logic := NewRefreshTokenLogic(ctx, svcCtx) resp, err := logic.RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + refreshToken, ProductCode: pc, }) require.NoError(t, err) require.NotNil(t, resp) assert.NotEmpty(t, resp.RefreshToken, "应返回新的refreshToken") assert.Equal(t, "SUPER_ADMIN", resp.UserInfo.MemberType) assert.Contains(t, resp.UserInfo.Perms, permCode) assert.Equal(t, int64(1), resp.UserInfo.IsSuperAdmin) } 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), "会话劫持防线:重放同一旧 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") } func TestRefreshToken_TokenOpLimiter_BlocksBurst(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() username := "rt_rl_" + testutil.UniqueId() password := "TestPass123" userId, cleanUser := insertRefreshTestUser(t, ctx, username, password, 1, 2) t.Cleanup(cleanUser) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:refresh:ut:"+testutil.UniqueId()) mkReq := func(tv int64) *types.RefreshTokenReq { rt, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", tv) require.NoError(t, err) return &types.RefreshTokenReq{Authorization: "Bearer " + rt} } resp1, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(0)) require.NoError(t, err, "首次刷新应放行") require.NotNil(t, resp1) // DB tokenVersion 已变为 1,旧 claims.TokenVersion=0 的 refreshToken 已失效, // 所以第二次必须用新 token;但限流判定在 TokenVersion 校验之**后**、IncrementTokenVersion 之**前**, // 因此使用新版本号构造的 token 会先通过前置校验,再被 TokenOpLimiter 拦截。 u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) tvAfterFirst := u.TokenVersion require.Equal(t, int64(1), tvAfterFirst) _, err = NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(tvAfterFirst)) require.Error(t, err, "超限的第二次刷新必须被 429 拦截") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 429, ce.Code()) assert.Contains(t, ce.Error(), "过于频繁") uAfter, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, tvAfterFirst, uAfter.TokenVersion, "被限流的 refresh 请求绝不可递增 tokenVersion") } // TC-0742: -B 修复 —— 限流按用户粒度隔离(productCode 无关)。 // 场景:同一用户连续两次带 productCode=空的刷新请求,若限流命中,不会影响其它用户。 func TestRefreshToken_TokenOpLimiter_PerUserIsolated(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() uaId, cleanA := insertRefreshTestUser(t, ctx, "rt_iso_a_"+testutil.UniqueId(), "TestPass123", 1, 2) t.Cleanup(cleanA) ubId, cleanB := insertRefreshTestUser(t, ctx, "rt_iso_b_"+testutil.UniqueId(), "TestPass123", 1, 2) t.Cleanup(cleanB) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.TokenOpLimiter = limit.NewPeriodLimit(60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:refresh:iso:"+testutil.UniqueId()) mkReq := func(uid, tv int64) *types.RefreshTokenReq { rt, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, uid, "", tv) require.NoError(t, err) return &types.RefreshTokenReq{Authorization: "Bearer " + rt} } // A:两次刷新,第 2 次必 429 _, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(uaId, 0)) require.NoError(t, err) _, err = NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(uaId, 1)) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) require.Equal(t, 429, ce.Code()) // B 应当还能刷新 respB, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(mkReq(ubId, 0)) require.NoError(t, err, "B 用户的限流桶应当独立于 A") require.NotNil(t, respB) } func TestRefreshToken_M3_SuccessEmbedsFreshVersion(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() svcCtx.TokenOpLimiter = nil username := "rt_m3_ok_" + testutil.UniqueId() userId, cleanup := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2) t.Cleanup(cleanup) rt, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 0, ) require.NoError(t, err) resp, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + rt, }) require.NoError(t, err) require.NotNil(t, resp) u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion, "正常刷新 DB tokenVersion 必须 +1") var accessClaims middleware.Claims _, err = authHelper.ParseWithHMAC(resp.AccessToken, svcCtx.Config.Auth.AccessSecret, &accessClaims) require.NoError(t, err, "新 accessToken 必须可解析") assert.Equal(t, u.TokenVersion, accessClaims.TokenVersion, "新 accessToken.TokenVersion 必须等于 DB 新 tokenVersion;不等说明 CAS/签名顺序错位") refreshClaims, err := authHelper.ParseRefreshToken(resp.RefreshToken, svcCtx.Config.Auth.RefreshSecret) require.NoError(t, err, "新 refreshToken 必须可解析") assert.Equal(t, u.TokenVersion, refreshClaims.TokenVersion, "新 refreshToken.TokenVersion 必须等于 DB 新 tokenVersion;客户端下一次刷新必须可用") } // TC-0984: CAS 失败路径 —— 模拟并发抢先递增后再刷新,DB 不得再次被 +1。 func TestRefreshToken_M3_CASMismatch_DoesNotDoubleAdvance(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() svcCtx.TokenOpLimiter = nil username := "rt_m3_cas_" + testutil.UniqueId() userId, cleanup := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2) t.Cleanup(cleanup) // 构造旧 refresh token(claims.TokenVersion=0)。 rt, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 0, ) require.NoError(t, err) // 模拟"并发赢家已经把 DB tokenVersion 推到 1":直接 CAS 一次。 newVer, err := svcCtx.SysUserModel.IncrementTokenVersionIfMatch(ctx, userId, username, 0) require.NoError(t, err) require.Equal(t, int64(1), newVer) // 清掉用户缓存,确保下一步 Load 能读到 DB 的最新 tokenVersion=1。 svcCtx.UserDetailsLoader.Clean(ctx, userId) // 现在第二个刷新进来:ud.TokenVersion=1 ≠ claims.TokenVersion=0, // 会在 logic 第 73 行 "claims.TokenVersion != ud.TokenVersion" 被直接 401 拒, // 根本到不了 Generate/CAS。DB 不得再次 +1。 resp, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + rt, }) assert.Nil(t, resp) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce), "必须是 response.CodeError") assert.Equal(t, 401, ce.Code(), "claims 过期必须是 401") assert.Equal(t, "登录状态已失效,请重新登录", ce.Error()) // 关键断言:失败分支不得二次推进 tokenVersion。 u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion, "失败分支必须不推进 tokenVersion;若变成 2 说明 CAS 被放在"+ "签名/校验前,已经把用户状态破坏了") } // TC-0985: 重放拦截 —— 用第一次刷新拿到的新 refreshToken 再刷一次必须成功; // 再拿"同一个新 refreshToken"做第三次刷新必须被 401 拦截(tokenVersion 已 +2,claims=+1)。 // 这组断言同时证明 修复之后"预签 token 的版本号 == 最终 DB 版本号"的强契约。 func TestRefreshToken_M3_NewRefreshTokenMatchesDBVersion(t *testing.T) { ctx := context.Background() svcCtx := newTestSvcCtx() svcCtx.TokenOpLimiter = nil username := "rt_m3_chain_" + testutil.UniqueId() userId, cleanup := insertRefreshTestUser(t, ctx, username, "TestPass123", 1, 2) t.Cleanup(cleanup) first, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 0, ) require.NoError(t, err) r1, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + first, }) require.NoError(t, err) require.NotNil(t, r1) // 等 loader 缓存被 Clean 后,再用 r1.RefreshToken 续签,理应成功,tokenVersion 从 1 → 2。 r2, err := NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + r1.RefreshToken, }) require.NoError(t, err, "新 refreshToken 必须能顶替旧的继续刷新") require.NotNil(t, r2) u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), u.TokenVersion) // 第三次重放第一步就签下的 r1 → 401,DB 不得再 +1。 _, err = NewRefreshTokenLogic(ctx, svcCtx).RefreshToken(&types.RefreshTokenReq{ Authorization: "Bearer " + r1.RefreshToken, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code(), "重放旧 refreshToken 必须 401;服务端绝不得因签 token 副作用推进 DB") u2, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), u2.TokenVersion, "重放失败分支不得推进 tokenVersion") }