|
|
@@ -4,13 +4,16 @@ import (
|
|
|
"context"
|
|
|
"errors"
|
|
|
"fmt"
|
|
|
+ "github.com/stretchr/testify/assert"
|
|
|
+ "github.com/stretchr/testify/require"
|
|
|
+ "github.com/zeromicro/go-zero/core/stores/sqlx"
|
|
|
+ "go.uber.org/mock/gomock"
|
|
|
"math"
|
|
|
"math/rand"
|
|
|
- "testing"
|
|
|
- "time"
|
|
|
-
|
|
|
+ "os"
|
|
|
"perms-system-server/internal/consts"
|
|
|
"perms-system-server/internal/loaders"
|
|
|
+ "perms-system-server/internal/middleware"
|
|
|
deptModel "perms-system-server/internal/model/dept"
|
|
|
"perms-system-server/internal/model/productmember"
|
|
|
userModel "perms-system-server/internal/model/user"
|
|
|
@@ -18,10 +21,9 @@ import (
|
|
|
"perms-system-server/internal/svc"
|
|
|
"perms-system-server/internal/testutil"
|
|
|
"perms-system-server/internal/testutil/ctxhelper"
|
|
|
-
|
|
|
- "github.com/stretchr/testify/assert"
|
|
|
- "github.com/stretchr/testify/require"
|
|
|
- "github.com/zeromicro/go-zero/core/stores/sqlx"
|
|
|
+ "perms-system-server/internal/testutil/mocks"
|
|
|
+ "testing"
|
|
|
+ "time"
|
|
|
)
|
|
|
|
|
|
// =====================================================================
|
|
|
@@ -501,3 +503,1206 @@ func TestCheckManageAccess_SameDeptLowerLevel(t *testing.T) {
|
|
|
|
|
|
// suppress unused import
|
|
|
var _ = sqlx.ErrNotFound
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 覆盖目标: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: 产品 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, "产品 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: 产品 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, "target 在 caller 部门子树内应允许添加")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0942: 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: 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: 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: 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: 未登录 / 缺少 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: 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: 非 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: 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())
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// checkDeptHierarchy 对 caller.DeptId=0 / DeptPath=""
|
|
|
+// 的历史 MEMBER / DEVELOPER 账号直接 403。
|
|
|
+//
|
|
|
+// 契约期望(fix 后):历史账号任意一次管理动作时,CheckManageAccess 要么走
|
|
|
+// (a) 明确的"未归属部门,拒绝管理他人"403(当前行为,方向正确但文案 / 缺失)
|
|
|
+// (b) 自动把缺失部门挪到默认部门 → 正常走部门链校验
|
|
|
+// 无论走 (a) 还是 (b),都需要有 **response.CodeError 结构** 而不是普通 string error,
|
|
|
+// 否则前端做不到"按错误码触发数据迁移工单"。
|
|
|
+//
|
|
|
+// 本测试用 skipPending 标签,方便 report 识别未落地项;fix 落地(或数据迁移脚本
|
|
|
+// 跑完)后把 AUDIT_RUN_PENDING=1 打开并调整断言即可切换成真正的回归保护。
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+const auditPendingEnv = "AUDIT_RUN_PENDING"
|
|
|
+
|
|
|
+func skipPending(t *testing.T, marker, reason string) {
|
|
|
+ t.Helper()
|
|
|
+ if os.Getenv(auditPendingEnv) != "" {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ t.Skipf("AUDIT_PENDING %s (Round 8 fix 未落地) —— %s", marker, reason)
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0993: 历史 DEVELOPER(DeptId=0)对合法目标的管理操作 —— fix 后必须是
|
|
|
+// 可识别的 response.CodeError,且带有迁移提示("您未归属任何部门"),让运维据此跑数据迁移。
|
|
|
+func TestCheckManageAccess_L3_LegacyDeveloperWithDeptZero_MustReturnCodedError(t *testing.T) {
|
|
|
+ skipPending(t, "L-3",
|
|
|
+ "当前返回 403 但文案分叉('您未归属任何部门' / '您的部门信息异常'),建议"+
|
|
|
+ "合一为 '您未归属任何部门' 且带 CodeError.Code=403;fix 落地后移除 Skip")
|
|
|
+ ctx := context.Background()
|
|
|
+ svcCtx := svc.NewServiceContext(testutil.GetTestConfig())
|
|
|
+
|
|
|
+ // caller 是 legacy developer,DeptId=0 / DeptPath=""。
|
|
|
+ callerCtx := middleware.WithUserDetails(ctx, &loaders.UserDetails{
|
|
|
+ UserId: 999001, Username: "legacy_dev", IsSuperAdmin: false,
|
|
|
+ MemberType: consts.MemberTypeDeveloper, Status: consts.StatusEnabled,
|
|
|
+ ProductCode: "test_product",
|
|
|
+ // DeptId=0, DeptPath="" —— legacy 账号
|
|
|
+ })
|
|
|
+
|
|
|
+ err := CheckManageAccess(callerCtx, svcCtx, 999002 /* target */, "test_product")
|
|
|
+ require.Error(t, err, "legacy caller 必须被拒绝")
|
|
|
+
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.ErrorAs(t, err, &ce, "必须是 response.CodeError,不得为裸 error(前端无法据此触发迁移)")
|
|
|
+ assert.Equal(t, 403, ce.Code(), "必须是 403 以便前端分类")
|
|
|
+ assert.Contains(t, ce.Error(), "未归属",
|
|
|
+ "文案必须显式提示'未归属任何部门',便于人工判定是否需要跑数据迁移")
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 覆盖目标: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(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
|
|
|
+
|
|
|
+ // 目标的 permsLevel 高于 caller(数值更大 → 权限更低),校验放行。
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
|
|
|
+ Return(int64(100), nil)
|
|
|
+ // 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,
|
|
|
+ "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(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
|
|
|
+ Return(int64(100), nil)
|
|
|
+ // 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,
|
|
|
+ "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(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil)
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc_m5").
|
|
|
+ Return(int64(math.MaxInt64), nil)
|
|
|
+ // 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)
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 覆盖目标:checkPermLevel 在 DB 非 ErrNotFound 错误时必须 fail-close 返回 500,
|
|
|
+// 而不是被默默降级为"目标无角色 → 权限最低 → 放行"。
|
|
|
+// 该测试用 gomock 伪造 SysRoleModel.FindMinPermsLevelByUserIdAndProductCode 返回一个通用 DB 错误,
|
|
|
+// 验证 CheckManageAccess 的响应是 500 CodeError(非 403)。
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+// TC-0819: checkPermLevel 遇到非 ErrNotFound 的 DB 错误时必须 500。
|
|
|
+func TestCheckManageAccess_DBError_FailCloseWith500(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ const targetUserId = int64(42)
|
|
|
+ const callerDeptId = int64(1)
|
|
|
+ const targetDeptId = int64(2)
|
|
|
+ const productCode = "test_product"
|
|
|
+
|
|
|
+ // 让 checkDeptHierarchy 顺利放行:target 在 caller 子部门下(path 前缀 /1/)。
|
|
|
+ mockUser := mocks.NewMockSysUserModel(ctrl)
|
|
|
+ mockUser.EXPECT().FindOne(gomock.Any(), int64(targetUserId)).
|
|
|
+ Return(&userModel.SysUser{Id: targetUserId, DeptId: targetDeptId}, nil).AnyTimes()
|
|
|
+
|
|
|
+ mockDept := mocks.NewMockSysDeptModel(ctrl)
|
|
|
+ mockDept.EXPECT().FindOne(gomock.Any(), targetDeptId).
|
|
|
+ Return(&deptModel.SysDept{Id: targetDeptId, Path: "/1/2/"}, nil).AnyTimes()
|
|
|
+
|
|
|
+ // 让 permsLevel 判定路径进入:"target 也是 MEMBER,同级 → 需要 DB 查 permsLevel"。
|
|
|
+ mockPM := mocks.NewMockSysProductMemberModel(ctrl)
|
|
|
+ mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), productCode, int64(targetUserId)).
|
|
|
+ Return(&productmember.SysProductMember{
|
|
|
+ UserId: targetUserId, ProductCode: productCode,
|
|
|
+ MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled,
|
|
|
+ }, nil).AnyTimes()
|
|
|
+
|
|
|
+ // 关键:SysRoleModel 返回非 ErrNotFound 的 DB 错误。
|
|
|
+ dbErr := errors.New("driver: bad connection")
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(targetUserId), productCode).
|
|
|
+ Return(int64(0), dbErr).AnyTimes()
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
+ User: mockUser,
|
|
|
+ Dept: mockDept,
|
|
|
+ Role: mockRole,
|
|
|
+ ProductMember: mockPM,
|
|
|
+ })
|
|
|
+
|
|
|
+ ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
|
|
|
+ UserId: 100,
|
|
|
+ Username: "l4_member_caller",
|
|
|
+ IsSuperAdmin: false,
|
|
|
+ MemberType: consts.MemberTypeMember,
|
|
|
+ Status: consts.StatusEnabled,
|
|
|
+ ProductCode: productCode,
|
|
|
+ DeptId: callerDeptId,
|
|
|
+ DeptPath: "/1/",
|
|
|
+ MinPermsLevel: 100,
|
|
|
+ })
|
|
|
+
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, targetUserId, productCode)
|
|
|
+ require.Error(t, err, "DB 错误时必须 fail-close")
|
|
|
+
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce), "必须是结构化 CodeError")
|
|
|
+ assert.Equal(t, 500, ce.Code(),
|
|
|
+ "DB 非 ErrNotFound 错误绝不能被伪装成'无角色'从而降级为 403/放行;必须是 500")
|
|
|
+ assert.NotContains(t, ce.Error(), "无权管理",
|
|
|
+ "错误消息不得看起来像权限判定成功后做出的业务决策(避免误导运维)")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0820: 对照组 —— ErrNotFound 仍应被视作"无角色",即按最低权限处理(由 caller.MinPermsLevel 决定放行还是 403)。
|
|
|
+// 这里构造 caller 的 MinPermsLevel=MaxInt64(sentinel),target 无角色(ErrNotFound) →
|
|
|
+// caller.MinPermsLevel(=MaxInt64) >= targetLevel(=MaxInt64) → 返回 403。这个分支不是本次回归重点,
|
|
|
+// 只是用来证明 ErrNotFound 路径没有被修复误伤为 500。
|
|
|
+func TestCheckManageAccess_ErrNotFound_StillTreatedAsNoRole(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ const targetUserId = int64(43)
|
|
|
+ const callerDeptId = int64(1)
|
|
|
+ const targetDeptId = int64(2)
|
|
|
+ const productCode = "test_product"
|
|
|
+
|
|
|
+ mockUser := mocks.NewMockSysUserModel(ctrl)
|
|
|
+ mockUser.EXPECT().FindOne(gomock.Any(), int64(targetUserId)).
|
|
|
+ Return(&userModel.SysUser{Id: targetUserId, DeptId: targetDeptId}, nil).AnyTimes()
|
|
|
+
|
|
|
+ mockDept := mocks.NewMockSysDeptModel(ctrl)
|
|
|
+ mockDept.EXPECT().FindOne(gomock.Any(), targetDeptId).
|
|
|
+ Return(&deptModel.SysDept{Id: targetDeptId, Path: "/1/2/"}, nil).AnyTimes()
|
|
|
+
|
|
|
+ mockPM := mocks.NewMockSysProductMemberModel(ctrl)
|
|
|
+ mockPM.EXPECT().FindOneByProductCodeUserId(gomock.Any(), productCode, int64(targetUserId)).
|
|
|
+ Return(&productmember.SysProductMember{
|
|
|
+ UserId: targetUserId, ProductCode: productCode,
|
|
|
+ MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled,
|
|
|
+ }, nil).AnyTimes()
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(targetUserId), productCode).
|
|
|
+ Return(int64(0), sqlx.ErrNotFound).AnyTimes()
|
|
|
+ // checkPermLevel 现在也会对 caller 做 fresh read。
|
|
|
+ // 这里构造"caller 同样无角色 → callerNoRole=true → >= 比较由 callerNoRole 决定,结果仍 403"。
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(101), productCode).
|
|
|
+ Return(int64(0), sqlx.ErrNotFound).AnyTimes()
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
+ User: mockUser, Dept: mockDept, Role: mockRole, ProductMember: mockPM,
|
|
|
+ })
|
|
|
+
|
|
|
+ ctx := ctxhelper.CustomCtx(&loaders.UserDetails{
|
|
|
+ UserId: 101,
|
|
|
+ Username: "l4_caller_no_role",
|
|
|
+ IsSuperAdmin: false, MemberType: consts.MemberTypeMember, Status: consts.StatusEnabled,
|
|
|
+ ProductCode: productCode, DeptId: callerDeptId, DeptPath: "/1/",
|
|
|
+ // sentinel:自己也没有任何角色。
|
|
|
+ MinPermsLevel: math.MaxInt64,
|
|
|
+ })
|
|
|
+
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, targetUserId, productCode)
|
|
|
+ require.Error(t, err, "caller 与 target 都 sentinel → >= 比较应拦截")
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 403, ce.Code(),
|
|
|
+ "ErrNotFound 正常降级为 sentinel;结果应是业务 403 而非基础设施 500")
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 覆盖目标:checkPermLevel 必须对 caller.MinPermsLevel 做 DB fresh read。
|
|
|
+//
|
|
|
+// 修复前:caller.MinPermsLevel 来自 UserDetailsLoader 5min TTL 缓存。超管刚把 caller 降级后
|
|
|
+// 缓存里仍是旧(更高权)级别,降级 admin 在 5min 内仍可跨级管辖目标用户 —— 这是 的
|
|
|
+// GuardRoleLevelAssignable 修复所没有覆盖的"直接管人"分支。
|
|
|
+//
|
|
|
+// 修复后契约:
|
|
|
+// 1. caller 刚被降级,缓存 MinPermsLevel=低(高权)但 DB 实值=高(低权)→ 走 DB 值,仍拒 403。
|
|
|
+// 2. caller 在 DB 里已无角色(sqlx.ErrNotFound)→ callerNoRole=true → 同级管辖拒。
|
|
|
+// 3. caller 侧 DB 抖动(非 ErrNotFound)→ 500 fail-close,不得降级为"无角色放行"。
|
|
|
+// 4. SuperAdmin caller 短路,永不触发 caller 侧 DB 读。
|
|
|
+// 5. caller 管自己时短路在 CheckManageAccess 顶部,根本不到 checkPermLevel。
|
|
|
+//
|
|
|
+// 命名规则:TC-0969 ~ TC-0975
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+const h2TestProductCode = "pc_h2"
|
|
|
+
|
|
|
+// h2MockTargetMemberAndDept 为所有 测试共享的 target 侧 mock:
|
|
|
+// - target.MemberType=MEMBER(与 MEMBER caller 同级 → 必然进入 permsLevel 对比路径)
|
|
|
+// - target.DeptPath=/100/101/ 使 caller.DeptPath=/100/ 顺利覆盖
|
|
|
+func h2MockTargetMemberAndDept(ctrl *gomock.Controller) (
|
|
|
+ *mocks.MockSysUserModel,
|
|
|
+ *mocks.MockSysDeptModel,
|
|
|
+ *mocks.MockSysProductMemberModel,
|
|
|
+) {
|
|
|
+ userMock := mocks.NewMockSysUserModel(ctrl)
|
|
|
+ userMock.EXPECT().FindOne(gomock.Any(), int64(42)).
|
|
|
+ Return(&userModel.SysUser{Id: 42, DeptId: 101}, nil).AnyTimes()
|
|
|
+ deptMock := mocks.NewMockSysDeptModel(ctrl)
|
|
|
+ deptMock.EXPECT().FindOne(gomock.Any(), int64(101)).
|
|
|
+ Return(&deptModel.SysDept{Id: 101, Path: "/100/101/"}, nil).AnyTimes()
|
|
|
+ pmMock := mocks.NewMockSysProductMemberModel(ctrl)
|
|
|
+ pmMock.EXPECT().FindOneByProductCodeUserId(gomock.Any(), h2TestProductCode, int64(42)).
|
|
|
+ Return(&productmember.SysProductMember{MemberType: consts.MemberTypeMember}, nil).AnyTimes()
|
|
|
+ return userMock, deptMock, pmMock
|
|
|
+}
|
|
|
+
|
|
|
+func h2DowngradedCallerCtx(cachedLevel int64) *loaders.UserDetails {
|
|
|
+ return &loaders.UserDetails{
|
|
|
+ UserId: 1,
|
|
|
+ Username: "h2_caller",
|
|
|
+ IsSuperAdmin: false,
|
|
|
+ MemberType: consts.MemberTypeMember,
|
|
|
+ Status: consts.StatusEnabled,
|
|
|
+ ProductCode: h2TestProductCode,
|
|
|
+ DeptId: 100,
|
|
|
+ DeptPath: "/100/",
|
|
|
+ MinPermsLevel: cachedLevel,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0969: caller 缓存里还是高权(level=10),但 DB 已被降级到低权(level=100)
|
|
|
+// target level=50;按缓存 "10 < 50" 应放行,按 DB fresh read "100 >= 50" 应拒绝。
|
|
|
+// 修复后必须以 DB 为准 → 403。
|
|
|
+func TestCheckPermLevel_StaleCacheHighPriv_FreshReadLowPriv_Forbids(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
|
|
|
+
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ // target fresh read:level=50
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
|
|
|
+ Return(int64(50), nil).Times(1)
|
|
|
+ // caller fresh read:DB 真实已降级到 100(低权),比 target 的 50 更低权 → 应拒
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
|
|
|
+ Return(int64(100), nil).Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
+ })
|
|
|
+ // 关键:缓存里还是 10(降级前的高权级别)—— 源码如果信任 caller.MinPermsLevel 就会放行。
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
|
|
|
+
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
|
|
|
+ require.Error(t, err, "降级后缓存仍高权的 caller 必须被 DB 实值拦截")
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 403, ce.Code(),
|
|
|
+ "TOCTOU 修复后必须走 DB,结果为 403;若测试看到 200/nil err 说明仍读的是 caller.MinPermsLevel 缓存")
|
|
|
+ assert.Contains(t, ce.Error(), "无权管理权限级别高于或等于您的用户")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0970: caller 在 DB 里已无任何角色(ErrNotFound)→ 即便缓存仍显示 MinPermsLevel=10,也必须拒。
|
|
|
+// 对称验证 里 GuardRoleLevelAssignable 对"caller 被清角色"的处理方式。
|
|
|
+func TestCheckPermLevel_CallerFreshRead_NotFound_ForbidsEvenWithCachedLevel(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
|
|
|
+
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
|
|
|
+ Return(int64(50), nil).Times(1)
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
|
|
|
+ Return(int64(0), sqlx.ErrNotFound).Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
+ })
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
|
|
|
+
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
|
|
|
+ require.Error(t, err, "caller 在 DB 已无角色时一律拒绝同级/跨级管辖")
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 403, ce.Code())
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0971: 正向通过路径 —— caller DB level=10(高权),target level=50 → 严格高权,放行。
|
|
|
+// 用来证明修复没有误伤合法管理路径。
|
|
|
+func TestCheckPermLevel_FreshRead_HigherPriv_Passes(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
|
|
|
+ Return(int64(50), nil).Times(1)
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
|
|
|
+ Return(int64(10), nil).Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
+ })
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
|
|
|
+
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
|
|
|
+ assert.NoError(t, err, "合法严格高权管理路径不得被修复误伤")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0972: caller 侧 DB 非 ErrNotFound 错误 → 500 fail-close。
|
|
|
+// 对称于 checkPermLevel 里 target 侧的 fail-close,把 caller 侧也钉死,避免"DB 抖动被伪装成无角色"。
|
|
|
+func TestCheckPermLevel_CallerFreshRead_GenericDBErr_FailsClosedWith500(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ userMock, deptMock, pmMock := h2MockTargetMemberAndDept(ctrl)
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), h2TestProductCode).
|
|
|
+ Return(int64(50), nil).Times(1)
|
|
|
+ // caller 侧 DB 抖动
|
|
|
+ dbErr := errors.New("driver: bad connection")
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(1), h2TestProductCode).
|
|
|
+ Return(int64(0), dbErr).Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
+ })
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(10))
|
|
|
+
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 42, h2TestProductCode)
|
|
|
+ require.Error(t, err)
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 500, ce.Code(),
|
|
|
+ "caller 侧 DB 抖动必须 fail-close 500,绝不能伪装成'无角色→放行'")
|
|
|
+ // 不得透传驱动细节
|
|
|
+ assert.NotContains(t, ce.Error(), "driver: bad connection")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0973: SuperAdmin caller 短路 —— 不应触发任何 caller 侧 DB 读。
|
|
|
+// 如果修复把 fresh read 放错位置,SuperAdmin 也会被多打一次 DB,测试会因 Role mock 收到未预期调用而挂。
|
|
|
+func TestCheckPermLevel_SuperAdmin_ShortCircuits_NoCallerFreshRead(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ // 故意不设任何 mock EXPECT —— 任何 DB 调用都会 fail。
|
|
|
+ userMock := mocks.NewMockSysUserModel(ctrl)
|
|
|
+ deptMock := mocks.NewMockSysDeptModel(ctrl)
|
|
|
+ pmMock := mocks.NewMockSysProductMemberModel(ctrl)
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
+ })
|
|
|
+
|
|
|
+ super := &loaders.UserDetails{
|
|
|
+ UserId: 999, Username: "super_h2", IsSuperAdmin: true,
|
|
|
+ MemberType: consts.MemberTypeSuperAdmin, Status: consts.StatusEnabled,
|
|
|
+ ProductCode: h2TestProductCode,
|
|
|
+ }
|
|
|
+ err := CheckManageAccess(ctxhelper.CustomCtx(super), svcCtx, 42, h2TestProductCode)
|
|
|
+ assert.NoError(t, err, "SuperAdmin 必须在 CheckManageAccess 顶部短路,不得触发 fresh read")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0974: caller 管理自己时必须在 CheckManageAccess 顶部短路( 已钉);
|
|
|
+// 修复不得把这条路径带偏到 fresh read。
|
|
|
+func TestCheckPermLevel_ManageSelf_ShortCircuits_NoFreshRead(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ // 故意不设 mock EXPECT,caller 管自己应该一次 DB 都不打。
|
|
|
+ userMock := mocks.NewMockSysUserModel(ctrl)
|
|
|
+ deptMock := mocks.NewMockSysDeptModel(ctrl)
|
|
|
+ pmMock := mocks.NewMockSysProductMemberModel(ctrl)
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{
|
|
|
+ User: userMock, Dept: deptMock, ProductMember: pmMock, Role: roleMock,
|
|
|
+ })
|
|
|
+
|
|
|
+ ctx := ctxhelper.CustomCtx(h2DowngradedCallerCtx(100))
|
|
|
+ err := CheckManageAccess(ctx, svcCtx, 1 /* 就是 caller.UserId */, h2TestProductCode)
|
|
|
+ assert.NoError(t, err, "caller 管自己永远放行,且不触发 DB fresh read")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0975: 与 共享 loadFreshMinPermsLevel helper(契约自洽性)。
|
|
|
+// 既然 checkPermLevel 与 GuardRoleLevelAssignable 两个授权决策点的 caller 侧都走同一个 helper,
|
|
|
+// 任意通用 DB 错误都必须映射为同一文案的 500 CodeError,任意 ErrNotFound 都映射为 notFound=true。
|
|
|
+// 这里覆盖 helper 的两个对称分支。
|
|
|
+func TestLoadFreshMinPermsLevel_ContractParity(t *testing.T) {
|
|
|
+ t.Run("generic DB error → 500 fail-close", func(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(7), h2TestProductCode).
|
|
|
+ Return(int64(0), errors.New("i/o timeout")).Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: roleMock})
|
|
|
+ level, notFound, err := loadFreshMinPermsLevel(ctxhelper.CustomCtx(nil), svcCtx, 7, h2TestProductCode)
|
|
|
+ assert.Equal(t, int64(0), level)
|
|
|
+ assert.False(t, notFound)
|
|
|
+ require.Error(t, err)
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 500, ce.Code())
|
|
|
+ })
|
|
|
+
|
|
|
+ t.Run("ErrNotFound → notFound=true, err=nil", func(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ roleMock := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ roleMock.EXPECT().FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(8), h2TestProductCode).
|
|
|
+ Return(int64(0), sqlx.ErrNotFound).Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: roleMock})
|
|
|
+ level, notFound, err := loadFreshMinPermsLevel(ctxhelper.CustomCtx(nil), svcCtx, 8, h2TestProductCode)
|
|
|
+ assert.NoError(t, err)
|
|
|
+ assert.True(t, notFound)
|
|
|
+ assert.Equal(t, int64(0), level)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 覆盖目标:GuardRoleLevelAssignable 必须每次走 DB 强一致查询,
|
|
|
+// 绝不能信任 caller(loaders.UserDetails)里可能已经 stale 的 MinPermsLevel 缓存。
|
|
|
+//
|
|
|
+// TOCTOU 场景:
|
|
|
+// 1. caller 原先是 permsLevel=5 的高阶成员。
|
|
|
+// 2. 超管把 caller 的高阶角色摘掉,现在 DB 里 MinPermsLevel=100(低阶)。
|
|
|
+// 3. UD 缓存还没被 Clean(Redis 抖动 / TTL 窗口内),caller.MinPermsLevel=5 是旧值。
|
|
|
+// 4. caller 此刻尝试分配 permsLevel=50 的角色 —— 若信缓存(5 vs 50)会**误放行**;
|
|
|
+// 修复后走 DB(100 vs 50),必须 403 拦截。
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+// TC-0930: stale caller.MinPermsLevel 不得影响判定,rolePermsLevel <= freshLevel 必须 403。
|
|
|
+func TestGuardRoleLevelAssignable_StaleCallerCache_FreshDBRejects(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ const productCode = "m3_pc_stale"
|
|
|
+ const callerId = int64(1001)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ // 关键:DB 强一致返回 100(被降级后的真实等级)。
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
|
|
|
+ Return(int64(100), nil).
|
|
|
+ Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: callerId,
|
|
|
+ Username: "m3_stale_caller",
|
|
|
+ MemberType: consts.MemberTypeMember,
|
|
|
+ ProductCode: productCode,
|
|
|
+ Status: consts.StatusEnabled,
|
|
|
+ MinPermsLevel: 5,
|
|
|
+ }
|
|
|
+
|
|
|
+ err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 50)
|
|
|
+ require.Error(t, err, "stale 缓存(5) 下试图分配 permsLevel=50,信缓存会放行;走 DB(100) 必须 403")
|
|
|
+
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 403, ce.Code(), "拒绝分配高于自身 fresh 等级的角色 → 403")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0931: 同级(rolePermsLevel == freshLevel)也要拦截,保持与 checkPermLevel 的 ">=" 对齐。
|
|
|
+func TestGuardRoleLevelAssignable_SameLevel_Rejected(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ const productCode = "m3_pc_same"
|
|
|
+ const callerId = int64(1002)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
|
|
|
+ Return(int64(50), nil).
|
|
|
+ Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: callerId,
|
|
|
+ Username: "m3_same_caller",
|
|
|
+ MemberType: consts.MemberTypeMember,
|
|
|
+ ProductCode: productCode,
|
|
|
+ Status: consts.StatusEnabled,
|
|
|
+ }
|
|
|
+
|
|
|
+ err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 50)
|
|
|
+ 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-0932: rolePermsLevel 严格低于 freshLevel(数值更大)时放行。
|
|
|
+func TestGuardRoleLevelAssignable_StrictlyLower_Allowed(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ const productCode = "m3_pc_ok"
|
|
|
+ const callerId = int64(1003)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
|
|
|
+ Return(int64(50), nil).
|
|
|
+ Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: callerId,
|
|
|
+ Username: "m3_ok_caller",
|
|
|
+ MemberType: consts.MemberTypeMember,
|
|
|
+ ProductCode: productCode,
|
|
|
+ Status: consts.StatusEnabled,
|
|
|
+ }
|
|
|
+
|
|
|
+ err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 80)
|
|
|
+ require.NoError(t, err, "permsLevel=80 严格低于 freshLevel=50(数值更大 = 更低权限)应放行")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0933: SuperAdmin 完全豁免,不触发任何 DB 查询。
|
|
|
+func TestGuardRoleLevelAssignable_SuperAdmin_BypassNoDBCall(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ // 预期 0 次调用:SuperAdmin 必须短路返回,不能浪费 DB RTT。
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
|
|
|
+ Times(0)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: 1, Username: "root", IsSuperAdmin: true, Status: consts.StatusEnabled,
|
|
|
+ }
|
|
|
+
|
|
|
+ err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 1)
|
|
|
+ require.NoError(t, err, "SuperAdmin 必须放行任何 permsLevel")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0934: 产品 ADMIN 拥有全权,豁免 DB 查询。
|
|
|
+func TestGuardRoleLevelAssignable_ProductAdmin_BypassNoDBCall(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
|
|
|
+ Times(0)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: 2, Username: "pa", MemberType: consts.MemberTypeAdmin,
|
|
|
+ ProductCode: "p1", Status: consts.StatusEnabled,
|
|
|
+ }
|
|
|
+ err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 1)
|
|
|
+ require.NoError(t, err, "产品 ADMIN 属于全权角色,必须豁免等级校验")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0935: DEVELOPER 同样享有全权豁免。
|
|
|
+func TestGuardRoleLevelAssignable_Developer_BypassNoDBCall(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
|
|
|
+ Times(0)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: 3, Username: "dev", MemberType: consts.MemberTypeDeveloper,
|
|
|
+ ProductCode: "p1", Status: consts.StatusEnabled,
|
|
|
+ }
|
|
|
+ err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 1)
|
|
|
+ require.NoError(t, err)
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0936: caller 在 DB 里**无任何角色**(ErrNotFound),必须 403,不能默认为 MaxInt64 放行。
|
|
|
+// 这里的语义是"没有可分配的角色等级":一个 MEMBER 连自己都没角色,自然不能分配角色给别人。
|
|
|
+func TestGuardRoleLevelAssignable_CallerHasNoRole_Rejected(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ const productCode = "m3_pc_noRole"
|
|
|
+ const callerId = int64(1004)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
|
|
|
+ Return(int64(0), sqlx.ErrNotFound).
|
|
|
+ Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: callerId, Username: "m3_no_role", MemberType: consts.MemberTypeMember,
|
|
|
+ ProductCode: productCode, Status: consts.StatusEnabled,
|
|
|
+ }
|
|
|
+
|
|
|
+ err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 99)
|
|
|
+ require.Error(t, err, "caller 无任何角色时必须拒绝,否则会被误判为 MaxInt64 最低级从而放行任何 permsLevel")
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 403, ce.Code())
|
|
|
+ assert.Contains(t, ce.Error(), "没有可分配的角色等级")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0937: DB 抖动(非 ErrNotFound)必须 fail-close 返回 500,不得降级为"无角色 → 放行"。
|
|
|
+func TestGuardRoleLevelAssignable_DBError_FailCloseWith500(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ const productCode = "m3_pc_dbErr"
|
|
|
+ const callerId = int64(1005)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
|
|
|
+ Return(int64(0), errors.New("driver: bad connection")).
|
|
|
+ Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: callerId, Username: "m3_db_err", MemberType: consts.MemberTypeMember,
|
|
|
+ ProductCode: productCode, Status: consts.StatusEnabled,
|
|
|
+ }
|
|
|
+
|
|
|
+ err := GuardRoleLevelAssignable(context.Background(), svcCtx, caller, 10)
|
|
|
+ require.Error(t, err)
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 500, ce.Code(),
|
|
|
+ "DB 非 ErrNotFound 错误必须 fail-close 500,不能被伪装成 ErrNotFound → 放行超权分配")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-0938: nil caller 防御:理论上无登录上下文绝不该进入此函数,防御性路径必须 403 而非 panic。
|
|
|
+func TestGuardRoleLevelAssignable_NilCaller_Rejected(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), gomock.Any(), gomock.Any()).
|
|
|
+ Times(0)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ err := GuardRoleLevelAssignable(context.Background(), svcCtx, nil, 10)
|
|
|
+ require.Error(t, err, "nil caller 必须被拦截,杜绝隐式放行")
|
|
|
+ var ce *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &ce))
|
|
|
+ assert.Equal(t, 403, ce.Code())
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+// 覆盖目标:LoadCallerAssignableLevel 在一次请求内对同一 caller 只做
|
|
|
+// 一次 DB 读;CheckRoleLevelAgainst 不再访问 DB,给 BindRoles 这种"批量覆盖"的接口把
|
|
|
+// N 次 loadFreshMinPermsLevel 合并为 1 次。
|
|
|
+//
|
|
|
+// 核心断言口径:
|
|
|
+// 1. SuperAdmin / ADMIN / DEVELOPER 等全权调用者不打 DB(HasFullPerms=true 短路);
|
|
|
+// 2. MEMBER caller 打 1 次 FindMinPermsLevelByUserIdAndProductCode;
|
|
|
+// 3. caller.ErrNotFound → NoRole=true(不打翻 500);
|
|
|
+// 4. caller 其他 DB 错误 → fail-close 500(保持与 loadFreshMinPermsLevel 一致的口径,
|
|
|
+// 避免降级为"无角色 = 最低级"放行)。
|
|
|
+// 5. CheckRoleLevelAgainst 是纯函数,不访问 svcCtx。
|
|
|
+// ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+// TC-1017: SuperAdmin / ADMIN / DEVELOPER 走 HasFullPerms 短路,不触碰 DB。
|
|
|
+func TestLoadCallerAssignableLevel_FullPermsShortCircuit_NoDB(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ // 关键:没有 EXPECT.FindMinPermsLevelByUserIdAndProductCode —— 一旦被调用会 fail。
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ cases := []struct {
|
|
|
+ name string
|
|
|
+ caller *loaders.UserDetails
|
|
|
+ }{
|
|
|
+ {
|
|
|
+ name: "SuperAdmin",
|
|
|
+ caller: &loaders.UserDetails{UserId: 1, IsSuperAdmin: true, ProductCode: "p"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "ADMIN",
|
|
|
+ caller: &loaders.UserDetails{UserId: 2, MemberType: consts.MemberTypeAdmin, ProductCode: "p"},
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: "DEVELOPER",
|
|
|
+ caller: &loaders.UserDetails{UserId: 3, MemberType: consts.MemberTypeDeveloper, ProductCode: "p"},
|
|
|
+ },
|
|
|
+ }
|
|
|
+ for _, c := range cases {
|
|
|
+ t.Run(c.name, func(t *testing.T) {
|
|
|
+ snap, err := LoadCallerAssignableLevel(context.Background(), svcCtx, c.caller)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.True(t, snap.HasFullPerms, "全权调用者必须落 HasFullPerms 分支")
|
|
|
+ assert.False(t, snap.NoRole)
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1018: MEMBER caller 仅打 1 次 FindMinPermsLevelByUserIdAndProductCode;
|
|
|
+// 循环内对 N 个角色走 CheckRoleLevelAgainst 不再打 DB。
|
|
|
+func TestLoadCallerAssignableLevel_Member_ReadsDBOnce_ThenConstantTime(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ const callerId = int64(1001)
|
|
|
+ const productCode = "pc_m_r10_3"
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ // 关键断言:Times(1) 保证 N 个角色场景不会退化为 N 次 DB 读。
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), callerId, productCode).
|
|
|
+ Return(int64(100), nil).
|
|
|
+ Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: callerId,
|
|
|
+ MemberType: consts.MemberTypeMember,
|
|
|
+ ProductCode: productCode,
|
|
|
+ }
|
|
|
+ snap, err := LoadCallerAssignableLevel(context.Background(), svcCtx, caller)
|
|
|
+ require.NoError(t, err)
|
|
|
+ assert.False(t, snap.HasFullPerms)
|
|
|
+ assert.False(t, snap.NoRole)
|
|
|
+ assert.Equal(t, int64(100), snap.Level)
|
|
|
+
|
|
|
+ // 模拟 BindRoles 批量覆盖的循环:5 个角色,全部走 CheckRoleLevelAgainst 的纯比较,
|
|
|
+ // 任何一个角色额外打 DB 都会命中 gomock 的 "unexpected call" 断言。
|
|
|
+ roleLevels := []int64{200, 150, 300, 120, 999}
|
|
|
+ for _, rl := range roleLevels {
|
|
|
+ if err := CheckRoleLevelAgainst(snap, rl); err != nil {
|
|
|
+ t.Fatalf("role level %d should be assignable against caller level %d: %v", rl, snap.Level, err)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 同级与更高级一律拒绝(与 GuardRoleLevelAssignable 对称):
|
|
|
+ for _, rl := range []int64{100, 50, 1} {
|
|
|
+ err := CheckRoleLevelAgainst(snap, rl)
|
|
|
+ var codeErr *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &codeErr), "同级或更高级必须返回 CodeError")
|
|
|
+ assert.Equal(t, 403, codeErr.Code())
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1019: caller 在该产品下无角色 → NoRole=true,不回滚 500。
|
|
|
+func TestLoadCallerAssignableLevel_Member_ErrNotFound_MapsToNoRole(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(42), "pc").
|
|
|
+ Return(int64(0), sqlx.ErrNotFound).
|
|
|
+ Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: 42,
|
|
|
+ MemberType: consts.MemberTypeMember,
|
|
|
+ ProductCode: "pc",
|
|
|
+ }
|
|
|
+ snap, err := LoadCallerAssignableLevel(context.Background(), svcCtx, caller)
|
|
|
+ require.NoError(t, err, "ErrNotFound 必须被归一为 NoRole=true,不得外泄为 500")
|
|
|
+ assert.False(t, snap.HasFullPerms)
|
|
|
+ assert.True(t, snap.NoRole)
|
|
|
+
|
|
|
+ // 验证 NoRole 的 caller 连最低级角色也无法分配(与修复前保持的业务语义一致)。
|
|
|
+ err = CheckRoleLevelAgainst(snap, 999)
|
|
|
+ var codeErr *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &codeErr))
|
|
|
+ assert.Equal(t, 403, codeErr.Code())
|
|
|
+ assert.Contains(t, codeErr.Error(), "没有可分配的角色等级")
|
|
|
+}
|
|
|
+
|
|
|
+// TC-1020: caller 其他 DB 错误必须 fail-close 500,不得降级为 NoRole 放行。
|
|
|
+// 保证修复没有把"DB 抖动"悄悄压成"无角色 → 最低级 → 403"这种语义欺骗。
|
|
|
+func TestLoadCallerAssignableLevel_Member_DBError_FailClose500(t *testing.T) {
|
|
|
+ ctrl := gomock.NewController(t)
|
|
|
+ t.Cleanup(ctrl.Finish)
|
|
|
+
|
|
|
+ dbErr := errors.New("driver: bad connection")
|
|
|
+ mockRole := mocks.NewMockSysRoleModel(ctrl)
|
|
|
+ mockRole.EXPECT().
|
|
|
+ FindMinPermsLevelByUserIdAndProductCode(gomock.Any(), int64(7), "pc").
|
|
|
+ Return(int64(0), dbErr).
|
|
|
+ Times(1)
|
|
|
+
|
|
|
+ svcCtx := mocks.NewMockServiceContext(mocks.MockModels{Role: mockRole})
|
|
|
+ caller := &loaders.UserDetails{
|
|
|
+ UserId: 7,
|
|
|
+ MemberType: consts.MemberTypeMember,
|
|
|
+ ProductCode: "pc",
|
|
|
+ }
|
|
|
+
|
|
|
+ _, err := LoadCallerAssignableLevel(context.Background(), svcCtx, caller)
|
|
|
+ var codeErr *response.CodeError
|
|
|
+ require.True(t, errors.As(err, &codeErr), "DB 抖动必须 fail-close 为 CodeError 而非 nil")
|
|
|
+ assert.Equal(t, 500, codeErr.Code())
|
|
|
+}
|