package pub import ( "context" "errors" "testing" authHelper "perms-system-server/internal/logic/auth" "perms-system-server/internal/middleware" "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" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 M-3(第 8 轮)—— RefreshToken 必须"先试签 → 再 CAS → 最后清缓存", // 使得签名失败/CAS 失败两条分支都不会推进 tokenVersion,不会出现"tokenVersion 已 +1 // 但客户端没拿到新 refreshToken"的放大式强制登出。 // // 由于 HMAC 实际不会失败(除非 OOM),本组测试从可观测契约入手: // TC-0983: 成功路径下新 token 的 claims.TokenVersion 必须严格等于 DB 的新 tokenVersion; // 如果顺序被颠倒(CAS 后再签),仍然成立,所以必须配合 TC-0984 防止退化。 // TC-0984: 模拟 CAS ErrTokenVersionMismatch(人工抢先把 DB TokenVersion 再递增一次), // 触发 401 "登录状态已失效";DB 再次递增的幅度必须 = 0,证明失败分支没有再 +1。 // TC-0985: 新签发的 refresh token 能被 ParseRefreshToken 解出且 claims.TokenVersion // = DB 新 tokenVersion —— 保证客户端拿到的 refreshToken 在下一轮必然可用; // 这就是 M-3 要守护的"客户端不会被自己的服务端新签 token 背刺"契约。 // --------------------------------------------------------------------------- // TC-0983: 成功路径 —— 新 access/refresh 的 tokenVersion 必须等于 DB 新 tokenVersion。 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, "M-3:正常刷新 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, "M-3:新 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, "M-3:新 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, "M-3:失败分支必须不推进 tokenVersion;若变成 2 说明 CAS 被放在"+ "签名/校验前,已经把用户状态破坏了") } // TC-0985: 重放拦截 —— 用第一次刷新拿到的新 refreshToken 再刷一次必须成功; // 再拿"同一个新 refreshToken"做第三次刷新必须被 401 拦截(tokenVersion 已 +2,claims=+1)。 // 这组断言同时证明 M-3 修复之后"预签 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(), "M-3:重放旧 refreshToken 必须 401;服务端绝不得因签 token 副作用推进 DB") u2, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), u2.TokenVersion, "M-3:重放失败分支不得推进 tokenVersion") }