checkManageAccessPrefetch_audit_test.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  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. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  60. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  61. })
  62. prefetched := &userModel.SysUser{Id: 42, DeptId: 101}
  63. err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(prefetched))
  64. assert.NoError(t, err,
  65. "M-5:prefetched 与 targetUserId 一致且业务级校验全部通过时应放行")
  66. // ctrl.Finish() 里会自动校验 userMock.FindOne 调用次数为 0(未显式 EXPECT),
  67. // 若源码回退到 FindOne 路径测试会抛 "unexpected call to FindOne" 直接 FAIL。
  68. }
  69. // TC-0861: 透传的 prefetched.Id 与 targetUserId 不一致 → option 被 defensive 忽略,
  70. // 必须真实调用 SysUserModel.FindOne(ctx, targetUserId) 一次。
  71. // 这是一条 "调用方把错 id 传进来时不能被当做合法 prefetched" 的安全断言:
  72. // 如果源码直接信任 prefetched 而不校验 Id,就会出现 "用 A 的 userDetails 去放行对 B 的管理"。
  73. func TestCheckManageAccess_PrefetchedIdMismatch_IgnoredAndFallsBackToFindOne(t *testing.T) {
  74. ctrl := gomock.NewController(t)
  75. t.Cleanup(ctrl.Finish)
  76. userMock := mocks.NewMockSysUserModel(ctrl)
  77. deptMock := mocks.NewMockSysDeptModel(ctrl)
  78. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  79. roleMock := mocks.NewMockSysRoleModel(ctrl)
  80. // 关键断言:FindOne(targetUserId=42) 必须真实被调用一次,说明 prefetched 没被盲信。
  81. // 我们返回的真实对象 DeptId=101(与乱传的 prefetched 一致),好让流程继续走通。
  82. userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
  83. Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1)
  84. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  85. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
  86. pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
  87. Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
  88. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
  89. Return(int64(100), nil)
  90. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  91. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  92. })
  93. // 故意传 Id=999,与 targetUserId=42 不一致。
  94. wrong := &userModel.SysUser{Id: 999, DeptId: 101}
  95. err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(wrong))
  96. assert.NoError(t, err,
  97. "M-5:prefetched.Id 不匹配时回退 FindOne 后,本场景仍应通过业务级校验")
  98. }
  99. // 正向防御:prefetched 为 nil 时也不应 panic,且必须走 FindOne 一次(不传 option 的等价路径)。
  100. func TestCheckManageAccess_NilPrefetched_FallsBackToFindOne(t *testing.T) {
  101. ctrl := gomock.NewController(t)
  102. t.Cleanup(ctrl.Finish)
  103. userMock := mocks.NewMockSysUserModel(ctrl)
  104. deptMock := mocks.NewMockSysDeptModel(ctrl)
  105. pmMock := mocks.NewMockSysProductMemberModel(ctrl)
  106. roleMock := mocks.NewMockSysRoleModel(ctrl)
  107. userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
  108. Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1)
  109. deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
  110. Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil)
  111. pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)).
  112. Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
  113. roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
  114. Return(int64(math.MaxInt64), nil)
  115. svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
  116. User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
  117. })
  118. err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(nil))
  119. assert.NoError(t, err)
  120. }