package auth import ( "context" "math" "testing" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" deptModel "perms-system-server/internal/model/dept" productmemberModel "perms-system-server/internal/model/productmember" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/testutil/mocks" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" ) // --------------------------------------------------------------------------- // 覆盖目标:审计第 6 轮 M-5 修复回归 —— CheckManageAccess(WithPrefetchedTarget(...)) // 允许调用方透传已经 FindOne 到的 target,避免单次请求内重复 FindOne(targetUserId)。 // // 修复前:UpdateUserStatus / UpdateUser 一次请求会先做 ValidateStatusChange 里的 FindOne, // 紧接着 checkDeptHierarchy 里又 FindOne 一次,DB/缓存都白打一次 RTT。 // // 修复后的契约: // * Option 与参数一致(target.Id == targetUserId)时,FindOne 必须被跳过; // * 不一致时 option 失效(defensive ignore),checkDeptHierarchy 回退到原有 FindOne 路径。 // --------------------------------------------------------------------------- func buildMemberCallerCtx() context.Context { caller := &loaders.UserDetails{ UserId: 1, Username: "op", IsSuperAdmin: false, MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled, ProductCode: "pc_m5", DeptId: 100, DeptPath: "/100/", MinPermsLevel: 50, } return middleware.WithUserDetails(context.Background(), caller) } // TC-0860: 透传的 prefetched.Id 与 targetUserId 一致 → SysUserModel.FindOne 必须一次都不被调用。 func TestCheckManageAccess_PrefetchedTarget_SkipsFindOne(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) userMock := mocks.NewMockSysUserModel(ctrl) // 关键断言:FindOne 次数为 0。gomock 默认不允许未声明的调用;省略 EXPECT 即相当于 0 次。 deptMock := mocks.NewMockSysDeptModel(ctrl) pmMock := mocks.NewMockSysProductMemberModel(ctrl) roleMock := mocks.NewMockSysRoleModel(ctrl) // 目标用户所在部门的 Path 需满足 HasPrefix caller.DeptPath="/100/" deptMock.EXPECT().FindOne(gomock.Any(), int64(101)). Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil) // 目标产品成员存在,MemberType=MEMBER 与 caller 同级 → 走 permsLevel 比较分支。 pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)). Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil) // 目标的 permsLevel 高于 caller(数值更大 → 权限更低),校验放行。 roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5"). Return(int64(100), nil) // 审计 H-2:checkPermLevel 现在会对 caller 也做一次 DB fresh read。 // caller.UserId=1,permsLevel=50(比 target=100 严格高权)→ 放行。 roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), "pc_m5"). Return(int64(50), nil) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock, }) prefetched := &userModel.SysUser{Id: 42, DeptId: 101} err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(prefetched)) assert.NoError(t, err, "M-5:prefetched 与 targetUserId 一致且业务级校验全部通过时应放行") // ctrl.Finish() 里会自动校验 userMock.FindOne 调用次数为 0(未显式 EXPECT), // 若源码回退到 FindOne 路径测试会抛 "unexpected call to FindOne" 直接 FAIL。 } // TC-0861: 透传的 prefetched.Id 与 targetUserId 不一致 → option 被 defensive 忽略, // 必须真实调用 SysUserModel.FindOne(ctx, targetUserId) 一次。 // 这是一条 "调用方把错 id 传进来时不能被当做合法 prefetched" 的安全断言: // 如果源码直接信任 prefetched 而不校验 Id,就会出现 "用 A 的 userDetails 去放行对 B 的管理"。 func TestCheckManageAccess_PrefetchedIdMismatch_IgnoredAndFallsBackToFindOne(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) userMock := mocks.NewMockSysUserModel(ctrl) deptMock := mocks.NewMockSysDeptModel(ctrl) pmMock := mocks.NewMockSysProductMemberModel(ctrl) roleMock := mocks.NewMockSysRoleModel(ctrl) // 关键断言:FindOne(targetUserId=42) 必须真实被调用一次,说明 prefetched 没被盲信。 // 我们返回的真实对象 DeptId=101(与乱传的 prefetched 一致),好让流程继续走通。 userMock.EXPECT().FindOne(gomock.Any(), int64(42)). Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1) deptMock.EXPECT().FindOne(gomock.Any(), int64(101)). Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil) pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)). Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil) roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5"). Return(int64(100), nil) // 审计 H-2:caller 侧 fresh read 仍需要。 roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), "pc_m5"). Return(int64(50), nil) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock, }) // 故意传 Id=999,与 targetUserId=42 不一致。 wrong := &userModel.SysUser{Id: 999, DeptId: 101} err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(wrong)) assert.NoError(t, err, "M-5:prefetched.Id 不匹配时回退 FindOne 后,本场景仍应通过业务级校验") } // 正向防御:prefetched 为 nil 时也不应 panic,且必须走 FindOne 一次(不传 option 的等价路径)。 func TestCheckManageAccess_NilPrefetched_FallsBackToFindOne(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) userMock := mocks.NewMockSysUserModel(ctrl) deptMock := mocks.NewMockSysDeptModel(ctrl) pmMock := mocks.NewMockSysProductMemberModel(ctrl) roleMock := mocks.NewMockSysRoleModel(ctrl) userMock.EXPECT().FindOne(gomock.Any(), int64(42)). Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).Times(1) deptMock.EXPECT().FindOne(gomock.Any(), int64(101)). Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil) pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), "pc_m5", int64(42)). Return(&productmemberModel.SysProductMember{MemberType: consts.MemberTypeMember}, nil) roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5"). Return(int64(math.MaxInt64), nil) // 审计 H-2:caller 侧 fresh read。 roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), "pc_m5"). Return(int64(50), nil) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock, }) err := CheckManageAccess(buildMemberCallerCtx(), svcCtx, 42, "pc_m5", WithPrefetchedTarget(nil)) assert.NoError(t, err) }