From: Teryk Bellahsene Date: Fri, 17 Mar 2017 17:11:43 +0000 (+0100) Subject: SONAR-8894 Search members of an organization WS api/organizations/search_members X-Git-Tag: 6.4-RC1~700 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=24549284b72daf95f14593a49fa84e63aa183b35;p=sonarqube.git SONAR-8894 Search members of an organization WS api/organizations/search_members --- diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberDao.java b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberDao.java index ebe6b5559ad..6a10a129ab4 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberDao.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberDao.java @@ -20,6 +20,7 @@ package org.sonar.db.organization; +import java.util.List; import java.util.Optional; import java.util.Set; import org.sonar.db.Dao; @@ -34,6 +35,10 @@ public class OrganizationMemberDao implements Dao { return Optional.ofNullable(mapper(dbSession).select(organizationUuid, userId)); } + public List selectLoginsByOrganizationUuid(DbSession dbSession, String organizationUuid) { + return mapper(dbSession).selectLogins(organizationUuid); + } + public void insert(DbSession dbSession, OrganizationMemberDto organizationMemberDto) { mapper(dbSession).insert(organizationMemberDto); } diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberMapper.java index 5f964aaaaaf..80237b276d1 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberMapper.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/organization/OrganizationMemberMapper.java @@ -20,6 +20,7 @@ package org.sonar.db.organization; +import java.util.List; import java.util.Set; import org.apache.ibatis.annotations.Param; @@ -28,6 +29,8 @@ public interface OrganizationMemberMapper { Set selectOrganizationUuidsByUser(@Param("userId") int userId); + List selectLogins(String organizationUuid); + void insert(OrganizationMemberDto organizationMember); void delete(@Param("organizationUuid") String organizationUuid, @Param("userId") Integer userId); diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipDao.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipDao.java index bde120c6167..4c30022d770 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipDao.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipDao.java @@ -23,6 +23,7 @@ import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Maps; import com.google.common.collect.Multimap; +import com.google.common.collect.Multiset; import java.util.Collection; import java.util.List; import java.util.Map; @@ -73,6 +74,21 @@ public class GroupMembershipDao implements Dao { return mapper(dbSession).selectGroupIdsByUserId(userId); } + public Multiset countGroupByLoginsAndOrganization(DbSession dbSession, Collection logins, String organizationUuid) { + Multimap result = ArrayListMultimap.create(); + executeLargeInputs( + logins, + input -> { + List groupMemberships = mapper(dbSession).selectGroupsByLoginsAndOrganization(input, organizationUuid); + for (LoginGroup membership : groupMemberships) { + result.put(membership.login(), membership.groupName()); + } + return groupMemberships; + }); + + return result.keys(); + } + public Multimap selectGroupsByLogins(DbSession session, Collection logins) { Multimap result = ArrayListMultimap.create(); executeLargeInputs( diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipMapper.java index 6bf021b158e..03672f133eb 100644 --- a/server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipMapper.java +++ b/server/sonar-db-dao/src/main/java/org/sonar/db/user/GroupMembershipMapper.java @@ -38,6 +38,7 @@ public interface GroupMembershipMapper { List selectGroupsByLogins(@Param("logins") List logins); + List selectGroupsByLoginsAndOrganization(@Param("logins") List logins, @Param("organizationUuid") String organizationUuid); + List selectGroupIdsByUserId(@Param("userId") int userId); - } diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMemberMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMemberMapper.xml index 28695788029..2a63ade892f 100644 --- a/server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMemberMapper.xml +++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/organization/OrganizationMemberMapper.xml @@ -17,6 +17,13 @@ and om.user_id = #{userId, jdbcType=INTEGER} + + + + FROM users u LEFT JOIN groups_users gu ON gu.user_id=u.id AND gu.group_id=#{groupId} diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationMemberDaoTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationMemberDaoTest.java index 893764f1321..a586f1be28b 100644 --- a/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationMemberDaoTest.java +++ b/server/sonar-db-dao/src/test/java/org/sonar/db/organization/OrganizationMemberDaoTest.java @@ -20,6 +20,7 @@ package org.sonar.db.organization; +import java.util.List; import java.util.Map; import java.util.Optional; import org.apache.ibatis.exceptions.PersistenceException; @@ -29,6 +30,7 @@ import org.junit.rules.ExpectedException; import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.DbTester; +import org.sonar.db.user.UserDto; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.entry; @@ -56,6 +58,22 @@ public class OrganizationMemberDaoTest { assertThat(underTest.select(dbSession, "O2", 512)).isNotPresent(); } + @Test + public void select_logins() { + OrganizationDto organization = db.organizations().insert(); + OrganizationDto anotherOrganization = db.organizations().insert(); + UserDto user = db.users().insertUser(); + UserDto anotherUser = db.users().insertUser(); + UserDto userInAnotherOrganization = db.users().insertUser(); + db.organizations().addMember(organization, user); + db.organizations().addMember(organization, anotherUser); + db.organizations().addMember(anotherOrganization, userInAnotherOrganization); + + List result = underTest.selectLoginsByOrganizationUuid(dbSession, organization.getUuid()); + + assertThat(result).containsOnly(user.getLogin(), anotherUser.getLogin()); + } + @Test public void select_organization_uuids_by_user_id() { OrganizationDto organizationDto1 = db.organizations().insert(); diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/user/GroupMembershipDaoTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/user/GroupMembershipDaoTest.java index b9f89695ffb..2f43d70e29b 100644 --- a/server/sonar-db-dao/src/test/java/org/sonar/db/user/GroupMembershipDaoTest.java +++ b/server/sonar-db-dao/src/test/java/org/sonar/db/user/GroupMembershipDaoTest.java @@ -63,7 +63,7 @@ public class GroupMembershipDaoTest { } @Test - public void count_groups_by_login() { + public void count_groups_by_logins() { dbTester.prepareDbUnit(getClass(), "shared.xml"); assertThat(underTest.selectGroupsByLogins(dbTester.getSession(), Arrays.asList()).keys()).isEmpty(); diff --git a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java index 764ce85ca83..77208d0f7a2 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/organization/ws/SearchMembersAction.java @@ -20,25 +20,74 @@ package org.sonar.server.organization.ws; +import com.google.common.collect.Multiset; +import com.google.common.collect.Ordering; +import com.google.common.hash.Hashing; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import javax.annotation.Nullable; import org.sonar.api.server.ws.Request; import org.sonar.api.server.ws.Response; import org.sonar.api.server.ws.WebService; +import org.sonar.api.server.ws.WebService.Param; +import org.sonar.api.server.ws.WebService.SelectionMode; +import org.sonar.core.util.stream.Collectors; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.organization.OrganizationDto; +import org.sonar.db.permission.OrganizationPermission; +import org.sonar.db.user.UserDto; +import org.sonar.server.es.SearchOptions; +import org.sonar.server.es.SearchResult; +import org.sonar.server.organization.DefaultOrganizationProvider; +import org.sonar.server.user.UserSession; +import org.sonar.server.user.index.UserDoc; +import org.sonar.server.user.index.UserIndex; +import org.sonar.server.user.index.UserQuery; +import org.sonarqube.ws.Common; +import org.sonarqube.ws.Organizations.SearchMembersWsResponse; +import org.sonarqube.ws.Organizations.User; +import static com.google.common.base.Preconditions.checkArgument; +import static org.sonar.core.util.Protobuf.setNullable; import static org.sonar.server.es.SearchOptions.MAX_LIMIT; +import static org.sonar.server.ws.WsUtils.checkFoundWithOptional; +import static org.sonar.server.ws.WsUtils.writeProtobuf; public class SearchMembersAction implements OrganizationsWsAction { + + private final DbClient dbClient; + private final UserIndex userIndex; + private final DefaultOrganizationProvider organizationProvider; + private final UserSession userSession; + + public SearchMembersAction(DbClient dbClient, UserIndex userIndex, DefaultOrganizationProvider organizationProvider, UserSession userSession) { + this.dbClient = dbClient; + this.userIndex = userIndex; + this.organizationProvider = organizationProvider; + this.userSession = userSession; + } + @Override public void define(WebService.NewController context) { WebService.NewAction action = context.createAction("search_members") + .setDescription("Search members of an organization") .setResponseExample(getClass().getResource("search_members-example.json")) .setSince("6.4") .setInternal(true) .setHandler(this); - action.addSelectionModeParam(); - action.addSearchQuery("freddy", "names", "logins"); + action.addSearchQuery("orwe", "names", "logins"); action.addPagingParams(50, MAX_LIMIT); + action.createParam(Param.SELECTED) + .setDescription("Depending on the value, show only selected items (selected=selected) or deselected items (selected=deselected).") + .setInternal(true) + .setDefaultValue(SelectionMode.SELECTED.value()) + .setPossibleValues(SelectionMode.SELECTED.value(), SelectionMode.DESELECTED.value()); + action.createParam("organization") .setDescription("Organization key") .setInternal(true) @@ -47,6 +96,92 @@ public class SearchMembersAction implements OrganizationsWsAction { @Override public void handle(Request request, Response response) throws Exception { - // TODO + try (DbSession dbSession = dbClient.openSession(false)) { + OrganizationDto organization = getOrganization(dbSession, request.param("organization")); + List memberLogins = dbClient.organizationMemberDao().selectLoginsByOrganizationUuid(dbSession, organization.getUuid()); + + UserQuery.Builder userQuery = buildUserQuery(request, memberLogins); + SearchOptions searchOptions = buildSearchOptions(request); + + SearchResult searchResults = userIndex.search(userQuery.build(), searchOptions); + List orderedLogins = searchResults.getDocs().stream().map(UserDoc::login).collect(Collectors.toList()); + + List users = dbClient.userDao().selectByLogins(dbSession, orderedLogins).stream() + .sorted(Ordering.explicit(orderedLogins).onResultOf(UserDto::getLogin)) + .collect(Collectors.toList()); + + Multiset groupCountByLogin = null; + if (userSession.hasPermission(OrganizationPermission.ADMINISTER, organization)) { + groupCountByLogin = dbClient.groupMembershipDao().countGroupByLoginsAndOrganization(dbSession, orderedLogins, organization.getUuid()); + } + + Common.Paging wsPaging = buildWsPaging(request, searchResults); + SearchMembersWsResponse wsResponse = buildResponse(users, wsPaging, groupCountByLogin); + + writeProtobuf(wsResponse, request, response); + } + } + + private static SearchMembersWsResponse buildResponse(List users, Common.Paging wsPaging, @Nullable Multiset groupCountByLogin) { + SearchMembersWsResponse.Builder response = SearchMembersWsResponse.newBuilder(); + + User.Builder wsUser = User.newBuilder(); + users.stream() + .map(userDto -> { + String login = userDto.getLogin(); + wsUser + .clear() + .setLogin(login) + .setName(userDto.getName()); + setNullable(userDto.getEmail(), text -> wsUser.setAvatar(hash(text))); + setNullable(groupCountByLogin, count -> wsUser.setGroupCount(groupCountByLogin.count(login))); + return wsUser; + }) + .forEach(response::addUsers); + response.setPaging(wsPaging); + + return response.build(); + } + + private static UserQuery.Builder buildUserQuery(Request request, List memberLogins) { + UserQuery.Builder userQuery = UserQuery.builder(); + String textQuery = request.param(Param.TEXT_QUERY); + checkArgument(textQuery == null || textQuery.length() >= 2, "Query length must be greater than or equal to 2"); + userQuery.setTextQuery(textQuery); + + SelectionMode selectionMode = SelectionMode.fromParam(request.mandatoryParam(Param.SELECTED)); + if (SelectionMode.DESELECTED.equals(selectionMode)) { + userQuery.setExcludedLogins(memberLogins); + } else { + userQuery.setLogins(memberLogins); + } + return userQuery; + } + + private static SearchOptions buildSearchOptions(Request request) { + int pageSize = request.mandatoryParamAsInt(Param.PAGE_SIZE); + checkArgument(pageSize <= SearchOptions.MAX_LIMIT, "Page size must lower than or equal to %s", SearchOptions.MAX_LIMIT); + + return new SearchOptions().setPage(request.mandatoryParamAsInt(Param.PAGE), pageSize); + } + + private static String hash(String text) { + return Hashing.md5().hashString(text.toLowerCase(Locale.ENGLISH), StandardCharsets.UTF_8).toString(); + } + + private static Common.Paging buildWsPaging(Request request, SearchResult searchResults) { + return Common.Paging.newBuilder() + .setPageIndex(request.mandatoryParamAsInt(Param.PAGE)) + .setPageSize(request.mandatoryParamAsInt(Param.PAGE_SIZE)) + .setTotal((int) searchResults.getTotal()) + .build(); + } + + private OrganizationDto getOrganization(DbSession dbSession, @Nullable String organizationParam) { + String organizationKey = Optional.ofNullable(organizationParam) + .orElseGet(organizationProvider.get()::getKey); + return checkFoundWithOptional( + dbClient.organizationDao().selectByKey(dbSession, organizationKey), + "No organization with key '%s'", organizationKey); } } diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/index/UserIndex.java b/server/sonar-server/src/main/java/org/sonar/server/user/index/UserIndex.java index 2881629c838..a3a17feb13d 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/user/index/UserIndex.java +++ b/server/sonar-server/src/main/java/org/sonar/server/user/index/UserIndex.java @@ -24,8 +24,8 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Optional; import javax.annotation.CheckForNull; -import javax.annotation.Nullable; import org.apache.commons.lang.StringUtils; import org.elasticsearch.action.get.GetRequestBuilder; import org.elasticsearch.action.get.GetResponse; @@ -133,20 +133,24 @@ public class UserIndex { return EsUtils.scroll(esClient, response.getScrollId(), DOC_CONVERTER); } - public SearchResult search(@Nullable String searchText, SearchOptions options) { + public SearchResult search(UserQuery userQuery, SearchOptions options) { SearchRequestBuilder request = esClient.prepareSearch(UserIndexDefinition.INDEX_TYPE_USER) .setSize(options.getLimit()) .setFrom(options.getOffset()) .addSort(FIELD_NAME, SortOrder.ASC); - BoolQueryBuilder filter = boolQuery() - .must(termQuery(FIELD_ACTIVE, true)); + BoolQueryBuilder filter = boolQuery().must(termQuery(FIELD_ACTIVE, true)); + + userQuery.getLogins().ifPresent( + logins -> filter.must(termsQuery(FIELD_LOGIN, userQuery.getLogins().get()))); + + userQuery.getExcludedLogins().ifPresent( + excludedLogins -> filter.mustNot(termsQuery(FIELD_LOGIN, userQuery.getExcludedLogins().get()))); - QueryBuilder query; - if (StringUtils.isEmpty(searchText)) { - query = matchAllQuery(); - } else { - query = QueryBuilders.multiMatchQuery(searchText, + QueryBuilder esQuery = matchAllQuery(); + Optional textQuery = userQuery.getTextQuery(); + if (textQuery.isPresent()) { + esQuery = QueryBuilders.multiMatchQuery(textQuery.get(), FIELD_LOGIN, USER_SEARCH_GRAMS_ANALYZER.subField(FIELD_LOGIN), FIELD_NAME, @@ -156,7 +160,7 @@ public class UserIndex { .operator(MatchQueryBuilder.Operator.AND); } - request.setQuery(boolQuery().must(query).filter(filter)); + request.setQuery(boolQuery().must(esQuery).filter(filter)); return new SearchResult<>(request.get(), DOC_CONVERTER); } diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/index/UserQuery.java b/server/sonar-server/src/main/java/org/sonar/server/user/index/UserQuery.java new file mode 100644 index 00000000000..9ff05e694ce --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/user/index/UserQuery.java @@ -0,0 +1,87 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.sonar.server.user.index; + +import com.google.common.collect.ImmutableList; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; +import javax.annotation.concurrent.Immutable; + +import static org.apache.commons.lang.StringUtils.isBlank; + +@Immutable +public class UserQuery { + private final String textQuery; + private final List logins; + private final List excludedLogins; + + private UserQuery(Builder builder) { + this.textQuery = builder.textQuery; + this.logins = builder.logins == null ? null : ImmutableList.copyOf(builder.logins); + this.excludedLogins = builder.excludedLogins == null ? null : ImmutableList.copyOf(builder.excludedLogins); + } + + public Optional getTextQuery() { + return Optional.ofNullable(textQuery); + } + + public Optional> getLogins() { + return Optional.ofNullable(logins); + } + + public Optional> getExcludedLogins() { + return Optional.ofNullable(excludedLogins); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String textQuery; + private List logins; + private List excludedLogins; + + private Builder() { + // enforce factory method + } + + public UserQuery build() { + return new UserQuery(this); + } + + public Builder setTextQuery(@Nullable String textQuery) { + this.textQuery = isBlank(textQuery) ? null : textQuery; + return this; + } + + public Builder setLogins(@Nullable List logins) { + this.logins = logins; + return this; + } + + public Builder setExcludedLogins(@Nullable List excludedLogins) { + this.excludedLogins = excludedLogins; + return this; + } + } +} diff --git a/server/sonar-server/src/main/java/org/sonar/server/user/ws/SearchAction.java b/server/sonar-server/src/main/java/org/sonar/server/user/ws/SearchAction.java index 1a0afb35b63..4fd0d0da761 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/user/ws/SearchAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/user/ws/SearchAction.java @@ -39,6 +39,7 @@ import org.sonar.server.es.SearchOptions; import org.sonar.server.es.SearchResult; import org.sonar.server.user.index.UserDoc; import org.sonar.server.user.index.UserIndex; +import org.sonar.server.user.index.UserQuery; import static com.google.common.base.MoreObjects.firstNonNull; import static org.sonar.server.es.SearchOptions.MAX_LIMIT; @@ -78,7 +79,8 @@ public class SearchAction implements UsersWsAction { SearchOptions options = new SearchOptions() .setPage(request.mandatoryParamAsInt(Param.PAGE), request.mandatoryParamAsInt(Param.PAGE_SIZE)); List fields = request.paramAsStrings(Param.FIELDS); - SearchResult result = userIndex.search(request.param(Param.TEXT_QUERY), options); + String textQuery = request.param(Param.TEXT_QUERY); + SearchResult result = userIndex.search(UserQuery.builder().setTextQuery(textQuery).build(), options); try (DbSession dbSession = dbClient.openSession(false)) { List logins = Lists.transform(result.getDocs(), UserDocToLogin.INSTANCE); diff --git a/server/sonar-server/src/main/resources/org/sonar/server/organization/ws/search_members-example.json b/server/sonar-server/src/main/resources/org/sonar/server/organization/ws/search_members-example.json index 2c63c085104..26b7786c7a7 100644 --- a/server/sonar-server/src/main/resources/org/sonar/server/organization/ws/search_members-example.json +++ b/server/sonar-server/src/main/resources/org/sonar/server/organization/ws/search_members-example.json @@ -1,2 +1,19 @@ { + "users": [ + { + "login": "ada.lovelace", + "name": "Ada Lovelace", + "avatar": "680b0001b4952664631511a81a4edc59" + }, + { + "login": "grace.hopper", + "name": "Grace Hopper", + "avatar": "36d90b0b8cc8639e4960e46116dce02c" + } + ], + "paging": { + "pageIndex": 1, + "pageSize": 50, + "total": 2 + } } diff --git a/server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchMembersActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchMembersActionTest.java index 0a79b637275..1e61a2718a8 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchMembersActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/organization/ws/SearchMembersActionTest.java @@ -20,26 +20,324 @@ package org.sonar.server.organization.ws; +import com.google.common.base.Throwables; +import java.io.IOException; +import java.util.stream.IntStream; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.sonar.api.config.MapSettings; import org.sonar.api.server.ws.WebService; +import org.sonar.api.server.ws.WebService.Param; +import org.sonar.db.DbClient; +import org.sonar.db.DbTester; +import org.sonar.db.organization.OrganizationDto; +import org.sonar.db.permission.OrganizationPermission; +import org.sonar.db.user.GroupDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.es.EsTester; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.organization.DefaultOrganizationProvider; +import org.sonar.server.organization.TestDefaultOrganizationProvider; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.user.index.UserIndex; +import org.sonar.server.user.index.UserIndexDefinition; +import org.sonar.server.user.index.UserIndexer; +import org.sonar.server.ws.TestRequest; import org.sonar.server.ws.WsActionTester; +import org.sonarqube.ws.Common.Paging; +import org.sonarqube.ws.MediaTypes; +import org.sonarqube.ws.Organizations.SearchMembersWsResponse; +import org.sonarqube.ws.Organizations.User; +import org.sonarqube.ws.client.organization.SearchMembersWsRequest; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.sonar.core.util.Protobuf.setNullable; +import static org.sonar.test.JsonAssert.assertJson; public class SearchMembersActionTest { - private WsActionTester ws = new WsActionTester(new SearchMembersAction()); + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + @Rule + public ExpectedException expectedException = ExpectedException.none(); + @Rule + public EsTester es = new EsTester(new UserIndexDefinition(new MapSettings())); + + @Rule + public DbTester db = DbTester.create(); + private DbClient dbClient = db.getDbClient(); + + private DefaultOrganizationProvider organizationProvider = TestDefaultOrganizationProvider.from(db); + private UserIndexer indexer = new UserIndexer(dbClient, es.client()); + + private WsActionTester ws = new WsActionTester(new SearchMembersAction(dbClient, new UserIndex(es.client()), organizationProvider, userSession)); + + private SearchMembersWsRequest request = new SearchMembersWsRequest(); + + @Test + public void empty_response() { + SearchMembersWsResponse result = call(); + + assertThat(result.getUsersList()).isEmpty(); + assertThat(result.getPaging()) + .extracting(Paging::getPageIndex, Paging::getPageSize, Paging::getTotal) + .containsExactly(1, 50, 0); + } + + @Test + public void search_members_of_default_organization() { + OrganizationDto defaultOrganization = db.getDefaultOrganization(); + OrganizationDto anotherOrganization = db.organizations().insert(); + UserDto user = insertUser(); + UserDto anotherUser = insertUser(); + UserDto userInAnotherOrganization = insertUser(); + db.organizations().addMember(defaultOrganization, user); + db.organizations().addMember(defaultOrganization, anotherUser); + db.organizations().addMember(anotherOrganization, userInAnotherOrganization); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsersList()) + .extracting(User::getLogin, User::getName) + .containsOnly( + tuple(user.getLogin(), user.getName()), + tuple(anotherUser.getLogin(), anotherUser.getName())); + } + + @Test + public void search_members_of_specified_organization() { + OrganizationDto organization = db.organizations().insert(); + OrganizationDto anotherOrganization = db.organizations().insert(); + UserDto user = insertUser(); + UserDto anotherUser = insertUser(); + UserDto userInAnotherOrganization = insertUser(); + db.organizations().addMember(organization, user); + db.organizations().addMember(organization, anotherUser); + db.organizations().addMember(anotherOrganization, userInAnotherOrganization); + request.setOrganization(organization.getKey()); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsersList()) + .extracting(User::getLogin, User::getName) + .containsOnly( + tuple(user.getLogin(), user.getName()), + tuple(anotherUser.getLogin(), anotherUser.getName())); + } + + @Test + public void return_avatar() { + UserDto user = db.users().insertUser(u -> u.setEmail("email@domain.com")); + indexer.index(user.getLogin()); + db.organizations().addMember(db.getDefaultOrganization(), user); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsers(0).getAvatar()).isEqualTo("7328fddefd53de471baeb6e2b764f78a"); + } + + @Test + public void do_not_return_group_count_if_no_admin_permission() { + UserDto user = insertUser(); + GroupDto group = db.users().insertGroup(); + db.users().insertMember(group, user); + db.organizations().addMember(db.getDefaultOrganization(), user); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsers(0).hasGroupCount()).isFalse(); + } + + @Test + public void return_group_counts_if_org_admin() { + userSession.addPermission(OrganizationPermission.ADMINISTER, db.getDefaultOrganization()); + UserDto user = insertUser(); + UserDto anotherUser = insertUser(); + IntStream.range(0, 10) + .mapToObj(i -> db.users().insertGroup()) + .forEach(g -> db.users().insertMembers(g, user)); + OrganizationDto anotherOrganization = db.organizations().insert(); + GroupDto anotherGroup = db.users().insertGroup(anotherOrganization); + db.users().insertMember(anotherGroup, user); + db.organizations().addMember(db.getDefaultOrganization(), user); + db.organizations().addMember(db.getDefaultOrganization(), anotherUser); + db.organizations().addMember(anotherOrganization, user); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsersList()).extracting(User::getLogin, User::getGroupCount).containsOnly( + tuple(user.getLogin(), 10), + tuple(anotherUser.getLogin(), 0)); + } + + @Test + public void search_non_members() { + OrganizationDto defaultOrganization = db.getDefaultOrganization(); + OrganizationDto anotherOrganization = db.organizations().insert(); + UserDto user = insertUser(); + UserDto anotherUser = insertUser(); + UserDto userInAnotherOrganization = insertUser(); + db.organizations().addMember(anotherOrganization, user); + db.organizations().addMember(anotherOrganization, anotherUser); + db.organizations().addMember(defaultOrganization, userInAnotherOrganization); + request.setSelected(WebService.SelectionMode.DESELECTED.value()); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsersList()) + .extracting(User::getLogin, User::getName) + .containsOnly( + tuple(user.getLogin(), user.getName()), + tuple(anotherUser.getLogin(), anotherUser.getName())); + } + + @Test + public void search_members_pagination() { + IntStream.range(0, 10).forEach(i -> { + UserDto userDto = db.users().insertUser(user -> user.setName("USER_" + i)); + db.organizations().addMember(db.getDefaultOrganization(), userDto); + indexer.index(userDto.getLogin()); + }); + request.setPage(2).setPageSize(3); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsersList()).extracting(User::getName) + .containsExactly("USER_3", "USER_4", "USER_5"); + assertThat(result.getPaging()) + .extracting(Paging::getPageIndex, Paging::getPageSize, Paging::getTotal) + .containsExactly(2, 3, 10); + } + + @Test + public void search_members_by_name() { + IntStream.range(0, 10).forEach(i -> { + UserDto userDto = db.users().insertUser(user -> user.setName("USER_" + i)); + db.organizations().addMember(db.getDefaultOrganization(), userDto); + indexer.index(userDto.getLogin()); + }); + request.setQuery("_9"); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsersList()).extracting(User::getName).containsExactly("USER_9"); + } + + @Test + public void search_members_by_login() { + IntStream.range(0, 10).forEach(i -> { + UserDto userDto = db.users().insertUser(user -> user.setLogin("USER_" + i)); + db.organizations().addMember(db.getDefaultOrganization(), userDto); + indexer.index(userDto.getLogin()); + }); + request.setQuery("_9"); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsersList()).extracting(User::getLogin).containsExactly("USER_9"); + } + + @Test + public void search_members_by_email() { + IntStream.range(0, 10).forEach(i -> { + UserDto userDto = db.users().insertUser(user -> user + .setLogin("L" + i) + .setEmail("USER_" + i + "@email.com")); + db.organizations().addMember(db.getDefaultOrganization(), userDto); + indexer.index(userDto.getLogin()); + }); + request.setQuery("_9"); + + SearchMembersWsResponse result = call(); + + assertThat(result.getUsersList()).extracting(User::getLogin).containsExactly("L9"); + } + + @Test + public void json_example() { + UserDto ada = db.users().insertUser(u -> u.setLogin("ada.lovelace").setName("Ada Lovelace").setEmail("ada@lovelace.com")); + indexer.index(ada.getLogin()); + UserDto grace = db.users().insertUser(u -> u.setLogin("grace.hopper").setName("Grace Hopper").setEmail("grace@hopper.com")); + indexer.index(grace.getLogin()); + db.organizations().addMember(db.getDefaultOrganization(), ada); + db.organizations().addMember(db.getDefaultOrganization(), grace); + + String result = ws.newRequest().execute().getInput(); + + assertJson(result).isSimilarTo(ws.getDef().responseExampleAsString()); + } + + @Test + public void fail_if_organization_is_unknown() { + request.setOrganization("ORGA 42"); + + expectedException.expect(NotFoundException.class); + expectedException.expectMessage("No organization with key 'ORGA 42'"); + + call(); + } + + @Test + public void fail_if_page_size_greater_than_500() { + request.setPageSize(501); + + expectedException.expect(IllegalArgumentException.class); + expectedException.expectMessage("Page size must lower than or equal to 500"); + + call(); + } + + @Test + public void fail_if_query_length_lower_than_2() { + request.setQuery("a"); + + expectedException.expect(IllegalArgumentException.class); + expectedException.expectMessage("Query length must be greater than or equal to 2"); + + call(); + } @Test public void definition() { WebService.Action action = ws.getDef(); assertThat(action.key()).isEqualTo("search_members"); - assertThat(action.params()).extracting(WebService.Param::key) - .containsOnly("q", "selected", "p", "ps", "organization"); + assertThat(action.params()).extracting(Param::key).containsOnly("q", "selected", "p", "ps", "organization"); + assertThat(action.description()).isNotEmpty(); assertThat(action.responseExampleAsString()).isNotEmpty(); assertThat(action.since()).isEqualTo("6.4"); assertThat(action.isInternal()).isTrue(); assertThat(action.isPost()).isFalse(); + assertThat(action.param("organization").isInternal()).isTrue(); + + Param selected = action.param("selected"); + assertThat(selected.possibleValues()).containsOnly("selected", "deselected"); + assertThat(selected.isInternal()).isTrue(); + assertThat(selected.defaultValue()).isEqualTo("selected"); + } + + private UserDto insertUser() { + UserDto userDto = db.users().insertUser(); + indexer.index(userDto.getLogin()); + + return userDto; + } + + private SearchMembersWsResponse call() { + TestRequest wsRequest = ws.newRequest() + .setMediaType(MediaTypes.PROTOBUF); + setNullable(request.getOrganization(), o -> wsRequest.setParam("organization", o)); + setNullable(request.getQuery(), q -> wsRequest.setParam(Param.TEXT_QUERY, q)); + setNullable(request.getPage(), p -> wsRequest.setParam(Param.PAGE, String.valueOf(p))); + setNullable(request.getPageSize(), ps -> wsRequest.setParam(Param.PAGE_SIZE, String.valueOf(ps))); + setNullable(request.getSelected(), s -> wsRequest.setParam(Param.SELECTED, s)); + + try { + return SearchMembersWsResponse.parseFrom(wsRequest.execute().getInputStream()); + } catch (IOException e) { + throw Throwables.propagate(e); + } } } diff --git a/server/sonar-server/src/test/java/org/sonar/server/user/index/UserIndexTest.java b/server/sonar-server/src/test/java/org/sonar/server/user/index/UserIndexTest.java index 29200204996..b3a257f6cac 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/user/index/UserIndexTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/user/index/UserIndexTest.java @@ -19,7 +19,6 @@ */ package org.sonar.server.user.index; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Locale; @@ -29,8 +28,11 @@ import org.junit.Test; import org.sonar.api.config.MapSettings; import org.sonar.server.es.EsTester; import org.sonar.server.es.SearchOptions; +import org.sonar.server.es.SearchResult; import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; import static org.assertj.core.api.Assertions.assertThat; import static org.sonar.server.user.index.UserIndexDefinition.INDEX_TYPE_USER; @@ -46,6 +48,8 @@ public class UserIndexTest { private UserIndex underTest; + private UserQuery.Builder userQuery = UserQuery.builder(); + @Before public void setUp() { underTest = new UserIndex(esTester.client()); @@ -127,16 +131,49 @@ public class UserIndexTest { @Test public void searchUsers() throws Exception { - esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER1_LOGIN, Arrays.asList("user_1", "u1")).setEmail("email1")); + esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER1_LOGIN, asList("user_1", "u1")).setEmail("email1")); esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER2_LOGIN, Collections.emptyList()).setEmail("email2")); - assertThat(underTest.search(null, new SearchOptions()).getDocs()).hasSize(2); - assertThat(underTest.search("user", new SearchOptions()).getDocs()).hasSize(2); - assertThat(underTest.search("ser", new SearchOptions()).getDocs()).hasSize(2); - assertThat(underTest.search(USER1_LOGIN, new SearchOptions()).getDocs()).hasSize(1); - assertThat(underTest.search(USER2_LOGIN, new SearchOptions()).getDocs()).hasSize(1); - assertThat(underTest.search("mail", new SearchOptions()).getDocs()).hasSize(2); - assertThat(underTest.search("EMAIL1", new SearchOptions()).getDocs()).hasSize(1); + assertThat(underTest.search(userQuery.build(), new SearchOptions()).getDocs()).hasSize(2); + assertThat(underTest.search(userQuery.setTextQuery("user").build(), new SearchOptions()).getDocs()).hasSize(2); + assertThat(underTest.search(userQuery.setTextQuery("ser").build(), new SearchOptions()).getDocs()).hasSize(2); + assertThat(underTest.search(userQuery.setTextQuery(USER1_LOGIN).build(), new SearchOptions()).getDocs()).hasSize(1); + assertThat(underTest.search(userQuery.setTextQuery(USER2_LOGIN).build(), new SearchOptions()).getDocs()).hasSize(1); + assertThat(underTest.search(userQuery.setTextQuery("mail").build(), new SearchOptions()).getDocs()).hasSize(2); + assertThat(underTest.search(userQuery.setTextQuery("EMAIL1").build(), new SearchOptions()).getDocs()).hasSize(1); + } + + @Test + public void search_users_filter_logins() throws Exception { + esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER1_LOGIN, asList("user_1", "u1")).setEmail("email1")); + esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER2_LOGIN, emptyList()).setEmail("email2")); + esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser("user3", emptyList()).setEmail("email2")); + + SearchResult result = underTest.search(userQuery.setLogins(asList(USER1_LOGIN, USER2_LOGIN)).build(), new SearchOptions()); + assertThat(result.getDocs()).hasSize(2) + .extracting(UserDoc::login) + .containsOnly(USER1_LOGIN, USER2_LOGIN); + } + + @Test + public void search_users_filter_logins_that_match_exactly() { + esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser("USER_1", asList("user_1", "u1")).setEmail("email1")); + esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser("UsEr_1", emptyList()).setEmail("email2")); + + assertThat(underTest.search(userQuery.setLogins(singletonList("USER_1")).build(), new SearchOptions()).getDocs()).hasSize(1); + assertThat(underTest.search(userQuery.setLogins(singletonList("USER_")).build(), new SearchOptions()).getDocs()).isEmpty(); + assertThat(underTest.search(userQuery.setLogins(emptyList()).build(), new SearchOptions()).getDocs()).isEmpty(); + } + + @Test + public void search_users_exclude_logins() throws Exception { + esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER1_LOGIN, asList("user_1", "u1")).setEmail("email1")); + esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser(USER2_LOGIN, emptyList()).setEmail("email2")); + esTester.putDocuments(INDEX_TYPE_USER.getIndex(), INDEX_TYPE_USER.getType(), newUser("user3", emptyList()).setEmail("email2")); + + assertThat(underTest.search(userQuery.setExcludedLogins(emptyList()).build(), new SearchOptions()).getDocs()).hasSize(3); + assertThat(underTest.search(userQuery.setExcludedLogins(asList(USER1_LOGIN, USER2_LOGIN, "user3")).build(), new SearchOptions()).getDocs()).isEmpty(); + assertThat(underTest.search(userQuery.setExcludedLogins(asList(USER1_LOGIN, USER2_LOGIN)).build(), new SearchOptions()).getDocs()).hasSize(1); } private static UserDoc newUser(String login, List scmAccounts) { diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/client/organization/SearchMembersWsRequest.java b/sonar-ws/src/main/java/org/sonarqube/ws/client/organization/SearchMembersWsRequest.java new file mode 100644 index 00000000000..06e8376117c --- /dev/null +++ b/sonar-ws/src/main/java/org/sonarqube/ws/client/organization/SearchMembersWsRequest.java @@ -0,0 +1,82 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.sonarqube.ws.client.organization; + +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; + +public class SearchMembersWsRequest { + private String organization; + private String selected; + private String query; + private Integer page; + private Integer pageSize; + + @CheckForNull + public String getOrganization() { + return organization; + } + + public SearchMembersWsRequest setOrganization(@Nullable String organization) { + this.organization = organization; + return this; + } + + @CheckForNull + public String getSelected() { + return selected; + } + + public SearchMembersWsRequest setSelected(@Nullable String selected) { + this.selected = selected; + return this; + } + + @CheckForNull + public String getQuery() { + return query; + } + + public SearchMembersWsRequest setQuery(@Nullable String query) { + this.query = query; + return this; + } + + @CheckForNull + public Integer getPage() { + return page; + } + + public SearchMembersWsRequest setPage(@Nullable Integer page) { + this.page = page; + return this; + } + + @CheckForNull + public Integer getPageSize() { + return pageSize; + } + + public SearchMembersWsRequest setPageSize(@Nullable Integer pageSize) { + this.pageSize = pageSize; + return this; + } +} diff --git a/sonar-ws/src/main/protobuf/ws-organizations.proto b/sonar-ws/src/main/protobuf/ws-organizations.proto index fa0251a9549..bb00aee3581 100644 --- a/sonar-ws/src/main/protobuf/ws-organizations.proto +++ b/sonar-ws/src/main/protobuf/ws-organizations.proto @@ -20,6 +20,8 @@ syntax = "proto2"; package sonarqube.ws.organizations; +import "ws-commons.proto"; + option java_package = "org.sonarqube.ws"; option java_outer_classname = "Organizations"; option optimize_for = SPEED; @@ -39,6 +41,12 @@ message SearchWsResponse { repeated Organization organizations = 1; } +// WS api/organizations/search_members +message SearchMembersWsResponse { + optional sonarqube.ws.commons.Paging paging = 1; + repeated User users = 2; +} + message Organization { optional string key = 1; optional string name = 2; @@ -47,3 +55,10 @@ message Organization { optional string avatar = 5; optional bool guarded = 6; } + +message User { + optional string login = 1; + optional string name = 2; + optional string avatar = 3; + optional int32 groupCount = 4; +}