checkManageAccessPrefetch_audit_test.go 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. package auth
  2. import (
  3. "context"
  4. "math"
  5. "testing"
  6. "perms-system-server/internal/consts"
  7. "perms-system-server/internal/loaders"
  8. "perms-system-server/internal/middleware"
  9. deptModel "perms-system-server/internal/model/dept"
  10. productmemberModel "perms-system-server/internal/model/productmember"
  11. userModel "perms-system-server/internal/model/user"
  12. "perms-system-server/internal/testutil/mocks"
  13. "github.com/stretchr/testify/assert"
  14. "go.uber.org/mock/gomock"
  15. )
  16. // ---------------------------------------------------------------------------
  17. // 覆盖目标:审计第 6 轮 M-5 修复回归 —— CheckManageAccess(WithPrefetchedTarget(...))
  18. // 允许调用方透传已经 FindOne 到的 target,避免单次请求内重复 FindOne(targetUserId)。
  19. //
  20. // 修复前:UpdateUserStatus / UpdateUser 一次请求会先做 ValidateStatusChange 里的 FindOne,
  21. // 紧接着 checkDeptHierarchy 里又 FindOne 一次,DB/缓存都白打一次 RTT。
  22. //
  23. // 修复后的契约:
  24. // * Option 与参数一致(target.Id == targetUserId)时,FindOne 必须被跳过;
  25. // * 不一致时 option 失效(defensive ignore),checkDeptHierarchy 回退到原有 FindOne 路径。
  26. // ---------------------------------------------------------------------------
  27. func buildMemberCallerCtx() context.Context {
  28. caller := &loaders.UserDetails{
  29. UserId: 1,
  30. Username: "op",
  31. IsSuperAdmin: false,
  32. MemberType: consts.MemberTypeMember,
  33. Status: consts.StatusEnabled,
  34. ProductCode: "pc_m5",
  35. DeptId: 100,
  36. DeptPath: "/100/",
  37. MinPermsLevel: 50,
  38. }
  39. return middleware.WithUserDetails(context.Background(), caller)
  40. }
  41. // TC-0860: 透传的 prefetched.Id 与 targetUserId 一致 → SysUserModel.FindOne 必须一次都不被调用。
  42. func TestCheckManageAccess_PrefetchedTarget_SkipsFindOne(t *testing.T) {
  43. ctrl := gomock.NewController(t)
  44. t.Cleanup(ctrl.Finish)
  45. userMock := mocks.NewMockSysUserModel(ctrl)
  46. // 关键断言:FindOne 次数为 0。gomock 默认不允许未声明的调用;省略 EXPECT 即相当于 0 次。
  47. deptMock := mocks.NewMockSysDeptModel(ctrl)
  48. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  49. roleMock := mocks.NewMockSysRoleModel(ctrl)
  50. // 目标用户所在部门的 Path 需满足 HasPrefix caller.DeptPath="/100/"
  51. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  52. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
  53. // 目标产品成员存在,MemberType=MEMBER 与 caller 同级 → 走 permsLevel 比较分支。
  54. pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
  55. Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
  56. // 目标的 permsLevel 高于 caller(数值更大 → 权限更低),校验放行。
  57. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
  58. Return(int64(100), nil)
  59. // 审计 H-2:checkPermLevel 现在会对 caller 也做一次 DB fresh read。
  60. // caller.UserId=1,permsLevel=50(比 target=100 严格高权)→ 放行。
  61. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), "pc_m5").
  62. Return(int64(50), nil)
  63. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  64. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  65. })
  66. prefetched := &userModel.SysUser{Id: 42, DeptId: 101}
  67. err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(prefetched))
  68. assert.NoError(t, err,
  69. "M-5:prefetched 与 targetUserId 一致且业务级校验全部通过时应放行")
  70. // ctrl.Finish() 里会自动校验 userMock.FindOne 调用次数为 0(未显式 EXPECT),
  71. // 若源码回退到 FindOne 路径测试会抛 "unexpected call to FindOne" 直接 FAIL。
  72. }
  73. // TC-0861: 透传的 prefetched.Id 与 targetUserId 不一致 → option 被 defensive 忽略,
  74. // 必须真实调用 SysUserModel.FindOne(ctx, targetUserId) 一次。
  75. // 这是一条 "调用方把错 id 传进来时不能被当做合法 prefetched" 的安全断言:
  76. // 如果源码直接信任 prefetched 而不校验 Id,就会出现 "用 A 的 userDetails 去放行对 B 的管理"。
  77. func TestCheckManageAccess_PrefetchedIdMismatch_IgnoredAndFallsBackToFindOne(t *testing.T) {
  78. ctrl := gomock.NewController(t)
  79. t.Cleanup(ctrl.Finish)
  80. userMock := mocks.NewMockSysUserModel(ctrl)
  81. deptMock := mocks.NewMockSysDeptModel(ctrl)
  82. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  83. roleMock := mocks.NewMockSysRoleModel(ctrl)
  84. // 关键断言:FindOne(targetUserId=42) 必须真实被调用一次,说明 prefetched 没被盲信。
  85. // 我们返回的真实对象 DeptId=101(与乱传的 prefetched 一致),好让流程继续走通。
  86. userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
  87. Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1)
  88. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  89. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
  90. pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
  91. Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
  92. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
  93. Return(int64(100), nil)
  94. // 审计 H-2:caller 侧 fresh read 仍需要。
  95. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), "pc_m5").
  96. Return(int64(50), nil)
  97. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  98. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  99. })
  100. // 故意传 Id=999,与 targetUserId=42 不一致。
  101. wrong := &userModel.SysUser{Id: 999, DeptId: 101}
  102. err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(wrong))
  103. assert.NoError(t, err,
  104. "M-5:prefetched.Id 不匹配时回退 FindOne 后,本场景仍应通过业务级校验")
  105. }
  106. // 正向防御:prefetched 为 nil 时也不应 panic,且必须走 FindOne 一次(不传 option 的等价路径)。
  107. func TestCheckManageAccess_NilPrefetched_FallsBackToFindOne(t *testing.T) {
  108. ctrl := gomock.NewController(t)
  109. t.Cleanup(ctrl.Finish)
  110. userMock := mocks.NewMockSysUserModel(ctrl)
  111. deptMock := mocks.NewMockSysDeptModel(ctrl)
  112. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  113. roleMock := mocks.NewMockSysRoleModel(ctrl)
  114. userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
  115. Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1)
  116. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  117. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
  118. pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
  119. Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
  120. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
  121. Return(int64(math.MaxInt64), nil)
  122. // 审计 H-2:caller 侧 fresh read。
  123. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), "pc_m5").
  124. Return(int64(50), nil)
  125. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  126. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  127. })
  128. err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(nil))
  129. assert.NoError(t, err)
  130. }