package server import ( "context" "database/sql" "testing" "time" authHelper "perms-system-server/internal/logic/auth" pubLogic "perms-system-server/internal/logic/pub" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/types" "perms-system-server/pb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // insertPermServerTestUser:server 包本地的 user 插入 helper。包内原本没有公共 helper, // 为了不把实现细节泄漏到 testutil(避免被其他包共用造成耦合),这里保留包内。 func insertPermServerTestUser(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, username, password string, status, isSuperAdmin int64) (int64, func()) { t.Helper() conn := testutil.GetTestSqlConn() now := time.Now().Unix() res, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword(password), Nickname: username, Avatar: sql.NullString{}, Email: username + "@ut.local", Phone: "13800000000", IsSuperAdmin: isSuperAdmin, MustChangePassword: 2, Status: status, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, err := res.LastInsertId() require.NoError(t, err) return id, func() { testutil.CleanTable(ctx, conn, "`sys_user`", id) } } // --------------------------------------------------------------------------- // 覆盖目标:审计 L-R11-5 —— HTTP RefreshToken 与 gRPC RefreshToken 共用 // authHelper.RotateRefreshToken,**签发出的新 refreshToken 必须可以互换使用**。 // 这是"helper 共享"最锋利的回归面:一旦某一侧背后悄悄改回自己的版本推进/签名流程, // 两边发出的 token 会在 tokenVersion / claims 结构上漂移,下一次交叉刷新会立刻 401。 // --------------------------------------------------------------------------- // TC-1070: L-R11-5 —— HTTP 签出的 refreshToken 必须能被 gRPC RefreshToken 无缝续签。 func TestRefreshToken_HTTPIssuedTokenAcceptedByGrpc(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) svcCtx.TokenOpLimiter = nil svcCtx.GrpcRefreshLimiter = nil username := "r11_5_interop_h2g_" + testutil.UniqueId() userId, cleanup := insertPermServerTestUser(t, ctx, svcCtx, username, "SomePass123", 1, 2) t.Cleanup(cleanup) rtV0, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 0, ) require.NoError(t, err) httpResp, err := pubLogic.NewRefreshTokenLogic(ctx, svcCtx). RefreshToken(&types.RefreshTokenReq{Authorization: "Bearer " + rtV0}) require.NoError(t, err, "HTTP 首刷应成功,DB tokenVersion 0 → 1") require.NotNil(t, httpResp) require.NotEmpty(t, httpResp.RefreshToken) u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion) // HTTP 新发的 refreshToken (claims.TokenVersion=1) 直接喂给 gRPC。 svcCtx.UserDetailsLoader.Clean(ctx, userId) grpcResp, err := NewPermServer(svcCtx).RefreshToken( ctx, &pb.RefreshTokenReq{RefreshToken: httpResp.RefreshToken}) require.NoError(t, err, "L-R11-5 契约:HTTP 发的 refreshToken 必须被 gRPC 无缝接收;"+ "若 gRPC 走自己的版本比对/签名链,这里会 Unauthenticated") assert.NotEmpty(t, grpcResp.RefreshToken) assert.NotEmpty(t, grpcResp.AccessToken) u2, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), u2.TokenVersion, "L-R11-5:gRPC 续签后 DB tokenVersion 必须 +1;两条路径共用同一 CAS 语义") } // TC-1071: L-R11-5 —— gRPC 签出的 refreshToken 必须能被 HTTP RefreshToken 无缝续签。 // 镜像 TC-1070 的反方向,两侧都 pin 死才能防"helper 只有一侧真在调"的回退。 func TestRefreshToken_GrpcIssuedTokenAcceptedByHttp(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) svcCtx.TokenOpLimiter = nil svcCtx.GrpcRefreshLimiter = nil username := "r11_5_interop_g2h_" + testutil.UniqueId() userId, cleanup := insertPermServerTestUser(t, ctx, svcCtx, username, "SomePass123", 1, 2) t.Cleanup(cleanup) rtV0, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 0, ) require.NoError(t, err) grpcResp, err := NewPermServer(svcCtx).RefreshToken( ctx, &pb.RefreshTokenReq{RefreshToken: rtV0}) require.NoError(t, err, "gRPC 首刷应成功,DB tokenVersion 0 → 1") require.NotEmpty(t, grpcResp.RefreshToken) u, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(1), u.TokenVersion) svcCtx.UserDetailsLoader.Clean(ctx, userId) httpResp, err := pubLogic.NewRefreshTokenLogic(ctx, svcCtx). RefreshToken(&types.RefreshTokenReq{Authorization: "Bearer " + grpcResp.RefreshToken}) require.NoError(t, err, "L-R11-5:gRPC 发的 refreshToken 必须被 HTTP 无缝接收") require.NotNil(t, httpResp) u2, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(2), u2.TokenVersion, "L-R11-5:HTTP 续签后 DB tokenVersion 必须 +1") } // TC-1072: L-R11-5 —— gRPC RefreshToken 对 ErrTokenVersionMismatch 的映射契约未回归 // 这里不再测"两次并发 CAS 只有一个赢"(已由 TestRefreshToken_ConcurrentSameToken_SingleWinner // 与 TestGrpcRefreshToken_ReplayOldToken 覆盖),而是显式钉死:一旦 helper 返回 // ErrTokenVersionMismatch,gRPC 侧必须走 codes.Unauthenticated 而不是 Internal。 func TestGrpcRefreshToken_ReplayedTokenMapsUnauthenticated(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) svcCtx.TokenOpLimiter = nil svcCtx.GrpcRefreshLimiter = nil username := "r11_5_replay_" + testutil.UniqueId() userId, cleanup := insertPermServerTestUser(t, ctx, svcCtx, username, "SomePass123", 1, 2) t.Cleanup(cleanup) rtV0, err := authHelper.GenerateRefreshToken( svcCtx.Config.Auth.RefreshSecret, svcCtx.Config.Auth.RefreshExpire, userId, "", 0, ) require.NoError(t, err) // 首次:成功,tokenVersion 0 → 1 _, err = NewPermServer(svcCtx).RefreshToken(ctx, &pb.RefreshTokenReq{RefreshToken: rtV0}) require.NoError(t, err) // 第二次重放同一个旧 rtV0:claims.TokenVersion=0 但 DB=1。 // Logic 上游 `claims.TokenVersion != ud.TokenVersion` 会先拦住并走 Unauthenticated, // 但本 TC 要确认的是:**即使未来有人把上游校验逻辑拿掉**,helper 的 CAS 依然兜底,且 gRPC // 侧仍映射到 codes.Unauthenticated(而非 Internal)。 svcCtx.UserDetailsLoader.Clean(ctx, userId) _, err = NewPermServer(svcCtx).RefreshToken(ctx, &pb.RefreshTokenReq{RefreshToken: rtV0}) require.Error(t, err) st, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, st.Code(), "L-R11-5:gRPC 侧 ErrTokenVersionMismatch 必须 codes.Unauthenticated;"+ "若漂移到 Internal,接入方会当成系统故障告警而非会话失效") assert.Contains(t, st.Message(), "失效") }