package member import ( "database/sql" "errors" "sync" "testing" "time" productModel "perms-system-server/internal/model/product" memberModel "perms-system-server/internal/model/productmember" userModel "perms-system-server/internal/model/user" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/testutil" "perms-system-server/internal/testutil/ctxhelper" "perms-system-server/internal/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TC-0213: 正常添加 func TestAddMember_Normal(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", uid) testutil.CleanTable(ctx, conn, "`sys_user`", uId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) logic := NewAddMemberLogic(ctx, svcCtx) resp, err := logic.AddMember(&types.AddMemberReq{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", }) require.NoError(t, err) assert.True(t, resp.Id > 0) member, err := svcCtx.SysProductMemberModel.FindOne(ctx, resp.Id) require.NoError(t, err) assert.Equal(t, uid, member.ProductCode) assert.Equal(t, uId, member.UserId) assert.Equal(t, "MEMBER", member.MemberType) assert.Equal(t, int64(1), member.Status) } // TC-0214: 产品不存在 func TestAddMember_ProductNotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewAddMemberLogic(ctx, svcCtx) _, err := logic.AddMember(&types.AddMemberReq{ ProductCode: "nonexistent_product_xyz", UserId: 1, MemberType: "MEMBER", }) require.Error(t, err) ce, ok := err.(*response.CodeError) require.True(t, ok) assert.Equal(t, 404, ce.Code()) assert.Equal(t, "产品不存在", ce.Error()) } // TC-0215: 用户不存在 func TestAddMember_UserNotFound(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) logic := NewAddMemberLogic(ctx, svcCtx) _, err = logic.AddMember(&types.AddMemberReq{ ProductCode: uid, UserId: 999999999, MemberType: "MEMBER", }) require.Error(t, err) ce, ok := err.(*response.CodeError) require.True(t, ok) assert.Equal(t, 404, ce.Code()) assert.Equal(t, "用户不存在", ce.Error()) } // TC-0216: 已是成员 func TestAddMember_AlreadyMember(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", mId) testutil.CleanTable(ctx, conn, "`sys_user`", uId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) logic := NewAddMemberLogic(ctx, svcCtx) _, err = logic.AddMember(&types.AddMemberReq{ ProductCode: uid, UserId: uId, MemberType: "ADMIN", }) require.Error(t, err) ce, ok := err.(*response.CodeError) require.True(t, ok) assert.Equal(t, 409, ce.Code()) assert.Equal(t, "该用户已是该产品成员", ce.Error()) } // TC-0218: 无效MemberType func TestAddMember_InvalidMemberType(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", uId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) logic := NewAddMemberLogic(ctx, svcCtx) _, err = logic.AddMember(&types.AddMemberReq{ ProductCode: uid, UserId: uId, MemberType: "INVALID", }) require.Error(t, err) ce, ok := err.(*response.CodeError) require.True(t, ok) assert.Equal(t, 400, ce.Code()) assert.Equal(t, "无效的成员类型", ce.Error()) } // TC-0217: 并发添加 func TestAddMember_ConcurrentSameUserProduct(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "concurrent_prod", AppKey: uid, AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pass123"), Nickname: "nick", Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", uid) testutil.CleanTable(ctx, conn, "`sys_user`", uId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) var wg sync.WaitGroup results := make(chan error, 2) for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() logic := NewAddMemberLogic(ctx, svcCtx) _, err := logic.AddMember(&types.AddMemberReq{ ProductCode: uid, UserId: uId, MemberType: "MEMBER", }) results <- err }() } wg.Wait() close(results) var errs []error for e := range results { errs = append(errs, e) } require.Len(t, errs, 2) successCount := 0 failCount := 0 for _, e := range errs { if e == nil { successCount++ } else { failCount++ } } assert.Equal(t, 1, successCount, "exactly one goroutine should succeed") assert.Equal(t, 1, failCount, "exactly one goroutine should fail (409 or DB duplicate)") } // TC-0950: 修复 —— AddMember 必须显式拒绝把 SuperAdmin 作为普通产品成员加入。 // 背景:loadMembership 会把 SuperAdmin 的 MemberType 固定为 SuperAdmin 让其实际权限不受影响, // 但若 sys_product_member 里仍落一条记录,会污染日志 / 权限推理工具,且给产品 ADMIN // "纳管了 superadmin" 的错觉。必须在 AddMember 入口就 403。 func TestAddMember_SuperAdminTargetRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() code := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() // target 是 SuperAdmin(IsSuperAdmin=1) uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: "h3_su_" + code, Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, IsSuperAdmin: 1, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTable(ctx, conn, "`sys_user`", uId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) _, err = NewAddMemberLogic(ctx, svcCtx).AddMember(&types.AddMemberReq{ ProductCode: code, UserId: uId, MemberType: "MEMBER", }) require.Error(t, err, "禁止把 SuperAdmin 加入具体产品为普通成员") var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code()) assert.Contains(t, ce.Error(), "超级管理员") // DB 侧必须没有落下 SuperAdmin 的成员记录(regression:确保 AddMember 未短路在插入之后) _, findErr := svcCtx.SysProductMemberModel.FindOneByProductCodeUserId(ctx, code, uId) require.Error(t, findErr, "SuperAdmin 不得被落入 sys_product_member") } // TC-0729: 修复:禁用产品不允许添加成员 func TestAddMember_DisabledProductRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() code := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: code, Name: "p_" + code, AppKey: code + "_k", AppSecret: "s", Status: 2, CreateTime: now, UpdateTime: now, // 禁用 }) require.NoError(t, err) pId, _ := pRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: code, Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", uId) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) _, err = NewAddMemberLogic(ctx, svcCtx).AddMember(&types.AddMemberReq{ ProductCode: code, UserId: uId, MemberType: "MEMBER", }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code()) assert.Contains(t, ce.Error(), "禁用") } // TC-1107: 非 ADMIN caller + 不存在的 productCode —— 必须 403(不是 404), // L-R13-1:`RequireProductAdminFor` 先行于 `SysProductModel.FindOneByCode`,消除通过 // "产品不存在 vs 权限不足"的响应码差分枚举产品 code 的 oracle。 func TestAddMember_L_R13_1_ProductEnumerationBlocked(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() // caller 是另一个产品的 ADMIN,对目标产品没有权限。 callerCtx := ctxhelper.AdminCtx("some_other_product") _, err := NewAddMemberLogic(callerCtx, svcCtx).AddMember(&types.AddMemberReq{ ProductCode: "definitely_does_not_exist_" + testutil.UniqueId(), UserId: 999999999, MemberType: "MEMBER", }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code(), "L-R13-1:productCode 不存在的差异必须被权限闸吞掉,不得 404 泄漏产品存在性") // 并发安全:DB 未因这条请求留下任何 sys_product_member 行。 var count int64 _ = conn.QueryRowCtx(callerCtx, &count, "SELECT COUNT(*) FROM `sys_product_member` WHERE `userId` = ?", int64(999999999)) assert.Equal(t, int64(0), count) } // TC-1108: 非 ADMIN caller + 非法 MemberType —— 必须 403 而不是 400(权限先于字面校验), // 防御通过 400 "无效的成员类型" 和 404 "产品不存在" 的差分探测 productCode 是否在线。 func TestAddMember_L_R13_1_InvalidMemberTypeBeforeAuth(t *testing.T) { ctx := ctxhelper.MemberCtx("test_product") svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) _, err := NewAddMemberLogic(ctx, svcCtx).AddMember(&types.AddMemberReq{ ProductCode: "test_product", UserId: 1, MemberType: "INVALID", }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 403, ce.Code(), "L-R13-1:MEMBER 无 ADMIN 权限必须先于 MemberType 字面校验 403,不得返 400") } // TC-1109: 超管 + 非法 MemberType:权限通过后仍必须命中 400 字面校验, // 回归 L-R13-1 改动没有把合法路径的 400 语义也吃掉。 func TestAddMember_L_R13_1_SuperAdminStillGets400ForInvalidType(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() uid := testutil.UniqueId() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: uid, Name: "test_prod", AppKey: uid, AppSecret: "s1", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pId, _ := pRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) _, err = NewAddMemberLogic(ctx, svcCtx).AddMember(&types.AddMemberReq{ ProductCode: uid, UserId: 999999999, MemberType: "INVALID", }) require.Error(t, err) var ce *response.CodeError require.True(t, errors.As(err, &ce)) assert.Equal(t, 400, ce.Code(), "超管权限通过后必须继续走字面 400 检查,不得因 L-R13-1 改动被吞掉") assert.Equal(t, "无效的成员类型", ce.Error()) }