package user import ( "context" "database/sql" "errors" "fmt" "math" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" deptLogic "perms-system-server/internal/logic/dept" "perms-system-server/internal/middleware" deptModel "perms-system-server/internal/model/dept" userModel "perms-system-server/internal/model/user" "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" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" ) func insertTestDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ Name: testutil.UniqueId(), ParentId: 0, Path: "/", Sort: 0, DeptType: "NORMAL", Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } // TC-0156: 正常更新 func TestUpdateUser_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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) deptId := insertTestDept(t, ctx, svcCtx) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr("新昵称"), Email: strPtr("new@example.com"), Phone: strPtr("13900139000"), Remark: strPtr("更新备注"), DeptId: int64Ptr(deptId), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "新昵称", user.Nickname) assert.Equal(t, "new@example.com", user.Email) assert.Equal(t, "13900139000", user.Phone) assert.Equal(t, "更新备注", user.Remark) assert.Equal(t, deptId, user.DeptId) } // TC-0157: 不存在 func TestUpdateUser_NotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: 999999999, Nickname: strPtr("ghost"), }) 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-0158: 仅传id func TestUpdateUser_OnlyId_NothingChanges(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) // 真实使用场景: 用户打开编辑页面后再提交, 插入与更新之间存在时间间隔, // updateTime 粒度为秒级, 因此在 INSERT 之后等待 1 秒再发起 UPDATE, 避免同秒 no-op SQL 被 MySQL 视为 0 affected rows. time.Sleep(1100 * time.Millisecond) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{Id: userId}) require.NoError(t, err) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, before.Nickname, after.Nickname) assert.Equal(t, before.Email, after.Email) assert.Equal(t, before.Phone, after.Phone) assert.Equal(t, before.DeptId, after.DeptId) } // TC-0159: 清空nickname func TestUpdateUser_ClearNickname(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr(""), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "", user.Nickname) } // TC-0160: 清空email func TestUpdateUser_ClearEmail(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Email: strPtr(""), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "", user.Email) } // TC-0162: 非法email格式 func TestUpdateUser_InvalidEmail(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Email: strPtr("bad-email"), }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "邮箱格式不正确", codeErr.Error()) } // TC-0166: DeptId设为0(取消部门) func TestUpdateUser_DeptIdZero_Clear(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) deptId := insertTestDept(t, ctx, svcCtx) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, DeptId: int64Ptr(deptId), }) require.NoError(t, err) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, DeptId: int64Ptr(0), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(0), user.DeptId) } // TC-0167: DeptId设为正值 func TestUpdateUser_DeptIdSet(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) deptId := insertTestDept(t, ctx, svcCtx) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, DeptId: int64Ptr(deptId), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, deptId, user.DeptId) } // TC-0168: DeptId不传(nil) func TestUpdateUser_NilDeptId_Unchanged(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) deptId := insertTestDept(t, ctx, svcCtx) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, DeptId: int64Ptr(deptId), }) require.NoError(t, err) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr("changed"), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, deptId, user.DeptId) assert.Equal(t, "changed", user.Nickname) } // TC-0161: 清空remark func TestUpdateUser_ClearRemark(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Remark: strPtr("some remark"), }) require.NoError(t, err) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Remark: strPtr(""), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "", user.Remark) } // TC-0164: 合法phone func TestUpdateUser_ValidInternationalPhone(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Phone: strPtr("+8613800138000"), }) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "+8613800138000", user.Phone) } // TC-0163: 非法phone格式 func TestUpdateUser_InvalidPhone(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Phone: strPtr("12345"), }) require.Error(t, err) var codeErr *response.CodeError require.True(t, errors.As(err, &codeErr)) assert.Equal(t, 400, codeErr.Code()) assert.Equal(t, "手机号格式不正确", codeErr.Error()) } // TC-0165: 不传email(nil) func TestUpdateUser_NilEmail_Unchanged(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) originalEmail := before.Email logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr("changed-nick"), }) require.NoError(t, err) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, originalEmail, after.Email) assert.Equal(t, "changed-nick", after.Nickname) } // TC-0542: MEMBER用户尝试修改其他用户被CheckManageAccess拒绝 func TestUpdateUser_MemberCannotManageOtherUser(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() targetName := testutil.UniqueId() targetId := insertTestUser(t, superCtx, targetName, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_user`", targetId) }) ctx := ctxhelper.MemberCtx("test_product") logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: targetId, Nickname: strPtr("hacked")}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) } // TC-0170: 产品管理员可以修改其管理范围内的用户信息 func TestUpdateUser_ProductAdminCanManageUser(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() deptId := insertTestDept(t, superCtx, svcCtx) t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_dept`", deptId) }) targetName := testutil.UniqueId() targetId := insertTestUser(t, superCtx, targetName, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_user`", targetId) }) productCode := "test_product" mId := insertTestMember(t, svcCtx, productCode, targetId) t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_product_member`", mId) }) ctx := ctxhelper.AdminCtx(productCode) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: targetId, Nickname: strPtr("new-nick")}) require.NoError(t, err) user, err := svcCtx.SysUserModel.FindOne(superCtx, targetId) require.NoError(t, err) assert.Equal(t, "new-nick", user.Nickname) } // TC-0171: UpdateUser 昵称超过64字符被拒绝 func TestUpdateUser_NicknameTooLong(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) longNick := string(make([]byte, 65)) for i := range longNick { longNick = longNick[:i] + "a" + longNick[i+1:] } logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: userId, Nickname: &longNick}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "昵称长度不能超过64个字符") } // TC-0172: UpdateUser 部门不存在被拒绝 func TestUpdateUser_DeptNotExists(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: userId, DeptId: int64Ptr(999999999)}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "部门不存在") } // TC-1101: UpdateUser 对 *req.DeptId < 0(-1 / MinInt64)必须 400 拒绝, // 与 CreateUser 的同规格 gating 闭环——不让已有账号通过 Update 变成 deptId=负数 的僵尸。 func TestUpdateUser_NegativeDeptIdRejected(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) cases := []struct { name string deptId int64 }{ {"negative_one", -1}, {"min_int64", math.MinInt64}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { err := NewUpdateUserLogic(ctx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: userId, DeptId: int64Ptr(tc.deptId), }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Equal(t, "部门ID必须为非负整数", ce.Error()) u, fErr := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, fErr) assert.NotEqual(t, tc.deptId, u.DeptId, "被拒绝的更新不得落盘") }) } } // TC-0543: updateUser自己修改DeptId被拒绝 func TestUpdateUser_SelfEditDeptIdRejected(t *testing.T) { ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: 100, Username: "self_user", Status: consts.StatusEnabled, }) svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, DeptId: int64Ptr(5)}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Equal(t, "不允许修改自己的部门和状态", ce.Error()) } // TC-0544: updateUser自己修改Status被拒绝 func TestUpdateUser_SelfEditStatusRejected(t *testing.T) { ctx := ctxhelper.CustomCtx(&loaders.UserDetails{ UserId: 100, Username: "self_user", Status: consts.StatusEnabled, }) svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: 100, Status: 2}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Equal(t, "不允许修改自己的部门和状态", ce.Error()) } // TC-0545: updateUser未登录被拒绝 func TestUpdateUser_NotLoggedInRejected(t *testing.T) { ctx := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUpdateUserLogic(ctx, svcCtx) err := logic.UpdateUser(&types.UpdateUserReq{Id: 1, Nickname: strPtr("hacked")}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code()) assert.Equal(t, "未登录", ce.Error()) } // TC-0169: 超管A通过updateUser修改超管B的状态被拒绝(修复验证) func TestUpdateUser_SuperAdminCannotFreezeOtherSuperAdmin(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: testutil.UniqueId(), Password: testutil.HashPassword("pass"), Nickname: "super_b", IsSuperAdmin: consts.IsSuperAdminYes, MustChangePassword: 2, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) superBId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", superBId) }) logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{ Id: superBId, Status: consts.StatusDisabled, }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) // 最新重构:Status 校验统一走 authHelper.ValidateStatusChange,文案为"不能修改超级管理员的状态" assert.Equal(t, "不能修改超级管理员的状态", ce.Error()) user, err := svcCtx.SysUserModel.FindOne(ctx, superBId) require.NoError(t, err) assert.Equal(t, int64(consts.StatusEnabled), user.Status, "超管B的状态不应被修改") } // TC-0173: updateUser 修改状态时会递增 tokenVersion(修复验证) func TestUpdateUser_StatusChange_IncrementsTokenVersion(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) origTv := before.TokenVersion logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{Id: userId, Status: consts.StatusDisabled}) require.NoError(t, err) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, int64(consts.StatusDisabled), after.Status) assert.Equal(t, origTv+1, after.TokenVersion, "状态变化应递增 tokenVersion") } // TC-0174: updateUser 只改 profile 不会递增 tokenVersion func TestUpdateUser_ProfileOnly_NoTokenVersionChange(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) before, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{ Id: userId, Nickname: strPtr("新名字"), Email: strPtr("new@example.com"), }) require.NoError(t, err) after, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) assert.Equal(t, "新名字", after.Nickname) assert.Equal(t, before.TokenVersion, after.TokenVersion, "不改状态时 tokenVersion 不应变化") } // TC-0175: updateUser 乐观锁冲突 -> 409 // 乐观锁依赖秒级 updateTime, 需在两次更新之间保证 >= 1 秒的间隔, 否则 MySQL 看到的新/旧 updateTime 相同无法生效. func TestUpdateUser_OptimisticLockConflict_Returns409(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")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) orig, err := svcCtx.SysUserModel.FindOne(ctx, userId) require.NoError(t, err) time.Sleep(1100 * time.Millisecond) logic := NewUpdateUserLogic(ctx, svcCtx) err = logic.UpdateUser(&types.UpdateUserReq{Id: userId, Nickname: strPtr("first")}) require.NoError(t, err) err = svcCtx.SysUserModel.UpdateProfile(ctx, userId, orig.Username, "second", orig.Email, orig.Phone, orig.Remark, orig.DeptId, orig.Status, false, orig.UpdateTime) require.ErrorIs(t, err, userModel.ErrUpdateConflict, "基于旧 updateTime 的更新应失败") } func insertTestDeptForScope(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, tag, path string) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0, DeptType: "NORMAL", Remark: "", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } func insertTestUserWithDept(t *testing.T, ctx context.Context, tag string, deptId int64) int64 { t.Helper() now := time.Now().Unix() return insertTestUserFull(t, ctx, &userModel.SysUser{ Username: "ddu_" + tag + "_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Nickname: "n", Avatar: sql.NullString{}, Email: "x@t.com", Phone: "13800000000", DeptId: deptId, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) } // TC-0746: -F 修复回归 —— DEVELOPER 调用者不得将目标用户的 deptId 调到 // 自己 DeptPath 子树之外的部门。UpdateUser 必须在 req.DeptId 变更时做 Path 前缀校验。 func TestUpdateUser_DeveloperCannotMoveTargetOutsideSubtree(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller", "/100/") targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "target", "/100/200/") outsideDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "outside", "/999/") targetId := insertTestUserWithDept(t, bootstrap, "lf_out", targetDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId, outsideDeptId) }) devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 55555, Username: "lf_dev", IsSuperAdmin: false, MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/100/", MinPermsLevel: math.MaxInt64, }) newDept := outsideDeptId err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }) 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(), "无权将用户调入") user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, targetDeptId, user.DeptId, "被拒绝的请求必须不改动 DB") } // TC-0747: -F 正向回归 —— DEVELOPER 将目标用户调入自己子树下的部门应允许。 func TestUpdateUser_DeveloperCanMoveTargetWithinSubtree(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "caller_in", "/200/") srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "src_in", "/200/1/") dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dst_in", "/200/2/") targetId := insertTestUserWithDept(t, bootstrap, "lf_in", srcDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, srcDeptId, dstDeptId) }) devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 66666, Username: "lf_dev_ok", IsSuperAdmin: false, MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/200/", MinPermsLevel: math.MaxInt64, }) newDept := dstDeptId require.NoError(t, NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }), "caller DeptPath 的前缀子部门必须允许调入") user, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, dstDeptId, user.DeptId) } // TC-0748: 产品 ADMIN 也必须满足 DeptPath 前缀校验,才能将成员调入目标部门。 // 背景:sys_user.deptId 是全局字段,产品 ADMIN 在 P1 的授权边界止于 P1;如果允许 ADMIN 跨 // 子树挪动同时归属 P1/P2 的共享成员,会在 P2 视角造成"目标 DeptPath 不再以任何 P2 管理员 // 的 DeptPath 为前缀"的结构性失联——与"deptId=0 把用户挪出部门树"是同构的攻击链。 // 因此 UpdateUser 对非超管(含 ADMIN)的 DeptPath 前缀校验必须生效;仅 SuperAdmin 可跨子树。 func TestUpdateUser_ProductAdminMustPassSubtreeCheck(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "admin_home", "/300/") targetHomeDept := insertTestDeptForScope(t, bootstrap, svcCtx, "target_home", "/400/") anywhereDept := insertTestDeptForScope(t, bootstrap, svcCtx, "anywhere", "/500/") targetId := insertTestUserWithDept(t, bootstrap, "lf_admin", targetHomeDept) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, targetHomeDept, anywhereDept) }) adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 77777, Username: "lf_admin", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: adminDeptId, DeptPath: "/300/", MinPermsLevel: math.MaxInt64, }) newDept := anywhereDept err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }) require.Error(t, err, "ADMIN 跨子树调入非自己管辖的部门必须被拒绝") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "无权将用户调入") u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, targetHomeDept, u.DeptId, "被拒绝的请求必须对 DB 零副作用,避免 403 伴随脏写入") } func TestUpdateUser_DeveloperCannotMoveTargetOutOfDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_caller_dev", "/700/") targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_target_dev", "/700/1/") targetId := insertTestUserWithDept(t, bootstrap, "h4_dev", targetDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId) }) devCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 88881, Username: "h4_dev", IsSuperAdmin: false, MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/700/", MinPermsLevel: math.MaxInt64, }) zero := int64(0) err := NewUpdateUserLogic(devCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &zero, }) require.Error(t, err, "DEVELOPER 不得把目标移出部门树") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "仅超级管理员可将用户移出部门", "L-R15-1:文案已收敛——产品 ADMIN 不再享有此权限,DEVELOPER 自然也不行;"+ "断言仅匹配'仅超级管理员...'前缀即可覆盖所有非超管拒绝分支") u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, targetDeptId, u.DeptId, "被拒绝的请求对 DB 零副作用") } // TC-0815: MEMBER 调用者同理被拒(即便是修改自身的其他字段也不能顺手把自己移出部门)。 // 用户修改自身时,路由层 if caller.UserId == req.Id 分支只拦 DeptId != nil/Status != 0; // 但修改他人为 deptId=0 的分支仍必须 403,以防任何下级调用者漂白组织结构。 func TestUpdateUser_MemberCannotMoveOtherOutOfDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() callerDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_caller", "/800/") targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_member_target", "/800/1/") targetId := insertTestUserWithDept(t, bootstrap, "h4_mem", targetDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", callerDeptId, targetDeptId) }) memberCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 88882, Username: "h4_mem", IsSuperAdmin: false, MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: callerDeptId, DeptPath: "/800/", MinPermsLevel: 10, }) zero := int64(0) err := NewUpdateUserLogic(memberCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &zero, }) require.Error(t, err, "MEMBER 更不得移出他人") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, targetDeptId, u.DeptId) } // TC-0816(L-R15-1 后契约反转):产品 ADMIN **不再**拥有"把他人移出部门"的权限。 // sys_user.deptId 是全局字段,P1 ADMIN 原先可以让共有成员 B 在 P2 视角下变成 // "DeptId=0 的孤儿"——P2 的 MEMBER/DEVELOPER/子 ADMIN 全部通不过 checkDeptHierarchy // 的目标部门校验,B 成为 P2 侧的"隐形成员",DeptTree 里也找不到。 // 该破坏组织结构语义的操作属于离职/转岗的 HR 行政流程,应当收敛给 SuperAdmin。 // // 断言重点不只是 403:还要验证 DB 零副作用(deptId 保持原值),防止实现从 // "check+exec"退化成"exec 后补 check"漏了副作用清理。 func TestUpdateUser_ProductAdminCannotMoveTargetOutOfDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin", "/900/") targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_admin_target", "/900/1/") targetId := insertTestUserWithDept(t, bootstrap, "h4_admin_tgt", targetDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, targetDeptId) }) adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 88883, Username: "h4_admin", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: adminDeptId, DeptPath: "/900/", MinPermsLevel: math.MaxInt64, }) zero := int64(0) err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &zero, }) require.Error(t, err, "产品 ADMIN 调 deptId=0 必须被 L-R15-1 拦下") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code(), "仅超级管理员可将用户移出部门——产品 ADMIN 必须 403") assert.Contains(t, ce.Error(), "仅超级管理员", "文案必须显式指向超级管理员权限,避免接入方继续误以为'产品管理员也可以'") u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, targetDeptId, u.DeptId, "被 403 拒绝的请求必须对 DB 零副作用——deptId 不得从 "+ "targetDeptId 退化为 0,防止 'check 失败但 exec 已落盘' 的绕过实现") } // TC-0817: SuperAdmin 有权将他人移出部门(豁免路径)。 func TestUpdateUser_SuperAdminCanMoveTargetOutOfDept(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() targetDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "h4_sa_target", "/950/") targetId := insertTestUserWithDept(t, bootstrap, "h4_sa_tgt", targetDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", targetDeptId) }) zero := int64(0) require.NoError(t, NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &zero, }), "SuperAdmin 的 deptId=0 操作是合法的顶层运维") u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, int64(0), u.DeptId) } func insertEnabledDept(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, name, path string) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ ParentId: 0, Name: name + "_" + testutil.UniqueId(), Path: path, Sort: 0, DeptType: "NORMAL", Remark: "", Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } // TC-1083: UpdateUser tx 分支在 commit 成功后必须失效 sysUser 低层缓存 func TestUpdateUser_DeptChange_PostCommitInvalidatesSysUserCache(t *testing.T) { bootstrap := context.Background() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() rds := redis.MustNewRedis(testutil.GetTestConfig().CacheRedis.Nodes[0].RedisConf) prefix := testutil.GetTestCachePrefix() srcDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_src", "/r12_1_src/") dstDeptId := insertEnabledDept(t, bootstrap, svcCtx, "r12_1_dst", "/r12_1_dst/") targetId := insertTestUserFull(t, bootstrap, &userModel.SysUser{ Username: "r12_1_upd_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Nickname: "orig_nick", Avatar: sql.NullString{}, Email: "r12_1@test.com", Phone: "13800000000", Remark: "orig_remark", DeptId: srcDeptId, IsSuperAdmin: consts.IsSuperAdminNo, MustChangePassword: 2, Status: consts.StatusEnabled, }) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, dstDeptId) }) // 走一次 FindOne 预热 id / username 两把低层缓存;UpdateUser 内部 `l.svcCtx.SysUserModel.FindOne` // 也会预热,这里显式做一次把预置断言打实。 pre, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) idKey := fmt.Sprintf("%s:cache:sysUser:id:%d", prefix, targetId) usernameKey := fmt.Sprintf("%s:cache:sysUser:username:%s", prefix, pre.Username) udKey := fmt.Sprintf("%s:ud:%d:%s", prefix, targetId, "test_product") cachedId, err := rds.Get(idKey) require.NoError(t, err) require.NotEmpty(t, cachedId, "预置:sysUser id 缓存已预热") // 先预热 UserDetails 聚合缓存(否则下面判断"Clean 之后为空"会因为本来就空而变成假通过) _, err = svcCtx.UserDetailsLoader.Load(bootstrap, targetId, "test_product") require.NoError(t, err) cachedUd, err := rds.Get(udKey) require.NoError(t, err) require.NotEmpty(t, cachedUd, "预置:UserDetails 聚合缓存已预热") callerCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 88888, Username: "r12_1_super", IsSuperAdmin: true, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", MinPermsLevel: 0, }) newDept := dstDeptId require.NoError(t, NewUpdateUserLogic(callerCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }), "超管改部门应成功走 tx 分支") // 关键断言 1:sysUser:id 低层缓存已被 post-commit 的 InvalidateProfileCache 清掉 afterId, err := rds.Get(idKey) require.NoError(t, err) assert.Empty(t, afterId, "UpdateUser tx 分支返回后,sysUser:id 低层缓存必须已被 InvalidateProfileCache 失效;"+ "若仍有值,则表示 Logic 层遗漏了 post-commit 的显式 DelCache 调用,"+ "并发读回源时会沿用预热时的旧 deptId/昵称/备注 payload") // 关键断言 2:username 低层缓存也要被一并清掉 afterUn, err := rds.Get(usernameKey) require.NoError(t, err) assert.Empty(t, afterUn, "sysUser:username 低层缓存也必须被 InvalidateProfileCache 同批失效") // 关键断言 3:UserDetails 聚合缓存也被清掉(l.svcCtx.UserDetailsLoader.Clean) afterUd, err := rds.Get(udKey) require.NoError(t, err) assert.Empty(t, afterUd, "UserDetailsLoader.Clean 必须在 post-commit 同一阶段被调用,"+ "保证上层聚合缓存和下层 sysUser 缓存一起过期,避免读链任一环读到旧值") // 关键断言 4:下一轮 FindOne 取到新 deptId(双重验证:DB 为权威且缓存已经让步) after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, dstDeptId, after.DeptId, "缓存失效后 FindOne 必须从 DB 读到 tx 已提交的新 deptId;"+ "若缓存未清,这里仍会是 srcDeptId(cache stale 的最终症状)") } func TestUpdateUser_DeptIdSwitch_VsDeleteDept_NoWriteSkew(t *testing.T) { bootstrap := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() // 构造:用户 U 在 deptA,新候选部门 deptX 空(无子部门 + 无关联用户,满足 DeleteDept 可删条件)。 deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptA", "/3100/") deptXId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_deptX", "/3200/") targetId := insertTestUserWithDept(t, bootstrap, "m113_user", deptAId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId, deptXId) }) // 超管身份用于 UpdateUser / DeleteDept。 superCtx := ctxhelper.SuperAdminCtx() // 两个 goroutine 并发: // G1: UpdateUser targetId.deptId = deptXId // G2: DeleteDept deptXId var ( wg sync.WaitGroup upErr atomic.Value upOK atomic.Bool delErr atomic.Value delOK atomic.Bool unexpected atomic.Value ) start := make(chan struct{}) wg.Add(2) go func() { defer wg.Done() <-start err := NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &deptXId, }) if err == nil { upOK.Store(true) } else { upErr.Store(err) } }() go func() { defer wg.Done() <-start err := deptLogic.NewDeleteDeptLogic(superCtx, svcCtx).DeleteDept(&types.DeleteDeptReq{ Id: deptXId, }) if err == nil { delOK.Store(true) } else { delErr.Store(err) } }() close(start) wg.Wait() // 允许的结果共两种: // A) upOK && !delOK:user.deptId==deptX,sys_dept[deptX] 仍在,DeleteDept 收 400 // B) !upOK && delOK:user.deptId==deptA(未动),sys_dept[deptX] 已删,UpdateUser 收 400 // 绝不能同时成功(write skew)。DB 终态须自洽。 u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId) require.NoError(t, err) // dept X 存在性:**绕过 go-zero 的 WithCache**,直接从 MySQL 查真相,避免 UpdateUser // 在 FindOne 时预热的缓存把 DeleteDept 的真实删除"遮住"。 var deptCount int64 require.NoError(t, conn.QueryRowCtx(context.Background(), &deptCount, "SELECT COUNT(*) FROM `sys_dept` WHERE `id` = ?", deptXId)) deptStillThere := deptCount > 0 switch { case upOK.Load() && !delOK.Load(): assert.Equal(t, deptXId, u.DeptId, "UpdateUser 胜出,user.deptId 必须为 deptX") assert.True(t, deptStillThere, "UpdateUser 胜出后 deptX 必须仍存在,否则存在 orphan 引用") var ce *response.CodeError require.NotNil(t, delErr.Load(), "DeleteDept 应返回 400 解释失败原因") if errors.As(delErr.Load().(error), &ce) { assert.Equal(t, 400, ce.Code(), "DeleteDept 看到新 user 后必须 400'该部门下仍有关联用户'") assert.Contains(t, ce.Error(), "关联用户") } case !upOK.Load() && delOK.Load(): assert.Equal(t, deptAId, u.DeptId, "DeleteDept 胜出,user.deptId 必须保持为 deptA(UpdateUser 被拒绝,不得写入)") assert.False(t, deptStillThere, "DeleteDept 胜出后 deptX 必须已被删除") var ce *response.CodeError require.NotNil(t, upErr.Load(), "UpdateUser 应返回 400 解释失败原因") if errors.As(upErr.Load().(error), &ce) { assert.Equal(t, 400, ce.Code(), "UpdateUser 发现目标 dept 已消失必须 400'部门不存在'") assert.Contains(t, ce.Error(), "部门不存在") } case upOK.Load() && delOK.Load(): t.Fatalf("UpdateUser + DeleteDept **同时成功** —— write skew 未被闭合。" + "DB 现在持有 user.deptId 指向已被删 dept 的 orphan 数据。") case !upOK.Load() && !delOK.Load(): unexpected.Store(struct{ up, del error }{upErr.Load().(error), delErr.Load().(error)}) t.Fatalf("两端都失败是不期望的调度:upErr=%v delErr=%v", upErr.Load(), delErr.Load()) } } // TC-1050: UpdateUser 只改 deptId 之外的字段(或 deptId=0)时不进事务(性能与锁范围约束) // 这是修复的**对偶契约**:避免 DEV 未来不分 case 把所有 UpdateProfile 都塞进事务 / 或反之。 // 用"目标部门不存在但仅改 Nickname"的 case 证明:非 deptId 变更路径不需要 FindOneForShareTx, // 且走的是 UpdateProfile(非事务)。该契约只能以"只调昵称也能成功"的正向场景间接证实: func TestUpdateUser_OnlyNicknameUpdate_DoesNotRequireDeptShareLock(t *testing.T) { bootstrap := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptAId := insertTestDeptForScope(t, bootstrap, svcCtx, "m113_onlyNick", "/3300/") targetId := insertTestUserWithDept(t, bootstrap, "m113_onlyNick", deptAId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", deptAId) }) newNick := "only_nick_mutate" superCtx := ctxhelper.SuperAdminCtx() require.NoError(t, NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, Nickname: &newNick, }), "只改昵称不应走事务路径(若走事务会无谓扩大锁范围)") u, err := svcCtx.SysUserModel.FindOne(context.Background(), targetId) require.NoError(t, err) assert.Equal(t, newNick, u.Nickname) assert.Equal(t, deptAId, u.DeptId, "deptId 未变") } // 备注:原本想写一条"deptId 从 A 改到 A 不走事务路径"的对偶用例,但 MySQL 对"所有字段都 // 不变"的 UPDATE 返回 RowsAffected=0,UpdateProfile 会把它升格为 ErrUpdateConflict → 409。 // 这是底层驱动/引擎层的 side-effect,非 关心的契约。若要验证该对偶,请同时改一个 // 真实字段(参见上面的 Nickname 用例)。 // insertTestDeptWithType —— 工具函数:按给定 DeptType 插入一个部门。 // 专用于 H-R14-1 场景:需要精确控制 DeptType=DEV 触发跨产品权限升级的护栏。 func insertTestDeptWithType(t *testing.T, ctx context.Context, svcCtx *svc.ServiceContext, tag, path, deptType string) int64 { t.Helper() now := time.Now().Unix() res, err := svcCtx.SysDeptModel.Insert(ctx, &deptModel.SysDept{ ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0, DeptType: deptType, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) id, _ := res.LastInsertId() return id } // TC-1124: H-R14-1 —— 产品 ADMIN 将目标用户调入 DEV 部门必须 403,防止跨产品权限升级。 // // 攻击链回放: // // P1.ADMIN 同时在 P2 也是普通成员 → target 同在 P1、P2 → P1.ADMIN 调用 UpdateUser // 把 target.deptId 改到 DEV 部门。sys_user.deptId 是全局字段,一次改动立即让 target // 在 **P2** 的 UD.loadPerms 里升级为"DEV 部门在编成员"—— 按 userDetailsLoader 的 // 全权分支,target 在 P2 将拥有 P2 全部 perm,等于绕过了 P2 信任边界。 // 因此本接口必须把"调入 DEV"收敛给 SuperAdmin,哪怕 ADMIN 的 DeptPath 前缀豁免仍在。 // // 断言:403 + 文案"仅超级管理员可将用户调入研发部门";DB 零副作用(deptId 仍为原值)。 func TestUpdateUser_H_R14_1_AdminCannotMoveToDevDept(t *testing.T) { bootstrap := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_admin", "/8100/", consts.DeptTypeNormal) srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_src", "/8100/1/", consts.DeptTypeNormal) devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_dev", "/8200/", consts.DeptTypeDev) targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_tgt", srcDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, srcDeptId, devDeptId) }) adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 99991, Username: "h_r14_1_admin", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: adminDeptId, DeptPath: "/8100/", MinPermsLevel: math.MaxInt64, }) newDept := devDeptId err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }) require.Error(t, err, "ADMIN 调入 DEV 必须被拒绝") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code(), "H-R14-1:必须 403 关闭跨产品权限升级路径,不得降级为 400/200") assert.Contains(t, ce.Error(), "仅超级管理员可将用户调入研发部门", "错误文案须明确指向'DEV 部门收敛到 SuperAdmin'的产品决策,方便 SRE 日志定位") // 关键:DB 不得被任何形式污染 —— 即便返回 403,sys_user.deptId 也必须停留在 srcDept。 u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, srcDeptId, u.DeptId, "被拒绝的调入 DEV 请求必须对 DB 零副作用,否则等于无视 403 的 bypass") } // TC-1125: H-R14-1 对偶正向 —— SuperAdmin 调入 DEV 必须成功。 // 确保护栏只卡"非超管"这一条路径,不会把 SuperAdmin 的合法运维动作误伤。 func TestUpdateUser_H_R14_1_SuperAdminCanMoveToDevDept(t *testing.T) { bootstrap := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_su_src", "/8300/", consts.DeptTypeNormal) devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_su_dev", "/8400/", consts.DeptTypeDev) targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_su_tgt", srcDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, devDeptId) }) superCtx := ctxhelper.SuperAdminCtx() newDept := devDeptId require.NoError(t, NewUpdateUserLogic(superCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }), "SuperAdmin 调入 DEV 必须允许,否则会把合法运维动作误杀") u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, devDeptId, u.DeptId, "SuperAdmin 路径下 DeptId 必须真的写入 DEV 部门") } // TC-1126: 对偶正向 —— ADMIN 在"自己管辖子树内"的跨节点调动(同 DeptPath 前缀)必须放行, // 避免 L-R16-1 把 DeptPath 前缀校验拉齐之后,误伤 ADMIN 在自己子树内的日常维护动作。 // 覆盖的不变量是:"严格前缀匹配"而不是"严格等于"——同子树 = 允许,跨子树 = 拒绝。 func TestUpdateUser_AdminCanMoveWithinOwnSubtree(t *testing.T) { bootstrap := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() adminDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_admin_x", "/8500/", consts.DeptTypeNormal) siblingDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_sibling", "/8500/2/", consts.DeptTypeNormal) srcDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "h_r14_1_src_x", "/8500/1/", consts.DeptTypeNormal) targetId := insertTestUserWithDept(t, bootstrap, "h_r14_1_tgt_x", srcDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, siblingDeptId, srcDeptId) }) adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 99992, Username: "h_r14_1_admin_x", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "test_product", DeptId: adminDeptId, DeptPath: "/8500/", MinPermsLevel: math.MaxInt64, }) newDept := siblingDeptId require.NoError(t, NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }), "ADMIN 在自己子树内(/8500/ 前缀下)的跨节点调动必须放行,否则 ADMIN 无法履职") u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, siblingDeptId, u.DeptId) } // TC-1170: 跨产品场景下,P1 ADMIN 不能把同时是 P2 成员的 target 挪到 P1 子树之外的 NORMAL // 部门。攻击面:调用者 ADMIN 在产品 P1 的授权面仅覆盖 P1,但 sys_user.deptId 是**全局**字段, // 一旦放行,target 在 P2 视角里的 DeptPath 会落到一个 P2 管理层根本看不到的子树上 // (P2 的 checkDeptHierarchy 立刻把所有 P2 管理员对 target 的管理动作全部 403)——与 // deptId=0 的 L-R15-1 攻击链完全同构。必须在 UpdateUser 这一层把 ADMIN 跨子树动作拦截。 func TestUpdateUser_AdminWithDualProductTarget_CrossSubtreeBlocked(t *testing.T) { bootstrap := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() p1 := "p_dual_1_" + testutil.UniqueId() p2 := "p_dual_2_" + testutil.UniqueId() adminDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dual_p1_admin", "/9100/") // target 原属 P1 子树;攻击方向是"挪到 /9200/ —— 完全不在 /9100/ 前缀下"。 srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "dual_src", "/9100/1/") otherSubtreeId := insertTestDeptForScope(t, bootstrap, svcCtx, "dual_other", "/9200/") targetId := insertTestUserWithDept(t, bootstrap, "dual_tgt", srcDeptId) m1 := insertTestMember(t, svcCtx, p1, targetId) m2 := insertTestMember(t, svcCtx, p2, targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", m1, m2) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", adminDeptId, srcDeptId, otherSubtreeId) }) adminCtx := middleware.WithUserDetails(context.Background(), &loaders.UserDetails{ UserId: 66610, Username: "dual_admin", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: p1, DeptId: adminDeptId, DeptPath: "/9100/", MinPermsLevel: math.MaxInt64, }) newDept := otherSubtreeId err := NewUpdateUserLogic(adminCtx, svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }) require.Error(t, err, "跨产品共享成员 + ADMIN 跨子树调度 = L-R15-1 同构越权,必须 403") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) u, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, srcDeptId, u.DeptId, "DB 不得被污染——否则 P2 视角会立刻失联:P2 所有管理动作对 target 都会 403") } // TC-1171: UpdateUser 把 target 从"DEV+Enabled 部门"调到 NORMAL 部门 → sys_user.tokenVersion +1。 // 收窄方向:loadPerms 的 DEV 全权分支以 (MemberType!="" && DeptType=DEV && DeptStatus=Enabled) // 为条件,DEV→NORMAL 让本用户在**所有**他已加入的产品里同时失去全权。必须在 UpdateUser 的 // 事务内把 sys_user.tokenVersion 原子性 +1,让 jwtauthMiddleware 下一次校验 401 旧 access token, // 而不是等 UD 聚合缓存的 5min TTL 自然过期。 func TestUpdateUser_L_R16_2_DevToNormal_RevokesSession(t *testing.T) { bootstrap := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "l_r16_dev", "/1100/", consts.DeptTypeDev) normalDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "l_r16_normal", "/1200/", consts.DeptTypeNormal) targetId := insertTestUserWithDept(t, bootstrap, "l_r16_tgt", devDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", devDeptId, normalDeptId) }) before, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) prevTokenVersion := before.TokenVersion newDept := normalDeptId require.NoError(t, NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, }), "SuperAdmin 调出 DEV 是合法运维,必须成功") after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, normalDeptId, after.DeptId, "deptId 必须真实落到 NORMAL 部门") assert.Equal(t, prevTokenVersion+1, after.TokenVersion, "DEV→NORMAL 构成 DEV 全权收窄,必须同事务 +1;否则 5min TTL 窗口内旧 access token 仍拥有全权") } // TC-1172: UpdateUser 把 target 从"DEV+Enabled 部门"挪到 deptId=0(移出部门树,SuperAdmin-only) // 同样是 DEV 全权收窄,必须把 tokenVersion +1;这里覆盖"newDept == nil 分支"的 devAccessRevoked 判定。 func TestUpdateUser_L_R16_2_DevToDeptZero_RevokesSession(t *testing.T) { bootstrap := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() devDeptId := insertTestDeptWithType(t, bootstrap, svcCtx, "l_r16_dev0", "/1300/", consts.DeptTypeDev) targetId := insertTestUserWithDept(t, bootstrap, "l_r16_dev0_tgt", devDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", devDeptId) }) before, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) prevTokenVersion := before.TokenVersion zero := int64(0) require.NoError(t, NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &zero, })) after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, int64(0), after.DeptId) assert.Equal(t, prevTokenVersion+1, after.TokenVersion, "DEV→deptId=0 同样构成 DEV 全权收窄,tokenVersion 必须 +1") } // TC-1173: UpdateUser 从 NORMAL→NORMAL 挪动 target 不得递增 tokenVersion —— 不构成任何权限收窄, // 升级为"吊销会话"会把合法用户无故踢下线,损害可用性。 func TestUpdateUser_L_R16_2_NormalToNormal_NoRevoke(t *testing.T) { bootstrap := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() srcDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "l_r16_n2n_src", "/1400/") dstDeptId := insertTestDeptForScope(t, bootstrap, svcCtx, "l_r16_n2n_dst", "/1500/") targetId := insertTestUserWithDept(t, bootstrap, "l_r16_n2n_tgt", srcDeptId) mId := insertTestMember(t, svcCtx, "test_product", targetId) t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", mId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) testutil.CleanTable(bootstrap, conn, "`sys_dept`", srcDeptId, dstDeptId) }) before, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) prevTokenVersion := before.TokenVersion newDept := dstDeptId require.NoError(t, NewUpdateUserLogic(ctxhelper.SuperAdminCtx(), svcCtx).UpdateUser(&types.UpdateUserReq{ Id: targetId, DeptId: &newDept, })) after, err := svcCtx.SysUserModel.FindOne(bootstrap, targetId) require.NoError(t, err) assert.Equal(t, dstDeptId, after.DeptId) assert.Equal(t, prevTokenVersion, after.TokenVersion, "NORMAL→NORMAL 不是收窄方向,tokenVersion 必须保持不变,否则等于无故 401 合法会话") }