diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/ProjectComponentsCreateProperties.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/ProjectComponentsCreateProperties.java new file mode 100644 index 0000000..bc6eb47 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/ProjectComponentsCreateProperties.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.project.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Data +@Configuration +@ConfigurationProperties(prefix = "apis.project-components.create") +public class ProjectComponentsCreateProperties { + + private List reservedParams; +} \ No newline at end of file diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java index 5af8579..a71df74 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java @@ -10,6 +10,7 @@ import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentErrorKey; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.http.HttpStatus; @@ -200,6 +201,22 @@ public ResponseEntity handleComponentBadRequestExceptio return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response); } + @ExceptionHandler(ComponentReservedParamException.class) + public ResponseEntity handleComponentReservedParamException( + ComponentReservedParamException ex, + HttpServletRequest request) { + + log.warn("Reserved parameter sent by client: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException( Exception ex, diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentReservedParamException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentReservedParamException.java new file mode 100644 index 0000000..8bc9f78 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentReservedParamException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentReservedParamException extends RuntimeException { + + public ComponentReservedParamException(String message) { + super(message); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index 56cc6c3..ee8f436 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -8,12 +8,14 @@ import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; import org.opendevstack.apiservice.externalservice.marketplace.service.CatalogItemOperations; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.config.ProjectComponentsCreateProperties; import org.opendevstack.apiservice.project.exception.CatalogItemNotFoundException; import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentBadRequestException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; @@ -22,6 +24,9 @@ import org.springframework.web.client.HttpClientErrorException; import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; @Service @AllArgsConstructor @@ -32,6 +37,8 @@ public class ComponentsFacade { private final MarketplaceMapper marketplaceMapper; + private final ProjectComponentsCreateProperties createProperties; + public Component getProjectComponent(String projectId, String componentId) { try { ProjectComponentExtendedInfo marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); @@ -61,6 +68,7 @@ public Component getProjectComponent(String projectId, String componentId) { } public void provisionProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + validateReservedParams(createComponentRequest); try { CatalogItem catalogItem = resolveCatalogItem(createComponentRequest); List createComponentParameterList = marketplaceMapper @@ -86,6 +94,30 @@ public void provisionProjectComponent(String projectId, CreateComponentRequest c } } + private void validateReservedParams(CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null || createComponentRequest.getParams() == null + || createComponentRequest.getParams().isEmpty()) { + return; + } + + if (createProperties.getReservedParams() == null || createProperties.getReservedParams().isEmpty()) { + return; + } + + Set reservedParams = createProperties.getReservedParams().stream() + .map(param -> param.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + + createComponentRequest.getParams().keySet().stream() + .filter(param -> param != null && reservedParams.contains(param.toLowerCase(Locale.ROOT))) + .findFirst() + .ifPresent(param -> { + throw new ComponentReservedParamException( + String.format("Parameter '%s' cannot be provided because it is managed by the system.", param) + ); + }); + } + /** * Resolves the catalog item that matches the requested {@code productId} (interpreted as the * Marketplace catalog item slug). Returns {@code null} when the request or product id is missing, diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java index 92a0601..268c146 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java @@ -9,6 +9,7 @@ import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; @@ -149,6 +150,21 @@ void handle_component_already_exists_exception_returns_conflict() { assertThat(response.getBody().getMessage()).isEqualTo("This component name already exists, please choose another name."); } + @Test + void handle_component_reserved_param_exception_returns_bad_request() { + ComponentReservedParamException exception = new ComponentReservedParamException( + "Parameter 'ods_namespace' cannot be provided because it is managed by the system."); + + ResponseEntity response = handler.handleComponentReservedParamException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("006"); + assertThat(response.getBody().getMessage()) + .isEqualTo("Parameter 'ods_namespace' cannot be provided because it is managed by the system."); + } + @Test void handle_generic_exception_returns_internal_server_error() { RuntimeException exception = new RuntimeException("boom"); diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java index 0507499..c7c92a7 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -10,10 +10,12 @@ import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.config.ProjectComponentsCreateProperties; import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; @@ -22,6 +24,8 @@ import org.springframework.http.HttpStatus; import org.springframework.web.client.HttpClientErrorException; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyList; @@ -42,11 +46,15 @@ class ComponentsFacadeTest { @Mock private MarketplaceService marketplaceExternalService; + private ProjectComponentsCreateProperties createProperties; + private ComponentsFacade componentsFacade; @BeforeEach void setup() { - componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper); + createProperties = new ProjectComponentsCreateProperties(); + createProperties.setReservedParams(List.of("workflow", "ods_namespace", "component_type", "quickstarter_repo")); + componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper, createProperties); } @Test @@ -103,27 +111,36 @@ void create_project_component_throws_creation_exception_when_marketplace_returns verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); } - @Test - void create_project_component_throws_already_exists_when_marketplace_returns_conflict() throws MarketplaceException { + @Test + void create_project_component_throws_bad_request_when_request_contains_reserved_param() { + CreateComponentRequest request = buildTestCreateComponentRequest(); + request.putParamsItem("ods_namespace", "null"); + + assertThatThrownBy(() -> componentsFacade.provisionProjectComponent("testProject", request)) + .isInstanceOf(ComponentReservedParamException.class) + .hasMessage("Parameter 'ods_namespace' cannot be provided because it is managed by the system."); + } + + @Test + void create_project_component_throws_already_exists_when_marketplace_returns_conflict() throws MarketplaceException { CreateComponentRequest request = buildTestCreateComponentRequest(); HttpClientErrorException conflict = HttpClientErrorException.Conflict.create( - HttpStatus.CONFLICT, - "Conflict", - HttpHeaders.EMPTY, - new byte[0], - null + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + new byte[0], + null ); when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) - .thenThrow(new MarketplaceException("This component name already exists, please choose another name.", conflict)); + .thenThrow(new MarketplaceException("This component name already exists, please choose another name.", conflict)); assertThatThrownBy(() -> componentsFacade.provisionProjectComponent("testProject", request)) - .isInstanceOf(ComponentAlreadyExistsException.class) - .hasMessage("This component name already exists, please choose another name."); + .isInstanceOf(ComponentAlreadyExistsException.class) + .hasMessage("This component name already exists, please choose another name."); verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); - } - - + } + @Test void delete_project_component_ends_successfully_for_existing_component() throws MarketplaceException { componentsFacade.deleteProjectComponent("testProject", "testComponent"); diff --git a/application.yaml b/application.yaml index deb7058..c17bf8d 100644 --- a/application.yaml +++ b/application.yaml @@ -203,6 +203,11 @@ automation: trust-store-type: ${UIPATH_SSL_TRUST_STORE_TYPE:JKS} apis: + project-components: + create: + # Parameters reserved for backend/system use. If provided by clients in params, request is rejected. + reserved-params: ${API_PROJECT_COMPONENTS_RESERVED_PARAMS:} + project-users: # Workflow name triggered for project user automation tasks. ansible-workflow-name: ${API_PROJECT_USERS_WORKFLOW_NAME:ansible++workflow}