package dept import ( "context" "testing" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" deptModel "perms-system-server/internal/model/dept" "perms-system-server/internal/testutil/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) // --------------------------------------------------------------------------- // 覆盖目标:审计第 6 轮 M-2 修复回归 —— 非超管/非 ADMIN 调 /api/dept/tree 时必须按 // caller.DeptPath 前缀过滤部门,只返回以其为根的子树。避免: // * MEMBER 级账号枚举全公司组织结构; // * 定位 DEV 部门再针对性申请权限。 // // ADMIN / SuperAdmin 保留完整树(运营使用场景)。 // // 测试数据:一棵 "/100/" 根下挂 "/100/1/"、"/100/1/5/",以及一个平行分支 "/200/"。 // 期望:caller DeptPath="/100/1/" 只能看到 "/100/1/" 和 "/100/1/5/"。 // --------------------------------------------------------------------------- var allDepts = []*deptModel.SysDept{ {Id: 100, ParentId: 0, Path: "/100/", Name: "root"}, {Id: 1, ParentId: 100, Path: "/100/1/", Name: "childA"}, {Id: 5, ParentId: 1, Path: "/100/1/5/", Name: "grandchild"}, {Id: 2, ParentId: 100, Path: "/100/2/", Name: "childB"}, {Id: 200, ParentId: 0, Path: "/200/", Name: "siblingRoot"}, } func ctxWith(caller *loaders.UserDetails) context.Context { return middleware.WithUserDetails(context.Background(), caller) } // TC-0855: MEMBER DeptPath="/100/1/" 应只看到 "/100/1/" 和 "/100/1/5/",且局部根就是 "/100/1/"。 func TestDeptTree_Member_PrunedToSubtree(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) caller := &loaders.UserDetails{ UserId: 42, IsSuperAdmin: false, MemberType: consts.MemberTypeMember, DeptId: 1, DeptPath: "/100/1/", ProductCode: "pA", } tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree() require.NoError(t, err) // 剪枝后只剩 2 个节点;根仍应只有一个(id=1,grandchild 挂在其下)。 require.Len(t, tree, 1, "M-2:剪枝后根只剩 1 个(caller 所在部门)") assert.Equal(t, int64(1), tree[0].Id, "M-2:局部根必须是 /100/1/,不得把 /100/ 也暴露") assert.Equal(t, "/100/1/", tree[0].Path) require.Len(t, tree[0].Children, 1, "M-2:grandchild 必须挂在局部根下") assert.Equal(t, int64(5), tree[0].Children[0].Id) } // TC-0856: MEMBER DeptPath="" —— 返回空切片,即使 DB 有数据。 func TestDeptTree_OrphanMember_ReturnsEmpty(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) // 注意:当前实现会先 FindAll 再剪枝,空 DeptPath 直接走空返回,但 FindAll 仍被调用一次(有些成本), // 这里设置 AnyTimes 适配 "就算调用一次也可以"。 deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil).AnyTimes() svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) caller := &loaders.UserDetails{ UserId: 43, IsSuperAdmin: false, MemberType: consts.MemberTypeMember, DeptId: 0, DeptPath: "", ProductCode: "pA", } tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree() require.NoError(t, err) assert.Len(t, tree, 0, "M-2:DeptPath 为空必须返回空树,不能泄露组织结构") } // TC-0857: 产品 ADMIN —— 视为 fullAccess,返回完整树(两个根)。 func TestDeptTree_Admin_FullTree(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) caller := &loaders.UserDetails{ UserId: 2, IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, DeptId: 100, DeptPath: "/100/", ProductCode: "pA", } tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree() require.NoError(t, err) // 完整树:根有 2 个(id=100, id=200)。 require.Len(t, tree, 2, "M-2:ADMIN 应看到完整部门树,包括兄弟分支") var rootIds []int64 for _, r := range tree { rootIds = append(rootIds, r.Id) } assert.ElementsMatch(t, []int64{100, 200}, rootIds) }