package member import ( "database/sql" "errors" "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" ) // strPtr / int64Ptr 是 L-R11-1 后 UpdateMemberReq.MemberType / Status 指针化的 helper。 // 若 nil 表示不改该字段,两者都 nil 会被 Logic 400。 func strPtr(s string) *string { return &s } type seededProduct struct { code string pId int64 uId int64 mId int64 admin int64 // 成员 id 当该成员为 ADMIN } // seedEnabledProductWithMember 创建 enabled product + user + product_member(memberType 指定) func seedEnabledProductWithMember(t *testing.T, svcCtx *svc.ServiceContext, memberType string) seededProduct { t.Helper() ctx := ctxhelper.SuperAdminCtx() 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() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: code, Password: testutil.HashPassword("pw"), Nickname: "n", 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: code, UserId: uId, MemberType: memberType, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() return seededProduct{code: code, pId: pId, uId: uId, mId: mId, admin: mId} } func cleanupSeeded(t *testing.T, svcCtx *svc.ServiceContext, sp seededProduct) { t.Helper() ctx := ctxhelper.SuperAdminCtx() conn := testutil.GetTestSqlConn() testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", sp.code) testutil.CleanTable(ctx, conn, "`sys_user`", sp.uId) testutil.CleanTable(ctx, conn, "`sys_product`", sp.pId) } // TC-0723: H-4 修复:不能移除产品最后一个 ADMIN func TestRemoveMember_LastAdminRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seededProduct{code: testutil.UniqueId()} now := time.Now().Unix() pRes, err := svcCtx.SysProductModel.Insert(ctx, &productModel.SysProduct{ Code: sp.code, Name: "p_" + sp.code, AppKey: sp.code + "_k", AppSecret: "s", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) sp.pId, _ = pRes.LastInsertId() uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: sp.code, Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) sp.uId, _ = uRes.LastInsertId() mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: sp.code, UserId: sp.uId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) sp.mId, _ = mRes.LastInsertId() t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) logic := NewRemoveMemberLogic(ctx, svcCtx) err = logic.RemoveMember(&types.RemoveMemberReq{Id: sp.mId}) 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(), "最后一个管理员") m, ferr := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, ferr, "ADMIN 必须仍然存在") assert.Equal(t, "ADMIN", m.MemberType) } // TC-0724: 存在 >=2 个 ADMIN 时可以移除其中一个 func TestRemoveMember_AdminNotLast_Allowed(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() var uIds, mIds []int64 for i := 0; i < 2; i++ { uid := testutil.UniqueId() + "_a" uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: uid, Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() uIds = append(uIds, uId) mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: code, UserId: uId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() mIds = append(mIds, mId) } t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTable(ctx, conn, "`sys_user`", uIds...) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) err = NewRemoveMemberLogic(ctx, svcCtx).RemoveMember(&types.RemoveMemberReq{Id: mIds[0]}) require.NoError(t, err) _, err = svcCtx.SysProductMemberModel.FindOne(ctx, mIds[0]) require.Error(t, err) _, err = svcCtx.SysProductMemberModel.FindOne(ctx, mIds[1]) require.NoError(t, err, "另一个 ADMIN 必须保留") } // TC-0725: H-4 修复:不能将最后一个 ADMIN 降级为 MEMBER func TestUpdateMember_DemoteLastAdminRejected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seedEnabledProductWithMember(t, svcCtx, "ADMIN") t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) err := NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: sp.mId, MemberType: strPtr("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(), "最后一个管理员") m, err := svcCtx.SysProductMemberModel.FindOne(ctx, sp.mId) require.NoError(t, err) assert.Equal(t, "ADMIN", m.MemberType, "MemberType 不应被改动") } // TC-0726: H-4 修复:有多个 ADMIN 时可以降级其中一个 func TestUpdateMember_DemoteAdmin_WhenMultiple_Allowed(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() var uIds, mIds []int64 for i := 0; i < 2; i++ { uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() uIds = append(uIds, uId) mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: code, UserId: uId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() mIds = append(mIds, mId) } t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTable(ctx, conn, "`sys_user`", uIds...) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: mIds[0], MemberType: strPtr("MEMBER"), }) require.NoError(t, err) m, err := svcCtx.SysProductMemberModel.FindOne(ctx, mIds[0]) require.NoError(t, err) assert.Equal(t, "MEMBER", m.MemberType) } // TC-0727: H-4 修复:禁用状态的 ADMIN 不计入 active admin 计数,导致剩余 0 个启用 ADMIN 时仍拒绝降级 // 说明:CountActiveAdmins 只统计 status=1 的 ADMIN;即便 DB 里有 2 个 ADMIN,但仅 1 个启用, // 降级这个唯一启用的 ADMIN 仍应被拒绝。 func TestUpdateMember_DemoteLastActiveAdmin_Rejected(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() var uIds, mIds []int64 statuses := []int64{1, 2} // 一个启用,一个禁用 for _, st := range statuses { uRes, err := svcCtx.SysUserModel.Insert(ctx, &userModel.SysUser{ Username: testutil.UniqueId(), Password: testutil.HashPassword("pw"), Avatar: sql.NullString{}, IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) uId, _ := uRes.LastInsertId() uIds = append(uIds, uId) mRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &memberModel.SysProductMember{ ProductCode: code, UserId: uId, MemberType: "ADMIN", Status: st, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) mId, _ := mRes.LastInsertId() mIds = append(mIds, mId) } t.Cleanup(func() { testutil.CleanTableByField(ctx, conn, "`sys_product_member`", "productCode", code) testutil.CleanTable(ctx, conn, "`sys_user`", uIds...) testutil.CleanTable(ctx, conn, "`sys_product`", pId) }) // 启用中的那个 ADMIN (mIds[0]) 降级应被拒绝 err = NewUpdateMemberLogic(ctx, svcCtx).UpdateMember(&types.UpdateMemberReq{ Id: mIds[0], MemberType: strPtr("DEVELOPER"), }) 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-0728: 移除非 ADMIN 成员不受 last-admin 保护 func TestRemoveMember_NonAdmin_Unaffected(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) sp := seedEnabledProductWithMember(t, svcCtx, "MEMBER") t.Cleanup(func() { cleanupSeeded(t, svcCtx, sp) }) err := NewRemoveMemberLogic(ctx, svcCtx).RemoveMember(&types.RemoveMemberReq{Id: sp.mId}) require.NoError(t, err) } // TC-0950: H-3 修复 —— 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, "H-3:禁止把 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: L-5 修复:禁用产品不允许添加成员 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(), "禁用") }