]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19964 GET /api/v2/users/:login endpoint
authorWojtek Wajerowicz <115081248+wojciech-wajerowicz-sonarsource@users.noreply.github.com>
Wed, 26 Jul 2023 14:43:50 +0000 (16:43 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 27 Jul 2023 20:03:45 +0000 (20:03 +0000)
server/sonar-webserver-common/src/it/java/org/sonar/server/common/user/service/UserServiceIT.java
server/sonar-webserver-common/src/main/java/org/sonar/server/common/user/service/UserService.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/DefaultUserController.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/controller/UserController.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGenerator.java
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/user/model/RestUser.java
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/controller/DefaultUserControllerTest.java
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/user/converter/UsersSearchRestResponseGeneratorTest.java

index 6b4c5d34d778afca33fa651416cbf9a7c8c4808d..3a42305da8c0472818593b7f582aa9216d529c65 100644 (file)
@@ -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<String> resultGroups = result.groups();
+
+    assertThat(resultUser).usingRecursiveComparison().isEqualTo(user);
+    assertThat(resultGroups).containsExactlyInAnyOrder(group1.getName(), group2.getName());
+    assertThat(result.managed()).isFalse();
+  }
+
   private void assertUserWithFilter(Function<UsersSearchRequest.Builder, UsersSearchRequest.Builder> query, String userLogin, boolean isExpectedToBeThere) {
 
     UsersSearchRequest.Builder builder = getBuilderWithDefaultsPageSize();
index 744e41f7df1c9c5d5fd15ca3acfac8b82ccb8dd8..69fabcc4c9e000feac3cac8101b6c27ce4eb2740 100644 (file)
@@ -108,15 +108,6 @@ public class UserService {
       .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()
@@ -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<String> 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<String> groups, int tokenCount, boolean managed, UserDto userDto) {
+    return new UserSearchResult(
+      userDto,
+      managed,
+      findAvatar(userDto),
+      groups,
+      tokenCount);
+  }
 }
index 4aa6fed9ed053ff221e1222e0f6e5b239f651389..40c9ad6e1925c06250c25700f916b17d67ea920f 100644 (file)
@@ -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));
+  }
 }
index fc4e35c4ebd05366296d11a67893a534ceb60c69..20fdac91ff8223e329761d05070dbde478018e64 100644 (file)
@@ -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);
 }
index 6ad0fad3341d18b1af2f3e18ae05332d9c583799..2611a05bfa17b3134d2449f8aadb26554f63f214 100644 (file)
@@ -50,11 +50,11 @@ public class UsersSearchRestResponseGenerator implements UsersSearchResponseGene
 
   private List<RestUser> toUsersForResponse(List<UserSearchResult> 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<String> 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) {
index f3f7d0f2408ddebe40fe96f252caa2f9c12e84eb..1815d5598f72f16243516cdc33948b4a84dd4eb1 100644 (file)
@@ -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<String> scmAccounts
 ) {
 }
index a143aeae2ac3b264c9820a8c943501cd4ec170b4..ccad66c8998bd73dc317f3ff6972db77836aa132 100644 (file)
@@ -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<UsersSearchRequest> 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<UsersSearchRequest> 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<UserSearchResult> users = List.of(user1, user2, user3, user4);
-    when(userService.findUsers(any())).thenReturn(new SearchResults<>(users, users.size()));
+    SearchResults<UserSearchResult> searchResult = new SearchResults<>(users, users.size());
+    when(userService.findUsers(any())).thenReturn(searchResult);
+    List<RestUser> 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);
+
+  }
 }
index 41bc8a916d39111d433e0334ce7007ad8fd287fe..17ac7aeec512911406c2c10130d7eae89a7dd2fb 100644 (file)
@@ -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
     );
   }