package dept import ( "database/sql" "errors" "fmt" "testing" "time" 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" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zeromicro/go-zero/core/stores/redis" ) // TC-0106: 正常删除(无子部门) func TestDeleteDept_NoChildren(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() id, err := insertDeptRaw(ctx, svcCtx, 0, "del_"+testutil.UniqueId(), "/") require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", id) }) l := NewDeleteDeptLogic(ctx, svcCtx) err = l.DeleteDept(&types.DeleteDeptReq{Id: id}) require.NoError(t, err) _, err = svcCtx.SysDeptModel.FindOne(ctx, id) assert.Error(t, err) } // TC-0108: 不存在的部门 (audit 修复后:放入事务 + SELECT ... FOR UPDATE,不存在时返回 404) func TestDeleteDept_NonExistentDept(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) l := NewDeleteDeptLogic(ctx, svcCtx) err := l.DeleteDept(&types.DeleteDeptReq{Id: 999999999}) require.Error(t, err, "删除不存在的部门应当显式返回 NotFound,而非静默成功") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 404, ce.Code()) assert.Contains(t, ce.Error(), "部门不存在") } // TC-0107: 有子部门 func TestDeleteDept_HasChildren(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() parentId, err := insertDeptRaw(ctx, svcCtx, 0, "del_par_"+testutil.UniqueId(), "/") require.NoError(t, err) parent, _ := svcCtx.SysDeptModel.FindOne(ctx, parentId) childId, err := insertDeptRaw(ctx, svcCtx, parentId, "del_child_"+testutil.UniqueId(), parent.Path) require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", childId, parentId) }) l := NewDeleteDeptLogic(ctx, svcCtx) err = l.DeleteDept(&types.DeleteDeptReq{Id: parentId}) 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(), "该部门下存在子部门,无法删除") _, err = svcCtx.SysDeptModel.FindOne(ctx, parentId) assert.NoError(t, err) } // TC-0109: 部门下有关联用户 func TestDeleteDept_HasAssociatedUsers(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() deptId, err := insertDeptRaw(ctx, svcCtx, 0, "has_users_"+testutil.UniqueId(), "/") require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", deptId) }) now := time.Now().Unix() userRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: "dept_user_" + testutil.UniqueId(), Password: testutil.HashPassword("pass123456"), Nickname: "test", Avatar: sql.NullString{}, DeptId: deptId, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) userId, _ := userRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) l := NewDeleteDeptLogic(ctx, svcCtx) err = l.DeleteDept(&types.DeleteDeptReq{Id: 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, "该部门下仍有关联用户,无法删除", ce.Error()) _, err = svcCtx.SysDeptModel.FindOne(ctx, deptId) assert.NoError(t, err) } // TC-1200: H-R17-2 —— DeleteDept 成功后必须在 post-commit 上显式失效 sysDept 缓存。 // // 攻击链:DeleteWithTx 内部走 sqlc.CachedConn.ExecCtx,其"exec → DelCache"钩子跑在 // 外层 TransactCtx commit 之前。RR 隔离下其他事务在 commit 前按非锁读路径仍能看到旧行, // 并把它回填进 Redis;commit 之后 DB 没了该行,但 Redis 里挂着一张"幽灵快照"直到 TTL // 到期(5min)。叠加 H-R17-1 的 orphan user,幽灵 dept 会让该用户继续按 dept/Path 被 // 授权。测试手工把 canary 写进 sysDeptIdKey,模拟上述"commit 后又被回填"的快照, // DeleteDept 在 post-commit 阶段必须把它删掉。 func TestDeleteDept_H_R17_2_InvalidatesCacheAfterCommit(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() cfg := testutil.GetTestConfig() svcCtx := svc.NewServiceContext(cfg) conn := testutil.GetTestSqlConn() rds := redis.MustNewRedis(cfg.CacheRedis.Nodes[0].RedisConf) id, err := insertDeptRaw(ctx, svcCtx, 0, "h_r17_2_"+testutil.UniqueId(), "/") require.NoError(t, err) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_dept`", id) }) cacheKey := fmt.Sprintf("%s:cache:sysDept:id:%d", cfg.CacheRedis.KeyPrefix, id) t.Cleanup(func() { _, _ = rds.Del(cacheKey) }) require.NoError(t, rds.Set(cacheKey, `{"Id":`+fmt.Sprint(id)+`,"Name":"ghost"}`), "预置 ghost snapshot 失败,Redis 不可用导致测试前置条件失败") pre, err := rds.Exists(cacheKey) require.NoError(t, err) require.True(t, pre, "canary 必须先出现在 Redis,作为 post-commit DEL 的观察对象") err = NewDeleteDeptLogic(ctx, svcCtx).DeleteDept(&types.DeleteDeptReq{Id: id}) require.NoError(t, err) exists, err := rds.Exists(cacheKey) require.NoError(t, err) assert.False(t, exists, "H-R17-2:DeleteDept commit 之后 InvalidateDeptCache 必须把 sysDeptIdKey DEL 掉;"+ "若 canary 残留 ≥1s,说明 post-commit 失效兜底被误撤,5min 内其他路径仍会读到 ghost dept,"+ "叠加 orphan user 会产生跨部门授权泄漏") } // TC-0534: deleteDept非超管拒绝 func TestDeleteDept_NonSuperAdminRejected(t *testing.T) { ctx := ctxhelper.AdminCtx("test_product") svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) l := NewDeleteDeptLogic(ctx, svcCtx) err := l.DeleteDept(&types.DeleteDeptReq{Id: 1}) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) }