package user import ( "errors" "testing" "time" productMemberModel "perms-system-server/internal/model/productmember" userModelPkg "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-0176: 含productCode func TestUserList_WithProductCode(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) productCode := testutil.UniqueId() now := time.Now().Unix() memberRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productMemberModel.SysProductMember{ ProductCode: productCode, UserId: userId, MemberType: "ADMIN", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) memberId, _ := memberRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", memberId) testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUserListLogic(ctx, svcCtx) resp, err := logic.UserList(&types.UserListReq{ ProductCode: productCode, Page: 1, PageSize: 100, }) require.NoError(t, err) require.NotNil(t, resp) assert.Greater(t, resp.Total, int64(0)) items := resp.List.([]types.UserItem) found := false for _, item := range items { if item.Id == userId { found = true assert.Equal(t, "ADMIN", item.MemberType) break } } assert.True(t, found, "should find the inserted user in the list") } // TC-0177: 不含productCode func TestUserList_WithoutProductCode(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() username := testutil.UniqueId() userId := insertTestUser(t, ctx, username, testutil.HashPassword("pass")) t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_user`", userId) }) logic := NewUserListLogic(ctx, svcCtx) resp, err := logic.UserList(&types.UserListReq{ Page: 1, PageSize: 100, }) require.NoError(t, err) require.NotNil(t, resp) items := resp.List.([]types.UserItem) for _, item := range items { if item.Id == userId { assert.Equal(t, "", item.MemberType) return } } t.Fatal("should find inserted user in the list") } // TC-0178: pageSize超过上限 func TestUserList_PageSizeOver100_Capped(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUserListLogic(ctx, svcCtx) resp, err := logic.UserList(&types.UserListReq{ Page: 1, PageSize: 200, }) require.NoError(t, err) require.NotNil(t, resp) items := resp.List.([]types.UserItem) assert.LessOrEqual(t, len(items), 100) } // TC-0179: 用户不在产品中 func TestUserList_PartialNonMember(t *testing.T) { ctx := ctxhelper.SuperAdminCtx() svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() now := time.Now().Unix() u1Name := testutil.UniqueId() u1Id := insertTestUser(t, ctx, u1Name, testutil.HashPassword("pass")) u2Name := testutil.UniqueId() u2Id := insertTestUser(t, ctx, u2Name, testutil.HashPassword("pass")) productCode := testutil.UniqueId() memberRes, err := svcCtx.SysProductMemberModel.Insert(ctx, &productMemberModel.SysProductMember{ ProductCode: productCode, UserId: u1Id, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) memberId, _ := memberRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(ctx, conn, "`sys_product_member`", memberId) testutil.CleanTable(ctx, conn, "`sys_user`", u1Id, u2Id) }) logic := NewUserListLogic(ctx, svcCtx) resp, err := logic.UserList(&types.UserListReq{ ProductCode: productCode, Page: 1, PageSize: 100, }) require.NoError(t, err) require.NotNil(t, resp) items := resp.List.([]types.UserItem) for _, item := range items { if item.Id == u1Id { assert.Equal(t, "MEMBER", item.MemberType) } if item.Id == u2Id { assert.Equal(t, "", item.MemberType) } } } // TC-0205: 非超管用户仅能看到产品成员(#1修复验证) func TestUserList_NonSuperAdminOnlySeeProductMembers(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() superCtx := ctxhelper.SuperAdminCtx() now := time.Now().Unix() productCode := testutil.UniqueId() memberName := testutil.UniqueId() memberId := insertTestUser(t, superCtx, memberName, testutil.HashPassword("pass")) nonMemberName := testutil.UniqueId() nonMemberId := insertTestUser(t, superCtx, nonMemberName, testutil.HashPassword("pass")) pmRes, err := svcCtx.SysProductMemberModel.Insert(superCtx, &productMemberModel.SysProductMember{ ProductCode: productCode, UserId: memberId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) pmId, _ := pmRes.LastInsertId() t.Cleanup(func() { testutil.CleanTable(superCtx, conn, "`sys_product_member`", pmId) testutil.CleanTable(superCtx, conn, "`sys_user`", memberId, nonMemberId) }) ctx := ctxhelper.AdminCtx(productCode) logic := NewUserListLogic(ctx, svcCtx) resp, err := logic.UserList(&types.UserListReq{ ProductCode: productCode, Page: 1, PageSize: 100, }) require.NoError(t, err) require.NotNil(t, resp) items := resp.List.([]types.UserItem) for _, item := range items { assert.NotEqual(t, nonMemberId, item.Id, "non-member user should NOT appear in product user list for non-super-admin (audit #1)") } } // TC-0206: 非超管不带productCode时返回403 func TestUserList_NonSuperAdminWithoutProductCode_Rejected(t *testing.T) { ctx := ctxhelper.AdminCtx("test_product") svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUserListLogic(ctx, svcCtx) _, err := logic.UserList(&types.UserListReq{ Page: 1, PageSize: 10, }) 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-1167 / TC-1168 / TC-1169 统一使用这套 PII 种子,保证 UserList 三类调用者看到的 // Email/Phone/Remark 与 UserDetail 口径完全对齐,避免"详情脱敏但列表不脱敏"的侧信道泄漏。 func seedPIIMemberForList(t *testing.T, svcCtx *svc.ServiceContext, productCode string, memberType string) (callerUserId, targetUserId, targetMId, callerMId int64) { t.Helper() bootstrap := ctxhelper.SuperAdminCtx() now := time.Now().Unix() tRes, err := svcCtx.SysUserModel.Insert(bootstrap, &userModelPkg.SysUser{ Username: "list_pii_tgt_" + testutil.UniqueId(), Password: testutil.HashPassword("pw"), Email: "list_target@example.com", Phone: "13922223333", Remark: "list-target-remark", IsSuperAdmin: 2, MustChangePassword: 2, Status: 1, DeptId: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) targetUserId, _ = tRes.LastInsertId() mRes, err := svcCtx.SysProductMemberModel.Insert(bootstrap, &productMemberModel.SysProductMember{ ProductCode: productCode, UserId: targetUserId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) targetMId, _ = mRes.LastInsertId() // caller 只在 MEMBER 场景需要真实落库(和 UserDetail 对齐:非超管必须是产品成员才能 list)。 // ADMIN / DEVELOPER 走 ctxhelper 预设的 UserId 即可,不必落库。 if memberType == "MEMBER" { callerUserId = 4 cRes, err := svcCtx.SysProductMemberModel.Insert(bootstrap, &productMemberModel.SysProductMember{ ProductCode: productCode, UserId: callerUserId, MemberType: "MEMBER", Status: 1, CreateTime: now, UpdateTime: now, }) require.NoError(t, err) callerMId, _ = cRes.LastInsertId() } return } // TC-1167: 产品 ADMIN 列表视角 —— 必须拿到所有成员的 Email/Phone/Remark 原值。 func TestUserList_M_R16_1_ProductAdmin_KeepsPII(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() bootstrap := ctxhelper.SuperAdminCtx() productCode := "list_m_r16_admin_" + testutil.UniqueId() _, targetId, targetMId, _ := seedPIIMemberForList(t, svcCtx, productCode, "ADMIN") t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", targetMId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) }) resp, err := NewUserListLogic(ctxhelper.AdminCtx(productCode), svcCtx).UserList(&types.UserListReq{ ProductCode: productCode, Page: 1, PageSize: 100, }) require.NoError(t, err) items := resp.List.([]types.UserItem) var found *types.UserItem for i := range items { if items[i].Id == targetId { found = &items[i] break } } require.NotNil(t, found, "ADMIN 列表必须能看到本产品下的 target 成员") assert.Equal(t, "list_target@example.com", found.Email) assert.Equal(t, "13922223333", found.Phone) assert.Equal(t, "list-target-remark", found.Remark) } // TC-1168: 产品 DEVELOPER 列表视角 —— 与 ADMIN 同口径,PII 完整返回。 func TestUserList_M_R16_1_ProductDeveloper_KeepsPII(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() bootstrap := ctxhelper.SuperAdminCtx() productCode := "list_m_r16_dev_" + testutil.UniqueId() _, targetId, targetMId, _ := seedPIIMemberForList(t, svcCtx, productCode, "DEVELOPER") t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", targetMId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) }) resp, err := NewUserListLogic(ctxhelper.DeveloperCtx(productCode), svcCtx).UserList(&types.UserListReq{ ProductCode: productCode, Page: 1, PageSize: 100, }) require.NoError(t, err) items := resp.List.([]types.UserItem) for _, item := range items { if item.Id == targetId { assert.Equal(t, "list_target@example.com", item.Email) assert.Equal(t, "13922223333", item.Phone) assert.Equal(t, "list-target-remark", item.Remark) return } } t.Fatal("DEVELOPER 列表必须能看到 target") } // TC-1169: 产品 MEMBER 列表视角 —— Email/Phone/Remark 必须为空字符串; // 其它字段(Username/Nickname/DeptId/Status 等)照常回填,保证 MEMBER 的"通讯录视图"退化为 // "看得到谁在这个产品里",而不是"拉到每个人的私信渠道"。 func TestUserList_M_R16_1_ProductMember_MasksPII(t *testing.T) { svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) conn := testutil.GetTestSqlConn() bootstrap := ctxhelper.SuperAdminCtx() productCode := "list_m_r16_mb_" + testutil.UniqueId() _, targetId, targetMId, callerMId := seedPIIMemberForList(t, svcCtx, productCode, "MEMBER") t.Cleanup(func() { testutil.CleanTable(bootstrap, conn, "`sys_product_member`", targetMId, callerMId) testutil.CleanTable(bootstrap, conn, "`sys_user`", targetId) }) resp, err := NewUserListLogic(ctxhelper.MemberCtx(productCode), svcCtx).UserList(&types.UserListReq{ ProductCode: productCode, Page: 1, PageSize: 100, }) require.NoError(t, err) items := resp.List.([]types.UserItem) var found *types.UserItem for i := range items { if items[i].Id == targetId { found = &items[i] break } } require.NotNil(t, found, "MEMBER 列表仍应能看到 target 的存在,仅 PII 字段被清空") assert.Empty(t, found.Email, "MEMBER 视角下 Email 必须是空字符串") assert.Empty(t, found.Phone, "MEMBER 视角下 Phone 必须是空字符串") assert.Empty(t, found.Remark, "MEMBER 视角下 Remark 必须是空字符串") assert.NotEmpty(t, found.Username, "Username 不应被脱敏,保证 UI 还能渲染出成员行") } // TC-0207: 非超管访问其他产品数据被拒绝 func TestUserList_NonSuperAdminWrongProductCode_Rejected(t *testing.T) { ctx := ctxhelper.AdminCtx("product_a") svcCtx := svc.NewServiceContext(testutil.GetTestConfig()) logic := NewUserListLogic(ctx, svcCtx) _, err := logic.UserList(&types.UserListReq{ ProductCode: "product_b", Page: 1, PageSize: 10, }) 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(), "无权访问该产品的数据") }