package user import ( "context" "errors" "fmt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "math" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" roleLogic "perms-system-server/internal/logic/role" deptModel "perms-system-server/internal/model/dept" memberModel "perms-system-server/internal/model/productmember" roleModel "perms-system-server/internal/model/role" userModel "perms-system-server/internal/model/user" userroleModel "perms-system-server/internal/model/userrole" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/ctxhelper" "perms-system-server/internal/types" "sync" "sync/atomic" "testing" "time" ) func insertTestMember(t *testing.T, svcCtx *svc.ServiceContext, productCode string, userId int64) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysProductMemberModel.Insert(ctxhelper.SuperAdminCtx(), &memberModel.SysProductMember{ ProductCode: productCode, UserId: userId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } func insertTestRole(t *testing.T, svcCtx *svc.ServiceContext, productCode string, status int64) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysRoleModel.Insert(ctxhelper.SuperAdminCtx(), &roleModel.SysRole{ ProductCode: productCode, Name: "role_" + testutil.UniqueId(), Status: status, PermsLevel: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } // TC-0184: 正常绑定 func TestBindRoles_Success(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, "test_product", userId) r1 := insertTestRole(t, svcCtx, "test_product", 1) r2 := insertTestRole(t, svcCtx, "test_product", 1) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_user_role`", "userId", userId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_role`", r1, r2) }) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{r1, r2}, ProductCode: "test_product", }) require.NoError(t, err) roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId) require.NoError(t, err) assert.ElementsMatch(t, []int64{r1, r2}, roleIds) } // TC-0185: 用户不存在 func TestBindRoles_UserNotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: 999999999, RoleIds: []int64{1}, }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 404, codeErr.Code()) assert.Equal(t, "用户不存在", codeErr.Error()) } // TC-0186: 清空角色 func TestBindRoles_EmptyRoleIds_ClearsAll(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, "test_product", userId) r1 := insertTestRole(t, svcCtx, "test_product", 1) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_user_role`", "userId", userId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_role`", r1) }) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{r1}, ProductCode: "test_product", }) require.NoError(t, err) err = logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{}, ProductCode: "test_product", }) require.NoError(t, err) roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId) require.NoError(t, err) assert.Empty(t, roleIds) } // TC-0184: 正常重新绑定 func TestBindRoles_Rebind(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, "test_product", userId) r1 := insertTestRole(t, svcCtx, "test_product", 1) r2 := insertTestRole(t, svcCtx, "test_product", 1) r3 := insertTestRole(t, svcCtx, "test_product", 1) t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_user_role`", "userId", userId) testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_role`", r1, r2, r3) }) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{r1, r2}, ProductCode: "test_product", }) require.NoError(t, err) err = logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{r2, r3}, ProductCode: "test_product", }) require.NoError(t, err) roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId) require.NoError(t, err) assert.ElementsMatch(t, []int64{r2, r3}, roleIds) } // TC-0188: 角色不属于当前产品 —— L-R14-2 后文案折叠为 "包含无效的角色ID", // 禁止以"跨产品"独立文案成为跨产品 roleId 存在性 oracle。 func TestBindRoles_RoleBelongsToOtherProduct(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, "test_product", userId) otherRole := insertTestRole(t, svcCtx, "other_product", 1) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_role`", otherRole) }) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{otherRole}, ProductCode: "test_product", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "包含无效的角色ID", codeErr.Error(), "L-R14-2:跨产品 roleId 必须与 不存在/已禁用 共用同一文案,避免枚举 oracle") } // TC-0189: 角色已禁用 —— L-R14-2 后折叠为统一文案 "包含无效的角色ID"。 func TestBindRoles_RoleDisabled(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, "test_product", userId) disabledRole := insertTestRole(t, svcCtx, "test_product", 2) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_role`", disabledRole) }) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{disabledRole}, ProductCode: "test_product", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "包含无效的角色ID", codeErr.Error(), "L-R14-2:已禁用角色不得以独立文案暴露启停状态") } // TC-1127: L-R14-2 三路径(跨产品 / 已禁用 / 不存在)必须返回同一 400 + 同一文案, // 阻止已通过 CheckManageAccess 的调用方借文案差异枚举他产品 roleId 分布 / 启停状态。 func TestBindRoles_L_R14_2_InvalidIdsUnifiedMessage(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, "test_product", userId) crossRole := insertTestRole(t, svcCtx, "l_r14_2_other_"+testutil.UniqueId(), 1) disabledRole := insertTestRole(t, svcCtx, "test_product", 2) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) testutil.CleanTable(ctx, conn, "`sys_role`", crossRole, disabledRole) }) logic := NewBindRolesLogic(ctx, svcCtx) cases := []struct { name string roleIds []int64 }{ {"cross_product", []int64{crossRole}}, {"disabled", []int64{disabledRole}}, {"missing_id", []int64{9_999_999_999}}, } var codes []int var msgs []string for _, c := range cases { err := logic.BindRoles(&types.BindRolesReq{UserId: userId, RoleIds: c.roleIds, ProductCode: "test_product"}) require.Error(t, err, c.name) var ce *response.CodeError require.True(t, errors.As(err, &ce), c.name) codes = append(codes, ce.Code()) msgs = append(msgs, ce.Error()) } for i := 1; i < len(cases); i++ { assert.Equal(t, codes[0], codes[i], "L-R14-2:三路径 Code 必须全等,避免成为枚举 oracle") assert.Equal(t, msgs[0], msgs[i], "L-R14-2:三路径文案必须全等(实际:%s vs %s)", msgs[0], msgs[i]) } assert.Equal(t, 400, codes[0]) assert.Equal(t, "包含无效的角色ID", msgs[0]) } // TC-0190: 角色不存在 func TestBindRoles_RoleNotExists(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, "test_product", userId) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{999999999}, ProductCode: "test_product", }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Contains(t, codeErr.Error(), "无效的角色ID") } func insertTestRoleWithLevel(t *testing.T, svcCtx *svc.ServiceContext, productCode string, status int64, permsLevel int64) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysRoleModel.Insert(ctxhelper.SuperAdminCtx(), &roleModel.SysRole{ ProductCode: productCode, Name: "role_" + testutil.UniqueId(), Status: status, PermsLevel: permsLevel, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } // setupDeptForCaller 插入一个 dept,同时构造 caller(使用该 dept)与 target(同 dept 下)的环境 // 返回 deptId、deptPath(caller 使用)、cleanup function func setupDeptForCaller(t *testing.T, svcCtx *svc.ServiceContext) (int64, string, func()) { t.Helper() now := time.Now().Unix() superCtx := ctxhelper.SuperAdminCtx() res, err := svcCtx.SysDeptModel.Insert(superCtx, &deptModel.SysDept{ Name: "dept_" + testutil.UniqueId(), ParentId: 0, Path: "/", DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) deptId, _ := res.LastInsertId() // 先占位再用真实 deptId 构造 path:"/{deptId}/" path := fmt.Sprintf("/%d/", deptId) _, err = svcCtx.SysDeptModel.Insert(superCtx, &deptModel.SysDept{}) // noop — keep linter happy _ = err // 更新 path dept, _ := svcCtx.SysDeptModel.FindOne(superCtx, deptId) dept.Path = path dept.UpdateTime = time.Now().Unix() require.NoError(t, svcCtx.SysDeptModel.Update(superCtx, dept)) conn := testutil.GetTestSqlConn() cleanup := func() { testutil.CleanTable(superCtx, conn, "`sys_dept`", deptId) } return deptId, path, cleanup } // TC-0208: MEMBER 调用者不能分配权限级别高于自身的角色 (audit 修复后 permsLevel 仅对 MEMBER 生效) func TestBindRoles_PermsLevelEscalation_Rejected(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() deptId, deptPath, cleanupDept := setupDeptForCaller(t, svcCtx) t.Cleanup(cleanupDept) productCode := "test_product" // 目标用户:放进 dept 下,MEMBER 产品成员 username := testutil.UniqueId() targetUserId := insertTestUserFull(t, superCtx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("pass"), Nickname: "tgt", DeptId: deptId, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, }) mId := insertTestMember(t, svcCtx, productCode, targetUserId) highLevelRole := insertTestRoleWithLevel(t, svcCtx, productCode, 1, 1) // 修复后 GuardRoleLevelAssignable 走 DB 强一致读取 caller 的 MinPermsLevel, // 因此需要在 DB 里为调用者落地真实的 user + role + user_role 关系链(permsLevel=50)。 callerUserId, callerCleanup := seedCallerWithRoleLevel(t, svcCtx, productCode, 50) t.Cleanup(callerCleanup) t.Cleanup(func() { testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", targetUserId) testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) testutil.CleanTable(superCtx, conn, "`sys_user`", targetUserId) testutil.CleanTable(superCtx, conn, "`sys_role`", highLevelRole) }) // MEMBER 调用者与 target 同 dept,DB 中 MinPermsLevel=50,目标角色 permsLevel=1 → 越级 ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: callerUserId, Username: "member_caller", IsSuperAdmin: false, MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled, ProductCode: productCode, DeptId: deptId, DeptPath: deptPath, MinPermsLevel: 50, }) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: targetUserId, RoleIds: []int64{highLevelRole}, ProductCode: productCode, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "不能分配权限级别高于自身的角色") } // TC-0711: ADMIN 调用者(MinPermsLevel=math.MaxInt64)不受 permsLevel 校验约束 (audit 回归) // 修复前:ADMIN 通过 member_type 获得权限,MinPermsLevel 保持 math.MaxInt64, // // r.PermsLevel < math.MaxInt64 必然成立 → ADMIN 无法绑定任何角色。 // // 修复后:代码显式豁免 ADMIN/DEVELOPER 的 permsLevel 校验。 func TestBindRoles_AdminBypassesPermsLevelCheck(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() productCode := "test_product" username := testutil.UniqueId() userId := insertTestUser(t, superCtx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, productCode, userId) lowLevelRole := insertTestRoleWithLevel(t, svcCtx, productCode, 1, 1) t.Cleanup(func() { testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", userId) testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) testutil.CleanTable(superCtx, conn, "`sys_user`", userId) testutil.CleanTable(superCtx, conn, "`sys_role`", lowLevelRole) }) // 关键:模拟 loader 真实产出——ADMIN 没有自定义角色,MinPermsLevel=math.MaxInt64 ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: 999997, Username: "admin_caller", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: productCode, DeptId: 1, DeptPath: "/1/", MinPermsLevel: math.MaxInt64, // 默认 sentinel }) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{lowLevelRole}, ProductCode: productCode, }) require.NoError(t, err, "ADMIN 调用者应当能绑定任意级别的角色") roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId) require.NoError(t, err) assert.Contains(t, roleIds, lowLevelRole) } // TC-0712: DEVELOPER 调用者同样不受 permsLevel 校验约束 (audit 回归) func TestBindRoles_DeveloperBypassesPermsLevelCheck(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() deptId, deptPath, cleanupDept := setupDeptForCaller(t, svcCtx) t.Cleanup(cleanupDept) productCode := "test_product" username := testutil.UniqueId() userId := insertTestUserFull(t, superCtx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("pass"), Nickname: "tgt_dev", DeptId: deptId, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, }) mId := insertTestMember(t, svcCtx, productCode, userId) lowLevelRole := insertTestRoleWithLevel(t, svcCtx, productCode, 1, 1) t.Cleanup(func() { testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", userId) testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) testutil.CleanTable(superCtx, conn, "`sys_user`", userId) testutil.CleanTable(superCtx, conn, "`sys_role`", lowLevelRole) }) ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: 999996, Username: "developer_caller", IsSuperAdmin: false, MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled, ProductCode: productCode, DeptId: deptId, DeptPath: deptPath, MinPermsLevel: math.MaxInt64, }) logic := NewBindRolesLogic(ctx, svcCtx) err := logic.BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{lowLevelRole}, ProductCode: productCode, }) require.NoError(t, err, "DEVELOPER 调用者应当能绑定任意级别的角色") } // TC-0713: MinPermsLevel == math.MaxInt64 的 MEMBER 调用者也必须被豁免 // (sentinel 判定路径:既不是 ADMIN/DEVELOPER,也没有角色,此时 r.PermsLevel 0 { t.Fatalf( "(轮次 %d):产生 orphan —— sys_role[%d] 已被 DeleteRole 删除,"+ "但 sys_user_role 仍有 (userId=%d, roleId=%d) 行。bindOK=%v delOK=%v "+ "bindErr=%v delErr=%v", round, roleId, userId, roleId, bindOK.Load(), deleteOK.Load(), bindErr.Load(), delErr.Load(), ) } // 其它合法终态一并回归:至少一端做了有效操作(不能都失败) if !bindOK.Load() && !deleteOK.Load() { t.Logf("轮次 %d: bindErr=%v delErr=%v", round, bindErr.Load(), delErr.Load()) t.Fatalf("轮次 %d:两端都失败,不是预期的并发交错(至少 DeleteRole 应成功,"+ "因为它持有 FindOne 之后所有行的独占链路)", round) } // L-R14-2 后,所有"角色无效"分支(race_deleted / disabled / cross-product / missing) // 被统一合并为 400 "包含无效的角色ID",详细 reason 仅落审计日志,避免枚举 oracle。 // 这里的并发 race 本质仍是"角色已不再是合法绑定目标",落到统一文案后即可。 if raw := bindErr.Load(); raw != nil { var ce *response.CodeError if errors.As(raw.(error), &ce) { assert.Equal(t, 400, ce.Code(), "BindRoles 在 DeleteRole 先成功时必须 400,不得泄漏为 500") assert.Equal(t, "包含无效的角色ID", ce.Error(), "L-R14-2:race_deleted 与 other_product / disabled / missing 必须共用同一份"+ "对外文案,否则攻击者能通过文案差分猜出 roleId 的具体状态(枚举 oracle)") } } cleanup() } } func seedCallerWithRoleLevel(t *testing.T, svcCtx *svc.ServiceContext, productCode string, callerLevel int64) (int64, func()) { t.Helper() superCtx := ctxhelper.SuperAdminCtx() conn := testutil.GetTestSqlConn() callerUserId := insertTestUserFull(t, superCtx, &userModel.SysUser{ Username: "caller_" + testutil.UniqueId(), Password: testutil.HashPassword("pass"), Nickname: "caller_seed", DeptId: 0, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, }) mId := insertTestMember(t, svcCtx, productCode, callerUserId) roleId := insertTestRoleWithLevel(t, svcCtx, productCode, consts.StatusEnabled, callerLevel) now := time.Now().Unix() _, err := svcCtx.SysUserRoleModel.Insert(superCtx, &userroleModel.SysUserRole{ UserId: callerUserId, RoleId: roleId, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) cleanup := func() { testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", callerUserId) testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) testutil.CleanTable(superCtx, conn, "`sys_user`", callerUserId) testutil.CleanTable(superCtx, conn, "`sys_role`", roleId) } return callerUserId, cleanup } // --------------------------------------------------------------------------- // 覆盖目标:"不能分配与自己同级(或更高)的角色"。 // 修复前代码仅拦 `>` 严格高于,允许 MEMBER 调用者把同级角色分配给别人,继而下一次 BindRoles 时 // 由于同级权限集相同,可用后续 upgrade 路径放大;修复后变为 `<=`(含同级)拦截。 // 本文件作为"同级也必须 403"的契约锚点。 // --------------------------------------------------------------------------- // TC-0813: MEMBER 调用者不能分配与自己同 permsLevel 的角色。 func TestBindRoles_EqualPermsLevel_Rejected(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() deptId, deptPath, cleanupDept := setupDeptForCaller(t, svcCtx) t.Cleanup(cleanupDept) productCode := "test_product" username := testutil.UniqueId() targetUserId := insertTestUserFull(t, superCtx, &userModel.SysUser{ Username: username, Password: testutil.HashPassword("pass"), Nickname: "tgt_eq", DeptId: deptId, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, }) mId := insertTestMember(t, svcCtx, productCode, targetUserId) const callerLevel int64 = 50 sameLevelRole := insertTestRoleWithLevel(t, svcCtx, productCode, consts.StatusEnabled, callerLevel) // 修复后 GuardRoleLevelAssignable 走 DB 强一致读取 caller 的 MinPermsLevel, // 因此需要在 DB 里为调用者落地真实的 user + role + user_role 关系链。 callerUserId, callerCleanup := seedCallerWithRoleLevel(t, svcCtx, productCode, callerLevel) t.Cleanup(callerCleanup) t.Cleanup(func() { testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", targetUserId) testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) testutil.CleanTable(superCtx, conn, "`sys_user`", targetUserId) testutil.CleanTable(superCtx, conn, "`sys_role`", sameLevelRole) }) ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: callerUserId, Username: "member_eq_level", IsSuperAdmin: false, MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled, ProductCode: productCode, DeptId: deptId, DeptPath: deptPath, MinPermsLevel: callerLevel, }) err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{ UserId: targetUserId, RoleIds: []int64{sameLevelRole}, ProductCode: productCode, }) require.Error(t, err, "同级角色分配必须被拒绝(含同级)") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "不能分配权限级别高于自身的角色", "错误消息应当明确点出'含同级'的拦截语义") // 同时验证 DB 未产生任何 user-role 关系。 rids, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserIdForProduct(ctx, targetUserId, productCode) require.NoError(t, err) assert.Empty(t, rids, "被拒绝的 BindRoles 不得落地任何行") } // TC-1102: 非超管 caller + 空 MemberType + 不存在的 userId —— 必须 403 "缺少产品成员上下文", // 而不是 404 "用户不存在"。L-R13-1 闸的目的就是消除通过 BindRoles 返回差异(403 vs 404) // 枚举 sys_user 行是否存在的 oracle。 func TestBindRoles_L_R13_1_EmptyMemberTypeForbidsBeforeUserLookup(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) // caller:未登录到任何产品(MemberType="" 且非超管),对任意不存在的 userId 都必须 403。 ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: 10000000001, Username: "ghost_member", IsSuperAdmin: false, MemberType: "", Status: consts.StatusEnabled, ProductCode: "test_product", }) err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{ UserId: 999999999, RoleIds: []int64{1}, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code(), "L-R13-1:非超管且无产品成员上下文时必须 403,不得返回 404 泄漏 userId 存在性") assert.Equal(t, "缺少产品成员上下文", ce.Error()) } // TC-1103: 超管 + 空 MemberType(理论上不该出现,但要回归 L-R13-1 闸没误伤超管)—— // 应当正常穿透到 FindOne,不存在的 userId 返 404 "用户不存在"。 func TestBindRoles_L_R13_1_SuperAdminWithEmptyMemberTypeStillProceeds(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: 1, Username: "super_no_member_ctx", IsSuperAdmin: true, MemberType: "", Status: consts.StatusEnabled, ProductCode: "test_product", }) err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{ UserId: 999999999, RoleIds: []int64{1}, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 404, ce.Code(), "超管不应被 L-R13-1 闸误伤,应穿透到 SysUserModel.FindOne 并返 404") assert.Equal(t, "用户不存在", ce.Error()) } // TC-1265: 非超管(ADMIN)传入 req.ProductCode 指向其他产品时,该字段必须被忽略, // 始终使用 JWT context 中的 productCode,不允许跨产品操作。 // 安全约束:若非超管能通过 req.ProductCode 切换产品,则可绕过 CheckManageAccess 的产品隔离。 func TestBindRoles_NonSuperAdmin_ReqProductCodeIgnored(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() productCode := "test_product" username := testutil.UniqueId() userId := insertTestUser(t, superCtx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, productCode, userId) role := insertTestRole(t, svcCtx, productCode, consts.StatusEnabled) t.Cleanup(func() { testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", userId) testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) testutil.CleanTable(superCtx, conn, "`sys_user`", userId) testutil.CleanTable(superCtx, conn, "`sys_role`", role) }) // ADMIN caller 的 JWT context productCode = "test_product", // 请求体传入 "other_product"——非超管时该字段必须被忽略。 ctx := ctxhelper.AdminCtx(productCode) err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{role}, ProductCode: "other_product", // 非超管时此字段应被忽略 }) require.NoError(t, err, "非超管传入其他产品的 productCode 应被忽略,仍按 JWT context 产品操作") roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId) require.NoError(t, err) assert.Contains(t, roleIds, role, "角色应成功绑定到 JWT context 对应的产品下") } // TC-1266: 非超管(ADMIN)不传 req.ProductCode,使用 JWT context 中的 productCode 正常绑定。 func TestBindRoles_NonSuperAdmin_UsesCtxProductCode(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() productCode := "test_product" username := testutil.UniqueId() userId := insertTestUser(t, superCtx, username, testutil.HashPassword("pass")) mId := insertTestMember(t, svcCtx, productCode, userId) role := insertTestRole(t, svcCtx, productCode, consts.StatusEnabled) t.Cleanup(func() { testutil.CleanTableByField(superCtx, conn, "`sys_user_role`", "userId", userId) testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) testutil.CleanTable(superCtx, conn, "`sys_user`", userId) testutil.CleanTable(superCtx, conn, "`sys_role`", role) }) ctx := ctxhelper.AdminCtx(productCode) err := NewBindRolesLogic(ctx, svcCtx).BindRoles(&types.BindRolesReq{ UserId: userId, RoleIds: []int64{role}, // ProductCode 不传,应自动使用 JWT context 中的 "test_product" }) require.NoError(t, err) roleIds, err := svcCtx.SysUserRoleModel.FindRoleIdsByUserId(ctx, userId) require.NoError(t, err) assert.Contains(t, roleIds, role) }