deptTreeLogic_test.go 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. package dept
  2. import (
  3. "context"
  4. "testing"
  5. "perms-system-server/internal/consts"
  6. "perms-system-server/internal/loaders"
  7. "perms-system-server/internal/middleware"
  8. deptModel "perms-system-server/internal/model/dept"
  9. "perms-system-server/internal/testutil/mocks"
  10. "github.com/stretchr/testify/assert"
  11. "github.com/stretchr/testify/require"
  12. "go.uber.org/mock/gomock"
  13. )
  14. // ---------------------------------------------------------------------------
  15. // 覆盖目标:只有 SuperAdmin 才能拿到完整部门树;产品 ADMIN / DEVELOPER / MEMBER 一律
  16. // 按 caller.DeptPath 前缀过滤,仅返回以其为根的子树。避免:
  17. // * MEMBER 级账号枚举全公司组织结构;
  18. // * 定位 DEV 部门再针对性申请权限;
  19. // * 审计 L-R15-2:小产品 ADMIN 借 fullAccess 侦察大产品的 DEV/HR/财务部门命名,
  20. // 为针对性社工 / 撞库提供前置输入——sys_dept 是全局命名空间,ADMIN 在产品 P1
  21. // 的授权范围不应扩散到 P2 的组织结构视图。
  22. //
  23. // 测试数据:一棵 "/100/" 根下挂 "/100/1/"、"/100/1/5/",以及一个平行分支 "/200/"。
  24. // 期望:caller DeptPath="/100/1/" 只能看到 "/100/1/" 和 "/100/1/5/";
  25. // SuperAdmin 看到完整的 `/100/` + `/200/` 两个根。
  26. // ---------------------------------------------------------------------------
  27. var allDepts = []*deptModel.SysDept{
  28. {Id: 100, ParentId: 0, Path: "/100/", Name: "root"},
  29. {Id: 1, ParentId: 100, Path: "/100/1/", Name: "childA"},
  30. {Id: 5, ParentId: 1, Path: "/100/1/5/", Name: "grandchild"},
  31. {Id: 2, ParentId: 100, Path: "/100/2/", Name: "childB"},
  32. {Id: 200, ParentId: 0, Path: "/200/", Name: "siblingRoot"},
  33. }
  34. func ctxWith(caller *loaders.UserDetails) context.Context {
  35. return middleware.WithUserDetails(context.Background(), caller)
  36. }
  37. // TC-0855: MEMBER DeptPath="/100/1/" 应只看到 "/100/1/" 和 "/100/1/5/",且局部根就是 "/100/1/"。
  38. func TestDeptTree_Member_PrunedToSubtree(t *testing.T) {
  39. ctrl := gomock.NewController(t)
  40. t.Cleanup(ctrl.Finish)
  41. deptMock := mocks.NewMockSysDeptModel(ctrl)
  42. deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil)
  43. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  44. caller := &loaders.UserDetails{
  45. UserId: 42, IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
  46. DeptId: 1, DeptPath: "/100/1/", ProductCode: "pA",
  47. }
  48. tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
  49. require.NoError(t, err)
  50. // 剪枝后只剩 2 个节点;根仍应只有一个(id=1,grandchild 挂在其下)。
  51. require.Len(t, tree, 1, "剪枝后根只剩 1 个(caller 所在部门)")
  52. assert.Equal(t, int64(1), tree[0].Id, "局部根必须是 /100/1/,不得把 /100/ 也暴露")
  53. assert.Equal(t, "/100/1/", tree[0].Path)
  54. require.Len(t, tree[0].Children, 1, "grandchild 必须挂在局部根下")
  55. assert.Equal(t, int64(5), tree[0].Children[0].Id)
  56. }
  57. // TC-0856: MEMBER DeptPath="" —— 返回空切片,即使 DB 有数据。
  58. func TestDeptTree_OrphanMember_ReturnsEmpty(t *testing.T) {
  59. ctrl := gomock.NewController(t)
  60. t.Cleanup(ctrl.Finish)
  61. deptMock := mocks.NewMockSysDeptModel(ctrl)
  62. // 注意:当前实现会先 FindAll 再剪枝,空 DeptPath 直接走空返回,但 FindAll 仍被调用一次(有些成本),
  63. // 这里设置 AnyTimes 适配 "就算调用一次也可以"。
  64. deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil).AnyTimes()
  65. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  66. caller := &loaders.UserDetails{
  67. UserId: 43, IsSuperAdmin: false, MemberType: consts.MemberTypeMember,
  68. DeptId: 0, DeptPath: "", ProductCode: "pA",
  69. }
  70. tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
  71. require.NoError(t, err)
  72. assert.Len(t, tree, 0, "DeptPath 为空必须返回空树,不能泄露组织结构")
  73. }
  74. // TC-0857(L-R15-2 后契约反转):产品 ADMIN 不再拥有 fullAccess——只能看到以 DeptPath
  75. // 为根的子树,与 MEMBER / DEVELOPER 同路径。
  76. //
  77. // 关键断言不只是"根只剩 1 个",还要显式证明 "平行分支 /200/ 对 ADMIN 不可见",
  78. // 以免未来如果有人把条件从 `caller.IsSuperAdmin` 又放宽回 `|| MemberType == Admin`,
  79. // 本测试能在第一次 run 立刻飘红。
  80. func TestDeptTree_Admin_PrunedToSubtree(t *testing.T) {
  81. ctrl := gomock.NewController(t)
  82. t.Cleanup(ctrl.Finish)
  83. deptMock := mocks.NewMockSysDeptModel(ctrl)
  84. deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil)
  85. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  86. caller := &loaders.UserDetails{
  87. UserId: 2, IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin,
  88. DeptId: 1, DeptPath: "/100/1/", ProductCode: "pA",
  89. }
  90. tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
  91. require.NoError(t, err)
  92. require.Len(t, tree, 1,
  93. "L-R15-2:产品 ADMIN 不再享有 fullAccess,剪枝后局部根只有 1 个(caller 自己的 DeptPath)")
  94. assert.Equal(t, int64(1), tree[0].Id,
  95. "局部根必须是 /100/1/,父部门 /100/ 不应暴露给 ADMIN")
  96. assert.Equal(t, "/100/1/", tree[0].Path)
  97. require.Len(t, tree[0].Children, 1, "grandchild /100/1/5/ 必须挂在局部根下")
  98. assert.Equal(t, int64(5), tree[0].Children[0].Id)
  99. for _, r := range tree {
  100. assert.NotEqual(t, int64(200), r.Id,
  101. "平行分支 /200/ 对产品 ADMIN 必须不可见——若回归到旧 fullAccess,这里立刻飘红")
  102. assert.NotEqual(t, int64(100), r.Id,
  103. "父部门 /100/ 也不应暴露给 ADMIN(同上)")
  104. }
  105. }
  106. // TC-1128:SuperAdmin 仍然享有 fullAccess,完整树返回两个根(/100/ + /200/)。
  107. // 这一条是 L-R15-2 收敛范围的正向回归:确认 `fullAccess = caller.IsSuperAdmin`
  108. // 的 true 分支未被连带削弱(否则超管运营视角会瘫痪)。
  109. func TestDeptTree_SuperAdmin_FullTree(t *testing.T) {
  110. ctrl := gomock.NewController(t)
  111. t.Cleanup(ctrl.Finish)
  112. deptMock := mocks.NewMockSysDeptModel(ctrl)
  113. deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil)
  114. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  115. // 即便 DeptPath 指定成某具体子部门,SuperAdmin 也必须拿到全局树——
  116. // fullAccess 判定是 IsSuperAdmin 而不是 DeptPath。
  117. caller := &loaders.UserDetails{
  118. UserId: 1, IsSuperAdmin: true, MemberType: consts.MemberTypeAdmin,
  119. DeptId: 100, DeptPath: "/100/", ProductCode: "pA",
  120. }
  121. tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
  122. require.NoError(t, err)
  123. require.Len(t, tree, 2, "SuperAdmin 必须看到完整树,包含所有平行根")
  124. var rootIds []int64
  125. for _, r := range tree {
  126. rootIds = append(rootIds, r.Id)
  127. }
  128. assert.ElementsMatch(t, []int64{100, 200}, rootIds,
  129. "SuperAdmin 走 fullAccess 分支,平行根 /100/ 与 /200/ 必须同时出现")
  130. }
  131. // TC-1129:产品 DEVELOPER 与 MEMBER 同路径——只能看自己 DeptPath 的子树。
  132. // 这一条补齐 L-R15-2 的"角色对称"覆盖:fullAccess 判定只认 SuperAdmin,
  133. // ADMIN / DEVELOPER / MEMBER 三者剪枝语义必须完全一致。
  134. func TestDeptTree_Developer_PrunedToSubtree(t *testing.T) {
  135. ctrl := gomock.NewController(t)
  136. t.Cleanup(ctrl.Finish)
  137. deptMock := mocks.NewMockSysDeptModel(ctrl)
  138. deptMock.EXPECT().FindAll(gomock.Any()).Return(allDepts, nil)
  139. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock})
  140. caller := &loaders.UserDetails{
  141. UserId: 3, IsSuperAdmin: false, MemberType: consts.MemberTypeDeveloper,
  142. DeptId: 1, DeptPath: "/100/1/", ProductCode: "pA",
  143. }
  144. tree, err := NewDeptTreeLogic(ctxWith(caller), svcCtx).DeptTree()
  145. require.NoError(t, err)
  146. require.Len(t, tree, 1, "DEVELOPER 同样不享有 fullAccess,只能看到自己子树")
  147. assert.Equal(t, int64(1), tree[0].Id, "局部根必须是 /100/1/")
  148. for _, r := range tree {
  149. assert.NotEqual(t, int64(200), r.Id,
  150. "平行分支 /200/ 对 DEVELOPER 同样不可见")
  151. }
  152. }