package server import ( "context" "database/sql" "fmt" "github.com/golang-jwt/jwt/v4" "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" "github.com/zeromicro/go-zero/core/stores/sqlx" "go.uber.org/mock/gomock" "golang.org/x/crypto/bcrypt" "google.golang.org/grpc/codes" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" "net" authHelper "perms-system-server/internal/logic/auth" pubLogic "perms-system-server/internal/logic/pub" deptModel "perms-system-server/internal/model/dept" permModel "perms-system-server/internal/model/perm" productModel "perms-system-server/internal/model/product" memberModel "perms-system-server/internal/model/productmember" roleModel "perms-system-server/internal/model/role" rolePermModel "perms-system-server/internal/model/roleperm" userModel "perms-system-server/internal/model/user" userPermModel "perms-system-server/internal/model/userperm" userRoleModel "perms-system-server/internal/model/userrole" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/mocks" "perms-system-server/internal/types" "perms-system-server/pb" "testing" "time" ) func bcryptHash(t *testing.T, plaintext string) string { t.Helper() h, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.MinCost) require.NoError(t, err) return string(h) } // ---------- SyncPermissions ---------- // TC-0230: 正常同步 func TestSyncPermissions_Normal(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: bcryptHash(t, "secret1"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_perm`", "productCode", uid) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) srv := NewPermServer(svcCtx) resp, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: uid, AppSecret: "secret1", Perms: []*pb.PermItem{ {Code: "perm_a", Name: "Perm A", Remark: "remark_a"}, {Code: "perm_b", Name: "Perm B", Remark: "remark_b"}, }, }) require.NoError(t, err) assert.Equal(t, int64(2), resp.Added) assert.Equal(t, int64(0), resp.Updated) assert.Equal(t, int64(0), resp.Disabled) resp2, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: uid, AppSecret: "secret1", Perms: []*pb.PermItem{ {Code: "perm_a", Name: "Perm A Updated", Remark: "remark_a"}, }, }) require.NoError(t, err) assert.Equal(t, int64(0), resp2.Added) assert.Equal(t, int64(1), resp2.Updated) assert.Equal(t, int64(1), resp2.Disabled) } // TC-0231: appKey无效 func TestSyncPermissions_InvalidAppKey(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) srv := NewPermServer(svcCtx) _, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: "nonexistent_key", AppSecret: "any", Perms: []*pb.PermItem{{Code: "c", Name: "n"}}, }) require.Error(t, err) assert.Equal(t, codes.Unauthenticated, status.Code(err)) assert.Equal(t, "无效的appKey", status.Convert(err).Message()) } // TC-0232: appSecret错误 func TestSyncPermissions_WrongAppSecret(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: bcryptHash(t, "real_secret"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) srv := NewPermServer(svcCtx) _, err = srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: uid, AppSecret: "wrong_secret", Perms: []*pb.PermItem{{Code: "c", Name: "n"}}, }) require.Error(t, err) assert.Equal(t, codes.Unauthenticated, status.Code(err)) assert.Equal(t, "appSecret验证失败", status.Convert(err).Message()) } // TC-0233: 产品已禁用 func TestSyncPermissions_ProductDisabled(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: bcryptHash(t, "secret1"), Status: 2, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) srv := NewPermServer(svcCtx) _, err = srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: uid, AppSecret: "secret1", Perms: []*pb.PermItem{{Code: "c", Name: "n"}}, }) require.Error(t, err) assert.Equal(t, codes.PermissionDenied, status.Code(err)) assert.Equal(t, "产品已被禁用", status.Convert(err).Message()) } // ---------- Login ---------- // TC-0235: 正常登录(普通用户+productCode) func TestLogin_Normal(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pmId, _ := pmRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) srv := NewPermServer(svcCtx) resp, err := srv.Login(ctx, &pb.LoginReq{ Username: uid, Password: "pass123", ProductCode: uid, }) require.NoError(t, err) assert.NotEmpty(t, resp.AccessToken) assert.NotEmpty(t, resp.RefreshToken) assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳") assert.Equal(t, uId, resp.UserId) assert.Equal(t, uid, resp.Username) // BUG-01: proto定义了nickname字段,实现应返回用户昵称 assert.Equal(t, "nick", resp.Nickname, "BUG-01: LoginResp.Nickname 应返回用户昵称而非空字符串") } // TC-0236: 用户不存在 func TestLogin_UserNotFound(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) srv := NewPermServer(svcCtx) _, err := srv.Login(ctx, &pb.LoginReq{ Username: "nonexistent_user_xyz", Password: "any", ProductCode: "any_product", }) require.Error(t, err) assert.Equal(t, codes.Unauthenticated, status.Code(err)) assert.Equal(t, "用户名或密码错误", status.Convert(err).Message()) } // TC-0237: 密码错误 func TestLogin_WrongPassword(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("correct_pass"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) srv := NewPermServer(svcCtx) _, err = srv.Login(ctx, &pb.LoginReq{ Username: uid, Password: "wrong_pass", ProductCode: "any_product", }) require.Error(t, err) assert.Equal(t, codes.Unauthenticated, status.Code(err)) assert.Equal(t, "用户名或密码错误", status.Convert(err).Message()) } // TC-0238: 账号冻结 func TestLogin_AccountFrozen(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 2, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) srv := NewPermServer(svcCtx) _, err = srv.Login(ctx, &pb.LoginReq{ Username: uid, Password: "pass123", ProductCode: "any_product", }) require.Error(t, err) assert.Equal(t, codes.PermissionDenied, status.Code(err)) assert.Equal(t, "账号已被冻结", status.Convert(err).Message()) } // TC-0239: 超管被拒绝 func TestLogin_SuperAdminRejected(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "sa", Avatar: sql.NullString{}, IsSuperAdmin: 1, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) srv := NewPermServer(svcCtx) _, err = srv.Login(ctx, &pb.LoginReq{ Username: uid, Password: "pass123", ProductCode: "any_product", }) require.Error(t, err) assert.Equal(t, codes.PermissionDenied, status.Code(err)) assert.Equal(t, "超级管理员不允许通过产品端登录,请使用管理后台", status.Convert(err).Message()) } // TC-0240: 普通用户+productCode func TestLogin_NormalUserWithProductCode(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() mbrRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mbrId, _ := mbrRes.LastInsertId() roleRes, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{ ProductCode: uid, Name: uid + "_role", Status: 1, PermsLevel: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) roleId, _ := roleRes.LastInsertId() pm1Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "p1", Code: uid + "_c1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pm1Id, _ := pm1Res.LastInsertId() urRes, err := svcCtx.SysUserRoleModel.Insert(ctx, &userRoleModel.SysUserRole{ UserId: uId, RoleId: roleId, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) urId, _ := urRes.LastInsertId() rpRes, err := svcCtx.SysRolePermModel.Insert(ctx, &rolePermModel.SysRolePerm{ RoleId: roleId, PermId: pm1Id, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) rpId, _ := rpRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role_perm`", rpId) testutil.CleanTable(ctx, conn, "`sys_user_role`", urId) testutil.CleanTable(ctx, conn, "`sys_perm`", pm1Id) testutil.CleanTable(ctx, conn, "`sys_role`", roleId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mbrId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) srv := NewPermServer(svcCtx) resp, err := srv.Login(ctx, &pb.LoginReq{ Username: uid, Password: "pass123", ProductCode: uid, }) require.NoError(t, err) assert.Equal(t, "MEMBER", resp.MemberType) assert.Contains(t, resp.Perms, uid+"_c1") assert.NotEmpty(t, resp.AccessToken) assert.NotEmpty(t, resp.RefreshToken) } // TC-0242: productCode为空 func TestLogin_EmptyProductCode(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) srv := NewPermServer(svcCtx) _, err := srv.Login(ctx, &pb.LoginReq{ Username: "anyuser", Password: "anypass", ProductCode: "", }) require.Error(t, err) assert.Equal(t, codes.InvalidArgument, status.Code(err)) assert.Equal(t, "productCode不能为空", status.Convert(err).Message()) } // ---------- RefreshToken ---------- // TC-0243: 正常刷新(refreshToken原样返回,不重新生成) func TestRefreshToken_Normal(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) cfg := testutil.GetTestConfig() refreshToken, err := authHelper.GenerateRefreshToken(cfg.Auth.RefreshSecret, cfg.Auth.RefreshExpire, uId, "", 0) require.NoError(t, err) srv := NewPermServer(svcCtx) resp, err := srv.RefreshToken(ctx, &pb.RefreshTokenReq{ RefreshToken: refreshToken, }) require.NoError(t, err) assert.NotEmpty(t, resp.AccessToken) assert.NotEqual(t, refreshToken, resp.RefreshToken, "refreshToken必须发生轮转") newClaims, perr := authHelper.ParseRefreshToken(resp.RefreshToken, cfg.Auth.RefreshSecret) require.NoError(t, perr) assert.Equal(t, int64(1), newClaims.TokenVersion, "新 refreshToken 必须携带递增后的 tokenVersion") assert.True(t, resp.Expires > time.Now().Unix(), "expires应为未来的unix时间戳") } // TC-0244: token无效 func TestRefreshToken_InvalidToken(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) srv := NewPermServer(svcCtx) _, err := srv.RefreshToken(ctx, &pb.RefreshTokenReq{ RefreshToken: "invalid.token.string", }) require.Error(t, err) assert.Equal(t, codes.Unauthenticated, status.Code(err)) assert.Equal(t, "refreshToken无效或已过期", status.Convert(err).Message()) } // TC-0245: 账号冻结 func TestRefreshToken_AccountFrozen(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 2, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) cfg := testutil.GetTestConfig() refreshToken, err := authHelper.GenerateRefreshToken(cfg.Auth.RefreshSecret, cfg.Auth.RefreshExpire, uId, "", 0) require.NoError(t, err) srv := NewPermServer(svcCtx) _, err = srv.RefreshToken(ctx, &pb.RefreshTokenReq{ RefreshToken: refreshToken, }) require.Error(t, err) assert.Equal(t, codes.PermissionDenied, status.Code(err)) assert.Equal(t, "账号已被冻结", status.Convert(err).Message()) } // TC-0246: productCode回退到claims func TestRefreshToken_FallbackToClaimsProductCode(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() mbrRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mbrId, _ := mbrRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mbrId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) cfg := testutil.GetTestConfig() refreshToken, err := authHelper.GenerateRefreshToken(cfg.Auth.RefreshSecret, cfg.Auth.RefreshExpire, uId, uid, 0) require.NoError(t, err) srv := NewPermServer(svcCtx) resp, err := srv.RefreshToken(ctx, &pb.RefreshTokenReq{ RefreshToken: refreshToken, ProductCode: "", }) require.NoError(t, err) assert.NotEmpty(t, resp.AccessToken) assert.NotEqual(t, refreshToken, resp.RefreshToken, "refreshToken必须发生轮转") newClaims, perr := authHelper.ParseRefreshToken(resp.RefreshToken, cfg.Auth.RefreshSecret) require.NoError(t, perr) assert.Equal(t, int64(1), newClaims.TokenVersion, "新 refreshToken 必须携带递增后的 tokenVersion") assert.Equal(t, uid, newClaims.ProductCode, "fallback 分支:应使用 claims.ProductCode") } // TC-0247: 超管+productCode func TestRefreshToken_SuperAdminWithProductCode(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "sa", Avatar: sql.NullString{}, IsSuperAdmin: 1, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() pm1Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "p1", Code: uid + "_c1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pm1Id, _ := pm1Res.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", pm1Id) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) cfg := testutil.GetTestConfig() refreshToken, err := authHelper.GenerateRefreshToken(cfg.Auth.RefreshSecret, cfg.Auth.RefreshExpire, uId, uid, 0) require.NoError(t, err) srv := NewPermServer(svcCtx) resp, err := srv.RefreshToken(ctx, &pb.RefreshTokenReq{ RefreshToken: refreshToken, ProductCode: uid, }) require.NoError(t, err) assert.NotEmpty(t, resp.AccessToken) assert.NotEqual(t, refreshToken, resp.RefreshToken, "refreshToken必须发生轮转") newClaims, perr := authHelper.ParseRefreshToken(resp.RefreshToken, cfg.Auth.RefreshSecret) require.NoError(t, perr) assert.Equal(t, int64(1), newClaims.TokenVersion, "新 refreshToken 必须携带递增后的 tokenVersion") } // TC-0248: 普通用户+productCode func TestRefreshToken_NormalUserWithProductCode(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() mbrRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mbrId, _ := mbrRes.LastInsertId() pm1Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "p1", Code: uid + "_c1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pm1Id, _ := pm1Res.LastInsertId() roleRes, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{ ProductCode: uid, Name: uid + "_role", Status: 1, PermsLevel: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) roleId, _ := roleRes.LastInsertId() urRes, err := svcCtx.SysUserRoleModel.Insert(ctx, &userRoleModel.SysUserRole{ UserId: uId, RoleId: roleId, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) urId, _ := urRes.LastInsertId() rpRes, err := svcCtx.SysRolePermModel.Insert(ctx, &rolePermModel.SysRolePerm{ RoleId: roleId, PermId: pm1Id, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) rpId, _ := rpRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_role_perm`", rpId) testutil.CleanTable(ctx, conn, "`sys_user_role`", urId) testutil.CleanTable(ctx, conn, "`sys_perm`", pm1Id) testutil.CleanTable(ctx, conn, "`sys_role`", roleId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mbrId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) cfg := testutil.GetTestConfig() refreshToken, err := authHelper.GenerateRefreshToken(cfg.Auth.RefreshSecret, cfg.Auth.RefreshExpire, uId, uid, 0) require.NoError(t, err) srv := NewPermServer(svcCtx) resp, err := srv.RefreshToken(ctx, &pb.RefreshTokenReq{ RefreshToken: refreshToken, ProductCode: uid, }) require.NoError(t, err) assert.NotEmpty(t, resp.AccessToken) assert.NotEqual(t, refreshToken, resp.RefreshToken, "refreshToken必须发生轮转") newClaims, perr := authHelper.ParseRefreshToken(resp.RefreshToken, cfg.Auth.RefreshSecret) require.NoError(t, perr) assert.Equal(t, int64(1), newClaims.TokenVersion, "新 refreshToken 必须携带递增后的 tokenVersion") } // ---------- VerifyToken ---------- // TC-0249: 有效token(VerifyToken 现在实时查询DB,需要真实数据) func TestVerifyToken_Valid(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() conn := testutil.GetTestSqlConn() ts := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick_verify", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "prod_verify", AppKey: uid + "_k", AppSecret: "s1", Status: 1, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "ADMIN", Status: 1, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) pmId, _ := pmRes.LastInsertId() pm1Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "perm_a", Code: "perm_a", Status: 1, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) pm1Id, _ := pm1Res.LastInsertId() pm2Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "perm_b", Code: "perm_b", Status: 1, CreateTime: ts, UpdateTime: ts, }) require.NoError(t, err) pm2Id, _ := pm2Res.LastInsertId() t.Cleanup(func() { svcCtx.UserDetailsLoader.Del(ctx, uId, uid) testutil.CleanTable(ctx, conn, "`sys_perm`", pm1Id, pm2Id) testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) svcCtx.UserDetailsLoader.Del(ctx, uId, uid) accessToken, err := authHelper.GenerateAccessToken( cfg.Auth.AccessSecret, cfg.Auth.AccessExpire, uId, uid, uid, "ADMIN", 0, ) require.NoError(t, err) srv := NewPermServer(svcCtx) resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: accessToken}) require.NoError(t, err) assert.True(t, resp.Valid) assert.Equal(t, uId, resp.UserId) assert.Equal(t, uid, resp.Username) assert.Equal(t, "ADMIN", resp.MemberType) assert.ElementsMatch(t, []string{"perm_a", "perm_b"}, resp.Perms) // BUG-02: proto定义了productCode字段,实现应返回产品编码 assert.Equal(t, uid, resp.ProductCode, "BUG-02: VerifyTokenResp.ProductCode 应返回产品编码而非空字符串") } // TC-0250: 无效token func TestVerifyToken_Invalid(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) srv := NewPermServer(svcCtx) resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: "invalid.token.here"}) require.NoError(t, err) assert.False(t, resp.Valid) } // TC-0251: 缺少userId func TestVerifyToken_MissingUserId(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() // Generate a token without userId by using raw JWT token := createTokenWithoutUserId(cfg.Auth.AccessSecret) srv := NewPermServer(svcCtx) resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: token}) require.NoError(t, err) assert.False(t, resp.Valid) } // ---------- GetUserPerms ---------- // TC-0255: 用户不存在 func TestGetUserPerms_UserNotFound(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: bcryptHash(t, "secret1"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) srv := NewPermServer(svcCtx) _, err = srv.GetUserPerms(ctx, &pb.GetUserPermsReq{ UserId: 999999999, ProductCode: uid, AppKey: uid, AppSecret: "secret1", }) require.Error(t, err) assert.Equal(t, codes.NotFound, status.Code(err)) // userId 不存在与非成员合并为同一响应,消除跨产品枚举 oracle assert.Equal(t, "用户不是该产品的有效成员", status.Convert(err).Message()) } // TC-0256: 超管 func TestGetUserPerms_SuperAdmin(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass"), Nickname: "sa", Avatar: sql.NullString{}, IsSuperAdmin: 1, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: bcryptHash(t, "secret1"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() pm1Res, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "p1", Code: uid + "_c1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pm1Id, _ := pm1Res.LastInsertId() mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_perm`", pm1Id) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) srv := NewPermServer(svcCtx) resp, err := srv.GetUserPerms(ctx, &pb.GetUserPermsReq{ UserId: uId, ProductCode: uid, AppKey: uid, AppSecret: "secret1", }) require.NoError(t, err) assert.Equal(t, "SUPER_ADMIN", resp.MemberType) assert.Contains(t, resp.Perms, uid+"_c1") } // TC-0234: 验证disabled计数 func TestSyncPermissions_VerifyDisabledCount(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: bcryptHash(t, "secret1"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() var permIds []int64 for i := 0; i < 5; i++ { pmRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "p", Code: fmt.Sprintf("%s_c%d", uid, i), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pmId, _ := pmRes.LastInsertId() permIds = append(permIds, pmId) } t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permIds...) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) srv := NewPermServer(svcCtx) resp, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: uid, AppSecret: "secret1", Perms: []*pb.PermItem{ {Code: fmt.Sprintf("%s_c0", uid), Name: "p"}, {Code: fmt.Sprintf("%s_c1", uid), Name: "p"}, }, }) require.NoError(t, err) assert.Equal(t, int64(3), resp.Disabled) } // TC-0257: MEMBER-DENY覆盖 func TestGetUserPerms_MemberDENYOverride(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid + "_k", AppSecret: bcryptHash(t, "secret1"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() mbrRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mbrId, _ := mbrRes.LastInsertId() roleRes, err := svcCtx.SysRoleModel.Insert(ctx, &roleModel.SysRole{ ProductCode: uid, Name: uid + "_role", Status: 1, PermsLevel: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) roleId, _ := roleRes.LastInsertId() permARes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "permA", Code: uid + "_pA", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permAId, _ := permARes.LastInsertId() permBRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "permB", Code: uid + "_pB", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permBId, _ := permBRes.LastInsertId() urRes, err := svcCtx.SysUserRoleModel.Insert(ctx, &userRoleModel.SysUserRole{ UserId: uId, RoleId: roleId, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) urId, _ := urRes.LastInsertId() rpARes, err := svcCtx.SysRolePermModel.Insert(ctx, &rolePermModel.SysRolePerm{ RoleId: roleId, PermId: permAId, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) rpAId, _ := rpARes.LastInsertId() rpBRes, err := svcCtx.SysRolePermModel.Insert(ctx, &rolePermModel.SysRolePerm{ RoleId: roleId, PermId: permBId, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) rpBId, _ := rpBRes.LastInsertId() upRes, err := svcCtx.SysUserPermModel.Insert(ctx, &userPermModel.SysUserPerm{ UserId: uId, PermId: permAId, Effect: "DENY", CreateTime: now, UpdateTime: now, }) require.NoError(t, err) upId, _ := upRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user_perm`", upId) testutil.CleanTable(ctx, conn, "`sys_role_perm`", rpAId, rpBId) testutil.CleanTable(ctx, conn, "`sys_user_role`", urId) testutil.CleanTable(ctx, conn, "`sys_perm`", permAId, permBId) testutil.CleanTable(ctx, conn, "`sys_role`", roleId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mbrId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) srv := NewPermServer(svcCtx) resp, err := srv.GetUserPerms(ctx, &pb.GetUserPermsReq{ UserId: uId, ProductCode: uid, AppKey: uid + "_k", AppSecret: "secret1", }) require.NoError(t, err) assert.Equal(t, "MEMBER", resp.MemberType) assert.Contains(t, resp.Perms, uid+"_pB") assert.NotContains(t, resp.Perms, uid+"_pA") } // TC-0252: gRPC VerifyToken 用户已冻结返回valid=false(修复验证) func TestVerifyToken_FrozenUserReturnsInvalid(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() cfg := testutil.GetTestConfig() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass"), Nickname: "frozen", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 2, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) accessToken, err := authHelper.GenerateAccessToken( cfg.Auth.AccessSecret, cfg.Auth.AccessExpire, uId, uid, "", "MEMBER", 0, ) require.NoError(t, err) srv := NewPermServer(svcCtx) resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: accessToken}) require.NoError(t, err) assert.False(t, resp.Valid, "frozen user token should be invalid") } // TC-0253: gRPC VerifyToken 非产品成员返回valid=false(修复验证) func TestVerifyToken_NonMemberReturnsInvalid(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pc := testutil.UniqueId() cfg := testutil.GetTestConfig() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass"), Nickname: "user", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: pc, Name: "prod", AppKey: testutil.UniqueId(), AppSecret: "s", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) accessToken, err := authHelper.GenerateAccessToken( cfg.Auth.AccessSecret, cfg.Auth.AccessExpire, uId, uid, pc, "MEMBER", 0, ) require.NoError(t, err) srv := NewPermServer(svcCtx) resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: accessToken}) require.NoError(t, err) assert.False(t, resp.Valid, "non-member user with productCode should be invalid") } // TC-0254: gRPC VerifyToken 返回实时权限和成员类型(修复验证) func TestVerifyToken_ReturnsRealtimeData(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() cfg := testutil.GetTestConfig() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass"), Nickname: "user", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "prod", AppKey: uid + "_k", AppSecret: "s", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() mbrRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mbrId, _ := mbrRes.LastInsertId() permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "realtime_perm", Code: uid + "_rt", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permId, _ := permRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mbrId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) accessToken, err := authHelper.GenerateAccessToken( cfg.Auth.AccessSecret, cfg.Auth.AccessExpire, uId, uid, uid, "MEMBER", 0, ) require.NoError(t, err) svcCtx.UserDetailsLoader.Clean(ctx, uId) srv := NewPermServer(svcCtx) resp, err := srv.VerifyToken(ctx, &pb.VerifyTokenReq{AccessToken: accessToken}) require.NoError(t, err) assert.True(t, resp.Valid) assert.Equal(t, "ADMIN", resp.MemberType, "should return realtime memberType, not token's") assert.Contains(t, resp.Perms, uid+"_rt", "should return realtime perms") } // TC-0241: gRPC Login 产品成员被禁用时拒绝(修复验证) func TestLogin_DisabledMemberRejected(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "prod", AppKey: uid + "_k", AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() pmRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 2, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pmId, _ := pmRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", pmId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) srv := NewPermServer(svcCtx) _, err = srv.Login(ctx, &pb.LoginReq{ Username: uid, Password: "pass123", ProductCode: uid, }) require.Error(t, err) assert.Equal(t, codes.PermissionDenied, status.Code(err)) // loginService 删除了多余的 FindOneByProductCodeUserId,改由 UD.MemberType=="" // 做统一判定,非成员/禁用成员合并为同一文案 assert.Equal(t, "您不是该产品的有效成员", status.Convert(err).Message()) } // helper: create a JWT with no userId claim func createTokenWithoutUserId(secret string) string { claims := jwt.MapClaims{ "username": "test", "exp": time.Now().Add(time.Hour).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) s, _ := token.SignedString([]byte(secret)) return s } // ============================================================================= // audit 修复回归测试:gRPC GetUserPerms 必须对齐 VerifyToken 的状态校验 // 修复前:GetUserPerms 仅校验"用户存在";冻结用户/被踢出产品的用户仍会被返回全量权限。 // 修复后:增加 StatusEnabled 判定 + (非超管下)MemberType 非空判定。 // ============================================================================= // TC-0700: GetUserPerms 对冻结用户 (Status=Disabled) 必须返回 PermissionDenied func TestGetUserPerms_FrozenUser_PermissionDenied(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() // 用户 Status=2 (Disabled) uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass"), Nickname: "frozen", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 2, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "prod", AppKey: uid + "_k", AppSecret: bcryptHash(t, "s"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() // 插入该产品下启用成员,保证 MemberType != "",排除冻结用户与非成员两个判定路径的干扰 mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) // 清理缓存确保 loader 从 DB 取最新的 Status=2 svcCtx.UserDetailsLoader.Clean(ctx, uId) srv := NewPermServer(svcCtx) _, err = srv.GetUserPerms(ctx, &pb.GetUserPermsReq{ UserId: uId, ProductCode: uid, AppKey: uid + "_k", AppSecret: "s", }) require.Error(t, err, "冻结用户的 GetUserPerms 必须返回错误,不能再返回全量权限") assert.Equal(t, codes.PermissionDenied, status.Code(err), "冻结用户应返回 PermissionDenied 以阻断跨系统一致性漏洞") assert.Contains(t, status.Convert(err).Message(), "冻结") } // TC-0701: GetUserPerms 对已被移出产品的启用用户(非超管 + MemberType 空)必须返回 PermissionDenied func TestGetUserPerms_NonMember_PermissionDenied(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() // 用户启用但不是目标产品的成员 uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass"), Nickname: "non_member", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "prod", AppKey: uid + "_k", AppSecret: bcryptHash(t, "s"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) svcCtx.UserDetailsLoader.Clean(ctx, uId) srv := NewPermServer(svcCtx) _, err = srv.GetUserPerms(ctx, &pb.GetUserPermsReq{ UserId: uId, ProductCode: uid, AppKey: uid + "_k", AppSecret: "s", }) require.Error(t, err) // 与"userId 不存在"合并为 NotFound,关闭跨产品枚举 oracle assert.Equal(t, codes.NotFound, status.Code(err), "用户不是产品成员时应返回 NotFound,与 Username 为空的分支同码") assert.Contains(t, status.Convert(err).Message(), "成员") } // TC-0702: GetUserPerms 对"产品成员被禁用的 DEV 部门用户"必须返回 PermissionDenied // 组合 的交叉场景:禁用成员 → MemberType 清空 → 即便 DeptType=DEV 也不应获得权限 func TestGetUserPerms_DisabledMemberInDevDept_PermissionDenied(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() // 插入 DEV 部门 deptRes, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ Name: "dev_" + uid, ParentId: 0, Path: "/", DeptType: "DEV", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) deptId, _ := deptRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass"), Nickname: "dev_user", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, DeptId: deptId, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "prod", AppKey: uid + "_k", AppSecret: bcryptHash(t, "s"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() // 被管理员禁用的产品成员 (Status=2) mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 2, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() // 放几条启用权限,验证"本来能拿到" permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "all", Code: uid + "_all", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permId, _ := permRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) svcCtx.UserDetailsLoader.Clean(ctx, uId) srv := NewPermServer(svcCtx) _, err = srv.GetUserPerms(ctx, &pb.GetUserPermsReq{ UserId: uId, ProductCode: uid, AppKey: uid + "_k", AppSecret: "s", }) require.Error(t, err, "产品成员被禁用的 DEV 部门用户不应再被 loadPerms 授予全量权限,"+ "GetUserPerms 也不应继续返回授权状态") // 非成员合并到 NotFound(禁用成员在 loadMembership 里会把 MemberType 清空) assert.Equal(t, codes.NotFound, status.Code(err)) } // TC-0703: GetUserPerms 对"启用的产品成员"返回成功( 回归基准) // 验证修复后的正常路径未被误伤 func TestGetUserPerms_EnabledMember_Succeeds(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass"), Nickname: "ok", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "prod", AppKey: uid + "_k", AppSecret: bcryptHash(t, "s"), Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() permRes, err := svcCtx.SysPermModel.Insert(ctx, &permModel.SysPerm{ ProductCode: uid, Name: "p", Code: uid + "_c", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) permId, _ := permRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_perm`", permId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) }) srv := NewPermServer(svcCtx) resp, err := srv.GetUserPerms(ctx, &pb.GetUserPermsReq{ UserId: uId, ProductCode: uid, AppKey: uid + "_k", AppSecret: "s", }) require.NoError(t, err) assert.Equal(t, "ADMIN", resp.MemberType) assert.Contains(t, resp.Perms, uid+"_c") } func FuzzVerifyToken_NeverPanicsAlwaysInvalid(f *testing.F) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) srv := NewPermServer(svcCtx) seeds := []string{ "", " ", ".", "..", "not.a.jwt", "a.b.c", "eyJhbGciOiJub25lIn0.eyJ1c2VySWQiOjF9.", // alg=none 试探 "Bearer xxx", "null", "\x00\x01\x02", "🔥token💥", string(make([]byte, 4096)), // 长令牌 "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjF9.sig", // 伪造 HS256 } for _, s := range seeds { f.Add(s) } f.Fuzz(func(t *testing.T, raw string) { defer func() { if r := recover(); r != nil { t.Fatalf("VerifyToken panicked on input=%q: %v", raw, r) } }() resp, err := srv.VerifyToken(context.Background(), &pb.VerifyTokenReq{AccessToken: raw}) if err != nil { t.Fatalf("VerifyToken must never return an error for malformed input, got err=%v (input=%q)", err, raw) } if resp == nil { t.Fatalf("VerifyToken must return non-nil response (input=%q)", raw) } if resp.Valid { t.Fatalf("malformed/invalid token must never be reported valid; input=%q", raw) } }) } // TC-0795: gRPC GetUserPerms 契约层 fuzz —— 任意 (appKey, appSecret, productCode, userId) 组合下: // (1) 必须返回 status.Error(非 200); 不允许 panic / nil error + 有权限返回 // (2) 错误码必须落在固定集合内: Unauthenticated / PermissionDenied / InvalidArgument / NotFound / Internal // // 否则契约漂移, 产品侧"权限网关"无法稳定处理 // // 此用例不需要预置任何数据, 专打输入校验/认证失败的快速拒绝路径。 func FuzzGetUserPerms_ErrorTaxonomyStable(f *testing.F) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) srv := NewPermServer(svcCtx) seeds := [][4]string{ {"", "", "", ""}, {"nonexistent_appkey_" + testutil.UniqueId(), "x", "p", "1"}, {"appkey", "wrong_secret", "code", "0"}, {"🔑", "🔒", "😈", "-1"}, {"'; DROP TABLE sys_product; --", "s", "p", "1"}, {string(make([]byte, 512)), "s", "p", "1"}, } for _, s := range seeds { f.Add(s[0], s[1], s[2], s[3]) } allowed := map[codes.Code]bool{ codes.Unauthenticated: true, codes.PermissionDenied: true, codes.InvalidArgument: true, codes.NotFound: true, codes.Internal: true, } f.Fuzz(func(t *testing.T, appKey, appSecret, productCode, userIdStr string) { defer func() { if r := recover(); r != nil { t.Fatalf("GetUserPerms panicked on input=(%q,%q,%q,%q): %v", appKey, appSecret, productCode, userIdStr, r) } }() var uid int64 for _, c := range userIdStr { if c >= '0' && c <= '9' { uid = uid*10 + int64(c-'0') if uid > 1e15 { break } } } _, err := srv.GetUserPerms(context.Background(), &pb.GetUserPermsReq{ AppKey: appKey, AppSecret: appSecret, ProductCode: productCode, UserId: uid, }) if err == nil { t.Fatalf("malformed/unauthenticated input must produce an error; appKey=%q", appKey) } st, ok := status.FromError(err) if !ok { t.Fatalf("error must be a grpc status.Error, got %T (%v)", err, err) } if !allowed[st.Code()] { t.Fatalf("error code %s is outside the agreed contract taxonomy; must be one of Unauthenticated/PermissionDenied/InvalidArgument/NotFound/Internal. msg=%q", st.Code(), st.Message()) } }) } // 覆盖目标:gRPC RefreshToken / VerifyToken / // SyncPermissions / GetUserPerms 的 IP × AppKey 双维度限流,以及 extractClientIP 剥端口契约。 func withPeerIP(ctx context.Context, hostPort string) context.Context { addr, err := net.ResolveTCPAddr("tcp", hostPort) if err != nil { panic(err) } return peer.NewContext(ctx, &peer.Peer{Addr: addr}) } // TC-0828: GrpcRefreshLimiter 在配额用尽后对同 IP 新请求返回 ResourceExhausted。 func TestGrpcRefreshToken_RateLimit_OverIP(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) // quota=1 的定制 limiter,让第 2 次必然 429/ResourceExhausted。 svcCtx.GrpcRefreshLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:refresh:ut:"+testutil.UniqueId()) svcCtx.TokenOpLimiter = nil srv := NewPermServer(svcCtx) // 第 1 次:故意用个无效 token,让 limiter 放行、业务层兜底返回 Unauthenticated。 // 这里只关心 limiter 是否"吃掉 1 个配额"。 ctx1 := withPeerIP(ctx, "10.1.2.3:11111") _, err1 := srv.RefreshToken(ctx1, &pb.RefreshTokenReq{RefreshToken: "invalid"}) require.Error(t, err1) st1, _ := status.FromError(err1) assert.Equal(t, codes.Unauthenticated, st1.Code(), "首次放行,业务层应返回 Unauthenticated(token 无效),不应是 ResourceExhausted") // 第 2 次:同 IP 但端口不同(模拟新 TCP 连接),必须被同一限流桶拦住。 ctx2 := withPeerIP(ctx, "10.1.2.3:22222") _, err2 := srv.RefreshToken(ctx2, &pb.RefreshTokenReq{RefreshToken: "anything"}) require.Error(t, err2) st2, _ := status.FromError(err2) assert.Equal(t, codes.ResourceExhausted, st2.Code(), "同 IP 第 2 次刷新必须 429;端口变化不得绕过限流(extractClientIP 剥端口)") assert.Contains(t, st2.Message(), "过于频繁") } // TC-0829: GrpcVerifyLimiter 在配额用尽后对同 IP 新请求返回 ResourceExhausted。 // VerifyToken 契约是"非法 token 返回 Valid=false 而不是 error",因此限流是唯一能让接口返回 gRPC error 的路径。 func TestGrpcVerifyToken_RateLimit_OverIP(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.GrpcVerifyLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:verify:ut:"+testutil.UniqueId()) srv := NewPermServer(svcCtx) ctx1 := withPeerIP(ctx, "10.9.8.7:30001") resp1, err1 := srv.VerifyToken(ctx1, &pb.VerifyTokenReq{AccessToken: "invalid"}) require.NoError(t, err1, "首次放行:VerifyToken 对非法 token 只返回 Valid=false,不 error") require.NotNil(t, resp1) assert.False(t, resp1.Valid) // 同 IP 不同端口 → 必须被限流拦住。 ctx2 := withPeerIP(ctx, "10.9.8.7:30002") _, err2 := srv.VerifyToken(ctx2, &pb.VerifyTokenReq{AccessToken: "whatever"}) require.Error(t, err2) st2, _ := status.FromError(err2) assert.Equal(t, codes.ResourceExhausted, st2.Code(), "gRPC VerifyToken 必须受 IP 级限流保护,防止下游被当 token oracle 爆破") } // TC-0830: extractClientIP 对 "host:port" 必须剥成 host; // 缺失 peer 时返回 error,由上层决定降级到 unknown 桶。 func TestExtractClientIP_StripsPort(t *testing.T) { addr, err := net.ResolveTCPAddr("tcp", "192.168.0.1:54321") require.NoError(t, err) ctx := peer.NewContext(context.Background(), &peer.Peer{Addr: addr}) ip, err := extractClientIP(ctx) require.NoError(t, err) assert.Equal(t, "192.168.0.1", ip, "gRPC peer.Addr 必须剥成纯 host;保留端口会导致限流形同虚设") // 无 peer 的 context _, err2 := extractClientIP(context.Background()) assert.Error(t, err2, "无 peer 时必须返回 error,让上层选择 fail-close 或降级到 unknown 桶") } // TC-0831: gRPC RefreshToken 成功一次后,旧 refreshToken 立刻失效; // 换用同 IP 重放旧 token 必须返回 Unauthenticated("登录状态已失效"), // 而不是因端口变化绕过限流或因 CAS 失败被伪装成 500。 func TestGrpcRefreshToken_CASInvalidatesOldToken(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) // 放开限流以聚焦 CAS 正确性(quota 大)。 svcCtx.GrpcRefreshLimiter = limit.NewPeriodLimit( 60, 100, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:refresh:cas:"+testutil.UniqueId()) svcCtx.TokenOpLimiter = nil now := time.Now().Unix() uid := testutil.UniqueId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "n", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) rt, err := authHelper.GenerateRefreshToken(cfg.Auth.RefreshSecret, cfg.Auth.RefreshExpire, userId, "", 0) require.NoError(t, err) srv := NewPermServer(svcCtx) // 第一次成功刷新。 ctx1 := withPeerIP(ctx, "172.16.0.1:11001") resp, err := srv.RefreshToken(ctx1, &pb.RefreshTokenReq{RefreshToken: rt}) require.NoError(t, err) require.NotEmpty(t, resp.RefreshToken) // 用同一个旧 rt 重放,应当 Unauthenticated; // 注意:旧 token 里 tokenVersion=0,DB 已被 CAS 推到 1,所以 "claims.TokenVersion != ud.TokenVersion" 这一步就会拦住。 // 端口换掉以确保不是限流在帮我们挡。 ctx2 := withPeerIP(ctx, "172.16.0.1:11002") _, err = srv.RefreshToken(ctx2, &pb.RefreshTokenReq{RefreshToken: rt}) require.Error(t, err, "旧 refreshToken 成功刷新一次后必须失效") st, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, st.Code(), "旧 token 重放必须返回 Unauthenticated,不能是 Internal/ResourceExhausted") assert.Contains(t, st.Message(), "登录状态已失效") } // TC-1052: SyncPermissions 按 AppKey 维度限流 func TestGrpcSyncPermissions_AppKeyRateLimit(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.GrpcSyncLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:sync:ut:"+testutil.UniqueId()) srv := NewPermServer(svcCtx) // 同一 appKey 的第 1 次:limiter 放行,业务层因 appKey 非法走 Unauthenticated。 appKey := "unknown_" + testutil.UniqueId() _, err1 := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: appKey, AppSecret: "anything", Perms: []*pb.PermItem{{Code: "p.a", Name: "A"}}, }) require.Error(t, err1) st1, _ := status.FromError(err1) assert.Equal(t, codes.Unauthenticated, st1.Code(), "首次 limiter 放行,业务应因 appKey 不存在 Unauthenticated,非 ResourceExhausted") // 同一 appKey 的第 2 次:必是 ResourceExhausted。 _, err2 := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: appKey, AppSecret: "whatever", Perms: []*pb.PermItem{{Code: "p.b", Name: "B"}}, }) require.Error(t, err2) st2, _ := status.FromError(err2) assert.Equal(t, codes.ResourceExhausted, st2.Code(), "同 appKey 达到配额必须 ResourceExhausted;严禁恶意方反复重放触发 bcrypt / X 锁") assert.Contains(t, st2.Message(), "过于频繁") // 另一 appKey 放行:证明 limiter 按 appKey 隔离,不是全局计数器。 otherKey := "unknown_other_" + testutil.UniqueId() _, err3 := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: otherKey, AppSecret: "whatever", Perms: []*pb.PermItem{{Code: "p.c", Name: "C"}}, }) require.Error(t, err3) st3, _ := status.FromError(err3) assert.Equal(t, codes.Unauthenticated, st3.Code(), "limiter 桶键形如 'grpc:sync:',不同 appKey 互不串扰") } // TC-1053: SyncPermissions 空 AppKey 不消耗 limiter 配额 // 代码里 `if req.AppKey != "" { Take(...) }` 的两层防护: // 1. 恶意方用空串连续打,不会把 limiter key space 膨胀为一个永不过期的"空串大桶"; // 2. 业务层统一由 FindOneByAppKey("") 命中 ErrNotFound 返回 Unauthenticated。 // // 契约:空 AppKey 连打 3 次后,quota=1 的 limiter 仍然是**全新**状态;任意新 appKey 的第一次 // 请求必须走业务层(Unauthenticated),绝不允许被 ResourceExhausted 截断。 func TestGrpcSyncPermissions_EmptyAppKeyDoesNotConsumeQuota(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.GrpcSyncLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:sync:empty:"+testutil.UniqueId()) srv := NewPermServer(svcCtx) for i := 0; i < 3; i++ { _, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: "", AppSecret: "x", Perms: []*pb.PermItem{{Code: "p", Name: "n"}}, }) require.Error(t, err) st, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, st.Code(), "空 AppKey 走 FindOneByAppKey('') → Unauthenticated;此路径不得触达 limiter") } // 真实新 AppKey 的第 1 次请求必须得到业务层的 Unauthenticated, // 而不是因"空串占用配额"退化出的 ResourceExhausted。 realKey := "sync_empty_probe_" + testutil.UniqueId() _, err := srv.SyncPermissions(ctx, &pb.SyncPermissionsReq{ AppKey: realKey, AppSecret: "x", Perms: []*pb.PermItem{{Code: "p", Name: "n"}}, }) require.Error(t, err) st, _ := status.FromError(err) assert.Equal(t, codes.Unauthenticated, st.Code(), "空 AppKey 不消耗 limiter 配额;若这里返回 ResourceExhausted 则说明"+ "空串也被计数,`req.AppKey != \"\"` 前置分支缺失或被回退") } // TC-1054: GetUserPerms 的 appKey 维度限流 func TestGrpcGetUserPerms_AppKeyRateLimit(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) svcCtx.GrpcGetUserPermsLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:perms:ut:"+testutil.UniqueId()) srv := NewPermServer(svcCtx) appKey := "perms_ak_" + testutil.UniqueId() ctx1 := withPeerIP(ctx, "172.31.0.10:40001") _, err1 := srv.GetUserPerms(ctx1, &pb.GetUserPermsReq{ AppKey: appKey, AppSecret: "x", ProductCode: "test_product", UserId: 1, }) require.Error(t, err1) st1, _ := status.FromError(err1) assert.Equal(t, codes.Unauthenticated, st1.Code(), "首次放行,业务层应因 appKey 不存在 Unauthenticated") // 同 appKey 第二次:appKey 桶即告罄。 ctx2 := withPeerIP(ctx, "172.31.0.11:40002") // 换 IP,证明拦的是 appKey 桶而不是 IP 桶 _, err2 := srv.GetUserPerms(ctx2, &pb.GetUserPermsReq{ AppKey: appKey, AppSecret: "x", ProductCode: "test_product", UserId: 2, }) require.Error(t, err2) st2, _ := status.FromError(err2) assert.Equal(t, codes.ResourceExhausted, st2.Code(), "同 appKey 达到 appKey 维度配额,必须 ResourceExhausted") } // TC-1055: GetUserPerms 的 IP 维度限流 // 双维度叠加意味着:若 appKey 维度没爆但 IP 维度爆了,同样必须拒绝。 // 这里用两个不同 appKey(消耗两份 appKey 配额,各占 1 个)但共用同一源 IP, // 第 2 次因为 IP 桶也只剩 1 个配额而必定 ResourceExhausted。 func TestGrpcGetUserPerms_IPRateLimit_OrthogonalToAppKey(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) cfg := testutil.GetTestConfig() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) // quota=1:一次调用会消耗 appKey 桶 + IP 桶 各 1 个; // 第 2 次用"新 appKey"但"同 IP",appKey 桶还够,IP 桶已见底 → IP 桶拒绝。 svcCtx.GrpcGetUserPermsLimiter = limit.NewPeriodLimit( 60, 1, rds, cfg.CacheRedis.KeyPrefix+":rl:grpc:perms:ip:"+testutil.UniqueId()) srv := NewPermServer(svcCtx) appKeyA := "perms_ak_a_" + testutil.UniqueId() appKeyB := "perms_ak_b_" + testutil.UniqueId() ctxSameIP1 := withPeerIP(ctx, "198.51.100.7:50001") ctxSameIP2 := withPeerIP(ctx, "198.51.100.7:50002") // 同 IP 不同端口 _, err1 := srv.GetUserPerms(ctxSameIP1, &pb.GetUserPermsReq{ AppKey: appKeyA, AppSecret: "x", ProductCode: "test_product", UserId: 1, }) require.Error(t, err1) st1, _ := status.FromError(err1) assert.Equal(t, codes.Unauthenticated, st1.Code(), "首次放行(appKey 桶 + IP 桶各耗 1 个)") // 第 2 次:appKey 不同(appKey 桶还有配额),但同 IP 的 IP 桶已耗尽。 _, err2 := srv.GetUserPerms(ctxSameIP2, &pb.GetUserPermsReq{ AppKey: appKeyB, AppSecret: "x", ProductCode: "test_product", UserId: 2, }) require.Error(t, err2) st2, _ := status.FromError(err2) assert.Equal(t, codes.ResourceExhausted, st2.Code(), "appKey 桶有余但 IP 桶已爆,必须 ResourceExhausted;双维度是'谁先爆谁拒'") } // 覆盖目标:HTTP RefreshToken 与 gRPC RefreshToken 共用 // authHelper.RotateRefreshToken,**签发出的新 refreshToken 必须可以互换使用**。 // 这是"helper 共享"最锋利的回归面:一旦某一侧背后悄悄改回自己的版本推进/签名流程, // 两边发出的 token 会在 tokenVersion / claims 结构上漂移,下一次交叉刷新会立刻 401。 // insertPermServerTestUser:server 包本地的 user 插入 helper。 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) } } // TC-1070: 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, "契约: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, "gRPC 续签后 DB tokenVersion 必须 +1;两条路径共用同一 CAS 语义") } // TC-1071: 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, "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, "HTTP 续签后 DB tokenVersion 必须 +1") } // TC-1072: 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(), "gRPC 侧 ErrTokenVersionMismatch 必须 codes.Unauthenticated;"+ "若漂移到 Internal,接入方会当成系统故障告警而非会话失效") assert.Contains(t, st.Message(), "失效") } // PermServer.SyncPermissions gRPC 侧必须把 SyncPermsError{Code:404} // 映射为 codes.NotFound;此前落到 default 分支时会被统一为 codes.Internal,使接入方 SDK // 把"产品不存在"当作系统故障触发重试/告警。 // TC-0981: gRPC 404 → codes.NotFound(配合 permserver.go:81 的 case 404 分支)。 func TestSyncPermissions_gRPC_LockByCodeTxNotFound_MapsToCodesNotFound(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() hashedSecret, err := bcrypt.GenerateFromPassword([]byte("m2_secret_grpc"), bcrypt.MinCost) require.NoError(t, err) mockProduct := mocks.NewMockSysProductModel(ctrl) mockProduct.EXPECT().FindOneByAppKey(gomock.Any(), "m2_grpc_key"). Return(&productModel.SysProduct{ Id: 1, Code: "m2_grpc_prod", AppKey: "m2_grpc_key", AppSecret: string(hashedSecret), Status: 1, }, nil) // LockByCodeTx 命中 sqlx.ErrNotFound → service 内部构造 SyncPermsError{Code:404}。 mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "m2_grpc_prod"). Return((*productModel.SysProduct)(nil), sqlx.ErrNotFound) mockPerm := mocks.NewMockSysPermModel(ctrl) mockPerm.EXPECT().TransactCtx(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error { return fn(ctx, nil) }) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProduct, Perm: mockPerm}) srv := NewPermServer(svcCtx) _, err = srv.SyncPermissions(context.Background(), &pb.SyncPermissionsReq{ AppKey: "m2_grpc_key", AppSecret: "m2_secret_grpc", Perms: []*pb.PermItem{{Code: "p1", Name: "P1"}}, }) require.Error(t, err, "tx 内产品消失必须返回 gRPC 错误") st, ok := status.FromError(err) require.True(t, ok, "必须是 gRPC status.Error,不得为裸 error") assert.Equal(t, codes.NotFound, st.Code(), "SyncPermsError{Code:404} 必须映射为 codes.NotFound;若仍为 codes.Internal,"+ "说明 permserver.go 的 switch 缺少 case 404,接入方 SDK 会把业务未命中当作系统故障重试") assert.Equal(t, "产品不存在", st.Message(), "保留原始语义文案") } // TC-0982: 未映射的 SyncPermsError.Code(例如 500)必须继续落到 codes.Internal。 // 防御未来有人错误"兜底"把所有 SyncPermsError 全部变 NotFound。 func TestSyncPermissions_gRPC_UnmappedCode_StaysInternal(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() hashedSecret, err := bcrypt.GenerateFromPassword([]byte("m2_secret_grpc"), bcrypt.MinCost) require.NoError(t, err) mockProduct := mocks.NewMockSysProductModel(ctrl) mockProduct.EXPECT().FindOneByAppKey(gomock.Any(), "m2_grpc_key2"). Return(&productModel.SysProduct{ Id: 1, Code: "m2_grpc_prod2", AppKey: "m2_grpc_key2", AppSecret: string(hashedSecret), Status: 1, }, nil) // LockByCodeTx 拿到的行必须 Status=1 才能继续进入 diff 逻辑; // 否则会在事务内直接返回 SyncPermsError{Code:403},无法命中"未映射 code"这条路径。 mockProduct.EXPECT().LockByCodeTx(gomock.Any(), gomock.Any(), "m2_grpc_prod2"). Return(&productModel.SysProduct{Id: 1, Code: "m2_grpc_prod2", Status: 1}, nil) mockPerm := mocks.NewMockSysPermModel(ctrl) mockPerm.EXPECT().FindMapByProductCodeWithTx(gomock.Any(), gomock.Any(), "m2_grpc_prod2"). Return(nil, &pubLogic.SyncPermsError{Code: 500, Message: "any low-level"}) mockPerm.EXPECT().TransactCtx(gomock.Any(), gomock.Any()). DoAndReturn(func(ctx context.Context, fn func(context.Context, sqlx.Session) error) error { return fn(ctx, nil) }) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Product: mockProduct, Perm: mockPerm}) srv := NewPermServer(svcCtx) _, err = srv.SyncPermissions(context.Background(), &pb.SyncPermissionsReq{ AppKey: "m2_grpc_key2", AppSecret: "m2_secret_grpc", Perms: []*pb.PermItem{{Code: "p1", Name: "P1"}}, }) require.Error(t, err) st, ok := status.FromError(err) require.True(t, ok) assert.Equal(t, codes.Internal, st.Code(), "未识别的 SyncPermsError.Code 必须仍落到 codes.Internal,不得被"+ "一刀切映射成 codes.NotFound 掩盖真正的系统故障") }