package auth import ( "context" "database/sql" "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" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 L-R11-5 —— 把 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: L-R11-5 —— 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, "L-R11-5:预期 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, "L-R11-5:成功路径 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, "L-R11-5:新 refreshToken 承诺的 tokenVersion 必须等于 predictedVersion,"+ "即 claims.TokenVersion + 1;若错位,接入方下一次刷新会立刻 401 失效") } // TC-1068: L-R11-5 —— 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, "L-R11-5: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, "L-R11-5:CAS 失败时 DB.tokenVersion 不得被任何副作用推进,否则 helper 就成了"+ "'只要过了 Parse 就一定 +1'的攻击 oracle") } // TC-1069: L-R11-5 —— 目标 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, "L-R11-5:用户行已消失 → IncrementTokenVersionIfMatch RowsAffected=0,"+ "helper 必须折叠成 ErrTokenVersionMismatch;不得回底层 sqlx 错误让上游误映射为 500") }