package auth import ( "context" "errors" "testing" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" "perms-system-server/internal/middleware" deptModel "perms-system-server/internal/model/dept" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/testutil/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) // --------------------------------------------------------------------------- // 覆盖目标:审计 H-3 修复 —— CheckAddMemberAccess 专门为 AddMember 前置流程设计, // 用以堵住"产品 ADMIN 从部门树外把人强拉进自己产品"的漏洞。 // 对比 CheckManageAccess: // 1) 不做 memberType / permsLevel 比对; // 2) 对产品 ADMIN 不走 checkDeptHierarchy 的 bypass,强制做部门链校验; // 3) SuperAdmin 仍完全豁免; // 4) target 为空 / 未归属部门等情况 fail-close。 // --------------------------------------------------------------------------- func callerProductAdmin(deptId int64, deptPath string) *loaders.UserDetails { return &loaders.UserDetails{ UserId: 2, Username: "pa", IsSuperAdmin: false, MemberType: consts.MemberTypeAdmin, Status: consts.StatusEnabled, ProductCode: "pc_h3", DeptId: deptId, DeptPath: deptPath, } } func callerMember(deptId int64, deptPath string) *loaders.UserDetails { return &loaders.UserDetails{ UserId: 3, Username: "mbr", IsSuperAdmin: false, MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled, ProductCode: "pc_h3", DeptId: deptId, DeptPath: deptPath, } } // TC-0940: H-3 —— 产品 ADMIN 将部门树**外**的 target 拉进产品时必须 403, // 不得因其 MemberType=ADMIN 享受 checkDeptHierarchy 的 bypass。 func TestCheckAddMemberAccess_ProductAdmin_CrossDept_Rejected(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) // target 所在部门 path = /200/201/,与 caller 部门 path=/100/ 不在同一子树 deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), int64(201)). Return(&deptModel.SysDept{Id: 201, Path: "/200/201/"}, nil).Times(1) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) ctx := middleware.WithUserDetails(context.Background(), callerProductAdmin(100, "/100/")) target := &userModel.SysUser{Id: 42, DeptId: 201} err := CheckAddMemberAccess(ctx, svcCtx, target) require.Error(t, err, "H-3:产品 ADMIN 不能把部门树外的人拉进自己产品") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "其他部门") } // TC-0941: H-3 —— 产品 ADMIN 将部门树**内**的 target 拉进产品允许通过。 func TestCheckAddMemberAccess_ProductAdmin_SameSubtree_Allowed(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), int64(101)). Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil).Times(1) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) ctx := middleware.WithUserDetails(context.Background(), callerProductAdmin(100, "/100/")) target := &userModel.SysUser{Id: 42, DeptId: 101} err := CheckAddMemberAccess(ctx, svcCtx, target) require.NoError(t, err, "H-3:target 在 caller 部门子树内应允许添加") } // TC-0942: H-3 —— SuperAdmin 完全豁免,不触发 SysDeptModel.FindOne。 func TestCheckAddMemberAccess_SuperAdmin_BypassNoDBCall(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) su := &loaders.UserDetails{ UserId: 1, Username: "su", IsSuperAdmin: true, MemberType: consts.MemberTypeSuperAdmin, Status: consts.StatusEnabled, } ctx := middleware.WithUserDetails(context.Background(), su) target := &userModel.SysUser{Id: 42, DeptId: 999} // 任意部门 err := CheckAddMemberAccess(ctx, svcCtx, target) require.NoError(t, err) } // TC-0943: H-3 —— caller 自加自 (target.Id == caller.UserId) 豁免部门校验, // 避免阻塞"ADMIN 把自己添加进新产品"这类合法运维路径。 func TestCheckAddMemberAccess_SelfAdd_Allowed(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) caller := callerProductAdmin(100, "/100/") ctx := middleware.WithUserDetails(context.Background(), caller) target := &userModel.SysUser{Id: caller.UserId, DeptId: 999} err := CheckAddMemberAccess(ctx, svcCtx, target) require.NoError(t, err) } // TC-0944: H-3 —— caller 自身 DeptId=0(幽灵账号)时必须 403, // 不得让"无部门归属但拥有 product ADMIN"的账号绕过整个部门链校验。 func TestCheckAddMemberAccess_CallerWithoutDept_Rejected(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) caller := callerProductAdmin(0, "") ctx := middleware.WithUserDetails(context.Background(), caller) target := &userModel.SysUser{Id: 42, DeptId: 101} err := CheckAddMemberAccess(ctx, svcCtx, target) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "未归属任何部门") } // TC-0945: H-3 —— target 未归属部门时必须 403(仅超管可破例), // 避免"空 deptId 的 user 被部门前缀匹配逻辑误判"通过。 func TestCheckAddMemberAccess_TargetWithoutDept_Rejected(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) caller := callerProductAdmin(100, "/100/") ctx := middleware.WithUserDetails(context.Background(), caller) target := &userModel.SysUser{Id: 42, DeptId: 0} err := CheckAddMemberAccess(ctx, svcCtx, target) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "未归属部门") } // TC-0946: H-3 —— 未登录 / 缺少 UserDetails 上下文时返回 401, // 而不是 silently 放行或 panic。 func TestCheckAddMemberAccess_NoCallerCtx_Unauthorized(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) target := &userModel.SysUser{Id: 42, DeptId: 101} err := CheckAddMemberAccess(context.Background(), svcCtx, target) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 401, ce.Code()) } // TC-0947: H-3 —— SysDeptModel.FindOne 报错时必须 fail-close 返回 403(无法校验), // 不得静默放行。消息避免暴露底层 DB 细节。 func TestCheckAddMemberAccess_DeptFindOneError_FailClose(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), int64(777)). Return(nil, errors.New("db: connection refused")).Times(1) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) caller := callerProductAdmin(100, "/100/") ctx := middleware.WithUserDetails(context.Background(), caller) target := &userModel.SysUser{Id: 42, DeptId: 777} err := CheckAddMemberAccess(ctx, svcCtx, target) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.NotContains(t, ce.Error(), "db:", "错误消息不得泄漏底层 DB 细节") } // TC-0948: H-3 —— 非 ADMIN 的普通 MEMBER 作 caller 时同样走 CheckAddMemberAccess 的部门链判定 // (虽然 AddMember 的 RequireProductAdminFor 会更早拒绝,但防御深度仍需保证此函数独立正确)。 func TestCheckAddMemberAccess_Member_CrossDept_Rejected(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), int64(201)). Return(&deptModel.SysDept{Id: 201, Path: "/200/201/"}, nil).Times(1) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) caller := callerMember(100, "/100/") ctx := middleware.WithUserDetails(context.Background(), caller) target := &userModel.SysUser{Id: 42, DeptId: 201} err := CheckAddMemberAccess(ctx, svcCtx, target) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) } // TC-0949: H-3 —— target 为 nil 时必须 400,而不是 panic。 func TestCheckAddMemberAccess_NilTarget_BadRequest(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deptMock := mocks.NewMockSysDeptModel(ctrl) deptMock.EXPECT().FindOne(gomock.Any(), gomock.Any()).Times(0) svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Dept: deptMock}) caller := callerProductAdmin(100, "/100/") ctx := middleware.WithUserDetails(context.Background(), caller) err := CheckAddMemberAccess(ctx, svcCtx, nil) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) }