Browse Source

SONAR-21058 Add DELETE and PATCH verb for /api/v2/authorizations/groups

tags/10.4.0.87286
Aurelien Poscia 7 months ago
parent
commit
56f423531c

+ 45
- 6
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/DefaultGroupController.java View File

@@ -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());
}
}

+ 20
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/controller/GroupController.java View File

@@ -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);
}

+ 52
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/request/GroupUpdateRestRequest.java View File

@@ -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);
}

}

+ 23
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/api/group/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.group.request;

import javax.annotation.ParametersAreNonnullByDefault;

+ 6
- 0
server/sonar-webserver-webapi-v2/src/main/java/org/sonar/server/v2/common/model/UpdateField.java View File

@@ -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();

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

@@ -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);
}

}

+ 146
- 9
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/api/group/controller/DefaultGroupControllerTest.java View File

@@ -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);
}

}

+ 47
- 0
server/sonar-webserver-webapi-v2/src/test/java/org/sonar/server/v2/common/model/UpdateFieldTest.java View File

@@ -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();
}
}

Loading…
Cancel
Save