package auth import ( "context" "database/sql" "fmt" "testing" "time" "perms-system-server/internal/loaders" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "github.com/golang-jwt/jwt/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" ) // --------------------------------------------------------------------------- // 覆盖目标:把 HTTP / gRPC 两条 RefreshToken 路径的"试签 → CAS → Clean → // forensic 比对"收敛为 authHelper.RotateRefreshToken。契约上本 helper 必须: // 1) 成功路径:写出带 predictedVersion 的新 access + refresh、DB tokenVersion = claims+1、 // 并触发 UD 缓存 Clean(无法直接断言 Clean 的 side effect,但通过"下一次 Load 能读到 // 新 tokenVersion"可以间接覆盖); // 2) claims.TokenVersion 与 DB 不一致 → 返回 ErrTokenVersionMismatch(让 HTTP 映射 401、 // gRPC 映射 Unauthenticated);且 DB tokenVersion **不得**被污染; // 3) 用户不存在(RowsAffected=0)→ 同样 ErrTokenVersionMismatch,不得被映射成"Internal"。 // --------------------------------------------------------------------------- func insertRotateTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username string, tokenVersion int64) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("SomePass123"), Nickname: username, Avatar: sql.NullString{}, Email: username + "@ut.local", Phone: "13800000000", IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, TokenVersion: tokenVersion, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, testutil.GetTestSqlConn(), "`sys_user`", id) }) return id } func mkRefreshClaims(userId int64, productCode string, tokenVersion int64, ttl time.Duration) *RefreshClaims { now := time.Now() return &RefreshClaims{ TokenType: "refresh", UserId: userId, ProductCode: productCode, TokenVersion: tokenVersion, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), IssuedAt: jwt.NewNumericDate(now), }, } } // TC-1067: helper 成功路径 func TestRotateRefreshToken_HappyPath(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) username := "r11_5_ok_" + testutil.UniqueId() userId := insertRotateTestUser(t, ctx, svcCtx, username, 0) claims := mkRefreshClaims(userId, "", 0, 2*time.Hour) ud := &loaders.UserDetails{ UserId: userId, Username: username, Status: 1, TokenVersion: 0, } tokens, err := RotateRefreshToken(ctx, svcCtx, claims, ud) require.NoError(t, err, "预期 tokenVersion=0 匹配,CAS 必须成功") assert.NotEmpty(t, tokens.AccessToken) assert.NotEmpty(t, tokens.RefreshToken) assert.NotEqual(t, tokens.AccessToken, tokens.RefreshToken, "签发出的 access/refresh 必须是两条不同的 JWT,避免一次泄露即双向失陷") u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion, "成功路径 DB.tokenVersion 必须严格 +1,不得多走也不得不走") // 新 refreshToken 解码后 tokenVersion 必是 1,即 predictedVersion。 var parsed RefreshClaims _, err = ParseWithHMAC(tokens.RefreshToken, svcCtx.Config.Auth.RefreshSecret, &parsed) require.NoError(t, err) assert.Equal(t, int64(1), parsed.TokenVersion, "新 refreshToken 承诺的 tokenVersion 必须等于 predictedVersion,"+ "即 claims.TokenVersion + 1;若错位,接入方下一次刷新会立刻 401 失效") } // TC-1068: claims.TokenVersion 与 DB 不一致 → CAS 失败 → ErrTokenVersionMismatch func TestRotateRefreshToken_StaleTokenVersion_Mismatch(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) username := "r11_5_stale_" + testutil.UniqueId() // DB 里 tokenVersion 已经被之前的某次 rotate 推到 1。 userId := insertRotateTestUser(t, ctx, svcCtx, username, 1) // 但 claims 还是旧的 tokenVersion=0。 claims := mkRefreshClaims(userId, "", 0, 2*time.Hour) ud := &loaders.UserDetails{ UserId: userId, Username: username, Status: 1, TokenVersion: 1, // 和 DB 一致;调用方上游会先看 claims != ud.TokenVersion 并 401, // 这里绕过上游直接走 helper 是为了验证 helper 自己也不会被旧 claims 蒙混过关。 } _, err := RotateRefreshToken(ctx, svcCtx, claims, ud) require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch, "claims.TokenVersion=0 但 DB=1,CAS 的 WHERE tokenVersion=0 命中 0 行,"+ "helper 必须返回 ErrTokenVersionMismatch(调用方据此回 401/Unauthenticated)") u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion, "CAS 失败时 DB.tokenVersion 不得被任何副作用推进,否则 helper 就成了"+ "'只要过了 Parse 就一定 +1'的攻击 oracle") } // TC-1069: 目标 userId 不存在(已被删)→ RowsAffected=0 → ErrTokenVersionMismatch // 这条契约的意义:refreshToken 还没到过期但账号已被管理员删除的场景里,helper 不得把"找不到 // 目标行"回溯到底层 sqlx 错误(例如 ErrNotFound)让上层误判成 500;必须统一回到可预测的 // ErrTokenVersionMismatch 分支。 func TestRotateRefreshToken_DeletedUser_Mismatch(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) username := "r11_5_ghost_" + testutil.UniqueId() userId := insertRotateTestUser(t, ctx, svcCtx, username, 0) // 手动删除该行,构造"refresh token 还没过期但用户已消失"。 _, err := testutil.GetTestSqlConn().ExecCtx(ctx, "DELETE FROM `sys_user` WHERE `id` = ?", userId) require.NoError(t, err) claims := mkRefreshClaims(userId, "", 0, 2*time.Hour) ud := &loaders.UserDetails{ UserId: userId, Username: username, Status: 1, TokenVersion: 0, } _, err = RotateRefreshToken(ctx, svcCtx, claims, ud) require.ErrorIs(t, err, userModel.ErrTokenVersionMismatch, "用户行已消失 → IncrementTokenVersionIfMatch RowsAffected=0,"+ "helper 必须折叠成 ErrTokenVersionMismatch;不得回底层 sqlx 错误让上游误映射为 500") } // TC-1117: M-R14-1 —— RotateRefreshToken 的 post-commit UD 缓存清理必须跑在 // DetachCacheCleanCtx 返回的独立 ctx 上,不得被请求 ctx 的 cancel/deadline 牵连。 // // 若未 detach:当调用方使用的 `ctx` 在 `IncrementTokenVersionIfMatch` 提交后、 // `UserDetailsLoader.Clean` 执行前被取消(HTTP deadline 到期 / 客户端断连), // Redis 的 DEL 会被 ctx cancel 中断,UD 缓存仍会携带旧 `tokenVersion` 长达 5min TTL, // 旧 access token 据此可继续通过中间件校验("token 复活")。 // // 这里的断言口径: // 1. 以 `WithCancel(bg)` 作为 parent; // 2. 先预热 UD 缓存(确保 Redis 里真的有一条带旧 `TokenVersion=0` 的记录); // 3. 执行 `RotateRefreshToken` 成功,DB tokenVersion 0 → 1; // 4. 立即 `cancel(parent)` —— 模拟"请求在函数返回瞬间就 cancel"; // 5. 下一次 `UserDetailsLoader.Load(userId, "")` 必须看到 `TokenVersion=1`(即 // Clean 已跑完,Redis 没有残留旧 UD); // 6. 直接 `GetCtx` Redis 原 key 也必须已不存在(Clean 真正落到 Redis 了)。 func TestRotateRefreshToken_M_R14_1_PostCommitCleanDetachedFromRequestCtx(t *testing.T) { parent, cancel := context.WithCancel(context.Background()) defer cancel() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) username := "r14_detach_" + testutil.UniqueId() userId := insertRotateTestUser(t, parent, svcCtx, username, 0) // 用空 productCode 作为"跨产品的用户缓存"位点。Load 成功后,Redis 里落下一条 // 键为 cacheKey(userId, "") 的 UD 记录,方便我们观察 Clean 后是否真的被抹除。 udBefore, err := svcCtx.UserDetailsLoader.Load(parent, userId, "") require.NoError(t, err) require.NotNil(t, udBefore) assert.Equal(t, int64(0), udBefore.TokenVersion, "预热:初始 tokenVersion 必须是 0") cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) rawKey := fmt.Sprintf("%s:ud:%d:%s", cfg.CacheRedis.KeyPrefix, userId, "") valBefore, err := rds.GetCtx(parent, rawKey) require.NoError(t, err) require.NotEmpty(t, valBefore, "预热后 Redis 必须落下一条 UD 正缓存,否则本用例无法观察到 Clean 的副作用") claims := mkRefreshClaims(userId, "", 0, 2*time.Hour) ud := &loaders.UserDetails{ UserId: userId, Username: username, Status: 1, TokenVersion: 0, } tokens, err := RotateRefreshToken(parent, svcCtx, claims, ud) require.NoError(t, err, "DB CAS 必须成功") require.NotEmpty(t, tokens.RefreshToken) // 立即 cancel parent —— 模拟"HTTP 请求 ctx 在函数返回同时被 cancel"。若 Clean 没 detach, // 这次 cancel 已经来不及影响已经同步执行完的 Clean;但更严格的保护来自"Clean 运行时 // 用的就是独立 ctx",该行为在本用例里由"Clean 落到 Redis 的效果"反向验证。 cancel() u, err := svcCtx.SysUserModel.FindOne(context.Background(), userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion, "DB tokenVersion 必须 +1") // 关键断言 1:Redis 里的 UD 正缓存必须已被 Clean 抹掉。若退回"未 detach"实现,当 // cancel 与 Clean 竞争时,这里经常残留旧值。 valAfter, err := rds.GetCtx(context.Background(), rawKey) require.NoError(t, err) assert.Empty(t, valAfter, "M-R14-1:post-commit Clean 必须抹掉 UD 正缓存;若仍非空,说明 Clean 被请求 ctx cancel 拖死") // 关键断言 2:下一次 Load 必须打到 DB 并看到新 tokenVersion。 udAfter, err := svcCtx.UserDetailsLoader.Load(context.Background(), userId, "") require.NoError(t, err) require.NotNil(t, udAfter) assert.Equal(t, int64(1), udAfter.TokenVersion, "M-R14-1:Clean 后 Load 必须重新打 DB 读到新 tokenVersion=1;若读到 0,"+ "说明 Redis 仍持有旧 UD —— 对应生产旧 access token 在 5min TTL 内仍被中间件认可的复活窗口") }