Browse Source

SONAR-19963 Add GET /api/v2/users endpoint

tags/10.2.0.77647
Aurelien Poscia 9 months ago
parent
commit
b0ab2c391e
68 changed files with 1980 additions and 327 deletions
  1. 4
    1
      build.gradle
  2. 29
    0
      server/sonar-webserver-common/build.gradle
  3. 408
    0
      server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java
  4. 25
    0
      server/sonar-webserver-common/src/main/java/org/sonar/server/common/SearchResults.java
  5. 1
    1
      server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolver.java
  6. 1
    1
      server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolverImpl.java
  7. 30
    0
      server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/UsersSearchResponseGenerator.java
  8. 27
    0
      server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserSearchResult.java
  9. 125
    0
      server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java
  10. 163
    0
      server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java
  11. 6
    7
      server/sonar-webserver-common/src/test/java/org/sonar/server/common/avatar/AvatarResolverImplTest.java
  12. 6
    2
      server/sonar-webserver-webapi-v2/build.gradle
  13. 28
    0
      server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java
  14. 6
    0
      server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/config/MockConfigForControllers.java
  15. 3
    3
      server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/DefaultLivenessControllerIT.java
  16. 4
    4
      server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/HealthControllerIT.java
  17. 1
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java
  18. 22
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestError.java
  19. 54
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java
  20. 23
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/package-info.java
  21. 23
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/PageRestResponse.java
  22. 23
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/package-info.java
  23. 90
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java
  24. 56
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java
  25. 23
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/package-info.java
  26. 111
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java
  27. 23
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/package-info.java
  28. 51
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java
  29. 23
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/package-info.java
  30. 61
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java
  31. 23
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/package-info.java
  32. 27
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/UsersSearchRestResponse.java
  33. 23
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/package-info.java
  34. 23
    14
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java
  35. 14
    1
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java
  36. 15
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java
  37. 3
    2
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java
  38. 2
    0
      server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java
  39. 44
    0
      server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/model/RestPageTest.java
  40. 205
    0
      server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java
  41. 1
    0
      server/sonar-webserver-webapi/build.gradle
  42. 2
    2
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/hotspot/ws/ShowActionIT.java
  43. 1
    0
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/IssueChangeWSSupportIT.java
  44. 1
    1
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ChangelogActionIT.java
  45. 1
    1
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ListActionIT.java
  46. 1
    1
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionComponentsIT.java
  47. 1
    1
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionFacetsIT.java
  48. 1
    1
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionIT.java
  49. 1
    1
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/UsersActionIT.java
  50. 1
    1
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/template/TemplateUsersActionIT.java
  51. 2
    2
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualitygate/ws/SearchUsersActionIT.java
  52. 2
    2
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualityprofile/ws/SearchUsersActionIT.java
  53. 1
    1
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionHomepageIT.java
  54. 1
    1
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionIT.java
  55. 9
    4
      server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java
  56. 1
    0
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java
  57. 1
    1
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java
  58. 1
    1
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/UserResponseFormatter.java
  59. 1
    1
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/UsersAction.java
  60. 1
    1
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/template/TemplateUsersAction.java
  61. 1
    1
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualitygate/ws/SearchUsersAction.java
  62. 1
    1
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java
  63. 1
    1
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CurrentAction.java
  64. 29
    265
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchAction.java
  65. 83
    0
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchWsReponseGenerator.java
  66. 3
    0
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java
  67. 1
    0
      server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/FakeAvatarResolver.java
  68. 1
    0
      settings.gradle

+ 4
- 1
build.gradle View File

@@ -409,9 +409,12 @@ subprojects {
dependency ("org.springframework:spring-webmvc:${springVersion}") {
exclude 'commons-logging:commons-logging'
}
dependency 'org.springdoc:springdoc-openapi-ui:1.7.0'
dependency 'org.springdoc:springdoc-openapi-webmvc-core:1.7.0'
dependency 'org.subethamail:subethasmtp:3.1.7'
dependency 'org.yaml:snakeyaml:2.0'
dependency 'org.hibernate:hibernate-validator:6.2.5.Final'
dependency 'javax.el:javax.el-api:3.0.0'
dependency 'org.glassfish:javax.el:3.0.0'

// please keep this list alphabetically ordered
}

+ 29
- 0
server/sonar-webserver-common/build.gradle View File

@@ -0,0 +1,29 @@
sonar {
properties {
property 'sonar.projectName', "${projectTitle} :: WebServer :: Common"
}
}

dependencies {
// please keep the list grouped by configuration and ordered by name
api 'com.google.guava:guava'

api project(':server:sonar-db-dao')
api project(':server:sonar-webserver-ws')

compileOnlyApi 'com.google.code.findbugs:jsr305'
compileOnlyApi 'javax.servlet:javax.servlet-api'

testImplementation 'org.apache.logging.log4j:log4j-api'
testImplementation 'org.apache.logging.log4j:log4j-core'
testImplementation 'com.google.code.findbugs:jsr305'
testImplementation 'com.tngtech.java:junit-dataprovider'

testImplementation 'junit:junit'
testImplementation 'org.assertj:assertj-core'
testImplementation 'org.mockito:mockito-core'

testImplementation project(':sonar-testing-harness')
testImplementation testFixtures(project(':server:sonar-db-dao'))

}

+ 408
- 0
server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java View File

@@ -0,0 +1,408 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.common.user.service;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.IntStream;
import org.junit.Rule;
import org.junit.Test;
import org.sonar.api.utils.DateUtils;
import org.sonar.core.util.UuidFactory;
import org.sonar.db.DbTester;
import org.sonar.db.scim.ScimUserDao;
import org.sonar.db.user.GroupDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.common.SearchResults;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.management.ManagedInstanceService;

import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static java.util.function.Function.identity;
import static java.util.stream.Collectors.toMap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class UserServiceIT {

private static final UsersSearchRequest SEARCH_REQUEST = getBuilderWithDefaultsPageSize().build();
@Rule
public DbTester db = DbTester.create();

private final ManagedInstanceService managedInstanceService = mock(ManagedInstanceService.class);

private final UserService userService = new UserService(db.getDbClient(), new AvatarResolverImpl(), managedInstanceService);

@Test
public void search_for_all_active_users() {
UserDto user1 = db.users().insertUser();
UserDto user2 = db.users().insertUser();
UserDto user3 = db.users().insertUser(u -> u.setActive(false));

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), r -> r.userDto().getName())
.containsExactlyInAnyOrder(
tuple(user1.getLogin(), user1.getName()),
tuple(user2.getLogin(), user2.getName()));
}

@Test
public void search_deactivated_users() {
UserDto user1 = db.users().insertUser(u -> u.setActive(false));
UserDto user2 = db.users().insertUser(u -> u.setActive(true));

SearchResults<UserSearchResult> users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setDeactivated(true).build());

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), r -> r.userDto().getName())
.containsExactlyInAnyOrder(
tuple(user1.getLogin(), user1.getName()));
}

@Test
public void search_with_query() {
UserDto user = db.users().insertUser(u -> u
.setLogin("user-%_%-login")
.setName("user-name")
.setEmail("user@mail.com")
.setLocal(true)
.setScmAccounts(singletonList("user1")));

SearchResults<UserSearchResult> users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setQuery("user-%_%-").build());
assertThat(users.searchResults()).extracting(UserSearchResult::userDto).extracting(UserDto::getLogin)
.containsExactly(user.getLogin());

users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setQuery("user@MAIL.com").build());
assertThat(users.searchResults()).extracting(UserSearchResult::userDto).extracting(UserDto::getLogin)
.containsExactly(user.getLogin());

users = userService.findUsers(getBuilderWithDefaultsPageSize().setQuery("user-name").build());
assertThat(users.searchResults()).extracting(UserSearchResult::userDto).extracting(UserDto::getLogin)
.containsExactly(user.getLogin());
}

@Test
public void return_avatar() {
UserDto user = db.users().insertUser(u -> u.setEmail("john@doe.com"));

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), UserSearchResult::avatar)
.containsExactlyInAnyOrder(
tuple(user.getLogin(), Optional.of("6a6c19fea4a3676970167ce51f39e6ee")));

}

@Test
public void return_isManagedFlag() {
UserDto nonManagedUser = db.users().insertUser(u -> u.setEmail("john@doe.com"));
UserDto managedUser = db.users().insertUser(u -> u.setEmail("externalUser@doe.com"));
mockUsersAsManaged(managedUser.getUuid());

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), UserSearchResult::managed)
.containsExactlyInAnyOrder(
tuple(managedUser.getLogin(), true),
tuple(nonManagedUser.getLogin(), false)
);

}

@Test
public void search_whenFilteringByManagedAndInstanceManaged_returnsCorrectResults() {
UserDto nonManagedUser = db.users().insertUser(u -> u.setEmail("john@doe.com"));
UserDto managedUser = db.users().insertUser(u -> u.setEmail("externalUser@doe.com"));
db.users().enableScimForUser(managedUser);
mockUsersAsManaged(managedUser.getUuid());
mockInstanceExternallyManagedAndFilterForManagedUsers();

SearchResults<UserSearchResult> users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setManaged(true).build());

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), UserSearchResult::managed)
.containsExactlyInAnyOrder(
tuple(managedUser.getLogin(), true)
);

}

@Test
public void search_whenFilteringByNonManagedAndInstanceManaged_returnsCorrectResults() {
UserDto nonManagedUser = db.users().insertUser(u -> u.setEmail("john@doe.com"));
UserDto managedUser = db.users().insertUser(u -> u.setEmail("externalUser@doe.com"));
db.users().enableScimForUser(managedUser);
mockUsersAsManaged(managedUser.getUuid());
mockInstanceExternallyManagedAndFilterForManagedUsers();

SearchResults<UserSearchResult> users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setManaged(false).build());

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), UserSearchResult::managed)
.containsExactlyInAnyOrder(
tuple(nonManagedUser.getLogin(), false)
);
}

private void mockInstanceExternallyManagedAndFilterForManagedUsers() {
when(managedInstanceService.isInstanceExternallyManaged()).thenReturn(true);
when(managedInstanceService.getManagedUsersSqlFilter(anyBoolean()))
.thenAnswer(invocation -> {
Boolean managed = invocation.getArgument(0, Boolean.class);
return new ScimUserDao(mock(UuidFactory.class)).getManagedUserSqlFilter(managed);
});
}

@Test
public void return_scm_accounts() {
UserDto user = db.users().insertUser(u -> u.setScmAccounts(asList("john1", "john2")));

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), userSearchResult -> userSearchResult.userDto().getSortedScmAccounts())
.containsExactlyInAnyOrder(tuple(user.getLogin(), asList("john1", "john2")));
}

@Test
public void return_tokens_count_when_system_administer() {
UserDto user = db.users().insertUser();
db.users().insertToken(user);
db.users().insertToken(user);

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), UserSearchResult::tokensCount)
.containsExactlyInAnyOrder(tuple(user.getLogin(), 2));
}

@Test
public void return_user_not_having_email() {
UserDto user = db.users().insertUser(u -> u.setEmail(null));

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), userSearchResult -> userSearchResult.userDto().getEmail())
.containsExactlyInAnyOrder(tuple(user.getLogin(), null));
}

@Test
public void return_groups() {
UserDto user = db.users().insertUser();
GroupDto group1 = db.users().insertGroup("group1");
GroupDto group2 = db.users().insertGroup("group2");
GroupDto group3 = db.users().insertGroup("group3");
db.users().insertMember(group1, user);
db.users().insertMember(group2, user);

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), UserSearchResult::groups)
.containsExactlyInAnyOrder(tuple(user.getLogin(), asList(group1.getName(), group2.getName())));
}

@Test
public void return_external_information() {
UserDto user = db.users().insertUser();

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(
r -> r.userDto().getLogin(),
userSearchResult -> userSearchResult.userDto().getExternalLogin(),
userSearchResult -> userSearchResult.userDto().getExternalIdentityProvider()
)
.containsExactlyInAnyOrder(tuple(user.getLogin(), user.getExternalLogin(), user.getExternalIdentityProvider()));
}

@Test
public void return_last_connection_date() {
UserDto userWithLastConnectionDate = db.users().insertUser();
db.users().updateLastConnectionDate(userWithLastConnectionDate, 10_000_000_000L);
UserDto userWithoutLastConnectionDate = db.users().insertUser();

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin(), userSearchResult -> userSearchResult.userDto().getLastConnectionDate())
.containsExactlyInAnyOrder(
tuple(userWithLastConnectionDate.getLogin(), 10_000_000_000L),
tuple(userWithoutLastConnectionDate.getLogin(), null));
}

@Test
public void return_all_fields_for_logged_user() {
UserDto user = db.users().insertUser(u -> u.setEmail("aa@bb.com"));
db.users().updateLastConnectionDate(user, 10_000_000_000L);
db.users().insertToken(user);
db.users().insertToken(user);
GroupDto group = db.users().insertGroup();
db.users().insertMember(group, user);

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults())
.extracting(UserSearchResult::userDto)
.extracting(UserDto::getLogin, UserDto::getName, UserDto::getEmail, UserDto::getExternalLogin, UserDto::getExternalIdentityProvider,
userDto -> !userDto.getSortedScmAccounts().isEmpty(), UserDto::getLastConnectionDate)
.containsExactlyInAnyOrder(
tuple(user.getLogin(), user.getName(), user.getEmail(), user.getExternalLogin(), user.getExternalIdentityProvider(), true, 10_000_000_000L));

assertThat(users.searchResults())
.extracting(UserSearchResult::avatar, UserSearchResult::tokensCount, userSearchResult -> userSearchResult.groups().size())
.containsExactly(tuple(Optional.of("5dcdf28d944831f2fb87d48b81500c66"), 2, 1));

}

@Test
public void search_whenNoPagingInformationProvided_setsDefaultValues() {
IntStream.rangeClosed(0, 9).forEach(i -> db.users().insertUser(u -> u.setLogin("user-" + i).setName("User " + i)));

SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.total()).isEqualTo(10);
}

@Test
public void search_with_paging() {
IntStream.rangeClosed(0, 9).forEach(i -> db.users().insertUser(u -> u.setLogin("user-" + i).setName("User " + i)));

SearchResults<UserSearchResult> users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(5).build());

assertThat(users.searchResults())
.extracting(u -> u.userDto().getLogin())
.containsExactly("user-0", "user-1", "user-2", "user-3", "user-4");
assertThat(users.total()).isEqualTo(10);

users = userService.findUsers(UsersSearchRequest.builder().setPage(2).setPageSize(5).build());

assertThat(users.searchResults())
.extracting(u -> u.userDto().getLogin())
.containsExactly("user-5", "user-6", "user-7", "user-8", "user-9");
assertThat(users.total()).isEqualTo(10);

}

@Test
public void return_empty_result_when_no_user() {
SearchResults<UserSearchResult> users = userService.findUsers(SEARCH_REQUEST);

assertThat(users.searchResults()).isEmpty();
assertThat(users.total()).isZero();
}

@Test
public void search_whenFilteringConnectionDate_shouldApplyFilter() {
final Instant lastConnection = Instant.now();
UserDto user = db.users().insertUser(u -> u
.setLogin("user-%_%-login")
.setName("user-name")
.setEmail("user@mail.com")
.setLocal(true)
.setScmAccounts(singletonList("user1")));
user = db.users().updateLastConnectionDate(user, lastConnection.toEpochMilli());
user = db.users().updateSonarLintLastConnectionDate(user, lastConnection.toEpochMilli());

SearchResults<UserSearchResult> users = userService.findUsers(UsersSearchRequest.builder().setPage(1).setPageSize(50).setQuery("user-%_%-").build());

assertThat(users.searchResults())
.extracting(r -> r.userDto().getLogin())
.containsExactlyInAnyOrder(user.getLogin());

assertUserWithFilter(b -> b.setLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.minus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), true);
assertUserWithFilter(b -> b.setLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.plus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), false);
assertUserWithFilter(b -> b.setLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.minus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), false);
assertUserWithFilter(b -> b.setLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.plus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), true);

assertUserWithFilter(b -> b.setSonarLintLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.minus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), true);
assertUserWithFilter(b -> b.setSonarLintLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.plus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), false);
assertUserWithFilter(b -> b.setSonarLintLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.minus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), false);
assertUserWithFilter(b -> b.setSonarLintLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.plus(1, ChronoUnit.DAYS).toEpochMilli())), user.getLogin(), true);

assertUserWithFilter(b -> b.setSonarLintLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), true);
assertUserWithFilter(b -> b.setSonarLintLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), true);
}

@Test
public void search_whenNoLastConnection_shouldReturnForBeforeOnly() {
final Instant lastConnection = Instant.now();
UserDto user = db.users().insertUser(u -> u
.setLogin("user-%_%-login")
.setName("user-name")
.setEmail("user@mail.com")
.setLocal(true)
.setScmAccounts(singletonList("user1")));

assertUserWithFilter(b -> b.setLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), false);
assertUserWithFilter(b -> b.setLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), true);

assertUserWithFilter(b -> b.setSonarLintLastConnectionDateFrom(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), false);
assertUserWithFilter(b -> b.setSonarLintLastConnectionDateTo(DateUtils.formatDateTime(lastConnection.toEpochMilli())), user.getLogin(), true);

}

private void assertUserWithFilter(Function<UsersSearchRequest.Builder, UsersSearchRequest.Builder> query, String userLogin, boolean isExpectedToBeThere) {

UsersSearchRequest.Builder builder = getBuilderWithDefaultsPageSize();
builder = query.apply(builder);

SearchResults<UserSearchResult> users = userService.findUsers(builder.setQuery("user-%_%-").build());

var assertion = assertThat(users.searchResults());
if (isExpectedToBeThere) {
assertion
.extracting(r -> r.userDto().getLogin())
.containsExactlyInAnyOrder(userLogin);
} else {
assertion.isEmpty();
}
}

private void mockUsersAsManaged(String... userUuids) {
when(managedInstanceService.getUserUuidToManaged(any(), any())).thenAnswer(invocation ->
{
Set<?> allUsersUuids = invocation.getArgument(1, Set.class);
return allUsersUuids.stream()
.map(userUuid -> (String) userUuid)
.collect(toMap(identity(), userUuid -> Set.of(userUuids).contains(userUuid)));
}
);
}

private static UsersSearchRequest.Builder getBuilderWithDefaultsPageSize() {
return UsersSearchRequest.builder().setPage(1).setPageSize(50);
}
}

+ 25
- 0
server/sonar-webserver-common/src/main/java/org/sonar/server/common/SearchResults.java View File

@@ -0,0 +1,25 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.common;

import java.util.List;

public record SearchResults<T>(List<T> searchResults, int total) {
}

server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolver.java → server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolver.java View File

@@ -17,7 +17,7 @@
* 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.issue;
package org.sonar.server.common.avatar;

import org.sonar.db.user.UserDto;


server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/AvatarResolverImpl.java → server/sonar-webserver-common/src/main/java/org/sonar/server/common/avatar/AvatarResolverImpl.java View File

@@ -17,7 +17,7 @@
* 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.issue;
package org.sonar.server.common.avatar;

import com.google.common.hash.Hashing;
import org.sonar.db.user.UserDto;

+ 30
- 0
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/UsersSearchResponseGenerator.java View File

@@ -0,0 +1,30 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.common.user;

import java.util.List;
import org.sonar.api.utils.Paging;
import org.sonar.server.common.user.service.UserSearchResult;

public interface UsersSearchResponseGenerator<T> {

T toUsersForResponse(List<UserSearchResult> userSearchResults, Paging paging);

}

+ 27
- 0
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserSearchResult.java View File

@@ -0,0 +1,27 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.common.user.service;

import java.util.Collection;
import java.util.Optional;
import org.sonar.db.user.UserDto;

public record UserSearchResult(UserDto userDto, boolean managed, Optional<String> avatar, Collection<String> groups, int tokensCount) {
}

+ 125
- 0
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java View File

@@ -0,0 +1,125 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.common.user.service;

import com.google.common.collect.Multimap;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.user.UserDto;
import org.sonar.db.user.UserQuery;
import org.sonar.server.common.SearchResults;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonar.server.exceptions.BadRequestException;
import org.sonar.server.management.ManagedInstanceService;

import static java.util.Comparator.comparing;

public class UserService {

private final DbClient dbClient;
private final AvatarResolver avatarResolver;
private final ManagedInstanceService managedInstanceService;

public UserService(DbClient dbClient, AvatarResolver avatarResolver, ManagedInstanceService managedInstanceService) {
this.dbClient = dbClient;
this.avatarResolver = avatarResolver;
this.managedInstanceService = managedInstanceService;
}

public SearchResults<UserSearchResult> findUsers(UsersSearchRequest request) {
UserQuery userQuery = buildUserQuery(request);
try (DbSession dbSession = dbClient.openSession(false)) {
int totalUsers = dbClient.userDao().countUsers(dbSession, userQuery);

List<UserSearchResult> searchResults = performSearch(dbSession, userQuery, request.getPage(), request.getPageSize());
return new SearchResults<>(searchResults, totalUsers);
}
}

private UserQuery buildUserQuery(UsersSearchRequest request) {
UserQuery.UserQueryBuilder builder = UserQuery.builder();
request.getLastConnectionDateFrom().ifPresent(builder::lastConnectionDateFrom);
request.getLastConnectionDateTo().ifPresent(builder::lastConnectionDateTo);
request.getSonarLintLastConnectionDateFrom().ifPresent(builder::sonarLintLastConnectionDateFrom);
request.getSonarLintLastConnectionDateTo().ifPresent(builder::sonarLintLastConnectionDateTo);

if (managedInstanceService.isInstanceExternallyManaged()) {
String managedInstanceSql = Optional.ofNullable(request.isManaged())
.map(managedInstanceService::getManagedUsersSqlFilter)
.orElse(null);
builder.isManagedClause(managedInstanceSql);
} else if (request.isManaged() != null) {
throw BadRequestException.create("The 'managed' parameter is only available for managed instances.");
}

return builder
.isActive(!request.isDeactivated())
.searchText(request.getQuery())
.build();
}

private List<UserSearchResult> performSearch(DbSession dbSession, UserQuery userQuery, int pageIndex, int pageSize) {
List<UserDto> userDtos = findUsersAndSortByLogin(dbSession, userQuery, pageIndex, pageSize);
List<String> logins = userDtos.stream().map(UserDto::getLogin).toList();
Multimap<String, String> groupsByLogin = dbClient.groupMembershipDao().selectGroupsByLogins(dbSession, logins);
Map<String, Integer> tokenCountsByLogin = dbClient.userTokenDao().countTokensByUsers(dbSession, userDtos);
Map<String, Boolean> userUuidToIsManaged = managedInstanceService.getUserUuidToManaged(dbSession, getUserUuids(userDtos));
return userDtos.stream()
.map(userDto -> toUserSearchResult(
groupsByLogin.get(userDto.getLogin()),
tokenCountsByLogin.getOrDefault(userDto.getUuid(), 0),
userUuidToIsManaged.getOrDefault(userDto.getUuid(), false),
userDto
)
).toList();
}

private UserSearchResult toUserSearchResult(Collection<String> groups, int tokenCount, boolean managed, UserDto userDto) {
return new UserSearchResult(
userDto,
managed,
findAvatar(userDto),
groups,
tokenCount
);
}

private List<UserDto> findUsersAndSortByLogin(DbSession dbSession, UserQuery userQuery, int page, int pageSize) {
return dbClient.userDao().selectUsers(dbSession, userQuery, page, pageSize)
.stream()
.sorted(comparing(UserDto::getLogin))
.toList();
}

private Optional<String> findAvatar(UserDto userDto) {
return Optional.ofNullable(userDto.getEmail()).map(email -> avatarResolver.create(userDto));
}

private static Set<String> getUserUuids(List<UserDto> users) {
return users.stream().map(UserDto::getUuid).collect(Collectors.toSet());
}

}

+ 163
- 0
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UsersSearchRequest.java View File

@@ -0,0 +1,163 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.common.user.service;

import java.time.OffsetDateTime;
import java.util.Optional;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import org.sonar.api.utils.DateUtils;
import org.sonar.api.utils.MessageException;
import org.sonar.server.exceptions.ServerException;

public class UsersSearchRequest {
private final Integer page;
private final Integer pageSize;
private final String query;
private final boolean deactivated;
private final Boolean managed;
private final OffsetDateTime lastConnectionDateFrom;
private final OffsetDateTime lastConnectionDateTo;
private final OffsetDateTime sonarLintLastConnectionDateFrom;
private final OffsetDateTime sonarLintLastConnectionDateTo;

private UsersSearchRequest(Builder builder) {
this.page = builder.page;
this.pageSize = builder.pageSize;
this.query = builder.query;
this.deactivated = builder.deactivated;
this.managed = builder.managed;
try {
this.lastConnectionDateFrom = Optional.ofNullable(builder.lastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null);
this.lastConnectionDateTo = Optional.ofNullable(builder.lastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null);
this.sonarLintLastConnectionDateFrom = Optional.ofNullable(builder.sonarLintLastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null);
this.sonarLintLastConnectionDateTo = Optional.ofNullable(builder.sonarLintLastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null);
} catch (MessageException me) {
throw new ServerException(400, me.getMessage());
}
}

public Integer getPage() {
return page;
}

public Integer getPageSize() {
return pageSize;
}

@CheckForNull
public String getQuery() {
return query;
}

public boolean isDeactivated() {
return deactivated;
}

@CheckForNull
public Boolean isManaged() {
return managed;
}

public Optional<OffsetDateTime> getLastConnectionDateFrom() {
return Optional.ofNullable(lastConnectionDateFrom);
}

public Optional<OffsetDateTime> getLastConnectionDateTo() {
return Optional.ofNullable(lastConnectionDateTo);
}

public Optional<OffsetDateTime> getSonarLintLastConnectionDateFrom() {
return Optional.ofNullable(sonarLintLastConnectionDateFrom);
}

public Optional<OffsetDateTime> getSonarLintLastConnectionDateTo() {
return Optional.ofNullable(sonarLintLastConnectionDateTo);
}

public static Builder builder() {
return new Builder();
}

public static class Builder {
private Integer page;
private Integer pageSize;
private String query;
private boolean deactivated;
private Boolean managed;
private String lastConnectionDateFrom;
private String lastConnectionDateTo;
private String sonarLintLastConnectionDateFrom;
private String sonarLintLastConnectionDateTo;

private Builder() {
// enforce factory method use
}

public Builder setPage(Integer page) {
this.page = page;
return this;
}

public Builder setPageSize(Integer pageSize) {
this.pageSize = pageSize;
return this;
}

public Builder setQuery(@Nullable String query) {
this.query = query;
return this;
}

public Builder setDeactivated(boolean deactivated) {
this.deactivated = deactivated;
return this;
}

public Builder setManaged(@Nullable Boolean managed) {
this.managed = managed;
return this;
}

public Builder setLastConnectionDateFrom(@Nullable String lastConnectionDateFrom) {
this.lastConnectionDateFrom = lastConnectionDateFrom;
return this;
}

public Builder setLastConnectionDateTo(@Nullable String lastConnectionDateTo) {
this.lastConnectionDateTo = lastConnectionDateTo;
return this;
}

public Builder setSonarLintLastConnectionDateFrom(@Nullable String sonarLintLastConnectionDateFrom) {
this.sonarLintLastConnectionDateFrom = sonarLintLastConnectionDateFrom;
return this;
}

public Builder setSonarLintLastConnectionDateTo(@Nullable String sonarLintLastConnectionDateTo) {
this.sonarLintLastConnectionDateTo = sonarLintLastConnectionDateTo;
return this;
}

public UsersSearchRequest build() {
return new UsersSearchRequest(this);
}
}
}

server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/AvatarResolverImplTest.java → server/sonar-webserver-common/src/test/java/org/sonar/server/common/avatar/AvatarResolverImplTest.java View File

@@ -17,29 +17,28 @@
* 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.issue;
package org.sonar.server.common.avatar;

import org.junit.Test;
import org.sonar.db.user.UserTesting;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.sonar.db.user.UserTesting.newUserDto;

public class AvatarResolverImplTest {


private AvatarResolverImpl underTest = new AvatarResolverImpl();

@Test
public void create() {
String avatar = underTest.create(newUserDto("john", "John", "john@doo.com"));
String avatar = underTest.create(UserTesting.newUserDto("john", "John", "john@doo.com"));

assertThat(avatar).isEqualTo("9297bfb538f650da6143b604e82a355d");
}

@Test
public void create_is_case_insensitive() {
assertThat(underTest.create(newUserDto("john", "John", "john@doo.com"))).isEqualTo(underTest.create(newUserDto("john", "John", "John@Doo.com")));
assertThat(underTest.create(UserTesting.newUserDto("john", "John", "john@doo.com"))).isEqualTo(underTest.create(UserTesting.newUserDto("john", "John", "John@Doo.com")));
}

@Test
@@ -51,14 +50,14 @@ public class AvatarResolverImplTest {

@Test
public void fail_with_NP_when_email_is_null() {
assertThatThrownBy(() -> underTest.create(newUserDto("john", "John", null)))
assertThatThrownBy(() -> underTest.create(UserTesting.newUserDto("john", "John", null)))
.isInstanceOf(NullPointerException.class)
.hasMessage("Email cannot be null");
}

@Test
public void fail_when_email_is_empty() {
assertThatThrownBy(() -> underTest.create(newUserDto("john", "John", "")))
assertThatThrownBy(() -> underTest.create(UserTesting.newUserDto("john", "John", "")))
.isInstanceOf(NullPointerException.class)
.hasMessage("Email cannot be null");
}

+ 6
- 2
server/sonar-webserver-webapi-v2/build.gradle View File

@@ -6,11 +6,15 @@ sonarqube {

dependencies {
// please keep the list grouped by configuration and ordered by name
api 'org.springdoc:springdoc-openapi-ui'
api 'org.springdoc:springdoc-openapi-webmvc-core'
api 'org.springframework:spring-webmvc'
api 'org.hibernate:hibernate-validator'
api 'javax.el:javax.el-api'
api 'org.glassfish:javax.el'

api project(':server:sonar-db-dao')
// We are not suppose to have a v1 dependency. The ideal would be to have another common module between webapi and webapi-v2 but that needs a lot of refactoring.
api project(':server:sonar-webserver-common')
// We are not supposed to have a v1 dependency. The ideal would be to have another common module between webapi and webapi-v2 but that needs a lot of refactoring.
api project(':server:sonar-webserver-webapi')

testImplementation 'org.mockito:mockito-core'

+ 28
- 0
server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java View File

@@ -0,0 +1,28 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.user.controller;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class DefaultUserControllerTest {

}

+ 6
- 0
server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/config/MockConfigForControllers.java View File

@@ -20,6 +20,7 @@
package org.sonar.server.v2.config;

import org.sonar.db.DbClient;
import org.sonar.server.common.user.service.UserService;
import org.sonar.server.health.CeStatusNodeCheck;
import org.sonar.server.health.DbConnectionNodeCheck;
import org.sonar.server.health.EsStatusNodeCheck;
@@ -98,4 +99,9 @@ public class MockConfigForControllers {
ManagedInstanceChecker managedInstanceChecker() {
return mock(ManagedInstanceChecker.class);
}

@Bean
UserService userService() {
return mock(UserService.class);
}
}

+ 3
- 3
server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/DefaultLivenessControllerIT.java View File

@@ -73,7 +73,7 @@ public class DefaultLivenessControllerIT extends ControllerIT {
mockMvc.perform(get(LIVENESS_ENDPOINT))
.andExpectAll(
status().isForbidden(),
content().string("Insufficient privileges"));
content().json("{\"message\":\"Insufficient privileges\"}"));
}

@Test
@@ -84,7 +84,7 @@ public class DefaultLivenessControllerIT extends ControllerIT {
mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, INVALID_PASSCODE))
.andExpectAll(
status().isForbidden(),
content().string("Insufficient privileges"));
content().json("{\"message\":\"Insufficient privileges\"}"));
}

@Test
@@ -95,6 +95,6 @@ public class DefaultLivenessControllerIT extends ControllerIT {
mockMvc.perform(get(LIVENESS_ENDPOINT).header(PASSCODE_HTTP_HEADER, VALID_PASSCODE))
.andExpectAll(
status().isInternalServerError(),
content().string("Liveness check failed"));
content().json("{\"message\":\"Liveness check failed\"}"));
}
}

+ 4
- 4
server/sonar-webserver-webapi-v2/src/it/java/org/sonar/server/v2/controller/HealthControllerIT.java View File

@@ -101,7 +101,7 @@ public class HealthControllerIT extends ControllerIT {
mockMvc.perform(get(HEALTH_ENDPOINT))
.andExpectAll(
status().isForbidden(),
content().string("Insufficient privileges"));
content().json("{\"message\":\"Insufficient privileges\"}"));
}

@Test
@@ -112,7 +112,7 @@ public class HealthControllerIT extends ControllerIT {
mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, INVALID_PASSCODE))
.andExpectAll(
status().isForbidden(),
content().string("Insufficient privileges"));
content().json("{\"message\":\"Insufficient privileges\"}"));
}

@Test
@@ -122,7 +122,7 @@ public class HealthControllerIT extends ControllerIT {
mockMvc.perform(get(HEALTH_ENDPOINT))
.andExpectAll(
status().isUnauthorized(),
content().string("UnauthorizedException"));
content().json("{\"message\":\"UnauthorizedException\"}"));
}

@Test
@@ -133,6 +133,6 @@ public class HealthControllerIT extends ControllerIT {
mockMvc.perform(get(HEALTH_ENDPOINT).header(PASSCODE_HTTP_HEADER, VALID_PASSCODE))
.andExpectAll(
status().is(HTTP_NOT_IMPLEMENTED),
content().string("Unsupported in cluster mode"));
content().json("{\"message\":\"Unsupported in cluster mode\"}"));
}
}

+ 1
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/WebApiEndpoints.java View File

@@ -25,6 +25,7 @@ public class WebApiEndpoints {
public static final String LIVENESS_ENDPOINT = SYSTEM_ENDPOINTS + "/liveness";

public static final String HEALTH_ENDPOINT = SYSTEM_ENDPOINTS + "/health";
public static final String USER_ENDPOINT = "/users";

private WebApiEndpoints() {
}

+ 22
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestError.java View File

@@ -0,0 +1,22 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.model;

public record RestError(String message) {}

+ 54
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/RestPage.java View File

@@ -0,0 +1,54 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.model;

import com.google.common.annotations.VisibleForTesting;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.Positive;
import org.jetbrains.annotations.Nullable;

public record RestPage(
@Min(1)
@Max(500)
@Parameter(
description = "Number of results per page",
schema = @Schema(defaultValue = DEFAULT_PAGE_SIZE, implementation = Integer.class))
Integer pageSize,
@Positive
@Parameter(
description = "1-based page number",
schema = @Schema(defaultValue = DEFAULT_PAGE_INDEX, implementation = Integer.class))
Integer pageIndex
) {

@VisibleForTesting
static final String DEFAULT_PAGE_SIZE = "50";
@VisibleForTesting
static final String DEFAULT_PAGE_INDEX = "1";

public RestPage(@Nullable Integer pageSize, @Nullable Integer pageIndex) {
this.pageSize = pageSize == null ? Integer.valueOf(DEFAULT_PAGE_SIZE) : pageSize;
this.pageIndex = pageIndex == null ? Integer.valueOf(DEFAULT_PAGE_INDEX) : pageIndex;
}

}

+ 23
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/model/package-info.java View File

@@ -0,0 +1,23 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.
*/
@ParametersAreNonnullByDefault
package org.sonar.server.v2.api.model;

import javax.annotation.ParametersAreNonnullByDefault;

+ 23
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/PageRestResponse.java View File

@@ -0,0 +1,23 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.response;

public record PageRestResponse(int pageIndex, int pageSize, int total) {
}

+ 23
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/response/package-info.java View File

@@ -0,0 +1,23 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.
*/
@ParametersAreNonnullByDefault
package org.sonar.server.v2.api.response;

import javax.annotation.ParametersAreNonnullByDefault;

+ 90
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java View File

@@ -0,0 +1,90 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.user.controller;

import java.util.Optional;
import javax.annotation.Nullable;
import org.sonar.api.utils.Paging;
import org.sonar.server.common.SearchResults;
import org.sonar.server.common.user.service.UserSearchResult;
import org.sonar.server.common.user.service.UserService;
import org.sonar.server.common.user.service.UsersSearchRequest;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.user.UserSession;
import org.sonar.server.v2.api.model.RestPage;
import org.sonar.server.v2.api.user.converter.UsersSearchRestResponseGenerator;
import org.sonar.server.v2.api.user.request.UsersSearchRestRequest;
import org.sonar.server.v2.api.user.response.UsersSearchRestResponse;

import static org.sonar.api.utils.Paging.forPageIndex;

public class DefaultUserController implements UserController {
private final UsersSearchRestResponseGenerator usersSearchResponseGenerator;
private final UserService userService;
private final UserSession userSession;

public DefaultUserController(UserSession userSession, UserService userService, UsersSearchRestResponseGenerator usersSearchResponseGenerator) {
this.userSession = userSession;
this.usersSearchResponseGenerator = usersSearchResponseGenerator;
this.userService = userService;
}

@Override
public UsersSearchRestResponse search(UsersSearchRestRequest usersSearchRestRequest, RestPage page) {
throwIfAdminOnlyParametersAreUsed(usersSearchRestRequest);

SearchResults<UserSearchResult> userSearchResults = userService.findUsers(toUserSearchRequest(usersSearchRestRequest, page));
Paging paging = forPageIndex(page.pageIndex()).withPageSize(page.pageSize()).andTotal(userSearchResults.total());

return usersSearchResponseGenerator.toUsersForResponse(userSearchResults.searchResults(), paging);
}

private void throwIfAdminOnlyParametersAreUsed(UsersSearchRestRequest usersSearchRestRequest) {
if (!userSession.isSystemAdministrator()) {
throwIfValuePresent("sonarLintLastConnectionDateFrom", usersSearchRestRequest.sonarLintLastConnectionDateFrom());
throwIfValuePresent("sonarLintLastConnectionDateTo", usersSearchRestRequest.sonarLintLastConnectionDateTo());
throwIfValuePresent("sonarQubeLastConnectionDateFrom", usersSearchRestRequest.sonarQubeLastConnectionDateFrom());
throwIfValuePresent("sonarQubeLastConnectionDateTo", usersSearchRestRequest.sonarQubeLastConnectionDateTo());
}
}

private static void throwIfValuePresent(String parameter, @Nullable Object value) {
Optional.ofNullable(value).ifPresent(v -> throwForbiddenFor(parameter));
}

private static void throwForbiddenFor(String parameterName) {
throw new ForbiddenException("parameter " + parameterName + " requires Administer System permission.");
}

private static UsersSearchRequest toUserSearchRequest(UsersSearchRestRequest usersSearchRestRequest, RestPage page) {
return UsersSearchRequest.builder()
.setDeactivated(Optional.ofNullable(usersSearchRestRequest.active()).map(active -> !active).orElse(false))
.setManaged(usersSearchRestRequest.managed())
.setQuery(usersSearchRestRequest.q())
.setLastConnectionDateFrom(usersSearchRestRequest.sonarQubeLastConnectionDateFrom())
.setLastConnectionDateTo(usersSearchRestRequest.sonarQubeLastConnectionDateTo())
.setSonarLintLastConnectionDateFrom(usersSearchRestRequest.sonarLintLastConnectionDateFrom())
.setSonarLintLastConnectionDateTo(usersSearchRestRequest.sonarLintLastConnectionDateTo())
.setPage(page.pageIndex())
.setPageSize(page.pageSize())
.build();
}

}

+ 56
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java View File

@@ -0,0 +1,56 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.user.controller;

import io.swagger.v3.oas.annotations.Operation;
import javax.validation.Valid;
import org.sonar.server.v2.api.model.RestPage;
import org.sonar.server.v2.api.user.request.UsersSearchRestRequest;
import org.sonar.server.v2.api.user.response.UsersSearchRestResponse;
import org.springdoc.api.annotations.ParameterObject;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import static org.sonar.server.v2.WebApiEndpoints.USER_ENDPOINT;

@RequestMapping(USER_ENDPOINT)
@RestController
public interface UserController {

@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
@ResponseStatus(HttpStatus.OK)
@Operation(summary = "Users search", description = """
Get a list of users. By default, only active users are returned.
The following fields are only returned when user has Administer System permission or for logged-in in user :
'email'
'externalIdentity'
'externalProvider'
'groups'
'lastConnectionDate'
'sonarLintLastConnectionDate'
'tokensCount'
Field 'sonarqubeLastConnectionDate' is only updated every hour, so it may not be accurate, for instance when a user authenticates many times in less than one hour.
""")
UsersSearchRestResponse search(@ParameterObject UsersSearchRestRequest usersSearchRestRequest, @Valid @ParameterObject RestPage restPage);
}

+ 23
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/package-info.java View File

@@ -0,0 +1,23 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.
*/
@ParametersAreNonnullByDefault
package org.sonar.server.v2.api.user.controller;

import javax.annotation.ParametersAreNonnullByDefault;

+ 111
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java View File

@@ -0,0 +1,111 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.user.converter;

import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.jetbrains.annotations.Nullable;
import org.sonar.api.utils.DateUtils;
import org.sonar.api.utils.Paging;
import org.sonar.db.user.UserDto;
import org.sonar.server.common.user.UsersSearchResponseGenerator;
import org.sonar.server.common.user.service.UserSearchResult;
import org.sonar.server.user.UserSession;
import org.sonar.server.v2.api.response.PageRestResponse;
import org.sonar.server.v2.api.user.model.RestUser;
import org.sonar.server.v2.api.user.response.UsersSearchRestResponse;

public class UsersSearchRestResponseGenerator implements UsersSearchResponseGenerator<UsersSearchRestResponse> {

private final UserSession userSession;

public UsersSearchRestResponseGenerator(UserSession userSession) {
this.userSession = userSession;
}

@Override
public UsersSearchRestResponse toUsersForResponse(List<UserSearchResult> userSearchResults, Paging paging) {
List<RestUser> usersForResponse = toUsersForResponse(userSearchResults);
PageRestResponse pageRestResponse = new PageRestResponse(paging.pageIndex(), paging.pageSize(), paging.total());
return new UsersSearchRestResponse(usersForResponse, pageRestResponse);
}

private List<RestUser> toUsersForResponse(List<UserSearchResult> userSearchResults) {
return userSearchResults.stream()
.map(this::toUser)
.toList();
}

private RestUser toUser(UserSearchResult userSearchResult) {
UserDto userDto = userSearchResult.userDto();

String login = userDto.getLogin();
String name = userDto.getName();
String avatar = null;
Boolean active = null;
Boolean local = null;
String email = null;
String externalIdentityProvider = null;
String externalLogin = null;
Boolean managed = null;
String sqLastConnectionDate = null;
String slLastConnectionDate = null;
Integer groupSize = null;
Integer tokensCount = null;

if (userSession.isLoggedIn()) {
avatar = userSearchResult.avatar().orElse(null);
active = userDto.isActive();
local = userDto.isLocal();
email = userDto.getEmail();
externalIdentityProvider = userDto.getExternalIdentityProvider();
}
if (userSession.isSystemAdministrator() || Objects.equals(userSession.getUuid(), userDto.getUuid())) {
externalLogin = userDto.getExternalLogin();
managed = userSearchResult.managed();
sqLastConnectionDate = toDateTime(userDto.getLastConnectionDate());
slLastConnectionDate = toDateTime(userDto.getLastSonarlintConnectionDate());
groupSize = userSearchResult.groups().size();
tokensCount = userSearchResult.tokensCount();
}

return new RestUser(
login,
login,
name,
email,
active,
local,
managed,
externalLogin,
externalIdentityProvider,
avatar,
sqLastConnectionDate,
slLastConnectionDate,
groupSize,
tokensCount
);
}

private static String toDateTime(@Nullable Long dateTimeMs) {
return Optional.ofNullable(dateTimeMs).map(DateUtils::formatDateTime).orElse(null);
}
}

+ 23
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/package-info.java View File

@@ -0,0 +1,23 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.
*/
@ParametersAreNonnullByDefault
package org.sonar.server.v2.api.user.converter;

import javax.annotation.ParametersAreNonnullByDefault;

+ 51
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java View File

@@ -0,0 +1,51 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.user.model;

import javax.annotation.Nullable;

public record RestUser(
String id,
String login,
String name,
@Nullable
String email,
@Nullable
Boolean active,
@Nullable
Boolean local,
@Nullable
Boolean managed,
@Nullable
String externalLogin,
@Nullable
String externalProvider,
@Nullable
String avatar,
@Nullable
String sonarQubeLastConnectionDate,
@Nullable
String sonarLintLastConnectionDate,
@Nullable
Integer groupsCount,
@Nullable
Integer tokensCount
) {
}

+ 23
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/package-info.java View File

@@ -0,0 +1,23 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.
*/
@ParametersAreNonnullByDefault
package org.sonar.server.v2.api.user.model;

import javax.annotation.ParametersAreNonnullByDefault;

+ 61
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/UsersSearchRestRequest.java View File

@@ -0,0 +1,61 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.user.request;

import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import javax.annotation.Nullable;

public record UsersSearchRestRequest(
@Parameter(
description = "Return active/inactive users",
schema = @Schema(defaultValue = "true", implementation = Boolean.class))
Boolean active,
@Nullable
@Parameter(description = "Return managed or non-managed users. Only available for managed instances, throws for non-managed instances")
Boolean managed,
@Nullable
@Parameter(description = "Filter on login, name and email.\n"
+ "This parameter can either perform an exact match, or a partial match (contains), it is case insensitive.")
String q,
@Nullable
@Parameter(description = "Filter the users based on the last connection date field. Only users who interacted with this instance at or after the date will be returned. "
+ "The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)",
example = "2020-01-01T00:00:00+0100")
String sonarQubeLastConnectionDateFrom,
@Nullable
@Parameter(description = "Filter the users based on the last connection date field. Only users that never connected or who interacted with this instance at "
+ "or before the date will be returned. The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)",
example = "2020-01-01T00:00:00+0100")
String sonarQubeLastConnectionDateTo,
@Nullable
@Parameter(description = "Filter the users based on the sonar lint last connection date field Only users who interacted with this instance using SonarLint at or after "
+ "the date will be returned. The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)",
example = "2020-01-01T00:00:00+0100")
String sonarLintLastConnectionDateFrom,
@Nullable
@Parameter(description = "Filter the users based on the sonar lint last connection date field. Only users that never connected or who interacted with this instance "
+ "using SonarLint at or before the date will be returned. The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)",
example = "2020-01-01T00:00:00+0100")
String sonarLintLastConnectionDateTo

) {

}

+ 23
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/request/package-info.java View File

@@ -0,0 +1,23 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.
*/
@ParametersAreNonnullByDefault
package org.sonar.server.v2.api.user.request;

import javax.annotation.ParametersAreNonnullByDefault;

+ 27
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/UsersSearchRestResponse.java View File

@@ -0,0 +1,27 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.user.response;

import java.util.List;
import org.sonar.server.v2.api.response.PageRestResponse;
import org.sonar.server.v2.api.user.model.RestUser;

public record UsersSearchRestResponse(List<RestUser> users, PageRestResponse pageRestResponse) {
}

+ 23
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/response/package-info.java View File

@@ -0,0 +1,23 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.
*/
@ParametersAreNonnullByDefault
package org.sonar.server.v2.api.user.response;

import javax.annotation.ParametersAreNonnullByDefault;

+ 23
- 14
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/RestResponseEntityExceptionHandler.java View File

@@ -20,11 +20,16 @@
package org.sonar.server.v2.common;

import java.util.Optional;
import java.util.stream.Collectors;
import org.sonar.server.exceptions.BadRequestException;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.ServerException;
import org.sonar.server.exceptions.UnauthorizedException;
import org.sonar.server.v2.api.model.RestError;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -34,25 +39,29 @@ public class RestResponseEntityExceptionHandler {

@ExceptionHandler(IllegalStateException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
protected ResponseEntity<Object> handleIllegalStateException(IllegalStateException illegalStateException) {
return new ResponseEntity<>(illegalStateException.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
protected ResponseEntity<RestError> handleIllegalStateException(IllegalStateException illegalStateException) {
return new ResponseEntity<>(new RestError(illegalStateException.getMessage()), HttpStatus.INTERNAL_SERVER_ERROR);
}

@ExceptionHandler(ForbiddenException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
protected ResponseEntity<Object> handleForbiddenException(ForbiddenException forbiddenException) {
return handleServerException(forbiddenException);
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
protected ResponseEntity<RestError> handleBindException(BindException bindException) {
String validationErrors = bindException.getFieldErrors().stream()
.map(RestResponseEntityExceptionHandler::handleFieldError)
.collect(Collectors.joining());
return new ResponseEntity<>(new RestError(validationErrors), HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(UnauthorizedException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
protected ResponseEntity<Object> handleUnauthorizedException(UnauthorizedException unauthorizedException) {
return handleServerException(unauthorizedException);
private static String handleFieldError(FieldError fieldError) {
String fieldName = fieldError.getField();
String rejectedValueAsString = Optional.ofNullable(fieldError.getRejectedValue()).map(Object::toString).orElse("{}");
String defaultMessage = fieldError.getDefaultMessage();
return String.format("Value %s for field %s was rejected. Error: %s", rejectedValueAsString, fieldName, defaultMessage);
}

@ExceptionHandler(ServerException.class)
protected ResponseEntity<Object> handleServerException(ServerException serverException) {
return new ResponseEntity<>(serverException.getMessage(), Optional.ofNullable(HttpStatus.resolve(serverException.httpCode())).orElse(HttpStatus.INTERNAL_SERVER_ERROR));
@ExceptionHandler({ServerException.class, ForbiddenException.class, UnauthorizedException.class, BadRequestException.class})
protected ResponseEntity<RestError> handleServerException(ServerException serverException) {
return new ResponseEntity<>(new RestError(serverException.getMessage()),
Optional.ofNullable(HttpStatus.resolve(serverException.httpCode())).orElse(HttpStatus.INTERNAL_SERVER_ERROR));
}
}

+ 14
- 1
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/CommonWebConfig.java View File

@@ -22,10 +22,14 @@ package org.sonar.server.v2.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.sonar.server.v2.common.RestResponseEntityExceptionHandler;
import org.springdoc.core.SpringDocConfigProperties;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.http.MediaType;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;

@Configuration
@@ -33,6 +37,10 @@ import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@ComponentScan(basePackages = {"org.springdoc"})
@PropertySource("classpath:springdoc.properties")
public class CommonWebConfig {
@Bean
public LocalValidatorFactoryBean validator() {
return new LocalValidatorFactoryBean();
}

@Bean
public RestResponseEntityExceptionHandler restResponseEntityExceptionHandler() {
@@ -45,9 +53,14 @@ public class CommonWebConfig {
.info(
new Info()
.title("SonarQube Web API")
.version("0.0.1 alpha")
.version("1.0.0 beta")
.description("Documentation of SonarQube Web API")
);
}

@Bean
public BeanFactoryPostProcessor beanFactoryPostProcessor1(SpringDocConfigProperties springDocConfigProperties) {
return beanFactory -> springDocConfigProperties.setDefaultProducesMediaType(MediaType.APPLICATION_JSON_VALUE);
}

}

+ 15
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/config/PlatformLevel4WebConfig.java View File

@@ -20,6 +20,7 @@
package org.sonar.server.v2.config;

import javax.annotation.Nullable;
import org.sonar.server.common.user.service.UserService;
import org.sonar.server.health.CeStatusNodeCheck;
import org.sonar.server.health.DbConnectionNodeCheck;
import org.sonar.server.health.EsStatusNodeCheck;
@@ -30,6 +31,9 @@ import org.sonar.server.platform.ws.LivenessChecker;
import org.sonar.server.platform.ws.LivenessCheckerImpl;
import org.sonar.server.user.SystemPasscode;
import org.sonar.server.user.UserSession;
import org.sonar.server.v2.api.user.controller.DefaultUserController;
import org.sonar.server.v2.api.user.controller.UserController;
import org.sonar.server.v2.api.user.converter.UsersSearchRestResponseGenerator;
import org.sonar.server.v2.controller.DefautLivenessController;
import org.sonar.server.v2.controller.HealthController;
import org.sonar.server.v2.controller.LivenessController;
@@ -57,4 +61,15 @@ public class PlatformLevel4WebConfig {
UserSession userSession) {
return new HealthController(healthChecker, systemPasscode, nodeInformation, userSession);
}

@Bean
public UsersSearchRestResponseGenerator usersSearchResponseGenerator(UserSession userSession) {
return new UsersSearchRestResponseGenerator(userSession);
}

@Bean
public UserController userController(UserSession userSession, UsersSearchRestResponseGenerator usersSearchResponseGenerator, UserService userService) {
return new DefaultUserController(userSession, userService, usersSearchResponseGenerator);
}

}

+ 3
- 2
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/DefautLivenessController.java View File

@@ -20,15 +20,16 @@
package org.sonar.server.v2.controller;

import javax.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.platform.ws.LivenessChecker;
import org.sonar.server.user.SystemPasscode;
import org.sonar.server.user.UserSession;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DefautLivenessController implements LivenessController {

private static final Logger LOGGER = LoggerFactory.getLogger(DefautLivenessController.class);
private final LivenessChecker livenessChecker;
private final UserSession userSession;
private final SystemPasscode systemPasscode;

+ 2
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/controller/LivenessController.java View File

@@ -28,10 +28,12 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import static org.sonar.server.v2.WebApiEndpoints.LIVENESS_ENDPOINT;

@RequestMapping(LIVENESS_ENDPOINT)
@RestController
public interface LivenessController {

@GetMapping

+ 44
- 0
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/model/RestPageTest.java View File

@@ -0,0 +1,44 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.model;

import org.junit.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

public class RestPageTest {

@Test
public void constructor_whenNoValueProvided_setsDefaultValues() {
RestPage restPage = new RestPage(null, null);
assertThat(restPage.pageIndex()).asString().isEqualTo(RestPage.DEFAULT_PAGE_INDEX);
assertThat(restPage.pageSize()).asString().isEqualTo(RestPage.DEFAULT_PAGE_SIZE);
}

@Test
public void constructor_whenValuesProvided_useThem() {
RestPage restPage = new RestPage(10, 100);
assertThat(restPage.pageIndex()).isEqualTo(100);
assertThat(restPage.pageSize()).isEqualTo(10);
}

}

+ 205
- 0
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java View File

@@ -0,0 +1,205 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.v2.api.user.converter;

import java.util.List;
import java.util.Optional;
import org.jetbrains.annotations.Nullable;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import org.sonar.api.utils.DateUtils;
import org.sonar.api.utils.Paging;
import org.sonar.db.user.UserDto;
import org.sonar.server.common.user.service.UserSearchResult;
import org.sonar.server.user.UserSession;
import org.sonar.server.v2.api.response.PageRestResponse;
import org.sonar.server.v2.api.user.model.RestUser;
import org.sonar.server.v2.api.user.response.UsersSearchRestResponse;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

@RunWith(MockitoJUnitRunner.class)
public class UsersSearchRestResponseGeneratorTest {

@Mock
private UserSession userSession;

@InjectMocks
private UsersSearchRestResponseGenerator usersSearchRestResponseGenerator;

@Test
public void toUsersForResponse_whenNoResults_mapsCorrectly() {
Paging paging = Paging.forPageIndex(1).withPageSize(2).andTotal(3);

UsersSearchRestResponse usersForResponse = usersSearchRestResponseGenerator.toUsersForResponse(List.of(), paging);

assertThat(usersForResponse.users()).isEmpty();
assertPaginationInformationAreCorrect(paging, usersForResponse.pageRestResponse());
}

@Test
public void toUsersForResponse_whenAdmin_mapsAllFields() {
when(userSession.isLoggedIn()).thenReturn(true);
when(userSession.isSystemAdministrator()).thenReturn(true);

Paging paging = Paging.forPageIndex(1).withPageSize(2).andTotal(3);

UserSearchResult userSearchResult1 = mockSearchResult(1, true);
UserSearchResult userSearchResult2 = mockSearchResult(2, false);

UsersSearchRestResponse usersForResponse = usersSearchRestResponseGenerator.toUsersForResponse(List.of(userSearchResult1, userSearchResult2), paging);

RestUser expectUser1 = buildExpectedResponseForAdmin(userSearchResult1);
RestUser expectUser2 = buildExpectedResponseForAdmin(userSearchResult2);
assertThat(usersForResponse.users()).containsExactly(expectUser1, expectUser2);
assertPaginationInformationAreCorrect(paging, usersForResponse.pageRestResponse());
}

private static RestUser buildExpectedResponseForAdmin(UserSearchResult userSearchResult) {
UserDto userDto = userSearchResult.userDto();
return new RestUser(
userDto.getLogin(),
userDto.getLogin(),
userDto.getName(),
userDto.getEmail(),
userDto.isActive(),
userDto.isLocal(),
userSearchResult.managed(),
userDto.getExternalLogin(),
userDto.getExternalIdentityProvider(),
userSearchResult.avatar().orElse(null),
toDateTime(userDto.getLastConnectionDate()),
toDateTime(userDto.getLastSonarlintConnectionDate()),
userSearchResult.groups().size(),
userSearchResult.tokensCount()
);
}

@Test
public void toUsersForResponse_whenNonAdmin_mapsNonAdminFields() {
when(userSession.isLoggedIn()).thenReturn(true);

Paging paging = Paging.forPageIndex(1).withPageSize(2).andTotal(3);

UserSearchResult userSearchResult1 = mockSearchResult(1, true);
UserSearchResult userSearchResult2 = mockSearchResult(2, false);

UsersSearchRestResponse usersForResponse = usersSearchRestResponseGenerator.toUsersForResponse(List.of(userSearchResult1, userSearchResult2), paging);

RestUser expectUser1 = buildExpectedResponseForUser(userSearchResult1);
RestUser expectUser2 = buildExpectedResponseForUser(userSearchResult2);
assertThat(usersForResponse.users()).containsExactly(expectUser1, expectUser2);
assertPaginationInformationAreCorrect(paging, usersForResponse.pageRestResponse());
}

private static RestUser buildExpectedResponseForUser(UserSearchResult userSearchResult) {
UserDto userDto = userSearchResult.userDto();
return new RestUser(
userDto.getLogin(),
userDto.getLogin(),
userDto.getName(),
userDto.getEmail(),
userDto.isActive(),
userDto.isLocal(),
null,
null,
userDto.getExternalIdentityProvider(),
userSearchResult.avatar().orElse(null),
null,
null,
null,
null
);
}

@Test
public void toUsersForResponse_whenAnonymous_returnsOnlyNameAndLogin() {
Paging paging = Paging.forPageIndex(1).withPageSize(2).andTotal(3);

UserSearchResult userSearchResult1 = mockSearchResult(1, true);
UserSearchResult userSearchResult2 = mockSearchResult(2, false);

UsersSearchRestResponse usersForResponse = usersSearchRestResponseGenerator.toUsersForResponse(List.of(userSearchResult1, userSearchResult2), paging);

RestUser expectUser1 = buildExpectedResponseForAnonymous(userSearchResult1);
RestUser expectUser2 = buildExpectedResponseForAnonymous(userSearchResult2);
assertThat(usersForResponse.users()).containsExactly(expectUser1, expectUser2);
assertPaginationInformationAreCorrect(paging, usersForResponse.pageRestResponse());
}

private static RestUser buildExpectedResponseForAnonymous(UserSearchResult userSearchResult) {
UserDto userDto = userSearchResult.userDto();
return new RestUser(
userDto.getLogin(),
userDto.getLogin(),
userDto.getName(),
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
);
}

private static String toDateTime(@Nullable Long dateTimeMs) {
return Optional.ofNullable(dateTimeMs).map(DateUtils::formatDateTime).orElse(null);
}

private static UserSearchResult mockSearchResult(int i, boolean booleanFlagsValue) {
UserSearchResult userSearchResult = mock(UserSearchResult.class, RETURNS_DEEP_STUBS);
UserDto user1 = new UserDto()
.setUuid("uuid_" + i)
.setLogin("login_" + i)
.setName("name_" + i)
.setEmail("email@" + i)
.setExternalId("externalId" + i)
.setExternalLogin("externalLogin" + 1)
.setExternalIdentityProvider("exernalIdp_" + i)
.setLastConnectionDate(100L + i)
.setLastSonarlintConnectionDate(200L + i)
.setLocal(booleanFlagsValue)
.setActive(booleanFlagsValue);

when(userSearchResult.userDto()).thenReturn(user1);
when(userSearchResult.managed()).thenReturn(booleanFlagsValue);
when(userSearchResult.tokensCount()).thenReturn(i);
when(userSearchResult.groups().size()).thenReturn(i * 100);
return userSearchResult;
}

private static void assertPaginationInformationAreCorrect(Paging paging, PageRestResponse pageRestResponse) {
assertThat(pageRestResponse.pageIndex()).isEqualTo(paging.pageIndex());
assertThat(pageRestResponse.pageSize()).isEqualTo(paging.pageSize());
assertThat(pageRestResponse.total()).isEqualTo(paging.total());
}

}

+ 1
- 0
server/sonar-webserver-webapi/build.gradle View File

@@ -18,6 +18,7 @@ dependencies {
api project(':server:sonar-db-dao')
api project(':server:sonar-process')
api project(':server:sonar-webserver-auth')
api project(':server:sonar-webserver-common')
api project(':server:sonar-webserver-es')
api project(':server:sonar-webserver-ws')
api project(':server:sonar-webserver-pushapi')

+ 2
- 2
server/sonar-webserver-webapi/src/it/java/org/sonar/server/hotspot/ws/ShowActionIT.java View File

@@ -67,8 +67,8 @@ import org.sonar.db.user.UserTesting;
import org.sonar.server.es.EsTester;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.NotFoundException;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.issue.IssueChangeWSSupport;
import org.sonar.server.issue.IssueChangeWSSupport.FormattingContext;
import org.sonar.server.issue.IssueChangeWSSupport.Load;

+ 1
- 0
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/IssueChangeWSSupportIT.java View File

@@ -48,6 +48,7 @@ import org.sonar.db.issue.IssueDto;
import org.sonar.db.issue.IssueTesting;
import org.sonar.db.user.UserDto;
import org.sonar.markdown.Markdown;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.issue.IssueChangeWSSupport.FormattingContext;
import org.sonar.server.issue.IssueChangeWSSupport.Load;
import org.sonar.server.tester.UserSessionRule;

+ 1
- 1
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ChangelogActionIT.java View File

@@ -36,7 +36,7 @@ import org.sonar.db.user.UserDto;
import org.sonar.db.user.UserTesting;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.NotFoundException;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.issue.IssueChangeWSSupport;
import org.sonar.server.issue.IssueFinder;
import org.sonar.server.tester.UserSessionRule;

+ 1
- 1
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/ListActionIT.java View File

@@ -45,10 +45,10 @@ import org.sonar.db.metric.MetricDto;
import org.sonar.db.protobuf.DbIssues;
import org.sonar.db.rule.RuleDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.component.ComponentFinder;
import org.sonar.server.component.TestComponentFinder;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.issue.IssueFieldsSetter;
import org.sonar.server.issue.NewCodePeriodResolver;
import org.sonar.server.issue.TextRangeResponseFormatter;

+ 1
- 1
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionComponentsIT.java View File

@@ -38,7 +38,7 @@ import org.sonar.db.issue.IssueDto;
import org.sonar.db.project.ProjectDto;
import org.sonar.db.rule.RuleDto;
import org.sonar.server.es.EsTester;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.issue.IssueFieldsSetter;
import org.sonar.server.issue.TextRangeResponseFormatter;
import org.sonar.server.issue.TransitionService;

+ 1
- 1
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionFacetsIT.java View File

@@ -37,7 +37,7 @@ import org.sonar.db.component.ComponentDto;
import org.sonar.db.rule.RuleDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.es.EsTester;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.issue.TextRangeResponseFormatter;
import org.sonar.server.issue.TransitionService;
import org.sonar.server.issue.index.IssueIndex;

+ 1
- 1
server/sonar-webserver-webapi/src/it/java/org/sonar/server/issue/ws/SearchActionIT.java View File

@@ -62,7 +62,7 @@ import org.sonar.db.rule.RuleTesting;
import org.sonar.db.user.UserDto;
import org.sonar.server.es.EsTester;
import org.sonar.server.es.SearchOptions;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.issue.IssueFieldsSetter;
import org.sonar.server.issue.TextRangeResponseFormatter;
import org.sonar.server.issue.TransitionService;

+ 1
- 1
server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/UsersActionIT.java View File

@@ -35,7 +35,7 @@ import org.sonar.server.exceptions.BadRequestException;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.NotFoundException;
import org.sonar.server.exceptions.UnauthorizedException;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.management.ManagedInstanceService;
import org.sonar.server.permission.PermissionService;
import org.sonar.server.permission.PermissionServiceImpl;

+ 1
- 1
server/sonar-webserver-webapi/src/it/java/org/sonar/server/permission/ws/template/TemplateUsersActionIT.java View File

@@ -35,7 +35,7 @@ import org.sonar.server.exceptions.BadRequestException;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.NotFoundException;
import org.sonar.server.exceptions.UnauthorizedException;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.permission.PermissionService;
import org.sonar.server.permission.PermissionServiceImpl;
import org.sonar.server.permission.RequestValidator;

+ 2
- 2
server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualitygate/ws/SearchUsersActionIT.java View File

@@ -28,8 +28,8 @@ import org.sonar.db.user.UserDto;
import org.sonar.server.component.TestComponentFinder;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.NotFoundException;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.issue.FakeAvatarResolver;
import org.sonar.server.tester.UserSessionRule;
import org.sonar.server.ws.TestRequest;

+ 2
- 2
server/sonar-webserver-webapi/src/it/java/org/sonar/server/qualityprofile/ws/SearchUsersActionIT.java View File

@@ -29,8 +29,8 @@ import org.sonar.db.qualityprofile.QProfileDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.exceptions.ForbiddenException;
import org.sonar.server.exceptions.NotFoundException;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.issue.FakeAvatarResolver;
import org.sonar.server.language.LanguageTesting;
import org.sonar.server.tester.UserSessionRule;

+ 1
- 1
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionHomepageIT.java View File

@@ -39,7 +39,7 @@ import org.sonar.db.DbTester;
import org.sonar.db.component.ComponentDto;
import org.sonar.db.component.ProjectData;
import org.sonar.db.user.UserDto;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.permission.PermissionService;
import org.sonar.server.permission.PermissionServiceImpl;
import org.sonar.server.tester.UserSessionRule;

+ 1
- 1
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/CurrentActionIT.java View File

@@ -34,7 +34,7 @@ import org.sonar.db.DbTester;
import org.sonar.db.component.ComponentDto;
import org.sonar.db.property.PropertyDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.permission.PermissionService;
import org.sonar.server.permission.PermissionServiceImpl;
import org.sonar.server.tester.UserSessionRule;

+ 9
- 4
server/sonar-webserver-webapi/src/it/java/org/sonar/server/user/ws/SearchActionIT.java View File

@@ -36,9 +36,10 @@ import org.sonar.db.DbTester;
import org.sonar.db.scim.ScimUserDao;
import org.sonar.db.user.GroupDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.common.user.service.UserService;
import org.sonar.server.exceptions.BadRequestException;
import org.sonar.server.exceptions.ServerException;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.management.ManagedInstanceService;
import org.sonar.server.tester.UserSessionRule;
import org.sonar.server.ws.TestRequest;
@@ -71,8 +72,13 @@ public class SearchActionIT {
@Rule
public DbTester db = DbTester.create();

private ManagedInstanceService managedInstanceService = mock(ManagedInstanceService.class);
private WsActionTester ws = new WsActionTester(new SearchAction(userSession, db.getDbClient(), new AvatarResolverImpl(), managedInstanceService));
private final ManagedInstanceService managedInstanceService = mock(ManagedInstanceService.class);

private final UserService userService = new UserService(db.getDbClient(), new AvatarResolverImpl(), managedInstanceService);

private final SearchWsReponseGenerator searchWsReponseGenerator = new SearchWsReponseGenerator(userSession);

private final WsActionTester ws = new WsActionTester(new SearchAction(userSession, userService, searchWsReponseGenerator));

@Test
public void search_for_all_active_users() {
@@ -529,7 +535,6 @@ public class SearchActionIT {
assertUserWithFilter(SearchAction.SONAR_LINT_LAST_CONNECTION_DATE_TO, lastConnection, user.getLogin(), true);
}


@Test
public void search_whenNoLastConnection_shouldReturnForBeforeOnly() {
userSession.logIn().setSystemAdministrator();

+ 1
- 0
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/IssueChangeWSSupport.java View File

@@ -47,6 +47,7 @@ import org.sonar.db.issue.IssueChangeDto;
import org.sonar.db.issue.IssueDto;
import org.sonar.db.user.UserDto;
import org.sonar.markdown.Markdown;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonar.server.user.UserSession;
import org.sonarqube.ws.Common;


+ 1
- 1
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/IssueWsModule.java View File

@@ -20,7 +20,7 @@
package org.sonar.server.issue.ws;

import org.sonar.core.platform.Module;
import org.sonar.server.issue.AvatarResolverImpl;
import org.sonar.server.common.avatar.AvatarResolverImpl;
import org.sonar.server.issue.IssueChangeWSSupport;
import org.sonar.server.issue.IssueFieldsSetter;
import org.sonar.server.issue.IssueFinder;

+ 1
- 1
server/sonar-webserver-webapi/src/main/java/org/sonar/server/issue/ws/UserResponseFormatter.java View File

@@ -20,7 +20,7 @@
package org.sonar.server.issue.ws;

import org.sonar.db.user.UserDto;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonarqube.ws.Common;

import static com.google.common.base.Strings.emptyToNull;

+ 1
- 1
server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/UsersAction.java View File

@@ -38,7 +38,7 @@ import org.sonar.db.entity.EntityDto;
import org.sonar.db.permission.PermissionQuery;
import org.sonar.db.permission.UserPermissionDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonar.server.management.ManagedInstanceService;
import org.sonar.server.permission.RequestValidator;
import org.sonar.server.user.UserSession;

+ 1
- 1
server/sonar-webserver-webapi/src/main/java/org/sonar/server/permission/ws/template/TemplateUsersAction.java View File

@@ -34,7 +34,7 @@ import org.sonar.db.permission.PermissionQuery;
import org.sonar.db.permission.template.PermissionTemplateDto;
import org.sonar.db.permission.template.PermissionTemplateUserDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonar.server.permission.RequestValidator;
import org.sonar.server.permission.ws.PermissionWsSupport;
import org.sonar.server.permission.ws.PermissionsWsAction;

+ 1
- 1
server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualitygate/ws/SearchUsersAction.java View File

@@ -33,7 +33,7 @@ import org.sonar.db.qualitygate.QualityGateDto;
import org.sonar.db.user.SearchPermissionQuery;
import org.sonar.db.user.SearchUserMembershipDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonarqube.ws.Common;
import org.sonarqube.ws.Qualitygates.SearchUsersResponse;


+ 1
- 1
server/sonar-webserver-webapi/src/main/java/org/sonar/server/qualityprofile/ws/SearchUsersAction.java View File

@@ -37,7 +37,7 @@ import org.sonar.db.qualityprofile.QProfileDto;
import org.sonar.db.qualityprofile.SearchQualityProfilePermissionQuery;
import org.sonar.db.user.SearchUserMembershipDto;
import org.sonar.db.user.UserDto;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonarqube.ws.Common;
import org.sonarqube.ws.Qualityprofiles.SearchUsersResponse;


+ 1
- 1
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/CurrentAction.java View File

@@ -36,7 +36,7 @@ import org.sonar.db.permission.GlobalPermission;
import org.sonar.db.project.ProjectDto;
import org.sonar.db.property.PropertyQuery;
import org.sonar.db.user.UserDto;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.common.avatar.AvatarResolver;
import org.sonar.server.permission.PermissionService;
import org.sonar.server.user.UserSession;
import org.sonarqube.ws.Users.CurrentWsResponse;

+ 29
- 265
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchAction.java View File

@@ -19,74 +19,47 @@
*/
package org.sonar.server.user.ws;

import com.google.common.collect.Multimap;
import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import org.sonar.api.server.ws.Change;
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.utils.DateUtils;
import org.sonar.api.utils.MessageException;
import org.sonar.api.utils.Paging;
import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.user.UserDto;
import org.sonar.db.user.UserQuery;
import org.sonar.server.common.SearchResults;
import org.sonar.server.common.user.service.UserSearchResult;
import org.sonar.server.common.user.service.UserService;
import org.sonar.server.common.user.service.UsersSearchRequest;
import org.sonar.server.es.SearchOptions;
import org.sonar.server.exceptions.BadRequestException;
import org.sonar.server.exceptions.ServerException;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.management.ManagedInstanceService;
import org.sonar.server.user.UserSession;
import org.sonarqube.ws.Users;
import org.sonarqube.ws.Users.SearchWsResponse;

import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Strings.emptyToNull;
import static java.lang.Boolean.TRUE;
import static java.util.Comparator.comparing;
import static java.util.Optional.ofNullable;
import static org.sonar.api.server.ws.WebService.Param.PAGE;
import static org.sonar.api.server.ws.WebService.Param.PAGE_SIZE;
import static org.sonar.api.server.ws.WebService.Param.TEXT_QUERY;
import static org.sonar.api.utils.Paging.forPageIndex;
import static org.sonar.server.ws.WsUtils.writeProtobuf;
import static org.sonarqube.ws.Users.SearchWsResponse.Groups;
import static org.sonarqube.ws.Users.SearchWsResponse.ScmAccounts;
import static org.sonarqube.ws.Users.SearchWsResponse.User;
import static org.sonarqube.ws.Users.SearchWsResponse.newBuilder;

public class SearchAction implements UsersWsAction {
private static final String DEACTIVATED_PARAM = "deactivated";
private static final String MANAGED_PARAM = "managed";


private static final int MAX_PAGE_SIZE = 500;
static final String LAST_CONNECTION_DATE_FROM = "lastConnectedAfter";
static final String LAST_CONNECTION_DATE_TO = "lastConnectedBefore";
static final String SONAR_LINT_LAST_CONNECTION_DATE_FROM = "slLastConnectedAfter";
static final String SONAR_LINT_LAST_CONNECTION_DATE_TO = "slLastConnectedBefore";
private final UserSession userSession;
private final DbClient dbClient;
private final AvatarResolver avatarResolver;
private final ManagedInstanceService managedInstanceService;

public SearchAction(UserSession userSession, DbClient dbClient, AvatarResolver avatarResolver,
ManagedInstanceService managedInstanceService) {
private final UserService userService;
private final SearchWsReponseGenerator searchWsReponseGenerator;

public SearchAction(UserSession userSession,
UserService userService, SearchWsReponseGenerator searchWsReponseGenerator) {
this.userSession = userSession;
this.dbClient = dbClient;
this.avatarResolver = avatarResolver;
this.managedInstanceService = managedInstanceService;
this.userService = userService;
this.searchWsReponseGenerator = searchWsReponseGenerator;
}

@Override
@@ -182,113 +155,31 @@ public class SearchAction implements UsersWsAction {

@Override
public void handle(Request request, Response response) throws Exception {
throwIfAdminOnlyParametersAreUsed(request);
Users.SearchWsResponse wsResponse = doHandle(toSearchRequest(request));
writeProtobuf(wsResponse, request, response);
}

private Users.SearchWsResponse doHandle(SearchRequest request) {
UserQuery userQuery = buildUserQuery(request);
try (DbSession dbSession = dbClient.openSession(false)) {
List<UserDto> users = findUsersAndSortByLogin(request, dbSession, userQuery);
int totalUsers = dbClient.userDao().countUsers(dbSession, userQuery);

List<String> logins = users.stream().map(UserDto::getLogin).toList();
Multimap<String, String> groupsByLogin = dbClient.groupMembershipDao().selectGroupsByLogins(dbSession, logins);
Map<String, Integer> tokenCountsByLogin = dbClient.userTokenDao().countTokensByUsers(dbSession, users);
Map<String, Boolean> userUuidToIsManaged = managedInstanceService.getUserUuidToManaged(dbSession, getUserUuids(users));
Paging paging = forPageIndex(request.getPage()).withPageSize(request.getPageSize()).andTotal(totalUsers);
return buildResponse(users, groupsByLogin, tokenCountsByLogin, userUuidToIsManaged, paging);
}
}

private static Set<String> getUserUuids(List<UserDto> users) {
return users.stream().map(UserDto::getUuid).collect(Collectors.toSet());
}

private UserQuery buildUserQuery(SearchRequest request) {
UserQuery.UserQueryBuilder builder = UserQuery.builder();
if(!userSession.isSystemAdministrator()) {
request.getLastConnectionDateFrom().ifPresent(v -> throwForbiddenFor(LAST_CONNECTION_DATE_FROM));
request.getLastConnectionDateTo().ifPresent(v -> throwForbiddenFor(LAST_CONNECTION_DATE_TO));
request.getSonarLintLastConnectionDateFrom().ifPresent(v -> throwForbiddenFor(SONAR_LINT_LAST_CONNECTION_DATE_FROM));
request.getSonarLintLastConnectionDateTo().ifPresent(v -> throwForbiddenFor(SONAR_LINT_LAST_CONNECTION_DATE_TO));
}
request.getLastConnectionDateFrom().ifPresent(builder::lastConnectionDateFrom);
request.getLastConnectionDateTo().ifPresent(builder::lastConnectionDateTo);
request.getSonarLintLastConnectionDateFrom().ifPresent(builder::sonarLintLastConnectionDateFrom);
request.getSonarLintLastConnectionDateTo().ifPresent(builder::sonarLintLastConnectionDateTo);

if (managedInstanceService.isInstanceExternallyManaged()) {
String managedInstanceSql = Optional.ofNullable(request.isManaged())
.map(managedInstanceService::getManagedUsersSqlFilter)
.orElse(null);
builder.isManagedClause(managedInstanceSql);
} else if (request.isManaged() != null) {
throw BadRequestException.create("The 'managed' parameter is only available for managed instances.");
private void throwIfAdminOnlyParametersAreUsed(Request request) {
if (!userSession.isSystemAdministrator()) {
throwIfParameterValuePresent(request, LAST_CONNECTION_DATE_FROM);
throwIfParameterValuePresent(request, LAST_CONNECTION_DATE_TO);
throwIfParameterValuePresent(request, SONAR_LINT_LAST_CONNECTION_DATE_FROM);
throwIfParameterValuePresent(request, SONAR_LINT_LAST_CONNECTION_DATE_TO);
}

return builder
.isActive(!request.isDeactivated())
.searchText(request.getQuery())
.build();
}

private static void throwForbiddenFor(String parameterName) {
throw new ServerException(403, "parameter " + parameterName + " requires Administer System permission.");
}

private List<UserDto> findUsersAndSortByLogin(SearchRequest request, DbSession dbSession, UserQuery userQuery) {
return dbClient.userDao().selectUsers(dbSession, userQuery, request.getPage(), request.getPageSize())
.stream()
.sorted(comparing(UserDto::getLogin))
.toList();
}
private Users.SearchWsResponse doHandle(UsersSearchRequest request) {
SearchResults<UserSearchResult> userSearchResults = userService.findUsers(request);
Paging paging = forPageIndex(request.getPage()).withPageSize(request.getPageSize()).andTotal(userSearchResults.total());

private SearchWsResponse buildResponse(List<UserDto> users, Multimap<String, String> groupsByLogin, Map<String, Integer> tokenCountsByLogin,
Map<String, Boolean> userUuidToIsManaged, Paging paging) {
SearchWsResponse.Builder responseBuilder = newBuilder();
users.forEach(user -> responseBuilder.addUsers(
towsUser(user, firstNonNull(tokenCountsByLogin.get(user.getUuid()), 0), groupsByLogin.get(user.getLogin()), userUuidToIsManaged.get(user.getUuid()))
));
responseBuilder.getPagingBuilder()
.setPageIndex(paging.pageIndex())
.setPageSize(paging.pageSize())
.setTotal(paging.total())
.build();
return responseBuilder.build();
return searchWsReponseGenerator.toUsersForResponse(userSearchResults.searchResults(), paging);
}

private User towsUser(UserDto user, @Nullable Integer tokensCount, Collection<String> groups, Boolean managed) {
User.Builder userBuilder = User.newBuilder().setLogin(user.getLogin());
ofNullable(user.getName()).ifPresent(userBuilder::setName);
if (userSession.isLoggedIn()) {
ofNullable(emptyToNull(user.getEmail())).ifPresent(u -> userBuilder.setAvatar(avatarResolver.create(user)));
userBuilder.setActive(user.isActive());
userBuilder.setLocal(user.isLocal());
ofNullable(user.getExternalIdentityProvider()).ifPresent(userBuilder::setExternalProvider);
if (!user.getSortedScmAccounts().isEmpty()) {
userBuilder.setScmAccounts(ScmAccounts.newBuilder().addAllScmAccounts(user.getSortedScmAccounts()));
}
}
if (userSession.isSystemAdministrator() || Objects.equals(userSession.getUuid(), user.getUuid())) {
ofNullable(user.getEmail()).ifPresent(userBuilder::setEmail);
if (!groups.isEmpty()) {
userBuilder.setGroups(Groups.newBuilder().addAllGroups(groups));
}
ofNullable(user.getExternalLogin()).ifPresent(userBuilder::setExternalIdentity);
ofNullable(tokensCount).ifPresent(userBuilder::setTokensCount);
ofNullable(user.getLastConnectionDate()).map(DateUtils::formatDateTime).ifPresent(userBuilder::setLastConnectionDate);
ofNullable(user.getLastSonarlintConnectionDate())
.map(DateUtils::formatDateTime).ifPresent(userBuilder::setSonarLintLastConnectionDate);
userBuilder.setManaged(TRUE.equals(managed));
}
return userBuilder.build();
}

private static SearchRequest toSearchRequest(Request request) {
private UsersSearchRequest toSearchRequest(Request request) {
int pageSize = request.mandatoryParamAsInt(PAGE_SIZE);
checkArgument(pageSize <= MAX_PAGE_SIZE, "The '%s' parameter must be less than %s", PAGE_SIZE, MAX_PAGE_SIZE);
return SearchRequest.builder()
return UsersSearchRequest.builder()
.setQuery(request.param(TEXT_QUERY))
.setDeactivated(request.mandatoryParamAsBoolean(DEACTIVATED_PARAM))
.setManaged(request.paramAsBoolean(MANAGED_PARAM))
@@ -301,139 +192,12 @@ public class SearchAction implements UsersWsAction {
.build();
}

private static class SearchRequest {
private final Integer page;
private final Integer pageSize;
private final String query;
private final boolean deactivated;
private final Boolean managed;
private final OffsetDateTime lastConnectionDateFrom;
private final OffsetDateTime lastConnectionDateTo;
private final OffsetDateTime sonarLintLastConnectionDateFrom;
private final OffsetDateTime sonarLintLastConnectionDateTo;

private SearchRequest(Builder builder) {
this.page = builder.page;
this.pageSize = builder.pageSize;
this.query = builder.query;
this.deactivated = builder.deactivated;
this.managed = builder.managed;
try {
this.lastConnectionDateFrom = Optional.ofNullable(builder.lastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null);
this.lastConnectionDateTo = Optional.ofNullable(builder.lastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null);
this.sonarLintLastConnectionDateFrom = Optional.ofNullable(builder.sonarLintLastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null);
this.sonarLintLastConnectionDateTo = Optional.ofNullable(builder.sonarLintLastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null);
} catch (MessageException me) {
throw new ServerException(400, me.getMessage());
}
}

public Integer getPage() {
return page;
}

public Integer getPageSize() {
return pageSize;
}

@CheckForNull
public String getQuery() {
return query;
}

public boolean isDeactivated() {
return deactivated;
}

@CheckForNull
private Boolean isManaged() {
return managed;
}

public Optional<OffsetDateTime> getLastConnectionDateFrom() {
return Optional.ofNullable(lastConnectionDateFrom);
}

public Optional<OffsetDateTime> getLastConnectionDateTo() {
return Optional.ofNullable(lastConnectionDateTo);
}

public Optional<OffsetDateTime> getSonarLintLastConnectionDateFrom() {
return Optional.ofNullable(sonarLintLastConnectionDateFrom);
}

public Optional<OffsetDateTime> getSonarLintLastConnectionDateTo() {
return Optional.ofNullable(sonarLintLastConnectionDateTo);
}

public static Builder builder() {
return new Builder();
}
private static void throwIfParameterValuePresent(Request request, String parameter) {
Optional.ofNullable(request.param(parameter)).ifPresent(v -> throwForbiddenFor(parameter));
}

private static class Builder {
private Integer page;
private Integer pageSize;
private String query;
private boolean deactivated;
private Boolean managed;
private String lastConnectionDateFrom;
private String lastConnectionDateTo;
private String sonarLintLastConnectionDateFrom;
private String sonarLintLastConnectionDateTo;


private Builder() {
// enforce factory method use
}

public Builder setPage(Integer page) {
this.page = page;
return this;
}

public Builder setPageSize(Integer pageSize) {
this.pageSize = pageSize;
return this;
}

public Builder setQuery(@Nullable String query) {
this.query = query;
return this;
}

public Builder setDeactivated(boolean deactivated) {
this.deactivated = deactivated;
return this;
}

public Builder setManaged(@Nullable Boolean managed) {
this.managed = managed;
return this;
}

public Builder setLastConnectionDateFrom(@Nullable String lastConnectionDateFrom) {
this.lastConnectionDateFrom = lastConnectionDateFrom;
return this;
}

public Builder setLastConnectionDateTo(@Nullable String lastConnectionDateTo) {
this.lastConnectionDateTo = lastConnectionDateTo;
return this;
}

public Builder setSonarLintLastConnectionDateFrom(@Nullable String sonarLintLastConnectionDateFrom) {
this.sonarLintLastConnectionDateFrom = sonarLintLastConnectionDateFrom;
return this;
}

public Builder setSonarLintLastConnectionDateTo(@Nullable String sonarLintLastConnectionDateTo) {
this.sonarLintLastConnectionDateTo = sonarLintLastConnectionDateTo;
return this;
}

public SearchRequest build() {
return new SearchRequest(this);
}
private static void throwForbiddenFor(String parameterName) {
throw new ServerException(403, "parameter " + parameterName + " requires Administer System permission.");
}

}

+ 83
- 0
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/SearchWsReponseGenerator.java View File

@@ -0,0 +1,83 @@
/*
* SonarQube
* Copyright (C) 2009-2023 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.ws;

import java.util.List;
import java.util.Objects;
import org.sonar.api.utils.DateUtils;
import org.sonar.api.utils.Paging;
import org.sonar.db.user.UserDto;
import org.sonar.server.common.user.UsersSearchResponseGenerator;
import org.sonar.server.common.user.service.UserSearchResult;
import org.sonar.server.user.UserSession;
import org.sonarqube.ws.Users;

import static java.lang.Boolean.TRUE;
import static java.util.Optional.ofNullable;
import static org.sonarqube.ws.Users.SearchWsResponse.newBuilder;

public class SearchWsReponseGenerator implements UsersSearchResponseGenerator<Users.SearchWsResponse> {

private final UserSession userSession;

public SearchWsReponseGenerator(UserSession userSession) {
this.userSession = userSession;
}

@Override
public Users.SearchWsResponse toUsersForResponse(List<UserSearchResult> userSearchResults, Paging paging) {
Users.SearchWsResponse.Builder responseBuilder = newBuilder();
userSearchResults.forEach(user -> responseBuilder.addUsers(toSearchResponsUser(user)));
responseBuilder.getPagingBuilder()
.setPageIndex(paging.pageIndex())
.setPageSize(paging.pageSize())
.setTotal(paging.total())
.build();
return responseBuilder.build();
}

private Users.SearchWsResponse.User toSearchResponsUser(UserSearchResult userSearchResult) {
UserDto userDto = userSearchResult.userDto();
Users.SearchWsResponse.User.Builder userBuilder = Users.SearchWsResponse.User.newBuilder().setLogin(userDto.getLogin());
ofNullable(userDto.getName()).ifPresent(userBuilder::setName);
if (userSession.isLoggedIn()) {
userSearchResult.avatar().ifPresent(userBuilder::setAvatar);
userBuilder.setActive(userDto.isActive());
userBuilder.setLocal(userDto.isLocal());
ofNullable(userDto.getExternalIdentityProvider()).ifPresent(userBuilder::setExternalProvider);
if (!userDto.getSortedScmAccounts().isEmpty()) {
userBuilder.setScmAccounts(Users.SearchWsResponse.ScmAccounts.newBuilder().addAllScmAccounts(userDto.getSortedScmAccounts()));
}
}
if (userSession.isSystemAdministrator() || Objects.equals(userSession.getUuid(), userDto.getUuid())) {
ofNullable(userDto.getEmail()).ifPresent(userBuilder::setEmail);
if (!userSearchResult.groups().isEmpty()) {
userBuilder.setGroups(Users.SearchWsResponse.Groups.newBuilder().addAllGroups(userSearchResult.groups()));
}
ofNullable(userDto.getExternalLogin()).ifPresent(userBuilder::setExternalIdentity);
userBuilder.setTokensCount(userSearchResult.tokensCount());
ofNullable(userDto.getLastConnectionDate()).map(DateUtils::formatDateTime).ifPresent(userBuilder::setLastConnectionDate);
ofNullable(userDto.getLastSonarlintConnectionDate())
.map(DateUtils::formatDateTime).ifPresent(userBuilder::setSonarLintLastConnectionDate);
userBuilder.setManaged(TRUE.equals(userSearchResult.managed()));
}
return userBuilder.build();
}
}

+ 3
- 0
server/sonar-webserver-webapi/src/main/java/org/sonar/server/user/ws/UsersWsModule.java View File

@@ -20,6 +20,7 @@
package org.sonar.server.user.ws;

import org.sonar.core.platform.Module;
import org.sonar.server.common.user.service.UserService;

public class UsersWsModule extends Module {

@@ -36,6 +37,8 @@ public class UsersWsModule extends Module {
ChangePasswordAction.class,
CurrentAction.class,
SearchAction.class,
UserService.class,
SearchWsReponseGenerator.class,
GroupsAction.class,
IdentityProvidersAction.class,
UserJsonWriter.class,

+ 1
- 0
server/sonar-webserver-webapi/src/test/java/org/sonar/server/issue/FakeAvatarResolver.java View File

@@ -20,6 +20,7 @@
package org.sonar.server.issue;

import org.sonar.db.user.UserDto;
import org.sonar.server.common.avatar.AvatarResolver;

public class FakeAvatarResolver implements AvatarResolver {


+ 1
- 0
settings.gradle View File

@@ -36,6 +36,7 @@ include 'server:sonar-web:design-system'
include 'server:sonar-webserver'
include 'server:sonar-webserver-api'
include 'server:sonar-webserver-auth'
include 'server:sonar-webserver-common'
include 'server:sonar-webserver-core'
include 'server:sonar-webserver-es'
include 'server:sonar-webserver-webapi'

Loading…
Cancel
Save