| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472 |
- package dept
- import (
- "context"
- "database/sql"
- "errors"
- "math"
- "perms-system-server/internal/consts"
- "perms-system-server/internal/loaders"
- "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/testutil/mocks"
- "perms-system-server/internal/types"
- "testing"
- "time"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "go.uber.org/mock/gomock"
- )
- func TestUpdateDept_Normal(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, err := insertDeptRaw(ctx, svcCtx, 0, "upd_"+testutil.UniqueId(), "/")
- require.NoError(t, err)
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
- newName := "updated_" + testutil.UniqueId()
- l := NewUpdateDeptLogic(ctx, svcCtx)
- err = l.UpdateDept(&types.UpdateDeptReq{
- Id: deptId,
- Name: newName,
- Sort: 99,
- Remark: "updated remark",
- Status: 2,
- })
- require.NoError(t, err)
- d, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
- require.NoError(t, err)
- assert.Equal(t, newName, d.Name)
- assert.Equal(t, int64(99), d.Sort)
- assert.Equal(t, "updated remark", d.Remark)
- assert.Equal(t, int64(2), d.Status)
- }
- // TC-0102: 不存在
- func TestUpdateDept_NotFound(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- l := NewUpdateDeptLogic(ctx, svcCtx)
- err := l.UpdateDept(&types.UpdateDeptReq{
- Id: 999999999,
- Name: "ghost_" + testutil.UniqueId(),
- })
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce))
- assert.Equal(t, 404, ce.Code())
- assert.Equal(t, "部门不存在", ce.Error())
- }
- // TC-0101: 正常更新
- func TestUpdateDept_StatusZeroUnchanged(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, err := insertDeptRaw(ctx, svcCtx, 0, "s0_"+testutil.UniqueId(), "/")
- require.NoError(t, err)
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
- before, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
- require.NoError(t, err)
- assert.Equal(t, int64(1), before.Status)
- l := NewUpdateDeptLogic(ctx, svcCtx)
- err = l.UpdateDept(&types.UpdateDeptReq{
- Id: deptId,
- Name: "changed_" + testutil.UniqueId(),
- Status: 0,
- })
- require.NoError(t, err)
- after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
- require.NoError(t, err)
- assert.Equal(t, int64(1), after.Status)
- }
- // TC-0103: DeptType NORMAL→DEV
- func TestUpdateDept_DeptType_NormalToDev(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, err := insertDeptRaw(ctx, svcCtx, 0, "dt_"+testutil.UniqueId(), "/")
- require.NoError(t, err)
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
- before, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
- require.NoError(t, err)
- assert.Equal(t, "NORMAL", before.DeptType)
- l := NewUpdateDeptLogic(ctx, svcCtx)
- err = l.UpdateDept(&types.UpdateDeptReq{
- Id: deptId,
- Name: "dt_changed_" + testutil.UniqueId(),
- DeptType: "DEV",
- })
- require.NoError(t, err)
- after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
- require.NoError(t, err)
- assert.Equal(t, "DEV", after.DeptType)
- }
- // TC-0104: DeptType无效值返回错误
- func TestUpdateDept_DeptType_InvalidRejected(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, err := insertDeptRaw(ctx, svcCtx, 0, "dti_"+testutil.UniqueId(), "/")
- require.NoError(t, err)
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
- l := NewUpdateDeptLogic(ctx, svcCtx)
- err = l.UpdateDept(&types.UpdateDeptReq{
- Id: deptId,
- Name: "dti_changed_" + testutil.UniqueId(),
- DeptType: "INVALID_TYPE",
- })
- 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(), "部门类型无效")
- after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
- require.NoError(t, err)
- assert.Equal(t, "NORMAL", after.DeptType, "无效DeptType不应修改数据库")
- }
- // TC-0533: updateDept非超管拒绝
- func TestUpdateDept_NonSuperAdminRejected(t *testing.T) {
- ctx := ctxhelper.AdminCtx("test_product")
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- l := NewUpdateDeptLogic(ctx, svcCtx)
- err := l.UpdateDept(&types.UpdateDeptReq{Id: 1, Name: "test"})
- require.Error(t, err)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce))
- assert.Equal(t, 403, ce.Code())
- }
- func superAdminCtx() context.Context {
- return middleware.WithUserDetails(context.Background(), &loaders.UserDetails{
- UserId: 1, Username: "su",
- IsSuperAdmin: true, MemberType: consts.MemberTypeSuperAdmin,
- Status: consts.StatusEnabled,
- })
- }
- // TC-0848: deptType 变更 → FindIdsByDeptId 恰好调用 1 次,返回 [100, 101];handler 返回 nil。
- // NORMAL→DEV 方向是放宽权限,不吊销会话;post-commit 仍需 FindIdsByDeptId 清 UD 聚合缓存。
- func TestUpdateDept_DeptTypeChanged_InvokesFindIdsOnce(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- deptMock := mocks.NewMockSysDeptModel(ctrl)
- userMock := mocks.NewMockSysUserModel(ctrl)
- // 老部门是 NORMAL,请求改成 DEV → deptTypeChanged=true 才能触发 FindIdsByDeptId。
- deptMock.EXPECT().FindOne(gomock.Any(), int64(77)).
- Return(&deptModel.SysDept{
- Id: 77, Name: "n", DeptType: consts.DeptTypeNormal,
- Status: consts.StatusEnabled, UpdateTime: 500,
- }, nil)
- runDeptTxInline(deptMock)
- deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(500)).Return(nil)
- deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(77))
- // 关键断言:post-commit 恰好调用 1 次;返回的切片会被继续塞进 CleanByUserIds(loader 内部走 Redis)。
- userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(77)).
- Return([]int64{100, 101}, nil).Times(1)
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
- Dept: deptMock, User: userMock,
- })
- err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{
- Id: 77, Name: "n", Sort: 0, Remark: "", DeptType: consts.DeptTypeDev,
- })
- require.NoError(t, err,
- "正常场景 UpdateDept 必须返回 nil,且仅调用一次 FindIdsByDeptId")
- }
- // TC-0849: FindIdsByDeptId 返回 err —— handler 仍返回 nil(degraded 成功),旧缓存由 TTL 过期兜底。
- func TestUpdateDept_FindIdsByDeptIdError_DegradedSuccess(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- deptMock := mocks.NewMockSysDeptModel(ctrl)
- userMock := mocks.NewMockSysUserModel(ctrl)
- deptMock.EXPECT().FindOne(gomock.Any(), int64(88)).
- Return(&deptModel.SysDept{
- Id: 88, Name: "n", DeptType: consts.DeptTypeNormal,
- Status: consts.StatusEnabled, UpdateTime: 1000,
- }, nil)
- runDeptTxInline(deptMock)
- deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(1000)).Return(nil)
- deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(88))
- userMock.EXPECT().FindIdsByDeptId(gomock.Any(), int64(88)).
- Return(nil, errors.New("transient DB error"))
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
- Dept: deptMock, User: userMock,
- })
- err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{
- Id: 88, Name: "n", DeptType: consts.DeptTypeDev,
- })
- assert.NoError(t, err,
- "FindIdsByDeptId 失败不得映射 500;TTL 过期兜底,客户端不应重试整次 UpdateDept")
- }
- // seedDeptWithUser 建一个自定义 DeptType/Status 的部门,并挂一个新 sys_user 到该部门下。
- // 返回 (deptId, userId);统一 cleanup 由调用方负责,避免测试之间相互拖拽。
- func seedDeptWithUser(t *testing.T, svcCtx *svc.ServiceContext,
- tag, path, deptType string, status int64) (int64, int64) {
- t.Helper()
- bootstrap := ctxhelper.SuperAdminCtx()
- now := time.Now().Unix()
- dRes, err := svcCtx.SysDeptModel.Insert(bootstrap, &deptModel.SysDept{
- ParentId: 0, Name: tag + "_" + testutil.UniqueId(), Path: path, Sort: 0,
- DeptType: deptType, Status: status, CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- deptId, _ := dRes.LastInsertId()
- uRes, err := svcCtx.SysUserModel.Insert(bootstrap, &userModel.SysUser{
- Username: tag + "_u_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"),
- Avatar: sql.NullString{}, DeptId: deptId,
- IsSuperAdmin: 2, MustChangePassword: 2, Status: 1,
- CreateTime: now, UpdateTime: now,
- })
- require.NoError(t, err)
- userId, _ := uRes.LastInsertId()
- return deptId, userId
- }
- // TC-1174: UpdateDept DeptType: DEV → NORMAL 必须把该部门下**所有**在编 sys_user 的
- // tokenVersion 原子性 +1(签发层吊销),否则 5min TTL 内旧 access token 仍享有 DEV 全权。
- func TestUpdateDept_L_R16_2_DevTypeToNormal_RevokesAllMembers(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_dev2norm", "/7100/",
- consts.DeptTypeDev, consts.StatusEnabled)
- t.Cleanup(func() {
- testutil.CleanTable(ctx, conn, "`sys_user`", userId)
- testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
- })
- before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- prev := before.TokenVersion
- require.NoError(t,
- NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
- Id: deptId, Name: "renamed_" + testutil.UniqueId(), DeptType: consts.DeptTypeNormal,
- }))
- after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, prev+1, after.TokenVersion,
- "DEV→NORMAL 让部门内所有人的 loadPerms 从全权分支掉到普通分支,tokenVersion 必须同事务 +1")
- }
- // TC-1175: DEV 部门 Status Enabled → Disabled —— 同样构成 DEV 全权收窄,tokenVersion 必须 +1。
- func TestUpdateDept_L_R16_2_DevStatusToDisabled_RevokesAllMembers(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_dev_dis", "/7200/",
- consts.DeptTypeDev, consts.StatusEnabled)
- t.Cleanup(func() {
- testutil.CleanTable(ctx, conn, "`sys_user`", userId)
- testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
- })
- before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- prev := before.TokenVersion
- require.NoError(t,
- NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
- Id: deptId, Name: "dev_disabled_" + testutil.UniqueId(),
- DeptType: consts.DeptTypeDev, Status: consts.StatusDisabled,
- }))
- after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, prev+1, after.TokenVersion,
- "DEV 部门 Enabled→Disabled 与 DeptType DEV→NORMAL 同构,都让 loadPerms 全权分支失效")
- }
- // TC-1176: NORMAL 部门 Status Enabled → Disabled —— 业务语义是"冻结本部门所有活动",
- // 必须把部门内成员的 tokenVersion 同事务 +1,防止 5min TTL 窗口内旧 token 继续读写。
- func TestUpdateDept_L_R16_2_NormalStatusToDisabled_RevokesAllMembers(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_norm_dis", "/7300/",
- consts.DeptTypeNormal, consts.StatusEnabled)
- t.Cleanup(func() {
- testutil.CleanTable(ctx, conn, "`sys_user`", userId)
- testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
- })
- before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- prev := before.TokenVersion
- require.NoError(t,
- NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
- Id: deptId, Name: "norm_disabled_" + testutil.UniqueId(),
- DeptType: consts.DeptTypeNormal, Status: consts.StatusDisabled,
- }))
- after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, prev+1, after.TokenVersion,
- "NORMAL 部门 Enabled→Disabled 是冻结动作,部门成员的 tokenVersion 必须 +1")
- }
- // TC-1177: NORMAL→DEV 是放宽权限的升级方向,tokenVersion 保持不变;把合法会话无故下线
- // 不仅没收益,还会让产品侧管理员需要重新登录 + 重新建会话,损害可用性。
- func TestUpdateDept_L_R16_2_NormalToDev_NoRevoke(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_norm2dev", "/7400/",
- consts.DeptTypeNormal, consts.StatusEnabled)
- t.Cleanup(func() {
- testutil.CleanTable(ctx, conn, "`sys_user`", userId)
- testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
- })
- before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- prev := before.TokenVersion
- require.NoError(t,
- NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
- Id: deptId, Name: "norm2dev_" + testutil.UniqueId(), DeptType: consts.DeptTypeDev,
- }))
- after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, prev, after.TokenVersion,
- "NORMAL→DEV 是升权方向,不得把现存会话吊销;否则每次类型调整都会触发一次大规模强制下线")
- }
- // TC-1178: 部门 Status Disabled→Enabled(恢复启用)同样是升权方向,tokenVersion 必须保持不变。
- func TestUpdateDept_L_R16_2_StatusReEnable_NoRevoke(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, userId := seedDeptWithUser(t, svcCtx, "l_r16_reenable", "/7500/",
- consts.DeptTypeNormal, consts.StatusDisabled)
- t.Cleanup(func() {
- testutil.CleanTable(ctx, conn, "`sys_user`", userId)
- testutil.CleanTable(ctx, conn, "`sys_dept`", deptId)
- })
- before, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- prev := before.TokenVersion
- require.NoError(t,
- NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
- Id: deptId, Name: "reenable_" + testutil.UniqueId(),
- DeptType: consts.DeptTypeNormal, Status: consts.StatusEnabled,
- }))
- after, err := svcCtx.SysUserModel.FindOne(ctx, userId)
- require.NoError(t, err)
- assert.Equal(t, prev, after.TokenVersion,
- "Disabled→Enabled 是恢复活动,不是收窄方向;tokenVersion 必须保持不变")
- }
- // deptType / status 都没变时,不应调 FindIdsByDeptId(避免无效缓存失效风暴);dept 自身行
- // 的 InvalidateDeptCache 仍需调用,因为 sys_dept 的 updateTime/name/remark/sort 已经被 UPDATE。
- func TestUpdateDept_NoEffectiveChange_SkipsFindIds(t *testing.T) {
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- deptMock := mocks.NewMockSysDeptModel(ctrl)
- userMock := mocks.NewMockSysUserModel(ctrl)
- // 老部门 DEV,请求也是 DEV;status 未传 → 没有变更。
- deptMock.EXPECT().FindOne(gomock.Any(), int64(99)).
- Return(&deptModel.SysDept{
- Id: 99, Name: "x", DeptType: consts.DeptTypeDev,
- Status: consts.StatusEnabled, UpdateTime: 200,
- }, nil)
- runDeptTxInline(deptMock)
- deptMock.EXPECT().UpdateWithOptLockTx(gomock.Any(), gomock.Any(), gomock.Any(), int64(200)).Return(nil)
- deptMock.EXPECT().InvalidateDeptCache(gomock.Any(), int64(99))
- // 关键:没有任何 FindIdsByDeptId / FindIdsByDeptIdForShareTx EXPECT 即等价 Times(0)。
- svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
- Dept: deptMock, User: userMock,
- })
- err := NewUpdateDeptLogic(superAdminCtx(), svcCtx).UpdateDept(&types.UpdateDeptReq{
- Id: 99, Name: "x", DeptType: consts.DeptTypeDev, Sort: 1,
- })
- require.NoError(t, err, "无变更时 UpdateDept 只做元字段更新,不得触发缓存清理风暴")
- }
- // TC-1203: UpdateDept Sort 超出范围 [-100000, 100000] 被拒绝。
- // 与 CreateDept 同口径校验,防极端 Sort 值破坏部门树排序稳定性。
- func TestUpdateDept_SortOutOfRange_Rejected(t *testing.T) {
- ctx := ctxhelper.SuperAdminCtx()
- svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
- conn := testutil.GetTestSqlConn()
- deptId, err := insertDeptRaw(ctx, svcCtx, 0, "sort_upd_"+testutil.UniqueId(), "/")
- require.NoError(t, err)
- t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) })
- before, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
- require.NoError(t, err)
- origSort := before.Sort
- for _, s := range []int64{100001, -100001, math.MaxInt64, math.MinInt64} {
- err := NewUpdateDeptLogic(ctx, svcCtx).UpdateDept(&types.UpdateDeptReq{
- Id: deptId, Name: "sort_test_" + testutil.UniqueId(), Sort: s,
- })
- require.Error(t, err, "Sort=%d 应被拒绝", s)
- var ce *response.CodeError
- require.True(t, errors.As(err, &ce), "Sort=%d 必须返回 CodeError", s)
- assert.Equal(t, 400, ce.Code(), "Sort=%d 应 400 拒绝", s)
- assert.Contains(t, ce.Error(), "排序值必须在 -100000 到 100000 之间")
- }
- // DB 中 Sort 值不得被改变。
- after, err := svcCtx.SysDeptModel.FindOne(ctx, deptId)
- require.NoError(t, err)
- assert.Equal(t, origSort, after.Sort, "Sort 越界被拒绝后 DB 值不得变化")
- }
|