From d39567bb31c3b30db8e1d6be4a3b1b5de5de4eec Mon Sep 17 00:00:00 2001 From: Wojtek Wajerowicz <115081248+wojciech-wajerowicz-sonarsource@users.noreply.github.com> Date: Wed, 26 Jul 2023 16:43:50 +0200 Subject: [PATCH] SONAR-19964 GET /api/v2/users/:login endpoint --- .../common/user/service/UserServiceIT.java | 30 ++++++++++ .../common/user/service/UserService.java | 28 +++++++--- .../controller/DefaultUserController.java | 6 ++ .../api/user/controller/UserController.java | 17 ++++++ .../UsersSearchRestResponseGenerator.java | 10 ++-- .../server/v2/api/user/model/RestUser.java | 5 +- .../controller/DefaultUserControllerTest.java | 55 ++++++++++++++----- .../UsersSearchRestResponseGeneratorTest.java | 5 +- 8 files changed, 128 insertions(+), 28 deletions(-) diff --git a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java index 6b4c5d34d77..3a42305da8c 100644 --- a/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java +++ b/server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java @@ -21,6 +21,7 @@ package org.sonar.server.common.user.service; import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.Collection; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -421,6 +422,35 @@ public class UserServiceIT { verify(userDeactivator, never()).deactivateUser(any(), eq(user.getLogin())); } + @Test + public void fetchUser_whenUserDoesntExist_shouldThrowNotFoundException() { + assertThatThrownBy(() -> userService.fetchUser("login")) + .isInstanceOf(NotFoundException.class) + .hasMessage("User 'login' not found"); + } + + @Test + public void fetchUser_whenUserExists_shouldReturnUser() { + UserDto user = db.users().insertUser(); + GroupDto group1 = db.users().insertGroup("group1"); + GroupDto group2 = db.users().insertGroup("group2"); + db.users().insertMember(group1, user); + db.users().insertMember(group2, user); + + db.users().insertToken(user); + db.users().insertToken(user); + + when(managedInstanceService.isUserManaged(any(), eq(user.getUuid()))).thenReturn(false); + + UserSearchResult result = userService.fetchUser(user.getLogin()); + UserDto resultUser = result.userDto(); + Collection resultGroups = result.groups(); + + assertThat(resultUser).usingRecursiveComparison().isEqualTo(user); + assertThat(resultGroups).containsExactlyInAnyOrder(group1.getName(), group2.getName()); + assertThat(result.managed()).isFalse(); + } + private void assertUserWithFilter(Function query, String userLogin, boolean isExpectedToBeThere) { UsersSearchRequest.Builder builder = getBuilderWithDefaultsPageSize(); diff --git a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java index 744e41f7df1..69fabcc4c9e 100644 --- a/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java +++ b/server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java @@ -108,15 +108,6 @@ public class UserService { .toList(); } - private UserSearchResult toUserSearchResult(Collection groups, int tokenCount, boolean managed, UserDto userDto) { - return new UserSearchResult( - userDto, - managed, - findAvatar(userDto), - groups, - tokenCount); - } - private List findUsersAndSortByLogin(DbSession dbSession, UserQuery userQuery, int page, int pageSize) { return dbClient.userDao().selectUsers(dbSession, userQuery, page, pageSize) .stream() @@ -146,4 +137,23 @@ public class UserService { return deactivatedUser; } } + + public UserSearchResult fetchUser(String login) { + try (DbSession dbSession = dbClient.openSession(false)) { + UserDto userDto = checkFound(dbClient.userDao().selectByLogin(dbSession, login), "User '%s' not found", login); + Collection groups = dbClient.groupMembershipDao().selectGroupsByLogins(dbSession, Set.of(login)).get(login); + int tokenCount = dbClient.userTokenDao().selectByUser(dbSession, userDto).size(); + boolean isManaged = managedInstanceService.isUserManaged(dbSession, userDto.getUuid()); + return toUserSearchResult(groups, tokenCount, isManaged, userDto); + } + } + + private UserSearchResult toUserSearchResult(Collection groups, int tokenCount, boolean managed, UserDto userDto) { + return new UserSearchResult( + userDto, + managed, + findAvatar(userDto), + groups, + tokenCount); + } } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java index 4aa6fed9ed0..40c9ad6e192 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java @@ -30,6 +30,7 @@ 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.model.RestUser; import org.sonar.server.v2.api.user.request.UsersSearchRestRequest; import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; @@ -97,4 +98,9 @@ public class DefaultUserController implements UserController { checkRequest(!login.equals(userSession.getLogin()), "Self-deactivation is not possible"); userService.deactivate(login, anonymize); } + + @Override + public RestUser fetchUser(String login) { + return usersSearchResponseGenerator.toRestUser(userService.fetchUser(login)); + } } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java index fc4e35c4ebd..20fdac91ff8 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java @@ -24,6 +24,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; import javax.validation.Valid; import org.sonar.server.v2.api.model.RestPage; +import org.sonar.server.v2.api.user.model.RestUser; import org.sonar.server.v2.api.user.request.UsersSearchRestRequest; import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; import org.springdoc.api.annotations.ParameterObject; @@ -65,4 +66,20 @@ public interface UserController { void deactivate( @PathVariable("login") @Parameter(description = "The login of the user to delete.", required = true, in = ParameterIn.PATH) String login, @RequestParam(value = "anonymize", required = false, defaultValue = "false") @Parameter(description = "Anonymize user in addition to deactivating it.") Boolean anonymize); + + @GetMapping(path = "/{login}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Fetch a single user", description = """ + Fetch a single user. + 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. + """) + RestUser fetchUser(@PathVariable("login") @Parameter(description = "The login of the user to fetch.", required = true, in = ParameterIn.PATH) String login); } diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java index 6ad0fad3341..2611a05bfa1 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java @@ -50,11 +50,11 @@ public class UsersSearchRestResponseGenerator implements UsersSearchResponseGene private List toUsersForResponse(List userSearchResults) { return userSearchResults.stream() - .map(this::toUser) + .map(this::toRestUser) .toList(); } - private RestUser toUser(UserSearchResult userSearchResult) { + public RestUser toRestUser(UserSearchResult userSearchResult) { UserDto userDto = userSearchResult.userDto(); String login = userDto.getLogin(); @@ -70,6 +70,7 @@ public class UsersSearchRestResponseGenerator implements UsersSearchResponseGene String slLastConnectionDate = null; Integer groupSize = null; Integer tokensCount = null; + List scmAccounts = null; if (userSession.isLoggedIn()) { avatar = userSearchResult.avatar().orElse(null); @@ -85,6 +86,7 @@ public class UsersSearchRestResponseGenerator implements UsersSearchResponseGene slLastConnectionDate = toDateTime(userDto.getLastSonarlintConnectionDate()); groupSize = userSearchResult.groups().size(); tokensCount = userSearchResult.tokensCount(); + scmAccounts = userSearchResult.userDto().getSortedScmAccounts(); } return new RestUser( @@ -101,8 +103,8 @@ public class UsersSearchRestResponseGenerator implements UsersSearchResponseGene sqLastConnectionDate, slLastConnectionDate, groupSize, - tokensCount - ); + tokensCount, + scmAccounts); } private static String toDateTime(@Nullable Long dateTimeMs) { diff --git a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java index f3f7d0f2408..1815d5598f7 100644 --- a/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java +++ b/server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java @@ -19,6 +19,7 @@ */ package org.sonar.server.v2.api.user.model; +import java.util.List; import javax.annotation.Nullable; public record RestUser( @@ -46,6 +47,8 @@ public record RestUser( @Nullable Integer groupsCount, @Nullable - Integer tokensCount + Integer tokensCount, + @Nullable + List scmAccounts ) { } diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java index a143aeae2ac..ccad66c8998 100644 --- a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java @@ -36,6 +36,7 @@ import org.sonar.server.exceptions.BadRequestException; import org.sonar.server.exceptions.NotFoundException; import org.sonar.server.tester.UserSessionRule; import org.sonar.server.v2.api.ControllerTester; +import org.sonar.server.v2.api.response.PageRestResponse; import org.sonar.server.v2.api.user.converter.UsersSearchRestResponseGenerator; import org.sonar.server.v2.api.user.model.RestUser; import org.sonar.server.v2.api.user.response.UsersSearchRestResponse; @@ -44,6 +45,7 @@ import org.springframework.test.web.servlet.MvcResult; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -62,7 +64,8 @@ public class DefaultUserControllerTest { @Rule public UserSessionRule userSession = UserSessionRule.standalone(); private final UserService userService = mock(UserService.class); - private final MockMvc mockMvc = ControllerTester.getMockMvc(new DefaultUserController(userSession, userService, new UsersSearchRestResponseGenerator(userSession))); + private final UsersSearchRestResponseGenerator responseGenerator = mock(UsersSearchRestResponseGenerator.class); + private final MockMvc mockMvc = ControllerTester.getMockMvc(new DefaultUserController(userSession, userService, responseGenerator)); private static final Gson gson = new Gson(); @@ -71,8 +74,7 @@ public class DefaultUserControllerTest { when(userService.findUsers(any())).thenReturn(new SearchResults<>(List.of(), 0)); mockMvc.perform(get(USER_ENDPOINT)) - .andExpect(status().isOk()) - .andReturn(); + .andExpect(status().isOk()); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(UsersSearchRequest.class); verify(userService).findUsers(requestCaptor.capture()); @@ -96,8 +98,7 @@ public class DefaultUserControllerTest { .param("sonarLintLastConnectionDateTo", "2020-01-01T00:00:00+0100") .param("pageSize", "100") .param("pageIndex", "2")) - .andExpect(status().isOk()) - .andReturn(); + .andExpect(status().isOk()); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(UsersSearchRequest.class); verify(userService).findUsers(requestCaptor.capture()); @@ -109,25 +110,25 @@ public class DefaultUserControllerTest { @Test public void search_whenAdminParametersUsedButNotAdmin_shouldFail() throws Exception { mockMvc.perform(get(USER_ENDPOINT) - .param("sonarQubeLastConnectionDateFrom", "2020-01-01T00:00:00+0100")) + .param("sonarQubeLastConnectionDateFrom", "2020-01-01T00:00:00+0100")) .andExpectAll( status().isForbidden(), content().string("{\"message\":\"parameter sonarQubeLastConnectionDateFrom requires Administer System permission.\"}")); mockMvc.perform(get(USER_ENDPOINT) - .param("sonarQubeLastConnectionDateTo", "2020-01-01T00:00:00+0100")) + .param("sonarQubeLastConnectionDateTo", "2020-01-01T00:00:00+0100")) .andExpectAll( status().isForbidden(), content().string("{\"message\":\"parameter sonarQubeLastConnectionDateTo requires Administer System permission.\"}")); mockMvc.perform(get(USER_ENDPOINT) - .param("sonarLintLastConnectionDateFrom", "2020-01-01T00:00:00+0100")) + .param("sonarLintLastConnectionDateFrom", "2020-01-01T00:00:00+0100")) .andExpectAll( status().isForbidden(), content().string("{\"message\":\"parameter sonarLintLastConnectionDateFrom requires Administer System permission.\"}")); mockMvc.perform(get(USER_ENDPOINT) - .param("sonarLintLastConnectionDateTo", "2020-01-01T00:00:00+0100")) + .param("sonarLintLastConnectionDateTo", "2020-01-01T00:00:00+0100")) .andExpectAll( status().isForbidden(), content().string("{\"message\":\"parameter sonarLintLastConnectionDateTo requires Administer System permission.\"}")); @@ -140,7 +141,10 @@ public class DefaultUserControllerTest { UserSearchResult user3 = generateUserSearchResult("user3", true, false, true, 1, 1); UserSearchResult user4 = generateUserSearchResult("user4", false, true, false, 0, 0); List users = List.of(user1, user2, user3, user4); - when(userService.findUsers(any())).thenReturn(new SearchResults<>(users, users.size())); + SearchResults searchResult = new SearchResults<>(users, users.size()); + when(userService.findUsers(any())).thenReturn(searchResult); + List restUsers = List.of(toRestUser(user1), toRestUser(user2), toRestUser(user3), toRestUser(user4)); + when(responseGenerator.toUsersForResponse(eq(searchResult.searchResults()), any())).thenReturn(new UsersSearchRestResponse(restUsers, new PageRestResponse(1, 50, 4))); userSession.logIn().setSystemAdministrator(); MvcResult mvcResult = mockMvc.perform(get(USER_ENDPOINT)) @@ -149,7 +153,7 @@ public class DefaultUserControllerTest { UsersSearchRestResponse actualUsersSearchRestResponse = gson.fromJson(mvcResult.getResponse().getContentAsString(), UsersSearchRestResponse.class); assertThat(actualUsersSearchRestResponse.users()) - .containsExactlyInAnyOrder(toRestUser(user1), toRestUser(user2), toRestUser(user3), toRestUser(user4)); + .containsExactlyElementsOf(restUsers); assertThat(actualUsersSearchRestResponse.pageRestResponse().total()).isEqualTo(users.size()); } @@ -189,8 +193,8 @@ public class DefaultUserControllerTest { formatDateTime(userSearchResult.userDto().getLastConnectionDate()), formatDateTime(userSearchResult.userDto().getLastSonarlintConnectionDate()), userSearchResult.groups().size(), - userSearchResult.tokensCount() - ); + userSearchResult.tokensCount(), + userSearchResult.userDto().getSortedScmAccounts()); } @Test @@ -273,4 +277,29 @@ public class DefaultUserControllerTest { verify(userService).deactivate("userToDelete", true); } + + @Test + public void fetchUser_whenUserServiceThrowsNotFoundException_returnsNotFound() throws Exception { + when(userService.fetchUser("userLogin")).thenThrow(new NotFoundException("Not found")); + mockMvc.perform(get(USER_ENDPOINT + "/userLogin")) + .andExpectAll( + status().isNotFound(), + content().json("{\"message\":\"Not found\"}") + ); + + } + + @Test + public void fetchUser_whenUserExists_shouldReturnUser() throws Exception { + UserSearchResult user = generateUserSearchResult("user1", true, true, false, 2, 3); + RestUser restUser = toRestUser(user); + when(userService.fetchUser("userLogin")).thenReturn(user); + when(responseGenerator.toRestUser(user)).thenReturn(restUser); + MvcResult mvcResult = mockMvc.perform(get(USER_ENDPOINT + "/userLogin")) + .andExpect(status().isOk()) + .andReturn(); + RestUser responseUser = gson.fromJson(mvcResult.getResponse().getContentAsString(), RestUser.class); + assertThat(responseUser).isEqualTo(restUser); + + } } diff --git a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java index 41bc8a916d3..17ac7aeec51 100644 --- a/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java +++ b/server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java @@ -94,7 +94,8 @@ public class UsersSearchRestResponseGeneratorTest { toDateTime(userDto.getLastConnectionDate()), toDateTime(userDto.getLastSonarlintConnectionDate()), userSearchResult.groups().size(), - userSearchResult.tokensCount() + userSearchResult.tokensCount(), + userSearchResult.userDto().getSortedScmAccounts() ); } @@ -131,6 +132,7 @@ public class UsersSearchRestResponseGeneratorTest { null, null, null, + null, null ); } @@ -166,6 +168,7 @@ public class UsersSearchRestResponseGeneratorTest { null, null, null, + null, null ); } -- 2.39.5