@@ -23,8 +23,10 @@ import org.sonar.db.DbClient; | |||
import org.sonar.db.DbSession; | |||
import org.sonar.db.user.GroupDto; | |||
import org.sonar.server.common.group.service.GroupService; | |||
import org.sonar.server.common.management.ManagedInstanceChecker; | |||
import org.sonar.server.exceptions.NotFoundException; | |||
import org.sonar.server.user.UserSession; | |||
import org.sonar.server.v2.api.group.request.GroupUpdateRestRequest; | |||
import org.sonar.server.v2.api.group.response.RestGroupResponse; | |||
public class DefaultGroupController implements GroupController { | |||
@@ -33,24 +35,61 @@ public class DefaultGroupController implements GroupController { | |||
private final GroupService groupService; | |||
private final DbClient dbClient; | |||
private final UserSession userSession; | |||
private final ManagedInstanceChecker managedInstanceChecker; | |||
public DefaultGroupController(GroupService groupService, DbClient dbClient, UserSession userSession) { | |||
public DefaultGroupController(GroupService groupService, DbClient dbClient, ManagedInstanceChecker managedInstanceChecker, UserSession userSession) { | |||
this.groupService = groupService; | |||
this.dbClient = dbClient; | |||
this.userSession = userSession; | |||
this.managedInstanceChecker = managedInstanceChecker; | |||
} | |||
@Override | |||
public RestGroupResponse fetchGroup(String id) { | |||
userSession.checkLoggedIn().checkIsSystemAdministrator(); | |||
try (DbSession session = dbClient.openSession(false)) { | |||
return groupService.findGroupByUuid(session, id) | |||
.map(DefaultGroupController::groupDtoToResponse) | |||
.orElseThrow(() -> new NotFoundException(String.format(GROUP_NOT_FOUND_MESSAGE, id))); | |||
GroupDto groupDto = findGroupDtoOrThrow(id, session); | |||
return toRestGroup(groupDto); | |||
} | |||
} | |||
private static RestGroupResponse groupDtoToResponse(GroupDto group) { | |||
return new RestGroupResponse(group.getUuid(), group.getName(), group.getDescription()); | |||
@Override | |||
public void deleteGroup(String id) { | |||
throwIfNotAllowedToChangeGroupName(); | |||
try (DbSession session = dbClient.openSession(false)) { | |||
GroupDto group = findGroupDtoOrThrow(id, session); | |||
groupService.delete(session, group); | |||
session.commit(); | |||
} | |||
} | |||
@Override | |||
public RestGroupResponse updateGroup(String id, GroupUpdateRestRequest updateRequest) { | |||
throwIfNotAllowedToChangeGroupName(); | |||
try (DbSession session = dbClient.openSession(false)) { | |||
GroupDto group = findGroupDtoOrThrow(id, session); | |||
GroupDto updatedGroup = groupService.updateGroup( | |||
session, | |||
group, | |||
updateRequest.getName().orElse(group.getName()), | |||
updateRequest.getDescription().orElse(group.getDescription()) | |||
); | |||
session.commit(); | |||
return toRestGroup(updatedGroup); | |||
} | |||
} | |||
private void throwIfNotAllowedToChangeGroupName() { | |||
userSession.checkIsSystemAdministrator(); | |||
managedInstanceChecker.throwIfInstanceIsManaged(); | |||
} | |||
private GroupDto findGroupDtoOrThrow(String id, DbSession session) { | |||
return groupService.findGroupByUuid(session, id) | |||
.orElseThrow(() -> new NotFoundException(String.format(GROUP_NOT_FOUND_MESSAGE, id))); | |||
} | |||
private static RestGroupResponse toRestGroup(GroupDto updatedGroup) { | |||
return new RestGroupResponse(updatedGroup.getUuid(), updatedGroup.getName(), updatedGroup.getDescription()); | |||
} | |||
} |
@@ -22,15 +22,22 @@ package org.sonar.server.v2.api.group.controller; | |||
import io.swagger.v3.oas.annotations.Operation; | |||
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.group.request.GroupUpdateRestRequest; | |||
import org.sonar.server.v2.api.group.response.RestGroupResponse; | |||
import org.springframework.http.HttpStatus; | |||
import org.springframework.http.MediaType; | |||
import org.springframework.web.bind.annotation.DeleteMapping; | |||
import org.springframework.web.bind.annotation.GetMapping; | |||
import org.springframework.web.bind.annotation.PatchMapping; | |||
import org.springframework.web.bind.annotation.PathVariable; | |||
import org.springframework.web.bind.annotation.RequestBody; | |||
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.GROUPS_ENDPOINT; | |||
import static org.sonar.server.v2.WebApiEndpoints.JSON_MERGE_PATCH_CONTENT_TYPE; | |||
@RequestMapping(GROUPS_ENDPOINT) | |||
@RestController | |||
@@ -40,4 +47,17 @@ public interface GroupController { | |||
@ResponseStatus(HttpStatus.OK) | |||
@Operation(summary = "Fetch a single group", description = "Fetch a single group.") | |||
RestGroupResponse fetchGroup(@PathVariable("id") @Parameter(description = "The id of the group to fetch.", required = true, in = ParameterIn.PATH) String id); | |||
@DeleteMapping(path = "/{id}") | |||
@ResponseStatus(HttpStatus.NO_CONTENT) | |||
@Operation(summary = "Deletes a group", description = "Deletes a group.") | |||
void deleteGroup(@PathVariable("id") @Parameter(description = "The ID of the group to delete.", required = true, in = ParameterIn.PATH) String id); | |||
@PatchMapping(path = "/{id}", consumes = JSON_MERGE_PATCH_CONTENT_TYPE, produces = MediaType.APPLICATION_JSON_VALUE) | |||
@ResponseStatus(HttpStatus.OK) | |||
@Operation(summary = "Update a user", description = """ | |||
Update a user. | |||
Allows updating user's name, email and SCM accounts. | |||
""") | |||
RestGroupResponse updateGroup(@PathVariable("id") String id, @Valid @RequestBody GroupUpdateRestRequest updateRequest); | |||
} |
@@ -0,0 +1,52 @@ | |||
/* | |||
* 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.group.request; | |||
import io.swagger.v3.oas.annotations.media.Schema; | |||
import javax.validation.constraints.Size; | |||
import org.sonar.server.v2.common.model.UpdateField; | |||
import static org.sonar.api.user.UserGroupValidation.GROUP_NAME_MAX_LENGTH; | |||
public class GroupUpdateRestRequest { | |||
private UpdateField<String> name = UpdateField.undefined(); | |||
private UpdateField<String> description = UpdateField.undefined(); | |||
@Size(min=1, max = GROUP_NAME_MAX_LENGTH) | |||
@Schema(implementation = String.class, description = "Group name") | |||
public UpdateField<String> getName() { | |||
return name; | |||
} | |||
public void setName(String name) { | |||
this.name = UpdateField.withValue(name); | |||
} | |||
@Size(max = 200) | |||
@Schema(implementation = String.class, description = "Description of the gorup") | |||
public UpdateField<String> getDescription() { | |||
return description; | |||
} | |||
public void setDescription(String description) { | |||
this.description = UpdateField.withValue(description); | |||
} | |||
} |
@@ -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.group.request; | |||
import javax.annotation.ParametersAreNonnullByDefault; |
@@ -66,6 +66,12 @@ public class UpdateField<T> { | |||
return undefined(); | |||
} | |||
@CheckForNull | |||
public T orElse(@Nullable T other) { | |||
return isDefined ? value : other; | |||
} | |||
@Override | |||
public String toString() { | |||
return value.toString(); |
@@ -26,6 +26,7 @@ import org.sonar.server.common.health.CeStatusNodeCheck; | |||
import org.sonar.server.common.health.DbConnectionNodeCheck; | |||
import org.sonar.server.common.health.EsStatusNodeCheck; | |||
import org.sonar.server.common.health.WebServerStatusNodeCheck; | |||
import org.sonar.server.common.management.ManagedInstanceChecker; | |||
import org.sonar.server.common.platform.LivenessChecker; | |||
import org.sonar.server.common.platform.LivenessCheckerImpl; | |||
import org.sonar.server.common.user.service.UserService; | |||
@@ -80,8 +81,8 @@ public class PlatformLevel4WebConfig { | |||
} | |||
@Bean | |||
public GroupController groupController(GroupService groupService, DbClient dbClient, UserSession userSession) { | |||
return new DefaultGroupController(groupService, dbClient, userSession); | |||
public GroupController groupController(GroupService groupService, DbClient dbClient, ManagedInstanceChecker managedInstanceChecker, UserSession userSession) { | |||
return new DefaultGroupController(groupService, dbClient, managedInstanceChecker, userSession); | |||
} | |||
} |
@@ -19,37 +19,52 @@ | |||
*/ | |||
package org.sonar.server.v2.api.group.controller; | |||
import com.google.gson.Gson; | |||
import com.google.gson.GsonBuilder; | |||
import java.util.Optional; | |||
import javax.annotation.Nullable; | |||
import org.junit.Before; | |||
import org.junit.Rule; | |||
import org.junit.Test; | |||
import org.junit.runner.RunWith; | |||
import org.mockito.junit.MockitoJUnitRunner; | |||
import org.sonar.db.DbClient; | |||
import org.sonar.db.DbSession; | |||
import org.sonar.db.user.GroupDto; | |||
import org.sonar.server.common.group.service.GroupService; | |||
import org.sonar.server.common.management.ManagedInstanceChecker; | |||
import org.sonar.server.exceptions.BadRequestException; | |||
import org.sonar.server.tester.UserSessionRule; | |||
import org.sonar.server.v2.api.ControllerTester; | |||
import org.sonar.server.v2.api.group.response.RestGroupResponse; | |||
import org.springframework.test.web.servlet.MockMvc; | |||
import org.springframework.test.web.servlet.MvcResult; | |||
import static org.assertj.core.api.Assertions.assertThat; | |||
import static org.mockito.Mockito.doThrow; | |||
import static org.mockito.Mockito.mock; | |||
import static org.mockito.Mockito.when; | |||
import static org.sonar.server.v2.WebApiEndpoints.GROUPS_ENDPOINT; | |||
import static org.sonar.server.v2.WebApiEndpoints.JSON_MERGE_PATCH_CONTENT_TYPE; | |||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | |||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; | |||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; | |||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |||
@RunWith(MockitoJUnitRunner.class) | |||
public class DefaultGroupControllerTest { | |||
private static final String GROUP_UUID = "1234"; | |||
private static final Gson GSON = new GsonBuilder().create(); | |||
@Rule | |||
public UserSessionRule userSession = UserSessionRule.standalone(); | |||
private final GroupService groupService = mock(GroupService.class); | |||
private final DbClient dbClient = mock(DbClient.class); | |||
private final DbSession dbSession = mock(DbSession.class); | |||
private final MockMvc mockMvc = ControllerTester.getMockMvc(new DefaultGroupController(groupService, dbClient, userSession)); | |||
private final GroupService groupService = mock(); | |||
private final DbClient dbClient = mock(); | |||
private final DbSession dbSession = mock(); | |||
private final ManagedInstanceChecker managedInstanceChecker = mock(); | |||
private final MockMvc mockMvc = ControllerTester.getMockMvc(new DefaultGroupController(groupService, dbClient, managedInstanceChecker, userSession)); | |||
@Before | |||
public void setUp() { | |||
@@ -80,7 +95,7 @@ public class DefaultGroupControllerTest { | |||
public void fetchGroup_whenCallerIsNotAdmin_shouldReturnForbidden() throws Exception { | |||
userSession.logIn().setNonSystemAdministrator(); | |||
mockMvc.perform( | |||
get(GROUPS_ENDPOINT + "/" + GROUP_UUID)) | |||
get(GROUPS_ENDPOINT + "/" + GROUP_UUID)) | |||
.andExpectAll( | |||
status().isForbidden(), | |||
content().json("{\"message\":\"Insufficient privileges\"}")); | |||
@@ -91,11 +106,133 @@ public class DefaultGroupControllerTest { | |||
userSession.logIn().setSystemAdministrator(); | |||
when(groupService.findGroupByUuid(dbSession, GROUP_UUID)).thenReturn(Optional.empty()); | |||
mockMvc.perform( | |||
get(GROUPS_ENDPOINT + "/" + GROUP_UUID) | |||
.content("{}")) | |||
get(GROUPS_ENDPOINT + "/" + GROUP_UUID).content("{}")) | |||
.andExpectAll( | |||
status().isNotFound(), | |||
content().json("{\"message\":\"Group '1234' not found\"}")); | |||
} | |||
@Test | |||
public void deleteGroup_whenCallerIsNotAdmin_shouldReturnForbidden() throws Exception { | |||
userSession.logIn().setNonSystemAdministrator(); | |||
mockMvc.perform( | |||
delete(GROUPS_ENDPOINT + "/" + GROUP_UUID)) | |||
.andExpectAll( | |||
status().isForbidden(), | |||
content().json("{\"message\":\"Insufficient privileges\"}")); | |||
} | |||
@Test | |||
public void deleteGroup_whenInstanceIsManaged_shouldReturnException() throws Exception { | |||
userSession.logIn().setSystemAdministrator(); | |||
doThrow(BadRequestException.create("the instance is managed")).when(managedInstanceChecker).throwIfInstanceIsManaged(); | |||
mockMvc.perform( | |||
delete(GROUPS_ENDPOINT + "/" + GROUP_UUID)) | |||
.andExpectAll( | |||
status().isBadRequest(), | |||
content().json("{\"message\":\"the instance is managed\"}")); | |||
} | |||
@Test | |||
public void deleteGroup_whenGroupDoesntExist_shouldReturnNotFound() throws Exception { | |||
userSession.logIn().setSystemAdministrator(); | |||
when(groupService.findGroupByUuid(dbSession, GROUP_UUID)).thenReturn(Optional.empty()); | |||
mockMvc.perform( | |||
delete(GROUPS_ENDPOINT + "/" + GROUP_UUID).content("{}")) | |||
.andExpectAll( | |||
status().isNotFound(), | |||
content().json("{\"message\":\"Group '1234' not found\"}")); | |||
} | |||
@Test | |||
public void deleteGroup_whenGroupExists_shouldDeleteAndReturn204() throws Exception { | |||
GroupDto groupDto = new GroupDto().setUuid(GROUP_UUID).setName("name").setDescription("description"); | |||
when(groupService.findGroupByUuid(dbSession, GROUP_UUID)).thenReturn(Optional.of(groupDto)); | |||
userSession.logIn().setSystemAdministrator(); | |||
mockMvc.perform( | |||
delete(GROUPS_ENDPOINT + "/" + GROUP_UUID)) | |||
.andExpectAll( | |||
status().isNoContent(), | |||
content().string("")); | |||
} | |||
@Test | |||
public void patchGroup_whenCallerIsNotAdmin_shouldReturnForbidden() throws Exception { | |||
userSession.logIn().setNonSystemAdministrator(); | |||
mockMvc.perform( | |||
patch(GROUPS_ENDPOINT + "/" + GROUP_UUID).contentType(JSON_MERGE_PATCH_CONTENT_TYPE).content("{}") | |||
) | |||
.andExpectAll( | |||
status().isForbidden(), | |||
content().json("{\"message\":\"Insufficient privileges\"}")); | |||
} | |||
@Test | |||
public void patchGroup_whenInstanceIsManaged_shouldReturnException() throws Exception { | |||
userSession.logIn().setSystemAdministrator(); | |||
doThrow(BadRequestException.create("the instance is managed")).when(managedInstanceChecker).throwIfInstanceIsManaged(); | |||
mockMvc.perform( | |||
patch(GROUPS_ENDPOINT + "/" + GROUP_UUID).contentType(JSON_MERGE_PATCH_CONTENT_TYPE).content("{}") | |||
) | |||
.andExpectAll( | |||
status().isBadRequest(), | |||
content().json("{\"message\":\"the instance is managed\"}")); | |||
} | |||
@Test | |||
public void patchGroup_whenGroupDoesntExist_shouldReturnNotFound() throws Exception { | |||
userSession.logIn().setSystemAdministrator(); | |||
when(groupService.findGroupByUuid(dbSession, GROUP_UUID)).thenReturn(Optional.empty()); | |||
mockMvc.perform( | |||
patch(GROUPS_ENDPOINT + "/" + GROUP_UUID).contentType(JSON_MERGE_PATCH_CONTENT_TYPE).content("{}") | |||
) | |||
.andExpectAll( | |||
status().isNotFound(), | |||
content().json("{\"message\":\"Group '1234' not found\"}")); | |||
} | |||
@Test | |||
public void patchGroup_whenGroupExists_shouldPatchAndReturnNewGroup() throws Exception { | |||
patchGroupAndAssertResponse("newName", "newDescription"); | |||
} | |||
@Test | |||
public void patchGroup_whenGroupExistsAndRemovingDescription_shouldPatchAndReturnNewGroup() throws Exception { | |||
patchGroupAndAssertResponse("newName", null); | |||
} | |||
@Test | |||
public void patchGroup_whenGroupExistsAndIdempotent_shouldPatch() throws Exception { | |||
patchGroupAndAssertResponse("newName", "newDescription"); | |||
patchGroupAndAssertResponse("newName", "newDescription"); | |||
} | |||
private void patchGroupAndAssertResponse(@Nullable String newName,@Nullable String newDescription) throws Exception { | |||
userSession.logIn().setSystemAdministrator(); | |||
GroupDto groupDto = new GroupDto().setUuid(GROUP_UUID).setName("name").setDescription("description"); | |||
when(groupService.findGroupByUuid(dbSession, GROUP_UUID)).thenReturn(Optional.of(groupDto)); | |||
GroupDto newDto = new GroupDto().setUuid(GROUP_UUID).setName(newName).setDescription(newDescription); | |||
when(groupService.updateGroup(dbSession, groupDto, newName, newDescription)).thenReturn(newDto); | |||
MvcResult mvcResult = mockMvc.perform( | |||
patch(GROUPS_ENDPOINT + "/" + GROUP_UUID).contentType(JSON_MERGE_PATCH_CONTENT_TYPE).content( | |||
""" | |||
{ | |||
"name": "%s", | |||
"description": %s | |||
} | |||
""".formatted(newName, newDescription == null ? "null" : "\"" + newDescription + "\"") | |||
) | |||
) | |||
.andExpect(status().isOk()) | |||
.andReturn(); | |||
RestGroupResponse restGroupResponse = GSON.fromJson(mvcResult.getResponse().getContentAsString(), RestGroupResponse.class); | |||
assertThat(restGroupResponse.id()).isEqualTo(GROUP_UUID); | |||
assertThat(restGroupResponse.name()).isEqualTo(newName); | |||
assertThat(restGroupResponse.description()).isEqualTo(newDescription); | |||
} | |||
} |
@@ -0,0 +1,47 @@ | |||
/* | |||
* 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.common.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 UpdateFieldTest { | |||
@Test | |||
public void orElse_whenNoValueDefined_useElseValue() { | |||
UpdateField<String> updateField = UpdateField.undefined(); | |||
assertThat(updateField.orElse("foo")).isEqualTo("foo"); | |||
} | |||
@Test | |||
public void orElse_whenNoNullValueDefined_useValue() { | |||
UpdateField<String> updateField = UpdateField.withValue("bar"); | |||
assertThat(updateField.orElse("foo")).isEqualTo("bar"); | |||
} | |||
@Test | |||
public void orElse_whenNullValueDefined_useValue() { | |||
UpdateField<String> updateField = UpdateField.withValue(null); | |||
assertThat(updateField.orElse("foo")).isNull(); | |||
} | |||
} |