From 77bee1d4f1395ad584e2c0323657c86fdadd6a34 Mon Sep 17 00:00:00 2001 From: Farhan Munshi <3207297+fmunshi@users.noreply.github.com> Date: Thu, 18 Jun 2020 10:22:35 -0400 Subject: [PATCH] MM-25263 Add group members to search and get users and create getGroupStats endpoint (#14733) Add tests for SearchInGroup --- api4/group.go | 39 ++++++++ api4/group_test.go | 48 ++++++++++ api4/user.go | 32 ++++++- api4/user_test.go | 86 ++++++++++++++++++ app/app_iface.go | 2 + app/group.go | 6 +- app/opentracing_layer.go | 44 +++++++++ app/user.go | 17 ++++ model/client4.go | 21 +++++ model/group.go | 11 +++ model/user_get.go | 2 + model/user_search.go | 1 + store/opentracing_layer.go | 18 ++++ store/sqlstore/user_store.go | 9 ++ store/store.go | 1 + store/storetest/mocks/UserStore.go | 25 +++++ store/storetest/user_store.go | 141 +++++++++++++++++++++++++++++ store/timer_layer.go | 16 ++++ 18 files changed, 517 insertions(+), 2 deletions(-) diff --git a/api4/group.go b/api4/group.go index 408bd6ced3..396aa352cf 100644 --- a/api4/group.go +++ b/api4/group.go @@ -52,6 +52,10 @@ func (api *API) InitGroup() { api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/{syncable_type:teams|channels}/{syncable_id:[A-Za-z0-9]+}/patch", api.ApiSessionRequired(patchGroupSyncable)).Methods("PUT") + // GET /api/v4/groups/:group_id/stats + api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/stats", + api.ApiSessionRequired(getGroupStats)).Methods("GET") + // GET /api/v4/groups/:group_id/members?page=0&per_page=100 api.BaseRoutes.Groups.Handle("/{group_id:[A-Za-z0-9]+}/members", api.ApiSessionRequired(getGroupMembers)).Methods("GET") @@ -530,6 +534,41 @@ func getGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(b) } +func getGroupStats(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireGroupId() + if c.Err != nil { + return + } + + if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.getGroupStats", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + groupID := c.Params.GroupId + count, err := c.App.GetGroupMemberCount(groupID) + if err != nil { + c.Err = err + return + } + + b, marshalErr := json.Marshal(model.GroupStats{ + GroupID: groupID, + TotalMemberCount: count, + }) + if marshalErr != nil { + c.Err = model.NewAppError("Api4.getGroupStats", "api.marshal_error", nil, marshalErr.Error(), http.StatusInternalServerError) + return + } + + w.Write(b) +} + func getGroupsByUserId(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireUserId() if c.Err != nil { diff --git a/api4/group_test.go b/api4/group_test.go index e4367ea079..3ce54ba4e9 100644 --- a/api4/group_test.go +++ b/api4/group_test.go @@ -1024,6 +1024,54 @@ func TestGetGroupsByUserId(t *testing.T) { } +func TestGetGroupStats(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + id := model.NewId() + group, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn-foo_" + id, + Name: model.NewString("name" + id), + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + var response *model.Response + var stats *model.GroupStats + + t.Run("Requires ldap license", func(t *testing.T) { + _, response = th.SystemAdminClient.GetGroupStats(group.Id) + CheckNotImplementedStatus(t, response) + }) + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + t.Run("Requires manage system permission to access group stats", func(t *testing.T) { + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + _, response = th.Client.GetGroupStats(group.Id) + CheckForbiddenStatus(t, response) + }) + + t.Run("Returns stats for a group with no members", func(t *testing.T) { + stats, _ = th.SystemAdminClient.GetGroupStats(group.Id) + assert.Equal(t, stats.GroupID, group.Id) + assert.Equal(t, stats.TotalMemberCount, int64(0)) + }) + + user1, err := th.App.CreateUser(&model.User{Email: th.GenerateTestEmail(), Nickname: "test user1", Password: "test-password-1", Username: "test-user-1", Roles: model.SYSTEM_USER_ROLE_ID}) + assert.Nil(t, err) + _, err = th.App.UpsertGroupMember(group.Id, user1.Id) + assert.Nil(t, err) + + t.Run("Returns stats for a group with members", func(t *testing.T) { + stats, _ = th.SystemAdminClient.GetGroupStats(group.Id) + assert.Equal(t, stats.GroupID, group.Id) + assert.Equal(t, stats.TotalMemberCount, int64(1)) + }) +} + func TestGetGroupsGroupConstrainedParentTeam(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/api4/user.go b/api4/user.go index 631599958e..c15a475bb2 100644 --- a/api4/user.go +++ b/api4/user.go @@ -527,6 +527,7 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { inTeamId := r.URL.Query().Get("in_team") notInTeamId := r.URL.Query().Get("not_in_team") inChannelId := r.URL.Query().Get("in_channel") + inGroupId := r.URL.Query().Get("in_group") notInChannelId := r.URL.Query().Get("not_in_channel") groupConstrained := r.URL.Query().Get("group_constrained") withoutTeam := r.URL.Query().Get("without_team") @@ -546,7 +547,7 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { // Currently only supports sorting on a team // or sort="status" on inChannelId - if (sort == "last_activity_at" || sort == "create_at") && (inTeamId == "" || notInTeamId != "" || inChannelId != "" || notInChannelId != "" || withoutTeam != "") { + if (sort == "last_activity_at" || sort == "create_at") && (inTeamId == "" || notInTeamId != "" || inChannelId != "" || notInChannelId != "" || withoutTeam != "" || inGroupId != "") { c.SetInvalidUrlParam("sort") return } @@ -570,6 +571,7 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { InChannelId: inChannelId, NotInTeamId: notInTeamId, NotInChannelId: notInChannelId, + InGroupId: inGroupId, GroupConstrained: groupConstrainedBool, WithoutTeam: withoutTeamBool, Inactive: inactiveBool, @@ -637,6 +639,22 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { } else { profiles, err = c.App.GetUsersInChannelPage(inChannelId, c.Params.Page, c.Params.PerPage, c.IsSystemAdmin()) } + } else if len(inGroupId) > 0 { + if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.getUsersInGroup", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + profiles, _, err = c.App.GetGroupMemberUsersPage(inGroupId, c.Params.Page, c.Params.PerPage) + if err != nil { + c.Err = err + return + } } else { userGetOptions, err = c.App.RestrictUsersGetByPermissions(c.App.Session().UserId, userGetOptions) if err != nil { @@ -749,6 +767,18 @@ func searchUsers(c *Context, w http.ResponseWriter, r *http.Request) { return } + if props.InGroupId != "" { + if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.LDAPGroups { + c.Err = model.NewAppError("Api4.searchUsers", "api.ldap_groups.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + } + if props.InChannelId != "" && !c.App.SessionHasPermissionToChannel(*c.App.Session(), props.InChannelId, model.PERMISSION_READ_CHANNEL) { c.SetPermissionError(model.PERMISSION_READ_CHANNEL) return diff --git a/api4/user_test.go b/api4/user_test.go index b1532ca589..2814680c7e 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -1084,6 +1084,44 @@ func TestSearchUsers(t *testing.T) { CheckNoError(t, resp) require.True(t, findUserInList(th.BasicUser.Id, users), "should have found user") + + id := model.NewId() + group, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn-foo_" + id, + Name: model.NewString("name" + id), + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + search = &model.UserSearch{Term: th.BasicUser.Username, InGroupId: group.Id} + t.Run("Requires ldap license when searching in group", func(t *testing.T) { + _, resp = th.SystemAdminClient.SearchUsers(search) + CheckNotImplementedStatus(t, resp) + }) + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + t.Run("Requires manage system permission when searching for users in a group", func(t *testing.T) { + _, resp = th.Client.SearchUsers(search) + CheckForbiddenStatus(t, resp) + }) + + t.Run("Returns empty list when no users found searching for users in a group", func(t *testing.T) { + users, resp = th.SystemAdminClient.SearchUsers(search) + CheckNoError(t, resp) + require.Empty(t, users) + }) + + _, err = th.App.UpsertGroupMember(group.Id, th.BasicUser.Id) + assert.Nil(t, err) + + t.Run("Returns user in group user found in group", func(t *testing.T) { + users, resp = th.SystemAdminClient.SearchUsers(search) + CheckNoError(t, resp) + require.Equal(t, users[0].Id, th.BasicUser.Id) + }) } func findUserInList(id string, users []*model.User) bool { @@ -2328,6 +2366,54 @@ func TestGetUsersNotInChannel(t *testing.T) { CheckNoError(t, resp) } +func TestGetUsersInGroup(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + id := model.NewId() + group, err := th.App.CreateGroup(&model.Group{ + DisplayName: "dn-foo_" + id, + Name: model.NewString("name" + id), + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewId(), + }) + assert.Nil(t, err) + + var response *model.Response + var users []*model.User + + t.Run("Requires ldap license", func(t *testing.T) { + _, response = th.SystemAdminClient.GetUsersInGroup(group.Id, 0, 60, "") + CheckNotImplementedStatus(t, response) + }) + + th.App.Srv().SetLicense(model.NewTestLicense("ldap")) + + t.Run("Requires manage system permission to access users in group", func(t *testing.T) { + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + _, response = th.Client.GetUsersInGroup(group.Id, 0, 60, "") + CheckForbiddenStatus(t, response) + }) + + user1, err := th.App.CreateUser(&model.User{Email: th.GenerateTestEmail(), Nickname: "test user1", Password: "test-password-1", Username: "test-user-1", Roles: model.SYSTEM_USER_ROLE_ID}) + assert.Nil(t, err) + _, err = th.App.UpsertGroupMember(group.Id, user1.Id) + assert.Nil(t, err) + + t.Run("Returns users in group when called by system admin", func(t *testing.T) { + users, response = th.SystemAdminClient.GetUsersInGroup(group.Id, 0, 60, "") + CheckNoError(t, response) + assert.Equal(t, users[0].Id, user1.Id) + }) + + t.Run("Returns no users when pagination out of range", func(t *testing.T) { + users, response = th.SystemAdminClient.GetUsersInGroup(group.Id, 5, 60, "") + CheckNoError(t, response) + assert.Empty(t, users) + }) +} + func TestUpdateUserMfa(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/app_iface.go b/app/app_iface.go index 34283821ee..bd31d38c6e 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -541,6 +541,7 @@ type AppIface interface { GetGroupByName(name string, opts model.GroupSearchOpts) (*model.Group, *model.AppError) GetGroupByRemoteID(remoteID string, groupSource model.GroupSource) (*model.Group, *model.AppError) GetGroupChannel(userIds []string) (*model.Channel, *model.AppError) + GetGroupMemberCount(groupID string) (int64, *model.AppError) GetGroupMemberUsers(groupID string) ([]*model.User, *model.AppError) GetGroupMemberUsersPage(groupID string, page int, perPage int) ([]*model.User, int, *model.AppError) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) @@ -836,6 +837,7 @@ type AppIface interface { SearchUserAccessTokens(term string) ([]*model.UserAccessToken, *model.AppError) SearchUsers(props *model.UserSearch, options *model.UserSearchOptions) ([]*model.User, *model.AppError) SearchUsersInChannel(channelId string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) + SearchUsersInGroup(groupID string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) SearchUsersInTeam(teamId, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) SearchUsersNotInChannel(teamId string, channelId string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) SearchUsersNotInTeam(notInTeamId string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) diff --git a/app/group.go b/app/group.go index 9ffd1b5727..daf8c7387f 100644 --- a/app/group.go +++ b/app/group.go @@ -59,6 +59,10 @@ func (a *App) DeleteGroup(groupID string) (*model.Group, *model.AppError) { return deletedGroup, err } +func (a *App) GetGroupMemberCount(groupID string) (int64, *model.AppError) { + return a.Srv().Store.Group().GetMemberCount(groupID) +} + func (a *App) GetGroupMemberUsers(groupID string) ([]*model.User, *model.AppError) { return a.Srv().Store.Group().GetMemberUsers(groupID) } @@ -69,7 +73,7 @@ func (a *App) GetGroupMemberUsersPage(groupID string, page int, perPage int) ([] return nil, 0, err } - count, err := a.Srv().Store.Group().GetMemberCount(groupID) + count, err := a.GetGroupMemberCount(groupID) if err != nil { return nil, 0, err } diff --git a/app/opentracing_layer.go b/app/opentracing_layer.go index 0e0c282105..40433bd119 100644 --- a/app/opentracing_layer.go +++ b/app/opentracing_layer.go @@ -5311,6 +5311,28 @@ func (a *OpenTracingAppLayer) GetGroupChannel(userIds []string) (*model.Channel, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetGroupMemberCount(groupID string) (int64, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetGroupMemberCount") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetGroupMemberCount(groupID) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetGroupMemberUsers(groupID string) ([]*model.User, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetGroupMemberUsers") @@ -12120,6 +12142,28 @@ func (a *OpenTracingAppLayer) SearchUsersInChannel(channelId string, term string return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) SearchUsersInGroup(groupID string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SearchUsersInGroup") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.SearchUsersInGroup(groupID, term, options) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) SearchUsersInTeam(teamId string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SearchUsersInTeam") diff --git a/app/user.go b/app/user.go index 5ab3744b5b..1de9b27bcd 100644 --- a/app/user.go +++ b/app/user.go @@ -1693,6 +1693,9 @@ func (a *App) SearchUsers(props *model.UserSearch, options *model.UserSearchOpti if props.NotInTeamId != "" { return a.SearchUsersNotInTeam(props.NotInTeamId, props.Term, options) } + if props.InGroupId != "" { + return a.SearchUsersInGroup(props.InGroupId, props.Term, options) + } return a.SearchUsersInTeam(props.TeamId, props.Term, options) } @@ -1768,6 +1771,20 @@ func (a *App) SearchUsersWithoutTeam(term string, options *model.UserSearchOptio return users, nil } +func (a *App) SearchUsersInGroup(groupID string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { + term = strings.TrimSpace(term) + users, err := a.Srv().Store.User().SearchInGroup(groupID, term, options) + if err != nil { + return nil, err + } + + for _, user := range users { + a.SanitizeProfile(user, options.IsAdmin) + } + + return users, nil +} + func (a *App) AutocompleteUsersInChannel(teamId string, channelId string, term string, options *model.UserSearchOptions) (*model.UserAutocompleteInChannel, *model.AppError) { term = strings.TrimSpace(term) diff --git a/model/client4.go b/model/client4.go index ee665c9fbc..153e00efca 100644 --- a/model/client4.go +++ b/model/client4.go @@ -994,6 +994,17 @@ func (c *Client4) GetUsersWithoutTeam(page int, perPage int, etag string) ([]*Us return UserListFromJson(r.Body), BuildResponse(r) } +// GetUsersInGroup returns a page of users in a group. Page counting starts at 0. +func (c *Client4) GetUsersInGroup(groupID string, page int, perPage int, etag string) ([]*User, *Response) { + query := fmt.Sprintf("?in_group=%v&page=%v&per_page=%v", groupID, page, perPage) + r, err := c.DoApiGet(c.GetUsersRoute()+query, etag) + if err != nil { + return nil, BuildErrorResponse(r, err) + } + defer closeBody(r) + return UserListFromJson(r.Body), BuildResponse(r) +} + // GetUsersByIds returns a list of users based on the provided user ids. func (c *Client4) GetUsersByIds(userIds []string) ([]*User, *Response) { r, err := c.DoApiPost(c.GetUsersRoute()+"/ids", ArrayToJson(userIds)) @@ -5127,3 +5138,13 @@ func (c *Client4) RequestTrialLicense(users int) (bool, *Response) { defer closeBody(r) return CheckStatusOK(r), BuildResponse(r) } + +// GetGroupStats retrieves stats for a Mattermost Group +func (c *Client4) GetGroupStats(groupID string) (*GroupStats, *Response) { + r, appErr := c.DoApiGet(c.GetGroupRoute(groupID)+"/stats", "") + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + return GroupStatsFromJson(r.Body), BuildResponse(r) +} diff --git a/model/group.go b/model/group.go index 4896683d59..d713582622 100644 --- a/model/group.go +++ b/model/group.go @@ -94,6 +94,11 @@ type PageOpts struct { PerPage int } +type GroupStats struct { + GroupID string `json:"group_id"` + TotalMemberCount int64 `json:"total_member_count"` +} + func (group *Group) Patch(patch *GroupPatch) { if patch.Name != nil { group.Name = patch.Name @@ -208,3 +213,9 @@ func GroupPatchFromJson(data io.Reader) *GroupPatch { json.NewDecoder(data).Decode(&groupPatch) return groupPatch } + +func GroupStatsFromJson(data io.Reader) *GroupStats { + var groupStats *GroupStats + json.NewDecoder(data).Decode(&groupStats) + return groupStats +} diff --git a/model/user_get.go b/model/user_get.go index 74fa569f09..e7ce0ae8c9 100644 --- a/model/user_get.go +++ b/model/user_get.go @@ -12,6 +12,8 @@ type UserGetOptions struct { InChannelId string // Filters the users not in the channel NotInChannelId string + // Filters the users in the group + InGroupId string // Filters the users group constrained GroupConstrained bool // Filters the users without a team diff --git a/model/user_search.go b/model/user_search.go index fa9fa8a283..7ae8a33103 100644 --- a/model/user_search.go +++ b/model/user_search.go @@ -18,6 +18,7 @@ type UserSearch struct { NotInTeamId string `json:"not_in_team_id"` InChannelId string `json:"in_channel_id"` NotInChannelId string `json:"not_in_channel_id"` + InGroupId string `json:"in_group_id"` GroupConstrained bool `json:"group_constrained"` AllowInactive bool `json:"allow_inactive"` WithoutTeam bool `json:"without_team"` diff --git a/store/opentracing_layer.go b/store/opentracing_layer.go index 511969a319..d45996febe 100644 --- a/store/opentracing_layer.go +++ b/store/opentracing_layer.go @@ -8225,6 +8225,24 @@ func (s *OpenTracingLayerUserStore) SearchInChannel(channelId string, term strin return resultVar0, resultVar1 } +func (s *OpenTracingLayerUserStore) SearchInGroup(groupID string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.SearchInGroup") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + resultVar0, resultVar1 := s.UserStore.SearchInGroup(groupID, term, options) + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (s *OpenTracingLayerUserStore) SearchNotInChannel(teamId string, channelId string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UserStore.SearchNotInChannel") diff --git a/store/sqlstore/user_store.go b/store/sqlstore/user_store.go index b5cdb68f88..8c112e0a9d 100644 --- a/store/sqlstore/user_store.go +++ b/store/sqlstore/user_store.go @@ -1240,6 +1240,15 @@ func (us SqlUserStore) SearchInChannel(channelId string, term string, options *m return us.performSearch(query, term, options) } +func (us SqlUserStore) SearchInGroup(groupID string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { + query := us.usersQuery. + Join("GroupMembers gm ON ( gm.UserId = u.Id AND gm.GroupId = ? )", groupID). + OrderBy("Username ASC"). + Limit(uint64(options.Limit)) + + return us.performSearch(query, term, options) +} + var spaceFulltextSearchChar = []string{ "<", ">", diff --git a/store/store.go b/store/store.go index fba1734a11..070367560b 100644 --- a/store/store.go +++ b/store/store.go @@ -322,6 +322,7 @@ type UserStore interface { SearchInChannel(channelId string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) SearchNotInChannel(teamId string, channelId string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) SearchWithoutTeam(term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) + SearchInGroup(groupID string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) AnalyticsGetInactiveUsersCount() (int64, *model.AppError) AnalyticsGetSystemAdminCount() (int64, *model.AppError) AnalyticsGetGuestCount() (int64, *model.AppError) diff --git a/store/storetest/mocks/UserStore.go b/store/storetest/mocks/UserStore.go index 142965ae4a..8500a844f3 100644 --- a/store/storetest/mocks/UserStore.go +++ b/store/storetest/mocks/UserStore.go @@ -1164,6 +1164,31 @@ func (_m *UserStore) SearchInChannel(channelId string, term string, options *mod return r0, r1 } +// SearchInGroup provides a mock function with given fields: groupID, term, options +func (_m *UserStore) SearchInGroup(groupID string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { + ret := _m.Called(groupID, term, options) + + var r0 []*model.User + if rf, ok := ret.Get(0).(func(string, string, *model.UserSearchOptions) []*model.User); ok { + r0 = rf(groupID, term, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + var r1 *model.AppError + if rf, ok := ret.Get(1).(func(string, string, *model.UserSearchOptions) *model.AppError); ok { + r1 = rf(groupID, term, options) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + // SearchNotInChannel provides a mock function with given fields: teamId, channelId, term, options func (_m *UserStore) SearchNotInChannel(teamId string, channelId string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { ret := _m.Called(teamId, channelId, term, options) diff --git a/store/storetest/user_store.go b/store/storetest/user_store.go index 4844123139..ea0276b183 100644 --- a/store/storetest/user_store.go +++ b/store/storetest/user_store.go @@ -72,6 +72,7 @@ func TestUserStore(t *testing.T, ss store.Store, s SqlSupplier) { t.Run("SearchInChannel", func(t *testing.T) { testUserStoreSearchInChannel(t, ss) }) t.Run("SearchNotInTeam", func(t *testing.T) { testUserStoreSearchNotInTeam(t, ss) }) t.Run("SearchWithoutTeam", func(t *testing.T) { testUserStoreSearchWithoutTeam(t, ss) }) + t.Run("SearchInGroup", func(t *testing.T) { testUserStoreSearchInGroup(t, ss) }) t.Run("GetProfilesNotInTeam", func(t *testing.T) { testUserStoreGetProfilesNotInTeam(t, ss) }) t.Run("ClearAllCustomRoleAssignments", func(t *testing.T) { testUserStoreClearAllCustomRoleAssignments(t, ss) }) t.Run("GetAllAfter", func(t *testing.T) { testUserStoreGetAllAfter(t, ss) }) @@ -2918,6 +2919,146 @@ func testUserStoreSearchWithoutTeam(t *testing.T, ss store.Store) { } } +func testUserStoreSearchInGroup(t *testing.T, ss store.Store) { + u1 := &model.User{ + Username: "jimbo1" + model.NewId(), + FirstName: "Tim", + LastName: "Bill", + Nickname: "Rob", + Email: "harold" + model.NewId() + "@simulator.amazonses.com", + } + _, err := ss.User().Save(u1) + require.Nil(t, err) + defer func() { require.Nil(t, ss.User().PermanentDelete(u1.Id)) }() + + u2 := &model.User{ + Username: "jim-bobby" + model.NewId(), + Email: MakeEmail(), + } + _, err = ss.User().Save(u2) + require.Nil(t, err) + defer func() { require.Nil(t, ss.User().PermanentDelete(u2.Id)) }() + + u3 := &model.User{ + Username: "jimbo3" + model.NewId(), + Email: MakeEmail(), + DeleteAt: 1, + } + _, err = ss.User().Save(u3) + require.Nil(t, err) + defer func() { require.Nil(t, ss.User().PermanentDelete(u3.Id)) }() + + // The users returned from the database will have AuthData as an empty string. + nilAuthData := model.NewString("") + + u1.AuthData = nilAuthData + u2.AuthData = nilAuthData + u3.AuthData = nilAuthData + + g1 := &model.Group{ + Name: model.NewString(model.NewId()), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + _, err = ss.Group().Create(g1) + require.Nil(t, err) + + g2 := &model.Group{ + Name: model.NewString(model.NewId()), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewId(), + } + _, err = ss.Group().Create(g2) + require.Nil(t, err) + + _, err = ss.Group().UpsertMember(g1.Id, u1.Id) + require.Nil(t, err) + + _, err = ss.Group().UpsertMember(g2.Id, u2.Id) + require.Nil(t, err) + + _, err = ss.Group().UpsertMember(g1.Id, u3.Id) + require.Nil(t, err) + + testCases := []struct { + Description string + GroupId string + Term string + Options *model.UserSearchOptions + Expected []*model.User + }{ + { + "search jimb, group 1", + g1.Id, + "jimb", + &model.UserSearchOptions{ + AllowFullNames: true, + Limit: model.USER_SEARCH_DEFAULT_LIMIT, + }, + []*model.User{u1}, + }, + { + "search jimb, group 1, allow inactive", + g1.Id, + "jimb", + &model.UserSearchOptions{ + AllowFullNames: true, + AllowInactive: true, + Limit: model.USER_SEARCH_DEFAULT_LIMIT, + }, + []*model.User{u1, u3}, + }, + { + "search jimb, group 1, limit 1", + g1.Id, + "jimb", + &model.UserSearchOptions{ + AllowFullNames: true, + AllowInactive: true, + Limit: 1, + }, + []*model.User{u1}, + }, + { + "search jimb, group 2", + g2.Id, + "jimb", + &model.UserSearchOptions{ + AllowFullNames: true, + Limit: model.USER_SEARCH_DEFAULT_LIMIT, + }, + []*model.User{}, + }, + { + "search jimb, allow inactive, group 2", + g2.Id, + "jimb", + &model.UserSearchOptions{ + AllowFullNames: true, + AllowInactive: true, + Limit: model.USER_SEARCH_DEFAULT_LIMIT, + }, + []*model.User{}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Description, func(t *testing.T) { + users, err := ss.User().SearchInGroup( + testCase.GroupId, + testCase.Term, + testCase.Options, + ) + require.Nil(t, err) + assertUsers(t, testCase.Expected, users) + }) + } +} + func testCount(t *testing.T, ss store.Store) { // Regular teamId := model.NewId() diff --git a/store/timer_layer.go b/store/timer_layer.go index 4fabf78518..94c9890cfb 100644 --- a/store/timer_layer.go +++ b/store/timer_layer.go @@ -7444,6 +7444,22 @@ func (s *TimerLayerUserStore) SearchInChannel(channelId string, term string, opt return resultVar0, resultVar1 } +func (s *TimerLayerUserStore) SearchInGroup(groupID string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { + start := timemodule.Now() + + resultVar0, resultVar1 := s.UserStore.SearchInGroup(groupID, term, options) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if resultVar1 == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("UserStore.SearchInGroup", success, elapsed) + } + return resultVar0, resultVar1 +} + func (s *TimerLayerUserStore) SearchNotInChannel(teamId string, channelId string, term string, options *model.UserSearchOptions) ([]*model.User, *model.AppError) { start := timemodule.Now()