From 9eaae82701b776d9826a5d8b8bf25cac535081bf Mon Sep 17 00:00:00 2001 From: soriaoli Date: Mon, 15 Jun 2026 12:02:46 +0200 Subject: [PATCH 01/19] [update-marketplace-to-consume-metrics] - Add new endpoint configuration. --- .../openapi-component_provisioner-v1.0.0.yaml | 160 +++++++++++++++++- 1 file changed, 159 insertions(+), 1 deletion(-) diff --git a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml index 2cf2681..3cdbf6f 100644 --- a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -366,6 +366,59 @@ paths: application/json: schema: $ref: '#/components/schemas/RestErrorMessage' + + /project/components: + get: + operationId: getAllProjectComponents + parameters: + - name: page + in: query + description: Page number (from 0 until limit) + required: false + schema: + type: integer + default: 0 + + - name: size + in: query + description: Page size + required: false + schema: + type: integer + default: 20 + responses: + "200": + content: + application/json: + schema: + items: + $ref: "#/components/schemas/ProjectComponentListResponse" + type: array + description: A paginated list of all the provisioned project components + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/RestErrorMessage" + description: Invalid client token on the request. + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/RestErrorMessage" + description: Insufficient permissions for the client to access the resource. + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/RestErrorMessage" + description: Server error. + summary: Returns the information of the project's components in the Bitbucket + repository. + tags: + - Project-components-with-provision-status + x-accepts: + - application/json components: securitySchemes: bearerAuth: @@ -610,4 +663,109 @@ components: parameters: type: array items: - $ref: '#/components/schemas/ProjectComponentStatusParameter' \ No newline at end of file + $ref: '#/components/schemas/ProjectComponentStatusParameter' + ProjectComponentListItem: + type: object + example: + projectKey: SOMEPROJECT + componentId: any-component-id-from-backend + caller: some-person@email.com + catalogItemSlug: some_technology-name + createdAt: 1707043200000 + updatedAt: 1707043200000 + properties: + projectKey: + description: The projectKey which the component is provisioned for. + example: SOMEPROJECT + type: string + componentId: + description: The componentId set by the user. + example: any-component-id-from-backend + minLength: 1 + pattern: ^(?!\s*$).+ + type: string + caller: + description: The email of who provisioned the component. + example: some-person@email.com + type: string + catalogItemSlug: + description: The provisioned catalog item slug. + example: some_technology-name + type: string + createdAt: + description: The timestamp of the provision action. + example: 1707043200000 + type: number + updatedAt: + description: The timestamp of the last change of the catalog item. + example: 1707043200000 + type: number + Pagination: + type: object + example: + page: 0 + size: 20 + totalElements: 117 + totalPages: 6 + next: "https://api.example.com/resources?page=1&size=20" + previous: null + properties: + page: + type: integer + example: 0 + description: Current page of the response. + size: + type: integer + example: 20 + description: Current page size of the response. + totalElements: + type: integer + format: integer + example: 117 + description: Total number of elements. + totalPages: + type: integer + example: 6 + description: Total number of pages of this exact size. + next: + type: string + format: uri + nullable: true + example: "https://api.example.com/resources?page=1&size=20" + description: URL of the next page (or null if the current is the last one) + previous: + type: string + format: uri + nullable: true + example: "https://api.example.com/resources?page=0&size=20" + description: URL of the previous page (or null if the current is the first one) + ProjectComponentListResponse: + type: object + example: + data: + - projectKey: SOMEPROJECT + componentId: any-component-id-from-backend + caller: some-person@email.com + catalogItemSlug: some_technology-name + createdAt: 1707043200000 + updatedAt: 1707043200000 + - projectKey: ANOTHERPROJECT + componentId: another-component-id-from-backend + caller: some-person2@email.com + catalogItemSlug: another_technology-name + createdAt: 1707043200001 + updatedAt: 1707043200002 + pagination: + page: 0 + size: 20 + totalElements: 117 + totalPages: 6 + next: https://api.example.com/resources?page=1&size=20 + previous: null + properties: + data: + type: array + items: + $ref: '#/components/schemas/ProjectComponentListItem' + pagination: + $ref: '#/components/schemas/Pagination' From 60a232942cfaaf534ed2c21d65c97cb75d3c8368 Mon Sep 17 00:00:00 2001 From: soriaoli Date: Mon, 15 Jun 2026 12:30:07 +0200 Subject: [PATCH 02/19] [update-marketplace-to-consume-metrics] - Add new endpoint configuration. --- .../openapi/openapi-component_provisioner-v1.0.0.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml index 3cdbf6f..09e72ca 100644 --- a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -391,9 +391,7 @@ paths: content: application/json: schema: - items: - $ref: "#/components/schemas/ProjectComponentListResponse" - type: array + $ref: "#/components/schemas/ProjectComponentListResponse" description: A paginated list of all the provisioned project components "401": content: From ff9df9bf1187869520f098ade19b30fffc17259d Mon Sep 17 00:00:00 2001 From: soriaoli Date: Mon, 15 Jun 2026 14:35:20 +0200 Subject: [PATCH 03/19] [update-marketplace-to-consume-metrics] - Whitelist new method so it can be generated in the client. --- .../openapi/openapi-component_provisioner-v1.0.0.yaml | 4 ++-- external-service-marketplace/pom.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml index 09e72ca..123a387 100644 --- a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -369,6 +369,8 @@ paths: /project/components: get: + tags: + - Project-components-with-provision-status operationId: getAllProjectComponents parameters: - name: page @@ -413,8 +415,6 @@ paths: description: Server error. summary: Returns the information of the project's components in the Bitbucket repository. - tags: - - Project-components-with-provision-status x-accepts: - application/json components: diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml index d3b1991..609e96a 100644 --- a/external-service-marketplace/pom.xml +++ b/external-service-marketplace/pom.xml @@ -186,7 +186,7 @@ generate - FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProjectComponentProvisionStatusById|getProvisionerHealth + FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProjectComponentProvisionStatusById|getAllProjectComponents|getProvisionerHealth java ${project.basedir}/target/generated-sources/openapi resttemplate From fe3d8c5ecc7e9282867f37f06a105a49c3a69774 Mon Sep 17 00:00:00 2001 From: soriaoli Date: Mon, 15 Jun 2026 14:48:13 +0200 Subject: [PATCH 04/19] [update-marketplace-to-consume-metrics] - Add new endpoint to client service. --- .../service/MarketplaceService.java | 5 +++ .../service/impl/MarketplaceServiceImpl.java | 33 +++++++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java index 98fc934..5e6ae7f 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java @@ -1,6 +1,7 @@ package org.opendevstack.apiservice.externalservice.marketplace.service; import org.opendevstack.apiservice.externalservice.api.ExternalService; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; @@ -22,6 +23,10 @@ public interface MarketplaceService extends ExternalService { ProjectComponentProvisionStatus getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + ProjectComponentListResponse getAllProjectComponents(Integer page, Integer size) throws MarketplaceException; + + ProjectComponentListResponse getAllProjectComponents(String instanceName, Integer page, Integer size) throws MarketplaceException; + CatalogItem getCatalogItem(String catalogItemId) throws MarketplaceException; CatalogItem getCatalogItem(String instanceName, String catalogItemId) throws MarketplaceException; diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index 74464ae..ceae318 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -5,6 +5,7 @@ import org.opendevstack.apiservice.core.security.obo.OboTokenService; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.*; import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; @@ -14,16 +15,6 @@ import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionResultsApi; import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerActionsApi; import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerHealthApi; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetCatalogHealth200Response; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetProvisionerHealth200Response; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionAction; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningDeleteRequest; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequest; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; @@ -111,6 +102,28 @@ public ProjectComponentProvisionStatus getProjectComponent(String projectId, Str return getProjectComponent(getDefaultInstance(), projectId, componentId); } + public ProjectComponentListResponse getAllProjectComponents(String instanceName, Integer page, Integer size) throws MarketplaceException { + log.debug("Marketplace service GET all components with page {} and size {} in instance {} ", page, size, instanceName); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + ProjectComponentsWithProvisionStatusApi projectComponentsApi = new ProjectComponentsWithProvisionStatusApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return projectComponentsApi.getAllProjectComponents(page, size); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting all project components in instance '%s'", + instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve all project components in instance '%s'", instanceName), e); + } + } + + public ProjectComponentListResponse getAllProjectComponents(Integer page, Integer size) throws MarketplaceException { + return getAllProjectComponents(getDefaultInstance(), page, size); + } + @Override public ProjectComponentProvisionStatus getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { log.debug("Marketplace service GET component with id {} for project {} in instance {} ", componentId, projectId, instanceName); From 99243f7f83a84aa1088501cbd1ce7848ac0a18c5 Mon Sep 17 00:00:00 2001 From: soriaoli Date: Mon, 15 Jun 2026 14:56:04 +0200 Subject: [PATCH 05/19] [update-marketplace-to-consume-metrics] - Add unit tests. --- .../service/MarketplaceServiceImplTest.java | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java index 417cb8f..7239b96 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -16,6 +16,7 @@ import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse; import org.opendevstack.apiservice.externalservice.marketplace.service.impl.MarketplaceServiceImpl; @@ -62,6 +63,7 @@ class MarketplaceServiceImplTest { // HTTP method changes unexpectedly. private static final String PATH_CATALOG_ITEM_BY_ID = "/catalog-items/{id}"; private static final String PATH_PROJECT_COMPONENT = "/project/{projectKey}/component/{componentId}"; + private static final String PATH_PROJECT_COMPONENTS = "/project/components"; private static final String PATH_PROVISION_ACTIONS = "/provision-actions"; private static final String PATH_DELETE_COMPONENT = "/support/delete/{projectKey}/{componentId}"; private static final String PATH_NOTIFY_PROVISIONING = "/provision/{projectKey}/{status}"; @@ -160,6 +162,104 @@ void testGetProjectComponent_InstanceNotConfigured() throws MarketplaceException verify(clientFactory).getClient(instanceName); } + // ------------------------------------------------------------------------- + // getAllProjectComponents + // ------------------------------------------------------------------------- + + @Test + void testGetAllProjectComponents_Success_ReturnsResponse() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + Integer page = 0; + Integer size = 10; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + ProjectComponentListResponse mockResponse = new ProjectComponentListResponse(); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENTS, HttpMethod.GET) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + ProjectComponentListResponse result = marketplaceService.getAllProjectComponents(instanceName, page, size); + + // Assert + assertEquals(mockResponse, result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testGetAllProjectComponents_Unauthorized_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + Integer page = 0; + Integer size = 10; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENTS, HttpMethod.GET).thenThrow(forbiddenEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getAllProjectComponents(instanceName, page, size)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testGetAllProjectComponents_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + Integer page = 0; + Integer size = 10; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENTS, HttpMethod.GET).thenThrow(new RestClientException("Connection failed")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getAllProjectComponents(instanceName, page, size)); + + assertTrue(exception.getMessage().contains("Failed to retrieve all project components")); + } + + @Test + void testGetAllProjectComponents_DefaultInstance_UsesDefaultClient() throws MarketplaceException { + // Arrange + Integer page = 0; + Integer size = 10; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + ProjectComponentListResponse mockResponse = new ProjectComponentListResponse(); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENTS, HttpMethod.GET) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + ProjectComponentListResponse result = marketplaceService.getAllProjectComponents(page, size); + + // Assert + assertEquals(mockResponse, result); + verify(clientFactory).getClient("default"); + } + @Test void testGetProjectComponent_RestClientException() throws MarketplaceException { // Arrange From 5721ecc6e7a8c639c351870acc113640fff81bcf Mon Sep 17 00:00:00 2001 From: soriaoli Date: Mon, 15 Jun 2026 15:04:34 +0200 Subject: [PATCH 06/19] [update-marketplace-to-consume-metrics] - Fix missdefinition. --- .../openapi/openapi-component_provisioner-v1.0.0.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml index 123a387..d7fe849 100644 --- a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -695,7 +695,7 @@ components: example: 1707043200000 type: number updatedAt: - description: The timestamp of the last change of the catalog item. + description: The timestamp of the last change of the provisioned component. example: 1707043200000 type: number Pagination: From 4fc4869e3897aa1638e423b225edd2a181f25e24 Mon Sep 17 00:00:00 2001 From: soriaoli Date: Mon, 15 Jun 2026 16:55:38 +0200 Subject: [PATCH 07/19] [update-marketplace-to-consume-metrics] - Add JWT token generation configuration. --- .../api/MarketplaceMetricsApi.java | 86 +++++++ .../MarketplaceProjectComponentMetrics.java | 205 ++++++++++++++++ .../MarketplaceProjectComponentsMetrics.java | 122 ++++++++++ ...aceProjectComponentsMetricsPagination.java | 219 ++++++++++++++++++ .../model/RestErrorMessage.java | 95 ++++++++ application.yaml | 9 + .../core/security/jwt/JwtTokenException.java | 12 + .../core/security/jwt/JwtTokenProperties.java | 17 ++ .../core/security/jwt/JwtTokenResponse.java | 25 ++ .../core/security/jwt/JwtTokenService.java | 68 ++++++ .../config/MarketplaceInstanceConfig.java | 11 + .../service/impl/MarketplaceServiceImpl.java | 33 ++- 12 files changed, 900 insertions(+), 2 deletions(-) create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentMetrics.java create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetrics.java create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetricsPagination.java create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/RestErrorMessage.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenException.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenProperties.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenResponse.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenService.java diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java new file mode 100644 index 0000000..b44ef2e --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java @@ -0,0 +1,86 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.15.0). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.opendevstack.apiservice.marketplacemetrics.api; + +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; +import org.opendevstack.apiservice.marketplacemetrics.model.RestErrorMessage; +import io.swagger.v3.oas.annotations.ExternalDocumentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") +@Validated +@Tag(name = "MarketplaceMetrics", description = "the MarketplaceMetrics API") +public interface MarketplaceMetricsApi { + + public static final String PATH_GET_MARKETPLACE_PROJECT_COMPONENTS_METRICS = "/metrics/marketplace/project-components"; + /** + * GET /metrics/marketplace/project-components : Get Marketplace metrics regarding project components. + * Returns all the components that were provisioned from the Marketplace in all the projects. + * + * @param page Page number (from 0 until limit) (optional, default to 0) + * @param size Page size (optional, default to 20) + * @return A paginated list of all the provisioned project components (status code 200) + * or Invalid client token on the request. (status code 401) + * or Insufficient permissions for the client to access the resource. (status code 403) + * or Server error. (status code 500) + */ + @Operation( + operationId = "getMarketplaceProjectComponentsMetrics", + summary = "Get Marketplace metrics regarding project components.", + description = "Returns all the components that were provisioned from the Marketplace in all the projects.", + tags = { "MarketplaceMetrics" }, + responses = { + @ApiResponse(responseCode = "200", description = "A paginated list of all the provisioned project components", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = MarketplaceProjectComponentsMetrics.class)) + }), + @ApiResponse(responseCode = "401", description = "Invalid client token on the request.", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) + }), + @ApiResponse(responseCode = "403", description = "Insufficient permissions for the client to access the resource.", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) + }), + @ApiResponse(responseCode = "500", description = "Server error.", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) + }) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = MarketplaceMetricsApi.PATH_GET_MARKETPLACE_PROJECT_COMPONENTS_METRICS, + produces = { "application/json" } + ) + + ResponseEntity getMarketplaceProjectComponentsMetrics( + @Parameter(name = "page", description = "Page number (from 0 until limit)", in = ParameterIn.QUERY) @Valid @RequestParam(value = "page", required = false, defaultValue = "0") Integer page, + @Parameter(name = "size", description = "Page size", in = ParameterIn.QUERY) @Valid @RequestParam(value = "size", required = false, defaultValue = "20") Integer size + ); + +} diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentMetrics.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentMetrics.java new file mode 100644 index 0000000..dfdda38 --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentMetrics.java @@ -0,0 +1,205 @@ +package org.opendevstack.apiservice.marketplacemetrics.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.math.BigDecimal; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * MarketplaceProjectComponentMetrics + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") +public class MarketplaceProjectComponentMetrics { + + private @Nullable String projectKey; + + private @Nullable String componentId; + + private @Nullable String caller; + + private @Nullable String catalogItemSlug; + + private @Nullable BigDecimal createdAt; + + private @Nullable BigDecimal updatedAt; + + public MarketplaceProjectComponentMetrics projectKey(@Nullable String projectKey) { + this.projectKey = projectKey; + return this; + } + + /** + * The projectKey which the component is provisioned for. + * @return projectKey + */ + + @Schema(name = "projectKey", example = "SOMEPROJECT", description = "The projectKey which the component is provisioned for.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("projectKey") + public @Nullable String getProjectKey() { + return projectKey; + } + + public void setProjectKey(@Nullable String projectKey) { + this.projectKey = projectKey; + } + + public MarketplaceProjectComponentMetrics componentId(@Nullable String componentId) { + this.componentId = componentId; + return this; + } + + /** + * The componentId set by the user. + * @return componentId + */ + @Pattern(regexp = "^(?!\\s*$).+") @Size(min = 1) + @Schema(name = "componentId", example = "any-component-id-from-backend", description = "The componentId set by the user.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("componentId") + public @Nullable String getComponentId() { + return componentId; + } + + public void setComponentId(@Nullable String componentId) { + this.componentId = componentId; + } + + public MarketplaceProjectComponentMetrics caller(@Nullable String caller) { + this.caller = caller; + return this; + } + + /** + * The email of who provisioned the component. + * @return caller + */ + + @Schema(name = "caller", example = "some-person@email.com", description = "The email of who provisioned the component.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("caller") + public @Nullable String getCaller() { + return caller; + } + + public void setCaller(@Nullable String caller) { + this.caller = caller; + } + + public MarketplaceProjectComponentMetrics catalogItemSlug(@Nullable String catalogItemSlug) { + this.catalogItemSlug = catalogItemSlug; + return this; + } + + /** + * The provisioned catalog item slug. + * @return catalogItemSlug + */ + + @Schema(name = "catalogItemSlug", example = "some_technology-name", description = "The provisioned catalog item slug.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("catalogItemSlug") + public @Nullable String getCatalogItemSlug() { + return catalogItemSlug; + } + + public void setCatalogItemSlug(@Nullable String catalogItemSlug) { + this.catalogItemSlug = catalogItemSlug; + } + + public MarketplaceProjectComponentMetrics createdAt(@Nullable BigDecimal createdAt) { + this.createdAt = createdAt; + return this; + } + + /** + * The timestamp of the provision action. + * @return createdAt + */ + @Valid + @Schema(name = "createdAt", example = "1707043200000", description = "The timestamp of the provision action.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("createdAt") + public @Nullable BigDecimal getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(@Nullable BigDecimal createdAt) { + this.createdAt = createdAt; + } + + public MarketplaceProjectComponentMetrics updatedAt(@Nullable BigDecimal updatedAt) { + this.updatedAt = updatedAt; + return this; + } + + /** + * The timestamp of the last change of the provisioned component. + * @return updatedAt + */ + @Valid + @Schema(name = "updatedAt", example = "1707043200000", description = "The timestamp of the last change of the provisioned component.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("updatedAt") + public @Nullable BigDecimal getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(@Nullable BigDecimal updatedAt) { + this.updatedAt = updatedAt; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MarketplaceProjectComponentMetrics marketplaceProjectComponentMetrics = (MarketplaceProjectComponentMetrics) o; + return Objects.equals(this.projectKey, marketplaceProjectComponentMetrics.projectKey) && + Objects.equals(this.componentId, marketplaceProjectComponentMetrics.componentId) && + Objects.equals(this.caller, marketplaceProjectComponentMetrics.caller) && + Objects.equals(this.catalogItemSlug, marketplaceProjectComponentMetrics.catalogItemSlug) && + Objects.equals(this.createdAt, marketplaceProjectComponentMetrics.createdAt) && + Objects.equals(this.updatedAt, marketplaceProjectComponentMetrics.updatedAt); + } + + @Override + public int hashCode() { + return Objects.hash(projectKey, componentId, caller, catalogItemSlug, createdAt, updatedAt); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class MarketplaceProjectComponentMetrics {\n"); + sb.append(" projectKey: ").append(toIndentedString(projectKey)).append("\n"); + sb.append(" componentId: ").append(toIndentedString(componentId)).append("\n"); + sb.append(" caller: ").append(toIndentedString(caller)).append("\n"); + sb.append(" catalogItemSlug: ").append(toIndentedString(catalogItemSlug)).append("\n"); + sb.append(" createdAt: ").append(toIndentedString(createdAt)).append("\n"); + sb.append(" updatedAt: ").append(toIndentedString(updatedAt)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetrics.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetrics.java new file mode 100644 index 0000000..5cfc12a --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetrics.java @@ -0,0 +1,122 @@ +package org.opendevstack.apiservice.marketplacemetrics.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetricsPagination; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * MarketplaceProjectComponentsMetrics + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") +public class MarketplaceProjectComponentsMetrics { + + @Valid + private List<@Valid MarketplaceProjectComponentMetrics> data = new ArrayList<>(); + + private @Nullable MarketplaceProjectComponentsMetricsPagination pagination; + + public MarketplaceProjectComponentsMetrics data(List<@Valid MarketplaceProjectComponentMetrics> data) { + this.data = data; + return this; + } + + public MarketplaceProjectComponentsMetrics addDataItem(MarketplaceProjectComponentMetrics dataItem) { + if (this.data == null) { + this.data = new ArrayList<>(); + } + this.data.add(dataItem); + return this; + } + + /** + * Get data + * @return data + */ + @Valid + @Schema(name = "data", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("data") + public List<@Valid MarketplaceProjectComponentMetrics> getData() { + return data; + } + + public void setData(List<@Valid MarketplaceProjectComponentMetrics> data) { + this.data = data; + } + + public MarketplaceProjectComponentsMetrics pagination(@Nullable MarketplaceProjectComponentsMetricsPagination pagination) { + this.pagination = pagination; + return this; + } + + /** + * Get pagination + * @return pagination + */ + @Valid + @Schema(name = "pagination", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("pagination") + public @Nullable MarketplaceProjectComponentsMetricsPagination getPagination() { + return pagination; + } + + public void setPagination(@Nullable MarketplaceProjectComponentsMetricsPagination pagination) { + this.pagination = pagination; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MarketplaceProjectComponentsMetrics marketplaceProjectComponentsMetrics = (MarketplaceProjectComponentsMetrics) o; + return Objects.equals(this.data, marketplaceProjectComponentsMetrics.data) && + Objects.equals(this.pagination, marketplaceProjectComponentsMetrics.pagination); + } + + @Override + public int hashCode() { + return Objects.hash(data, pagination); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class MarketplaceProjectComponentsMetrics {\n"); + sb.append(" data: ").append(toIndentedString(data)).append("\n"); + sb.append(" pagination: ").append(toIndentedString(pagination)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetricsPagination.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetricsPagination.java new file mode 100644 index 0000000..f694bf6 --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetricsPagination.java @@ -0,0 +1,219 @@ +package org.opendevstack.apiservice.marketplacemetrics.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import java.net.URI; +import java.util.Arrays; +import org.openapitools.jackson.nullable.JsonNullable; +import org.springframework.lang.Nullable; +import java.util.NoSuchElementException; +import org.openapitools.jackson.nullable.JsonNullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * MarketplaceProjectComponentsMetricsPagination + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") +public class MarketplaceProjectComponentsMetricsPagination { + + private @Nullable Integer page; + + private @Nullable Integer size; + + private @Nullable Integer totalElements; + + private @Nullable Integer totalPages; + + private JsonNullable next = JsonNullable.undefined(); + + private JsonNullable previous = JsonNullable.undefined(); + + public MarketplaceProjectComponentsMetricsPagination page(@Nullable Integer page) { + this.page = page; + return this; + } + + /** + * Current page of the response. + * @return page + */ + + @Schema(name = "page", example = "0", description = "Current page of the response.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("page") + public @Nullable Integer getPage() { + return page; + } + + public void setPage(@Nullable Integer page) { + this.page = page; + } + + public MarketplaceProjectComponentsMetricsPagination size(@Nullable Integer size) { + this.size = size; + return this; + } + + /** + * Current page size of the response. + * @return size + */ + + @Schema(name = "size", example = "20", description = "Current page size of the response.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("size") + public @Nullable Integer getSize() { + return size; + } + + public void setSize(@Nullable Integer size) { + this.size = size; + } + + public MarketplaceProjectComponentsMetricsPagination totalElements(@Nullable Integer totalElements) { + this.totalElements = totalElements; + return this; + } + + /** + * Total number of elements. + * @return totalElements + */ + + @Schema(name = "totalElements", example = "117", description = "Total number of elements.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("totalElements") + public @Nullable Integer getTotalElements() { + return totalElements; + } + + public void setTotalElements(@Nullable Integer totalElements) { + this.totalElements = totalElements; + } + + public MarketplaceProjectComponentsMetricsPagination totalPages(@Nullable Integer totalPages) { + this.totalPages = totalPages; + return this; + } + + /** + * Total number of pages of this exact size. + * @return totalPages + */ + + @Schema(name = "totalPages", example = "6", description = "Total number of pages of this exact size.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("totalPages") + public @Nullable Integer getTotalPages() { + return totalPages; + } + + public void setTotalPages(@Nullable Integer totalPages) { + this.totalPages = totalPages; + } + + public MarketplaceProjectComponentsMetricsPagination next(URI next) { + this.next = JsonNullable.of(next); + return this; + } + + /** + * URL of the next page (or null if the current is the last one) + * @return next + */ + @Valid + @Schema(name = "next", example = "https://api.example.com/resources?page=1&size=20", description = "URL of the next page (or null if the current is the last one)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("next") + public JsonNullable getNext() { + return next; + } + + public void setNext(JsonNullable next) { + this.next = next; + } + + public MarketplaceProjectComponentsMetricsPagination previous(URI previous) { + this.previous = JsonNullable.of(previous); + return this; + } + + /** + * URL of the previous page (or null if the current is the first one) + * @return previous + */ + @Valid + @Schema(name = "previous", example = "https://api.example.com/resources?page=0&size=20", description = "URL of the previous page (or null if the current is the first one)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + @JsonProperty("previous") + public JsonNullable getPrevious() { + return previous; + } + + public void setPrevious(JsonNullable previous) { + this.previous = previous; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MarketplaceProjectComponentsMetricsPagination marketplaceProjectComponentsMetricsPagination = (MarketplaceProjectComponentsMetricsPagination) o; + return Objects.equals(this.page, marketplaceProjectComponentsMetricsPagination.page) && + Objects.equals(this.size, marketplaceProjectComponentsMetricsPagination.size) && + Objects.equals(this.totalElements, marketplaceProjectComponentsMetricsPagination.totalElements) && + Objects.equals(this.totalPages, marketplaceProjectComponentsMetricsPagination.totalPages) && + equalsNullable(this.next, marketplaceProjectComponentsMetricsPagination.next) && + equalsNullable(this.previous, marketplaceProjectComponentsMetricsPagination.previous); + } + + private static boolean equalsNullable(JsonNullable a, JsonNullable b) { + return a == b || (a != null && b != null && a.isPresent() && b.isPresent() && Objects.deepEquals(a.get(), b.get())); + } + + @Override + public int hashCode() { + return Objects.hash(page, size, totalElements, totalPages, hashCodeNullable(next), hashCodeNullable(previous)); + } + + private static int hashCodeNullable(JsonNullable a) { + if (a == null) { + return 1; + } + return a.isPresent() ? Arrays.deepHashCode(new Object[]{a.get()}) : 31; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class MarketplaceProjectComponentsMetricsPagination {\n"); + sb.append(" page: ").append(toIndentedString(page)).append("\n"); + sb.append(" size: ").append(toIndentedString(size)).append("\n"); + sb.append(" totalElements: ").append(toIndentedString(totalElements)).append("\n"); + sb.append(" totalPages: ").append(toIndentedString(totalPages)).append("\n"); + sb.append(" next: ").append(toIndentedString(next)).append("\n"); + sb.append(" previous: ").append(toIndentedString(previous)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/RestErrorMessage.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/RestErrorMessage.java new file mode 100644 index 0000000..4376b03 --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/RestErrorMessage.java @@ -0,0 +1,95 @@ +package org.opendevstack.apiservice.marketplacemetrics.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import io.swagger.v3.oas.annotations.media.Schema; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * RestErrorMessage + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") +public class RestErrorMessage { + + private String message; + + public RestErrorMessage() { + super(); + } + + /** + * Constructor with only required parameters + */ + public RestErrorMessage(String message) { + this.message = message; + } + + public RestErrorMessage message(String message) { + this.message = message; + return this; + } + + /** + * Get message + * @return message + */ + @NotNull + @Schema(name = "message", requiredMode = Schema.RequiredMode.REQUIRED) + @JsonProperty("message") + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RestErrorMessage restErrorMessage = (RestErrorMessage) o; + return Objects.equals(this.message, restErrorMessage.message); + } + + @Override + public int hashCode() { + return Objects.hash(message); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class RestErrorMessage {\n"); + sb.append(" message: ").append(toIndentedString(message)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } +} + diff --git a/application.yaml b/application.yaml index f276531..8907f9d 100644 --- a/application.yaml +++ b/application.yaml @@ -68,6 +68,13 @@ app: - /actuator/health - /actuator/info - /api/v1/projects/*/platforms + jwt: + # Azure AD token endpoint used for request a token. + token-url: ${OBO_TOKEN_URL:https://login.microsoftonline.com//oauth2/v2.0/token} + # Client ID of this application's Azure AD app registration. + client-id: ${OBO_CLIENT_ID} + # Client secret for token request. Must be injected via environment variable or secret store. + client-secret: ${OBO_CLIENT_SECRET} obo: # Azure AD token endpoint used for On-Behalf-Of token exchange. token-url: ${OBO_TOKEN_URL:https://login.microsoftonline.com/${AZURE_TENANT_ID:}/oauth2/v2.0/token} @@ -315,3 +322,5 @@ externalservices: trust-all-certificates: ${MARKETPLACE_TRUST_ALL_CERTS:false} username: ${MARKETPLACE_USERNAME:} password: ${MARKETPLACE_PASSWORD:} + tenant-id: ${MARKETPLACE_TENANT_ID:} + jwt-scope: ${MARKETPLACE_JWT_SCOPE:} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenException.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenException.java new file mode 100644 index 0000000..4ca584e --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.core.security.jwt; + +public class JwtTokenException extends RuntimeException { + + public JwtTokenException(String message) { + super(message); + } + + public JwtTokenException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenProperties.java new file mode 100644 index 0000000..8d4084c --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenProperties.java @@ -0,0 +1,17 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "app.security.jwt") +@Data +public class JwtTokenProperties { + + private String tokenUrl; + + private String clientId; + + private String clientSecret; +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenResponse.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenResponse.java new file mode 100644 index 0000000..4cf01b9 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenResponse.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class JwtTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private int expiresIn; + + private String scope; +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenService.java new file mode 100644 index 0000000..1f5d80d --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenService.java @@ -0,0 +1,68 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@Service +@Slf4j +public class JwtTokenService { + + private static final String GRANT_TYPE = "client_credentials"; + + private final JwtTokenProperties properties; + private final RestTemplate restTemplate; + + @Autowired + public JwtTokenService(JwtTokenProperties properties, RestTemplate restTemplate) { + this.properties = properties; + this.restTemplate = restTemplate; + } + + /** + * Requests an access token using the given JWT assertion. + * + * @param scope the target API scope (e.g. {@code api:///Api.Access}) + * + * @return the access token string + * @throws JwtTokenException if the token request fails + */ + public String requestToken(String scope, String tenantId) { + log.debug("Requesting access token using JWT assertion"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", GRANT_TYPE); + body.add("client_id", properties.getClientId()); + body.add("client_secret", properties.getClientSecret()); + body.add("scope", scope); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> request = new HttpEntity<>(body, headers); + + try { + String urlWithTenant = properties.getTokenUrl().replace("", tenantId); + + ResponseEntity response = + restTemplate.postForEntity(urlWithTenant, request, JwtTokenResponse.class); + + if (response.getBody() == null || response.getBody().getAccessToken() == null) { + throw new JwtTokenException("JWT token response body or access_token is null"); + } + + log.debug("JWT token obtained successfully, expires in {} seconds", response.getBody().getExpiresIn()); + return response.getBody().getAccessToken(); + } catch (RestClientException e) { + throw new JwtTokenException("Failed to exchange JWT for JWT token: " + e.getMessage(), e); + } + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java index 5f0302f..9ad37ec 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -51,6 +51,17 @@ public class MarketplaceInstanceConfig { */ private String oboScope; + /** + * The tenant ID used for JWT token request when calling this Marketplace instance. + */ + private String tenantId; + + /** + * OAuth2 scope used for JWT token exchange when calling this Marketplace instance. + * Example: {@code api:///Api.Access} + */ + private String jwtScope; + /** * Bypass configuration. When the incoming token already targets the configured bypass * audience and scope, the OBO token exchange is skipped and the token is forwarded as-is. diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index ceae318..9140be0 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -1,6 +1,7 @@ package org.opendevstack.apiservice.externalservice.marketplace.service.impl; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtTokenService; import org.opendevstack.apiservice.core.security.jwt.JwtUtils; import org.opendevstack.apiservice.core.security.obo.OboTokenService; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; @@ -30,11 +31,14 @@ public class MarketplaceServiceImpl implements MarketplaceService { private final MarketplaceApiClientFactory clientFactory; private final OboTokenService oboTokenService; + private final JwtTokenService jwtTokenService; public MarketplaceServiceImpl(MarketplaceApiClientFactory clientFactory, - OboTokenService oboTokenService) { + OboTokenService oboTokenService, + JwtTokenService jwtTokenService) { this.clientFactory = clientFactory; this.oboTokenService = oboTokenService; + this.jwtTokenService = jwtTokenService; log.info("MarketplaceServiceImpl initialized"); } @@ -105,7 +109,7 @@ public ProjectComponentProvisionStatus getProjectComponent(String projectId, Str public ProjectComponentListResponse getAllProjectComponents(String instanceName, Integer page, Integer size) throws MarketplaceException { log.debug("Marketplace service GET all components with page {} and size {} in instance {} ", page, size, instanceName); try { - MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + MarketplaceApiClient marketplaceClient = getJwtAuthenticatedClient(instanceName); ApiClient apiClient = marketplaceClient.getApiClient(); ProjectComponentsWithProvisionStatusApi projectComponentsApi = new ProjectComponentsWithProvisionStatusApi(apiClient); apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); @@ -384,4 +388,29 @@ private String getOboToken(String instanceName, String assertion, String oboScop ex); } } + + /** + * Creates a {@link MarketplaceApiClient} authenticated with an OBO token + * obtained from the current request's JWT. + */ + private MarketplaceApiClient getJwtAuthenticatedClient(String instanceName) throws MarketplaceException { + MarketplaceApiClient client = clientFactory.getClient(instanceName); + + String jwtToken; + + try { + String scope = client.getConfig().getJwtScope(); + String tenantId = client.getConfig().getTenantId(); + + jwtToken = jwtTokenService.requestToken(scope, tenantId); + } catch (RuntimeException ex) { + throw new MarketplaceException( + String.format( + "Failed to obtain JWT token for Marketplace instance '%s'", instanceName), + ex); + } + + client.setBearerToken(jwtToken); + return client; + } } From 119d8f4d5fb22f71f7a1c2cc2c23edd88a6512c2 Mon Sep 17 00:00:00 2001 From: soriaoli Date: Tue, 16 Jun 2026 09:12:35 +0200 Subject: [PATCH 08/19] [update-marketplace-to-consume-metrics] - Fix unit tests. --- .../service/MarketplaceServiceImplTest.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java index 7239b96..d8eb18d 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -8,6 +8,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.stubbing.OngoingStubbing; +import org.opendevstack.apiservice.core.security.jwt.JwtTokenService; import org.opendevstack.apiservice.core.security.obo.OboTokenService; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; @@ -80,11 +81,14 @@ class MarketplaceServiceImplTest { @Mock private OboTokenService oboTokenService; + @Mock + private JwtTokenService jwtTokenService; + private MarketplaceService marketplaceService; @BeforeEach void setUp() { - marketplaceService = new MarketplaceServiceImpl(clientFactory, oboTokenService); + marketplaceService = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService); // Set up a fake SecurityContext so JwtUtils.getTokenValue() works Jwt jwt = Jwt.withTokenValue("test-jwt-assertion") @@ -315,7 +319,7 @@ void testGetProjectComponent_NotFound_ReturnsNull() throws MarketplaceException void testIsHealthy_NoInstancesConfigured_ReturnsFalse() { when(clientFactory.getAvailableInstances()).thenReturn(Set.of()); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService) { @Override protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { return true; @@ -338,7 +342,7 @@ void testIsHealthy_BothEndpointsUp_ReturnsTrue() throws MarketplaceException { when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService) { @Override protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { return true; @@ -361,7 +365,7 @@ void testIsHealthy_ProvisionerDown_ReturnsFalse() throws MarketplaceException { when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService) { @Override protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { return false; @@ -379,7 +383,7 @@ void testIsHealthy_CatalogDown_ReturnsFalse() throws MarketplaceException { when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService) { @Override protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { return true; From f9a5ba2113e0230aeaf07449680370897522aa9c Mon Sep 17 00:00:00 2001 From: "Vila,Jordi (IT EDP)" Date: Wed, 17 Jun 2026 09:34:53 +0200 Subject: [PATCH 09/19] Create api-marketplace-metrics module --- .gitignore | 2 + api-marketplace-metrics/README.md | 76 ++++++++ .../openapi/api-marketplace-metrics.yaml | 178 ++++++++++++++++++ api-marketplace-metrics/pom.xml | 142 ++++++++++++++ .../MarketplaceMetricsController.java | 40 ++++ .../MarketplaceMetricsException.java | 15 ++ .../facade/MarketplaceMetricsFacade.java | 8 + .../impl/MarketplaceMetricsFacadeImpl.java | 35 ++++ .../mapper/MarketplaceMetricsMapper.java | 93 +++++++++ .../MarketplaceMetricsFacadeImplTest.java | 41 ++++ .../mapper/MarketplaceMetricsMapperTest.java | 28 +++ application.yaml | 5 +- pom.xml | 1 + 13 files changed, 662 insertions(+), 2 deletions(-) create mode 100644 api-marketplace-metrics/README.md create mode 100644 api-marketplace-metrics/openapi/api-marketplace-metrics.yaml create mode 100644 api-marketplace-metrics/pom.xml create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/exception/MarketplaceMetricsException.java create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java create mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java create mode 100644 api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java create mode 100644 api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java diff --git a/.gitignore b/.gitignore index 46a7ede..7b150c8 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,8 @@ secring.gpg *.yaml.dec .scannerwork/ # Autogenerated API interface files +api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api +api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/api api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/model api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/api diff --git a/api-marketplace-metrics/README.md b/api-marketplace-metrics/README.md new file mode 100644 index 0000000..a2f0747 --- /dev/null +++ b/api-marketplace-metrics/README.md @@ -0,0 +1,76 @@ +# Marketplace Metrics API Module + +This module provides REST API endpoint to obtain metrics from the Marketplace. + +## Integration with Core Application + +To integrate these modules with your core application: + +1. **Add module dependencies** to your core `pom.xml`: + +```xml + + + org.opendevstack.apiservice + api-marketplace-metrics + ${project.version} + + +``` +## Error Handling + +The API provides comprehensive error handling with structured error responses: + +- **400 Bad Request**: Invalid request data +- **404 Not Found**: Resource not found (project, user) +- **409 Conflict**: Resource already exists +- **500 Internal Server Error**: System errors, automation platform failures + +## Monitoring and Observability + +The modules provide comprehensive logging and can be monitored through: + +1. **Application Logs**: Structured logging for all operations +2. **Metrics**: Integration with Spring Boot Actuator +3. **Health Checks**: Automation platform connectivity checks +4. **Distributed Tracing**: Support for distributed tracing systems with Opentelemetry and Dynatrace + +## Testing + +Both modules include comprehensive test suites: + +- **Unit Tests**: Business logic validation +- **Integration Tests**: API endpoint testing +- **Mock Tests**: Automation platform interaction testing + +Run tests with: + +```bash +./mvnw test +``` + +## Development + +### Building the Modules + +```bash +# Build all modules +./mvnw clean install + +# Build specific module +./mvnw clean install -pl api-project-users +./mvnw clean install -pl api-marketplace-metrics +./mvnw clean install -pl external-service-app +``` + +### Running Locally + +```bash +# Run with dev profile +./mvnw spring-boot:run -Dspring-boot.run.profiles=dev +``` + +### API Documentation + +Swagger UI is available at: `http://localhost:8080/swagger-ui.html` + diff --git a/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml b/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml new file mode 100644 index 0000000..a444e60 --- /dev/null +++ b/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml @@ -0,0 +1,178 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/v1 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: +- name: Marketplace metrics + description: API for managing marketplace metrics +paths: + /metrics/marketplace/project-components: + get: + tags: + - MarketplaceMetrics + summary: Get Marketplace metrics regarding project components. + description: Returns all the components that were provisioned from the Marketplace in all the projects. + operationId: getMarketplaceProjectComponentsMetrics + parameters: + - name: page + in: query + description: Page number (from 0 until limit) + required: false + schema: + type: integer + default: 0 + - name: size + in: query + description: Page size + required: false + schema: + type: integer + default: 20 + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/MarketplaceProjectComponentsMetrics" + description: A paginated list of all the provisioned project components + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/RestErrorMessage" + description: Invalid client token on the request. + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/RestErrorMessage" + description: Insufficient permissions for the client to access the resource. + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/RestErrorMessage" + description: Server error. +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + + MarketplaceProjectComponentsMetrics: + type: object + example: + data: + - projectKey: SOMEPROJECT + componentId: any-component-id-from-backend + caller: some-person@email.com + catalogItemSlug: some_technology-name + createdAt: 1707043200000 + updatedAt: 1707043200000 + - projectKey: ANOTHERPROJECT + componentId: another-component-id-from-backend + caller: some-person2@email.com + catalogItemSlug: another_technology-name + createdAt: 1707043200001 + updatedAt: 1707043200002 + pagination: + page: 0 + size: 20 + totalElements: 117 + totalPages: 6 + next: https://api.example.com/resources?page=1&size=20 + previous: null + properties: + data: + type: array + items: + $ref: '#/components/schemas/MarketplaceProjectComponentMetrics' + pagination: + $ref: '#/components/schemas/MarketplaceProjectComponentsMetricsPagination' + MarketplaceProjectComponentMetrics: + type: object + example: + projectKey: SOMEPROJECT + componentId: any-component-id-from-backend + caller: some-person@email.com + catalogItemSlug: some_technology-name + createdAt: 1707043200000 + updatedAt: 1707043200000 + properties: + projectKey: + description: The projectKey which the component is provisioned for. + example: SOMEPROJECT + type: string + componentId: + description: The componentId set by the user. + example: any-component-id-from-backend + minLength: 1 + pattern: ^(?!\s*$).+ + type: string + caller: + description: The email of who provisioned the component. + example: some-person@email.com + type: string + catalogItemSlug: + description: The provisioned catalog item slug. + example: some_technology-name + type: string + createdAt: + description: The timestamp of the provision action. + example: 1707043200000 + type: number + updatedAt: + description: The timestamp of the last change of the provisioned component. + example: 1707043200000 + type: number + MarketplaceProjectComponentsMetricsPagination: + type: object + example: + page: 0 + size: 20 + totalElements: 117 + totalPages: 6 + next: "https://api.example.com/resources?page=1&size=20" + previous: null + properties: + page: + type: integer + example: 0 + description: Current page of the response. + size: + type: integer + example: 20 + description: Current page size of the response. + totalElements: + type: integer + format: integer + example: 117 + description: Total number of elements. + totalPages: + type: integer + example: 6 + description: Total number of pages of this exact size. + next: + type: string + format: uri + nullable: true + example: "https://api.example.com/resources?page=1&size=20" + description: URL of the next page (or null if the current is the last one) + previous: + type: string + format: uri + nullable: true + example: "https://api.example.com/resources?page=0&size=20" + description: URL of the previous page (or null if the current is the first one) diff --git a/api-marketplace-metrics/pom.xml b/api-marketplace-metrics/pom.xml new file mode 100644 index 0000000..9dc6991 --- /dev/null +++ b/api-marketplace-metrics/pom.xml @@ -0,0 +1,142 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + api-marketplace-metrics + API Marketplace Metrics + API module for managing Marketplace metrics + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + + org.opendevstack.apiservice + external-service-marketplace + ${project.version} + + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + org.projectlombok + lombok + provided + + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-core + + + + + + + org.openapitools + openapi-generator-maven-plugin + + + generate-api-marketplace-metrics + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-marketplace-metrics.yaml + org.opendevstack.apiservice.marketplacemetrics.api + org.opendevstack.apiservice.marketplacemetrics.model + org.opendevstack.apiservice.marketplacemetrics + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java new file mode 100644 index 0000000..231fcfb --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java @@ -0,0 +1,40 @@ +package org.opendevstack.apiservice.marketplacemetrics.controller; + +import lombok.AllArgsConstructor; +import org.opendevstack.apiservice.marketplacemetrics.api.MarketplaceMetricsApi; +import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException; +import org.opendevstack.apiservice.marketplacemetrics.facade.MarketplaceMetricsFacade; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/projects") +@AllArgsConstructor +public class MarketplaceMetricsController implements MarketplaceMetricsApi { + + MarketplaceMetricsFacade marketplaceMetricsFacade; + + @Override + @CrossOrigin(origins = "*") + @GetMapping("/metrics/marketplace/project-components") + public ResponseEntity getMarketplaceProjectComponentsMetrics(Integer page, Integer size) { + MarketplaceProjectComponentsMetrics marketplaceProjectComponentsMetrics; + try { + marketplaceProjectComponentsMetrics = marketplaceMetricsFacade.getMarketplaceProjectComponentsMetrics(page, size); + } catch (MarketplaceMetricsException e) { + return sneakyThrow(e); + } + + if (marketplaceProjectComponentsMetrics == null) { + return ResponseEntity.notFound().build(); + } else { + return ResponseEntity.ok(marketplaceProjectComponentsMetrics); + } + } + + @SuppressWarnings("unchecked") + private static R sneakyThrow(Throwable e) throws E { + throw (E) e; + } +} diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/exception/MarketplaceMetricsException.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/exception/MarketplaceMetricsException.java new file mode 100644 index 0000000..1cae255 --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/exception/MarketplaceMetricsException.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.marketplacemetrics.exception; + +/** + * Exception thrown when there are issues with projects info service operations. + */ +public class MarketplaceMetricsException extends Exception { + + public MarketplaceMetricsException(String message) { + super(message); + } + + public MarketplaceMetricsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java new file mode 100644 index 0000000..94bd912 --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.marketplacemetrics.facade; + +import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; + +public interface MarketplaceMetricsFacade { + MarketplaceProjectComponentsMetrics getMarketplaceProjectComponentsMetrics(Integer page, Integer size) throws MarketplaceMetricsException; +} diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java new file mode 100644 index 0000000..5114781 --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java @@ -0,0 +1,35 @@ +package org.opendevstack.apiservice.marketplacemetrics.facade.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; +import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException; +import org.opendevstack.apiservice.marketplacemetrics.facade.MarketplaceMetricsFacade; +import org.opendevstack.apiservice.marketplacemetrics.mapper.MarketplaceMetricsMapper; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class MarketplaceMetricsFacadeImpl implements MarketplaceMetricsFacade { + + private final MarketplaceService marketplaceService; + private final MarketplaceMetricsMapper mapper; + + public MarketplaceMetricsFacadeImpl(MarketplaceService marketplaceService, MarketplaceMetricsMapper mapper) { + this.marketplaceService = marketplaceService; + this.mapper = mapper; + } + + @Override + public MarketplaceProjectComponentsMetrics getMarketplaceProjectComponentsMetrics(Integer page, Integer size) throws MarketplaceMetricsException { + try { + ProjectComponentListResponse allProjectComponents = + marketplaceService.getAllProjectComponents(page, size); + return mapper.toApiModel(allProjectComponents); + } catch (MarketplaceException e) { + throw new MarketplaceMetricsException("Failed to retrieve marketplace project components metrics.", e); + } + } +} diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java new file mode 100644 index 0000000..b7eeba9 --- /dev/null +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java @@ -0,0 +1,93 @@ +package org.opendevstack.apiservice.marketplacemetrics.mapper; + +import org.openapitools.jackson.nullable.JsonNullable; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.Pagination; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetricsPagination; +import org.springframework.stereotype.Component; + +import java.util.stream.Collectors; + +/** + * Mapper class for converting between external service and API models. + */ +@Component +public class MarketplaceMetricsMapper { + + /** + * Converts external service ProjectComponentListResponse to API MarketplaceProjectComponentsMetrics. + * + * @param projectComponentListResponse the external service project components metrics + * @return the API MarketplaceProjectComponentsMetrics + */ + public MarketplaceProjectComponentsMetrics toApiModel(ProjectComponentListResponse projectComponentListResponse) { + + if (projectComponentListResponse == null) { + return null; + } + + MarketplaceProjectComponentsMetrics marketplaceProjectComponentsMetrics = new MarketplaceProjectComponentsMetrics(); + + if (projectComponentListResponse.getData() != null) { + marketplaceProjectComponentsMetrics.setData( + projectComponentListResponse.getData().stream() + .map(this::toApiMarketplaceProjectComponentMetric) + .collect(Collectors.toList()) + ); + } + + marketplaceProjectComponentsMetrics.setPagination(this.toApiMarketplaceProjectComponentsMetricsPagination(projectComponentListResponse.getPagination())); + + return marketplaceProjectComponentsMetrics; + } + + /** + * Converts external service ProjectComponentListItem to API MarketplaceProjectComponentMetrics. + * + * @param projectComponentListItem the external service ProjectComponentListItem + * @return the API MarketplaceProjectComponentMetrics + */ + private MarketplaceProjectComponentMetrics toApiMarketplaceProjectComponentMetric(ProjectComponentListItem projectComponentListItem) { + + if (projectComponentListItem == null) { + return null; + } + + MarketplaceProjectComponentMetrics marketplaceProjectComponentMetrics = new MarketplaceProjectComponentMetrics(); + marketplaceProjectComponentMetrics.setComponentId(projectComponentListItem.getComponentId()); + marketplaceProjectComponentMetrics.setProjectKey(projectComponentListItem.getProjectKey()); + marketplaceProjectComponentMetrics.setCaller(projectComponentListItem.getCaller()); + marketplaceProjectComponentMetrics.setCreatedAt(projectComponentListItem.getCreatedAt()); + marketplaceProjectComponentMetrics.setUpdatedAt(projectComponentListItem.getUpdatedAt()); + marketplaceProjectComponentMetrics.setCatalogItemSlug(projectComponentListItem.getCatalogItemSlug()); + + return marketplaceProjectComponentMetrics; + } + + /** + * Converts external service Pagination to API MarketplaceProjectComponentsMetricsPagination. + * + * @param pagination the external service Pagination + * @return the API MarketplaceProjectComponentsMetricsPagination + */ + private MarketplaceProjectComponentsMetricsPagination toApiMarketplaceProjectComponentsMetricsPagination(Pagination pagination) { + + if (pagination == null) { + return null; + } + + MarketplaceProjectComponentsMetricsPagination marketplaceProjectComponentsMetricsPagination = new MarketplaceProjectComponentsMetricsPagination(); + marketplaceProjectComponentsMetricsPagination.setNext(JsonNullable.of(pagination.getNext())); + marketplaceProjectComponentsMetricsPagination.setPage(pagination.getPage()); + marketplaceProjectComponentsMetricsPagination.setPrevious(JsonNullable.of(pagination.getPrevious())); + marketplaceProjectComponentsMetricsPagination.setSize(pagination.getSize()); + marketplaceProjectComponentsMetricsPagination.setTotalPages(pagination.getTotalPages()); + marketplaceProjectComponentsMetricsPagination.setTotalElements(pagination.getTotalElements()); + + return marketplaceProjectComponentsMetricsPagination; + } +} + diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java new file mode 100644 index 0000000..acaf051 --- /dev/null +++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java @@ -0,0 +1,41 @@ +package org.opendevstack.apiservice.marketplacemetrics.facade.impl; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.marketplacemetrics.mapper.MarketplaceMetricsMapper; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class MarketplaceMetricsFacadeImplTest { + + private MarketplaceService marketplaceService; + private MarketplaceMetricsMapper mapper; + + private MarketplaceMetricsFacadeImpl sut; + + @BeforeEach + void setup() { + marketplaceService = mock(MarketplaceService.class); + mapper = mock(MarketplaceMetricsMapper.class); + + sut = new MarketplaceMetricsFacadeImpl(marketplaceService, mapper); + + // Reset security context before each test + SecurityContextHolder.clearContext(); + } + + @AfterEach + void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + void fakeTest() { + assertThat(true).isTrue(); + } + +} \ No newline at end of file diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java new file mode 100644 index 0000000..5f228ae --- /dev/null +++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.marketplacemetrics.mapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class MarketplaceMetricsMapperTest { + + @InjectMocks + private MarketplaceMetricsMapper mapper; + + @BeforeEach + void setUp() { + mapper = new MarketplaceMetricsMapper(); + } + + @Test + void fakeTest() { + assertThat(true).isTrue(); + } + +} + diff --git a/application.yaml b/application.yaml index 8907f9d..fa1367d 100644 --- a/application.yaml +++ b/application.yaml @@ -68,6 +68,7 @@ app: - /actuator/health - /actuator/info - /api/v1/projects/*/platforms + - /api/v1/projects/metrics/marketplace/project-components jwt: # Azure AD token endpoint used for request a token. token-url: ${OBO_TOKEN_URL:https://login.microsoftonline.com//oauth2/v2.0/token} @@ -322,5 +323,5 @@ externalservices: trust-all-certificates: ${MARKETPLACE_TRUST_ALL_CERTS:false} username: ${MARKETPLACE_USERNAME:} password: ${MARKETPLACE_PASSWORD:} - tenant-id: ${MARKETPLACE_TENANT_ID:} - jwt-scope: ${MARKETPLACE_JWT_SCOPE:} + tenant-id: ${AZURE_TENANT_ID:} + jwt-scope: ${MARKETPLACE_OBO_SCOPE:} diff --git a/pom.xml b/pom.xml index f14bc6d..06f8435 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,7 @@ external-service-marketplace external-service-webhookproxy service-projects + api-marketplace-metrics api-project-users api-project-platform api-project From 0cb682716aff80351d678e6b5eee4675a9065e67 Mon Sep 17 00:00:00 2001 From: "Vila,Jordi (IT EDP)" Date: Wed, 17 Jun 2026 12:40:15 +0200 Subject: [PATCH 10/19] Fixes to integrate metrics e2e --- application.yaml | 1 - core/pom.xml | 6 ++++++ .../marketplace/service/impl/MarketplaceServiceImpl.java | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/application.yaml b/application.yaml index fa1367d..207bd0d 100644 --- a/application.yaml +++ b/application.yaml @@ -68,7 +68,6 @@ app: - /actuator/health - /actuator/info - /api/v1/projects/*/platforms - - /api/v1/projects/metrics/marketplace/project-components jwt: # Azure AD token endpoint used for request a token. token-url: ${OBO_TOKEN_URL:https://login.microsoftonline.com//oauth2/v2.0/token} diff --git a/core/pom.xml b/core/pom.xml index a0ba531..7a70f78 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -145,6 +145,12 @@ api-project-platform ${project.version} + + + org.opendevstack.apiservice + api-marketplace-metrics + ${project.version} + diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index 9140be0..43d1cb2 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -112,7 +112,7 @@ public ProjectComponentListResponse getAllProjectComponents(String instanceName, MarketplaceApiClient marketplaceClient = getJwtAuthenticatedClient(instanceName); ApiClient apiClient = marketplaceClient.getApiClient(); ProjectComponentsWithProvisionStatusApi projectComponentsApi = new ProjectComponentsWithProvisionStatusApi(apiClient); - apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); return projectComponentsApi.getAllProjectComponents(page, size); } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { throw new MarketplaceException( From e01ea541619ee6f9f8d842dadcab617e0eb46ec9 Mon Sep 17 00:00:00 2001 From: sergio-soria-bi Date: Wed, 17 Jun 2026 16:12:56 +0200 Subject: [PATCH 11/19] Feature/update marketplace client to consume new endpoint (#35) --- .../ClientCredentialsTokenException.java | 12 ++ .../ClientCredentialsTokenProperties.java} | 4 +- .../ClientCredentialsTokenResponse.java} | 4 +- .../ClientCredentialsTokenService.java} | 18 +-- .../core/security/jwt/JwtTokenException.java | 12 -- .../security/config/SecurityConfigTest.java | 2 +- .../ClientCredentialsTokenServiceTest.java | 139 ++++++++++++++++++ .../service/impl/MarketplaceServiceImpl.java | 10 +- .../service/MarketplaceServiceImplTest.java | 14 +- 9 files changed, 177 insertions(+), 38 deletions(-) create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenException.java rename core-security/src/main/java/org/opendevstack/apiservice/core/security/{jwt/JwtTokenProperties.java => client/crendentials/ClientCredentialsTokenProperties.java} (73%) rename core-security/src/main/java/org/opendevstack/apiservice/core/security/{jwt/JwtTokenResponse.java => client/crendentials/ClientCredentialsTokenResponse.java} (81%) rename core-security/src/main/java/org/opendevstack/apiservice/core/security/{jwt/JwtTokenService.java => client/crendentials/ClientCredentialsTokenService.java} (74%) delete mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenException.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenException.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenException.java new file mode 100644 index 0000000..f8d5eb7 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.core.security.client.crendentials; + +public class ClientCredentialsTokenException extends RuntimeException { + + public ClientCredentialsTokenException(String message) { + super(message); + } + + public ClientCredentialsTokenException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java similarity index 73% rename from core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenProperties.java rename to core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java index 8d4084c..f4c52a1 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenProperties.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.core.security.jwt; +package org.opendevstack.apiservice.core.security.client.crendentials; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -7,7 +7,7 @@ @Configuration @ConfigurationProperties(prefix = "app.security.jwt") @Data -public class JwtTokenProperties { +public class ClientCredentialsTokenProperties { private String tokenUrl; diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenResponse.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenResponse.java similarity index 81% rename from core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenResponse.java rename to core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenResponse.java index 4cf01b9..55cbe96 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenResponse.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenResponse.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.core.security.jwt; +package org.opendevstack.apiservice.core.security.client.crendentials; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @@ -10,7 +10,7 @@ @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) -public class JwtTokenResponse { +public class ClientCredentialsTokenResponse { @JsonProperty("access_token") private String accessToken; diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenService.java similarity index 74% rename from core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenService.java rename to core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenService.java index 1f5d80d..8875243 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenService.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenService.java @@ -1,4 +1,4 @@ -package org.opendevstack.apiservice.core.security.jwt; +package org.opendevstack.apiservice.core.security.client.crendentials; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -14,15 +14,15 @@ @Service @Slf4j -public class JwtTokenService { +public class ClientCredentialsTokenService { private static final String GRANT_TYPE = "client_credentials"; - private final JwtTokenProperties properties; + private final ClientCredentialsTokenProperties properties; private final RestTemplate restTemplate; @Autowired - public JwtTokenService(JwtTokenProperties properties, RestTemplate restTemplate) { + public ClientCredentialsTokenService(ClientCredentialsTokenProperties properties, RestTemplate restTemplate) { this.properties = properties; this.restTemplate = restTemplate; } @@ -33,7 +33,7 @@ public JwtTokenService(JwtTokenProperties properties, RestTemplate restTemplate) * @param scope the target API scope (e.g. {@code api:///Api.Access}) * * @return the access token string - * @throws JwtTokenException if the token request fails + * @throws ClientCredentialsTokenException if the token request fails */ public String requestToken(String scope, String tenantId) { log.debug("Requesting access token using JWT assertion"); @@ -52,17 +52,17 @@ public String requestToken(String scope, String tenantId) { try { String urlWithTenant = properties.getTokenUrl().replace("", tenantId); - ResponseEntity response = - restTemplate.postForEntity(urlWithTenant, request, JwtTokenResponse.class); + ResponseEntity response = + restTemplate.postForEntity(urlWithTenant, request, ClientCredentialsTokenResponse.class); if (response.getBody() == null || response.getBody().getAccessToken() == null) { - throw new JwtTokenException("JWT token response body or access_token is null"); + throw new ClientCredentialsTokenException("JWT token response body or access_token is null"); } log.debug("JWT token obtained successfully, expires in {} seconds", response.getBody().getExpiresIn()); return response.getBody().getAccessToken(); } catch (RestClientException e) { - throw new JwtTokenException("Failed to exchange JWT for JWT token: " + e.getMessage(), e); + throw new ClientCredentialsTokenException("Failed to exchange JWT for JWT token: " + e.getMessage(), e); } } } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenException.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenException.java deleted file mode 100644 index 4ca584e..0000000 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtTokenException.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.opendevstack.apiservice.core.security.jwt; - -public class JwtTokenException extends RuntimeException { - - public JwtTokenException(String message) { - super(message); - } - - public JwtTokenException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java index 48f4a2a..6df3118 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java @@ -35,7 +35,7 @@ void setUp() { securityConfig = new SecurityConfig( securityProperties, policyAuthorizationManager, - azureJwtAuthenticationConverter, + azureJwtAuthenticationConverter, cachedBodyRequestFilter ); } diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java new file mode 100644 index 0000000..b76ccd1 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java @@ -0,0 +1,139 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenException; +import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenProperties; +import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenResponse; +import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenService; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ClientCredentialsTokenServiceTest { + + @Mock + private RestTemplate restTemplate; + + private ClientCredentialsTokenProperties properties; + + private ClientCredentialsTokenService sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + properties = new ClientCredentialsTokenProperties(); + properties.setTokenUrl("https://login.microsoftonline.com//oauth2/v2.0/token"); + properties.setClientId("test-client-id"); + properties.setClientSecret("test-client-secret"); + sut = new ClientCredentialsTokenService(properties, restTemplate); + } + + @Test + void request_token_returns_access_token_on_success() { + // GIVEN + String tenantId = "tenant-id"; + String scope = "api://target-app/.default"; + ClientCredentialsTokenResponse tokenResponse = new ClientCredentialsTokenResponse(); + tokenResponse.setAccessToken("jwt-access-token"); + tokenResponse.setTokenType("Bearer"); + tokenResponse.setExpiresIn(3600); + tokenResponse.setScope(scope); + + String expectedUrl = properties.getTokenUrl().replace("", tenantId); + + when(restTemplate.postForEntity(eq(expectedUrl), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class))) + .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); + + // WHEN + String result = sut.requestToken(scope, tenantId); + + // THEN + assertEquals("jwt-access-token", result); + verify(restTemplate).postForEntity(eq(expectedUrl), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class)); + } + + @Test + @SuppressWarnings("unchecked") + void request_token_sends_correct_form_parameters_and_headers() { + // GIVEN + String tenantId = "t-1"; + String scope = "api://app/scope"; + ClientCredentialsTokenResponse tokenResponse = new ClientCredentialsTokenResponse(); + tokenResponse.setAccessToken("token"); + + ArgumentCaptor>> captor = ArgumentCaptor.forClass(HttpEntity.class); + when(restTemplate.postForEntity(anyString(), captor.capture(), eq(ClientCredentialsTokenResponse.class))) + .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); + + // WHEN + sut.requestToken(scope, tenantId); + + // THEN + MultiValueMap body = captor.getValue().getBody(); + assertNotNull(body); + assertEquals("client_credentials", body.getFirst("grant_type")); + assertEquals("test-client-id", body.getFirst("client_id")); + assertEquals("test-client-secret", body.getFirst("client_secret")); + assertEquals("api://app/scope", body.getFirst("scope")); + + MediaType contentType = captor.getValue().getHeaders().getContentType(); + assertNotNull(contentType); + assertEquals(MediaType.APPLICATION_FORM_URLENCODED, contentType); + } + + @Test + void request_token_throws_jwt_exception_when_response_body_is_null() { + // GIVEN + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class))) + .thenReturn(new ResponseEntity<>(null, HttpStatus.OK)); + + // WHEN / THEN + ClientCredentialsTokenException ex = assertThrows(ClientCredentialsTokenException.class, + () -> sut.requestToken("scope", "tenant")); + assertTrue(ex.getMessage().contains("null")); + } + + @Test + void request_token_throws_jwt_exception_when_access_token_is_null() { + // GIVEN + ClientCredentialsTokenResponse response = new ClientCredentialsTokenResponse(); + response.setAccessToken(null); + + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class))) + .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + + // WHEN / THEN + assertThrows(ClientCredentialsTokenException.class, () -> sut.requestToken("scope", "tenant")); + } + + @Test + void request_token_throws_jwt_exception_on_rest_client_error() { + // GIVEN + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class))) + .thenThrow(new RestClientException("Connection refused")); + + // WHEN / THEN + ClientCredentialsTokenException ex = assertThrows(ClientCredentialsTokenException.class, + () -> sut.requestToken("scope", "tenant")); + assertTrue(ex.getMessage().contains("Connection refused")); + } +} + diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index 43d1cb2..dc756c1 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -1,7 +1,7 @@ package org.opendevstack.apiservice.externalservice.marketplace.service.impl; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.core.security.jwt.JwtTokenService; +import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenService; import org.opendevstack.apiservice.core.security.jwt.JwtUtils; import org.opendevstack.apiservice.core.security.obo.OboTokenService; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; @@ -31,14 +31,14 @@ public class MarketplaceServiceImpl implements MarketplaceService { private final MarketplaceApiClientFactory clientFactory; private final OboTokenService oboTokenService; - private final JwtTokenService jwtTokenService; + private final ClientCredentialsTokenService clientCredentialsTokenService; public MarketplaceServiceImpl(MarketplaceApiClientFactory clientFactory, OboTokenService oboTokenService, - JwtTokenService jwtTokenService) { + ClientCredentialsTokenService clientCredentialsTokenService) { this.clientFactory = clientFactory; this.oboTokenService = oboTokenService; - this.jwtTokenService = jwtTokenService; + this.clientCredentialsTokenService = clientCredentialsTokenService; log.info("MarketplaceServiceImpl initialized"); } @@ -402,7 +402,7 @@ private MarketplaceApiClient getJwtAuthenticatedClient(String instanceName) thro String scope = client.getConfig().getJwtScope(); String tenantId = client.getConfig().getTenantId(); - jwtToken = jwtTokenService.requestToken(scope, tenantId); + jwtToken = clientCredentialsTokenService.requestToken(scope, tenantId); } catch (RuntimeException ex) { throw new MarketplaceException( String.format( diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java index d8eb18d..81dee1c 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -8,7 +8,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.stubbing.OngoingStubbing; -import org.opendevstack.apiservice.core.security.jwt.JwtTokenService; +import org.opendevstack.apiservice.core.security.client.crendentials.ClientCredentialsTokenService; import org.opendevstack.apiservice.core.security.obo.OboTokenService; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; @@ -82,13 +82,13 @@ class MarketplaceServiceImplTest { private OboTokenService oboTokenService; @Mock - private JwtTokenService jwtTokenService; + private ClientCredentialsTokenService clientCredentialsTokenService; private MarketplaceService marketplaceService; @BeforeEach void setUp() { - marketplaceService = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService); + marketplaceService = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService); // Set up a fake SecurityContext so JwtUtils.getTokenValue() works Jwt jwt = Jwt.withTokenValue("test-jwt-assertion") @@ -319,7 +319,7 @@ void testGetProjectComponent_NotFound_ReturnsNull() throws MarketplaceException void testIsHealthy_NoInstancesConfigured_ReturnsFalse() { when(clientFactory.getAvailableInstances()).thenReturn(Set.of()); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService) { + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService) { @Override protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { return true; @@ -342,7 +342,7 @@ void testIsHealthy_BothEndpointsUp_ReturnsTrue() throws MarketplaceException { when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService) { + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService) { @Override protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { return true; @@ -365,7 +365,7 @@ void testIsHealthy_ProvisionerDown_ReturnsFalse() throws MarketplaceException { when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService) { + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService) { @Override protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { return false; @@ -383,7 +383,7 @@ void testIsHealthy_CatalogDown_ReturnsFalse() throws MarketplaceException { when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, jwtTokenService) { + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService, clientCredentialsTokenService) { @Override protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { return true; From 4bb965df31f371834b3ba3598122432076ac6797 Mon Sep 17 00:00:00 2001 From: "Vila,Jordi (IT EDP)" Date: Thu, 18 Jun 2026 09:36:42 +0200 Subject: [PATCH 12/19] Add unit tests --- .../MarketplaceMetricsFacadeImplTest.java | 41 ++++++++- .../mapper/MarketplaceMetricsMapperTest.java | 90 +++++++++++++++++-- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java index acaf051..b75faa5 100644 --- a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java +++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java @@ -3,12 +3,20 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException; import org.opendevstack.apiservice.marketplacemetrics.mapper.MarketplaceMetricsMapper; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; import org.springframework.security.core.context.SecurityContextHolder; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; class MarketplaceMetricsFacadeImplTest { @@ -34,8 +42,37 @@ void cleanup() { } @Test - void fakeTest() { - assertThat(true).isTrue(); + void get_marketplace_project_components_metrics_returns_mapped_result_when_service_succeeds() throws Exception { + Integer page = 1; + Integer size = 25; + ProjectComponentListResponse serviceResponse = mock(ProjectComponentListResponse.class); + MarketplaceProjectComponentsMetrics mappedResponse = mock(MarketplaceProjectComponentsMetrics.class); + + when(marketplaceService.getAllProjectComponents(page, size)).thenReturn(serviceResponse); + when(mapper.toApiModel(serviceResponse)).thenReturn(mappedResponse); + + MarketplaceProjectComponentsMetrics result = sut.getMarketplaceProjectComponentsMetrics(page, size); + + assertThat(result).isSameAs(mappedResponse); + verify(marketplaceService).getAllProjectComponents(page, size); + verify(mapper).toApiModel(serviceResponse); + } + + @Test + void get_marketplace_project_components_metrics_wraps_marketplace_exception() throws Exception { + Integer page = 2; + Integer size = 10; + MarketplaceException cause = new MarketplaceException("marketplace unavailable"); + + when(marketplaceService.getAllProjectComponents(page, size)).thenThrow(cause); + + assertThatThrownBy(() -> sut.getMarketplaceProjectComponentsMetrics(page, size)) + .isInstanceOf(MarketplaceMetricsException.class) + .hasMessage("Failed to retrieve marketplace project components metrics.") + .hasCause(cause); + + verify(marketplaceService).getAllProjectComponents(page, size); + verifyNoInteractions(mapper); } } \ No newline at end of file diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java index 5f228ae..49354d4 100644 --- a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java +++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java @@ -2,16 +2,20 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.Pagination; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; + +import java.math.BigDecimal; +import java.net.URI; +import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; -@ExtendWith(MockitoExtension.class) class MarketplaceMetricsMapperTest { - @InjectMocks private MarketplaceMetricsMapper mapper; @BeforeEach @@ -20,9 +24,81 @@ void setUp() { } @Test - void fakeTest() { - assertThat(true).isTrue(); + void to_api_model_returns_null_when_response_is_null() { + assertThat(mapper.toApiModel(null)).isNull(); + } + + @Test + void to_api_model_maps_all_fields_when_response_contains_data_and_pagination() { + ProjectComponentListItem item = new ProjectComponentListItem(); + item.setComponentId("comp-1"); + item.setProjectKey("PRJ1"); + item.setCaller("user@example.com"); + item.setCatalogItemSlug("java-17"); + item.setCreatedAt(new BigDecimal("1707043200000")); + item.setUpdatedAt(new BigDecimal("1707043300000")); + + Pagination pagination = new Pagination(); + pagination.setPage(1); + pagination.setSize(20); + pagination.setTotalPages(3); + pagination.setTotalElements(57); + pagination.setNext(URI.create("https://api.example.com/resources?page=2&size=20")); + pagination.setPrevious(URI.create("https://api.example.com/resources?page=0&size=20")); + + ProjectComponentListResponse response = new ProjectComponentListResponse(); + response.setData(Arrays.asList(item)); + response.setPagination(pagination); + + MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response); + + assertThat(result).isNotNull(); + assertThat(result.getData()).hasSize(1); + + MarketplaceProjectComponentMetrics mappedItem = result.getData().get(0); + assertThat(mappedItem.getComponentId()).isEqualTo("comp-1"); + assertThat(mappedItem.getProjectKey()).isEqualTo("PRJ1"); + assertThat(mappedItem.getCaller()).isEqualTo("user@example.com"); + assertThat(mappedItem.getCatalogItemSlug()).isEqualTo("java-17"); + assertThat(mappedItem.getCreatedAt()).isEqualByComparingTo("1707043200000"); + assertThat(mappedItem.getUpdatedAt()).isEqualByComparingTo("1707043300000"); + + assertThat(result.getPagination()).isNotNull(); + assertThat(result.getPagination().getPage()).isEqualTo(1); + assertThat(result.getPagination().getSize()).isEqualTo(20); + assertThat(result.getPagination().getTotalPages()).isEqualTo(3); + assertThat(result.getPagination().getTotalElements()).isEqualTo(57); + assertThat(result.getPagination().getNext().isPresent()).isTrue(); + assertThat(result.getPagination().getNext().get()) + .isEqualTo(URI.create("https://api.example.com/resources?page=2&size=20")); + assertThat(result.getPagination().getPrevious().isPresent()).isTrue(); + assertThat(result.getPagination().getPrevious().get()) + .isEqualTo(URI.create("https://api.example.com/resources?page=0&size=20")); } + @Test + void to_api_model_maps_null_item_in_data_list_as_null_entry() { + ProjectComponentListResponse response = new ProjectComponentListResponse(); + response.setData(Arrays.asList((ProjectComponentListItem) null)); + + MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response); + + assertThat(result).isNotNull(); + assertThat(result.getData()).hasSize(1); + assertThat(result.getData().get(0)).isNull(); + } + + @Test + void to_api_model_keeps_default_data_and_sets_null_pagination_when_source_has_null_data_and_pagination() { + ProjectComponentListResponse response = new ProjectComponentListResponse(); + response.setData(null); + response.setPagination(null); + + MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response); + + assertThat(result).isNotNull(); + assertThat(result.getData()).isEmpty(); + assertThat(result.getPagination()).isNull(); + } } From b06b0574459b6fb316b2b9a1672fa5e61b03228d Mon Sep 17 00:00:00 2001 From: "Vila,Jordi (IT EDP)" Date: Fri, 19 Jun 2026 13:23:19 +0200 Subject: [PATCH 13/19] Rename properly properties --- application.yaml | 10 +++++----- .../ClientCredentialsTokenProperties.java | 2 +- .../config/MarketplaceInstanceConfig.java | 6 +++--- .../service/impl/MarketplaceServiceImpl.java | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/application.yaml b/application.yaml index 207bd0d..23a4a84 100644 --- a/application.yaml +++ b/application.yaml @@ -68,13 +68,13 @@ app: - /actuator/health - /actuator/info - /api/v1/projects/*/platforms - jwt: + client-credentials: # Azure AD token endpoint used for request a token. - token-url: ${OBO_TOKEN_URL:https://login.microsoftonline.com//oauth2/v2.0/token} + token-url: ${CLIENT_CREDENTIALS_TOKEN_URL:https://login.microsoftonline.com//oauth2/v2.0/token} # Client ID of this application's Azure AD app registration. - client-id: ${OBO_CLIENT_ID} + client-id: ${CLIENT_CREDENTIALS_CLIENT_ID} # Client secret for token request. Must be injected via environment variable or secret store. - client-secret: ${OBO_CLIENT_SECRET} + client-secret: ${CLIENT_CREDENTIALS_CLIENT_SECRET} obo: # Azure AD token endpoint used for On-Behalf-Of token exchange. token-url: ${OBO_TOKEN_URL:https://login.microsoftonline.com/${AZURE_TENANT_ID:}/oauth2/v2.0/token} @@ -323,4 +323,4 @@ externalservices: username: ${MARKETPLACE_USERNAME:} password: ${MARKETPLACE_PASSWORD:} tenant-id: ${AZURE_TENANT_ID:} - jwt-scope: ${MARKETPLACE_OBO_SCOPE:} + client-credentials-scope: ${MARKETPLACE_CLIENT_CREDENTIALS_SCOPE:} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java index f4c52a1..0db8e60 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenProperties.java @@ -5,7 +5,7 @@ import org.springframework.context.annotation.Configuration; @Configuration -@ConfigurationProperties(prefix = "app.security.jwt") +@ConfigurationProperties(prefix = "app.security.client-credentials") @Data public class ClientCredentialsTokenProperties { diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java index 9ad37ec..0915b4c 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -57,10 +57,10 @@ public class MarketplaceInstanceConfig { private String tenantId; /** - * OAuth2 scope used for JWT token exchange when calling this Marketplace instance. - * Example: {@code api:///Api.Access} + * OAuth2 scope used for Client Credentials token exchange when calling this Marketplace instance. + * Example: {@code api:///.default} */ - private String jwtScope; + private String clientCredentialsScope; /** * Bypass configuration. When the incoming token already targets the configured bypass diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index dc756c1..c6c3e39 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -109,7 +109,7 @@ public ProjectComponentProvisionStatus getProjectComponent(String projectId, Str public ProjectComponentListResponse getAllProjectComponents(String instanceName, Integer page, Integer size) throws MarketplaceException { log.debug("Marketplace service GET all components with page {} and size {} in instance {} ", page, size, instanceName); try { - MarketplaceApiClient marketplaceClient = getJwtAuthenticatedClient(instanceName); + MarketplaceApiClient marketplaceClient = getClientCredentialsAuthenticatedClient(instanceName); ApiClient apiClient = marketplaceClient.getApiClient(); ProjectComponentsWithProvisionStatusApi projectComponentsApi = new ProjectComponentsWithProvisionStatusApi(apiClient); apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); @@ -390,23 +390,23 @@ private String getOboToken(String instanceName, String assertion, String oboScop } /** - * Creates a {@link MarketplaceApiClient} authenticated with an OBO token - * obtained from the current request's JWT. + * Creates a {@link MarketplaceApiClient} authenticated with a JWT token + * obtained using the client credentials flow. */ - private MarketplaceApiClient getJwtAuthenticatedClient(String instanceName) throws MarketplaceException { + private MarketplaceApiClient getClientCredentialsAuthenticatedClient(String instanceName) throws MarketplaceException { MarketplaceApiClient client = clientFactory.getClient(instanceName); String jwtToken; try { - String scope = client.getConfig().getJwtScope(); + String scope = client.getConfig().getClientCredentialsScope(); String tenantId = client.getConfig().getTenantId(); jwtToken = clientCredentialsTokenService.requestToken(scope, tenantId); } catch (RuntimeException ex) { throw new MarketplaceException( String.format( - "Failed to obtain JWT token for Marketplace instance '%s'", instanceName), + "Failed to obtain JWT token for Marketplace instance '%s' using the client credentials flow.", instanceName), ex); } From a0698709676c402eaa0ebc14142946752daf2083 Mon Sep 17 00:00:00 2001 From: "Vila,Jordi (IT EDP)" Date: Thu, 25 Jun 2026 09:02:47 +0200 Subject: [PATCH 14/19] Add liquibasemigration to include new api in api_definitions --- .../migrations/006_add_marketplace_metrics_apis.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 persistence/src/main/resources/db/changelog/migrations/006_add_marketplace_metrics_apis.sql diff --git a/persistence/src/main/resources/db/changelog/migrations/006_add_marketplace_metrics_apis.sql b/persistence/src/main/resources/db/changelog/migrations/006_add_marketplace_metrics_apis.sql new file mode 100644 index 0000000..9dd7f57 --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/006_add_marketplace_metrics_apis.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +-- ============================================================================ +-- changeset ods:009-seed-marketplace-metrics-api-definitions +-- Description: Seed API definitions for Marketplace Metrics API. +-- ============================================================================ +INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled) +VALUES + ('marketplace-metrics-v1', 'Marketplace Metrics API v1', 'projects/metrics/marketplace/*', 'v1', ARRAY['CLIENT_CREDENTIALS'], FALSE, TRUE) +ON CONFLICT (api_id) DO NOTHING; + +--rollback DELETE FROM api_definitions WHERE api_id IN ('marketplace-metrics-v1'); From dea1628ef14be8a6920089abd145b55e8247f99b Mon Sep 17 00:00:00 2001 From: brais <26645694+BraisVQ@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:10:16 +0200 Subject: [PATCH 15/19] Merge from develop to master (#42) --- .gitignore | 17 +- CHANGELOG.md | 24 +- Makefile | 163 +- README.md | 48 +- .../api-project-component-v0-internal.yaml | 66 + .../openapi/api-project-component-v0.yaml | 226 +++ api-project-component-v0/pom.xml | 205 +++ .../project/config/JsonNullableConfig.java | 26 + .../ProjectComponentsCreateProperties.java | 15 + .../controller/ComponentsResponseFactory.java | 62 + .../ProjectComponentsController.java | 59 + .../ProjectComponentsInternalController.java | 29 + .../ProjectComponentsExceptionHandler.java | 252 +++ .../CatalogItemNotFoundException.java | 12 + .../ComponentAlreadyExistsException.java | 12 + .../ComponentBadRequestException.java | 12 + .../exception/ComponentCreationException.java | 12 + .../exception/ComponentDeletionException.java | 12 + .../project/exception/ComponentErrorKey.java | 30 + .../exception/ComponentErrorMessage.java | 14 + .../exception/ComponentNotFoundException.java | 12 + .../ComponentRegistrationException.java | 12 + .../ComponentReservedParamException.java | 8 + .../ComponentRetrievalException.java | 12 + .../project/facade/ComponentsFacade.java | 227 +++ .../mapper/ComponentResponseMapper.java | 14 + .../project/mapper/MarketplaceMapper.java | 128 ++ .../apiservice/project/mapper/StatusMap.java | 26 + .../ProjectComponentsControllerTest.java | 162 ++ ...ojectComponentsInternalControllerTest.java | 64 + ...ProjectComponentsExceptionHandlerTest.java | 213 +++ .../project/facade/ComponentsFacadeTest.java | 208 +++ .../project/mapper/MarketplaceMapperTest.java | 196 +++ .../project/util/TestObjectsBuilder.java | 54 + api-project-platform/pom.xml | 2 +- .../openapi/api-project-users.yaml | 4 +- api-project-users/pom.xml | 2 +- .../exception/GlobalExceptionHandler.java | 167 +- api-project/openapi/api-project-internal.yaml | 63 + api-project/openapi/api-project.yaml | 184 +++ api-project/pom.xml | 211 +++ .../project/controller/ProjectController.java | 65 + .../controller/ProjectInternalController.java | 50 + .../controller/ProjectResponseFactory.java | 28 + .../advice/ProjectExceptionHandler.java | 150 ++ .../ProjectInternalExceptionHandler.java | 22 + .../ClientAppNotRegisteredException.java | 8 + .../project/exception/ErrorKey.java | 52 + .../project/exception/ErrorMessage.java | 28 + .../ProjectAlreadyExistsException.java | 19 + .../exception/ProjectCreationException.java | 12 + .../project/exception/ProjectException.java | 12 + .../ProjectUpdateValidationException.java | 19 + .../exception/ProjectValidationException.java | 20 + .../project/facade/ProjectsFacade.java | 16 + .../facade/impl/ProjectCreationCommand.java | 31 + .../impl/ProjectCreationCommandBuilder.java | 193 +++ .../facade/impl/ProjectsFacadeImpl.java | 129 ++ .../mapper/AutomationParametersMapper.java | 26 + .../mapper/ProjectCreationResponseMapper.java | 25 + .../project/mapper/ProjectMapper.java | 52 + .../project/service/ClientAppService.java | 28 + .../validation/ProjectRequestValidator.java | 87 ++ .../controller/ProjectControllerTest.java | 117 ++ .../ProjectInternalControllerTest.java | 86 ++ .../advice/ProjectExceptionHandlerTest.java | 320 ++++ .../ProjectInternalExceptionHandlerTest.java | 53 + .../ProjectCreationCommandBuilderTest.java | 240 +++ .../facade/impl/ProjectsFacadeImplTest.java | 228 +++ .../project/mapper/ProjectMapperTest.java | 191 +++ .../project/service/ClientAppServiceTest.java | 58 + .../ProjectRequestValidatorTest.java | 203 +++ application.yaml | 222 ++- core-contracts/pom.xml | 41 + .../core/contracts/api/ApiModule.java | 14 + .../core/contracts/auth/AuthType.java | 7 + .../contracts/auth/AuthorizationDecision.java | 18 + .../persistence/ApiDefinitionDao.java | 21 + .../core/contracts/persistence/ClientDao.java | 12 + .../contracts/persistence/ClientInfo.java | 22 + .../core/contracts/persistence/PolicyDao.java | 18 + .../core/contracts/policy/PolicyContext.java | 40 + .../contracts/policy/PolicyEvaluator.java | 10 + .../core/contracts/policy/PolicyRule.java | 20 + .../core/contracts/policy/PolicyTypes.java | 15 + .../contracts/registry/ApiDefinition.java | 32 + core-security/pom.xml | 57 + .../PolicyAuthorizationManager.java | 111 ++ .../authorization/PolicyContextFactory.java | 34 + .../security/authorization/PolicyEngine.java | 49 + .../security/authorization/PolicyService.java | 28 + .../evaluator/AllowedClientsEvaluator.java | 47 + .../evaluator/FlavorRestrictionEvaluator.java | 53 + .../core/security/config/SecurityConfig.java | 120 ++ .../security/config/SecurityProperties.java | 45 + .../filter/AuthTypeEnforcementFilter.java | 75 + .../filter/CachedBodyHttpServletRequest.java | 56 + .../filter/CachedBodyRequestFilter.java | 38 + .../core/security/flow/AuthFlowResolver.java | 22 + .../core/security/flow/AuthFlowValidator.java | 11 + .../ClientCredentialFlowValidator.java | 45 + .../flow/validator/OboFlowValidator.java | 40 + .../jwt/AzureJwtAuthenticationConverter.java | 62 + .../core/security/jwt/JwtUtils.java | 140 ++ .../core/security/obo/OboTokenException.java | 12 + .../core/security/obo/OboTokenProperties.java | 17 + .../core/security/obo/OboTokenResponse.java | 25 + .../core/security/obo/OboTokenService.java | 68 + .../registry/ApiDefinitionResolver.java | 32 + .../security/registry/CoreApiRegistry.java | 79 + .../core/security/util/Base64Operations.java | 40 + .../PolicyAuthorizationManagerTest.java | 160 ++ .../PolicyContextFactoryTest.java | 90 ++ .../authorization/PolicyEngineTest.java | 118 ++ .../authorization/PolicyServiceTest.java | 67 + .../security/config/SecurityConfigTest.java | 86 ++ .../filter/AuthTypeEnforcementFilterTest.java | 174 +++ .../security/flow/AuthFlowResolverTest.java | 54 + .../ClientCredentialFlowValidatorTest.java | 139 ++ .../AzureJwtAuthenticationConverterTest.java | 147 ++ .../core/security/jwt/JwtUtilsTest.java | 263 ++++ .../security/obo/OboTokenServiceTest.java | 129 ++ .../registry/ApiDefinitionResolverTest.java | 100 ++ .../registry/CoreApiRegistryTest.java | 144 ++ .../security/util/Base64OperationsTest.java | 51 + core/pom.xml | 57 +- .../core/DevstackApiServiceApplication.java | 4 + .../apiservice/core/config/AspectConfig.java | 10 - .../core/config/CustomRoleConverter.java | 78 - .../core/config/FlowProperties.java | 47 - .../apiservice/core/config/JacksonConfig.java | 21 + .../core/config/OnBehalfOfConfig.java | 10 - .../core/config/OnBehalfOfProperties.java | 111 -- .../apiservice/core/config/OpenApiConfig.java | 99 -- .../core/config/SecurityConfig.java | 177 --- .../core/config/SecurityProperties.java | 38 - ...CustomMethodSecurityExpressionHandler.java | 21 - .../CustomSecurityExpressionRoot.java | 69 - .../core/security/FlowEnforcementAspect.java | 165 -- .../core/security/FlowValidator.java | 228 --- .../apiservice/core/security/RequireFlow.java | 41 - .../DevstackApiServiceApplicationTest.java | 20 - .../core/config/CustomRoleConverterTest.java | 264 ---- .../core/config/FlowPropertiesTest.java | 366 ----- .../core/config/SecurityConfigTest.java | 67 - .../security/FlowEnforcementAspectTest.java | 370 ----- .../core/security/FlowValidatorTest.java | 345 ----- .../security/SecurityExpressionRootTest.java | 327 ---- doc/core-security/tests.md | 195 +++ docker/install-certs.sh | 6 +- external-service-aap/pom.xml | 2 +- .../AutomationPlatformException.java | 2 +- .../service/AutomationPlatformService.java | 4 +- external-service-api/pom.xml | 2 +- external-service-bitbucket/pom.xml | 2 +- external-service-jira/pom.xml | 2 +- .../.openapi-generator-ignore | 43 + .../openapi-component_catalog-v1.0.0.yaml | 1366 +++++++++++++++++ .../openapi-component_provisioner-v1.0.0.yaml | 613 ++++++++ external-service-marketplace/pom.xml | 251 +++ .../client/MarketplaceApiClient.java | 77 + .../client/MarketplaceApiClientFactory.java | 215 +++ .../config/MarketplaceInstanceConfig.java | 76 + .../config/MarketplaceServiceConfig.java | 25 + .../exception/MarketplaceException.java | 12 + .../marketplace/model/ProjectComponent.java | 28 + .../service/CatalogItemOperations.java | 29 + .../service/MarketplaceService.java | 45 + .../service/impl/MarketplaceServiceImpl.java | 374 +++++ .../MarketplaceApiClientFactoryTest.java | 163 ++ .../service/CatalogItemOperationsTest.java | 39 + .../service/MarketplaceServiceImplTest.java | 918 +++++++++++ external-service-ocp/pom.xml | 2 +- .../pom.xml | 2 +- external-service-uipath/pom.xml | 2 +- external-service-webhookproxy/pom.xml | 2 +- persistence/liquibase.properties.example | 28 + persistence/pom.xml | 118 ++ .../persistence/dao/ApiDefinitionDaoImpl.java | 77 + .../persistence/dao/ClientDaoImpl.java | 41 + .../persistence/dao/PolicyDaoImpl.java | 71 + .../entity/ApiDefinitionEntity.java | 51 + .../persistence/entity/AuditableEntity.java | 39 + .../entity/AuthorizationPolicyEntity.java | 36 + .../persistence/entity/ClientAppEntity.java | 56 + .../entity/ClientAppProjectFlavorEntity.java | 75 + .../persistence/entity/ProjectEntity.java | 103 ++ .../ApiDefinitionJpaRepository.java | 17 + .../AuthorizationPolicyJpaRepository.java | 24 + .../repository/ClientAppRepository.java | 45 + .../repository/ProjectRepository.java | 32 + .../db/changelog/db.changelog-master.xml | 25 + .../migrations/001_create_projects_table.sql | 46 + .../002_create_client_apps_table.sql | 63 + .../003_create_policy_engine_tables.sql | 81 + ...04_alter_client_apps_client_id_to_uuid.sql | 12 + .../migrations/005_add_component_apis.sql | 26 + .../006_add_projects_internal_api.sql | 12 + .../PersistenceTestApplication.java | 15 + .../PersistenceIntegrationTestConfig.java | 19 + .../ProjectRepositoryIntegrationTest.java | 273 ++++ .../repository/ClientAppRepositoryTest.java | 126 ++ .../repository/ProjectRepositoryTest.java | 163 ++ .../resources/application-local.properties | 10 + .../src/test/resources/application.properties | 18 + pom.xml | 13 +- security-tests.http | 547 +++++++ service-projects/pom.xml | 116 ++ .../ProjectExistenceServiceException.java | 12 + .../ProjectKeyGenerationException.java | 13 + .../mapper/ProjectResponseMapper.java | 37 + .../serviceproject/model/ProjectRequest.java | 35 + .../serviceproject/model/ProjectResponse.java | 29 + .../serviceproject/model/Status.java | 15 + .../service/GenerateProjectKeyService.java | 12 + .../service/ProjectExistenceService.java | 12 + .../service/ProjectService.java | 18 + .../impl/GenerateProjectKeyServiceImpl.java | 60 + .../impl/ProjectExistenceServiceImpl.java | 97 ++ .../service/impl/ProjectServiceImpl.java | 88 ++ .../GenerateProjectKeyServiceImplTest.java | 77 + .../impl/ProjectExistenceServiceImplTest.java | 158 ++ .../service/impl/ProjectServiceImplTest.java | 235 +++ 223 files changed, 17055 insertions(+), 3006 deletions(-) create mode 100644 api-project-component-v0/openapi/api-project-component-v0-internal.yaml create mode 100644 api-project-component-v0/openapi/api-project-component-v0.yaml create mode 100644 api-project-component-v0/pom.xml create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/ProjectComponentsCreateProperties.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalController.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentDeletionException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRegistrationException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentReservedParamException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRetrievalException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalControllerTest.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java create mode 100644 api-project/openapi/api-project-internal.yaml create mode 100644 api-project/openapi/api-project.yaml create mode 100644 api-project/pom.xml create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectAlreadyExistsException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java create mode 100644 core-contracts/pom.xml create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/api/ApiModule.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthType.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthorizationDecision.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ApiDefinitionDao.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientDao.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientInfo.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/PolicyDao.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyContext.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyEvaluator.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyRule.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/registry/ApiDefinition.java create mode 100644 core-security/pom.xml create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/util/Base64Operations.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidatorTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/util/Base64OperationsTest.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java create mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/DevstackApiServiceApplicationTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java create mode 100644 doc/core-security/tests.md create mode 100644 external-service-marketplace/.openapi-generator-ignore create mode 100644 external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml create mode 100644 external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml create mode 100644 external-service-marketplace/pom.xml create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java create mode 100644 external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java create mode 100644 external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java create mode 100644 external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java create mode 100644 persistence/liquibase.properties.example create mode 100644 persistence/pom.xml create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ApiDefinitionDaoImpl.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ApiDefinitionEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuditableEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuthorizationPolicyEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ApiDefinitionJpaRepository.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java create mode 100644 persistence/src/main/resources/db/changelog/db.changelog-master.xml create mode 100644 persistence/src/main/resources/db/changelog/migrations/001_create_projects_table.sql create mode 100644 persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql create mode 100644 persistence/src/main/resources/db/changelog/migrations/003_create_policy_engine_tables.sql create mode 100644 persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql create mode 100644 persistence/src/main/resources/db/changelog/migrations/005_add_component_apis.sql create mode 100644 persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/PersistenceIntegrationTestConfig.java create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java create mode 100644 persistence/src/test/resources/application-local.properties create mode 100644 persistence/src/test/resources/application.properties create mode 100644 security-tests.http create mode 100644 service-projects/pom.xml create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java create mode 100644 service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java create mode 100644 service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java create mode 100644 service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java diff --git a/.gitignore b/.gitignore index b117982..46a7ede 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,14 @@ dependency-reduced-pom.xml *.swp *.bak *.tmp + +# Persistence module — local database credentials (never commit real passwords) +persistence/liquibase.properties +persistence/.env + +# Legacy: Database module references (migrated to persistence/) +database/liquibase.properties +database/.env *.orig *.rej @@ -85,6 +93,11 @@ api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/api api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/model api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/api api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/model -api-project-users/.openapi-generator -/api-project-platform/.openapi-generator/ +api-project/src/main/java/org/opendevstack/apiservice/project/api +api-project/src/main/java/org/opendevstack/apiservice/project/model +api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/api +api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/model +external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/openapi + +**/.openapi-generator diff --git a/CHANGELOG.md b/CHANGELOG.md index a01dc46..ed33183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,30 @@ All notable changes to the DevStack API Service project will be documented in th The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +#### Security (`core-security`) +- Support for **multiple JWT issuers**: each trusted issuer pairs an issuer URI (`iss` claim) with a JWK set URI, selected by the incoming token's `iss` claim (`OAUTH2_ISSUER_V1`/`OAUTH2_JWK_SET_URI_V1`, `OAUTH2_ISSUER_V2`/`OAUTH2_JWK_SET_URI_V2`). +- Shared **audience validation** across all configured issuers (`OAUTH2_AUDIENCE`, `OAUTH2_AUDIENCE2`); a valid token must carry at least one accepted `aud` value. +- `JwtUtils` audience and scope extraction helpers, including `tokenMatchesScopeAudience` to detect tokens that already target a given audience and scope. + +#### External Service Marketplace (`external-service-marketplace`) +- **OBO exchange bypass**: when the incoming token already targets the configured bypass audience and scope, the token is forwarded as-is instead of performing an On-Behalf-Of exchange (`MARKETPLACE_BYPASS_AUDIENCE`, `MARKETPLACE_BYPASS_SCOPE`). + +### Changed +- Refactored `SecurityConfig` to resolve the authentication manager per issuer using `JwtIssuerAuthenticationManagerResolver`. +- Moved OAuth2 resource server configuration from Spring's `spring.security.oauth2.resourceserver` properties to application-managed `app` security properties. +- `ClientCredentialFlowValidator` now accepts the client identifier from either the `appid` (v1 tokens) or `azp` (v2 tokens) claim. + ## [0.0.3] - 2026-03-03 ### Added +- Created **New API module** for managing EDP Projects with create and retrieve endpoints. +- Created **New module** external service projects to manage EDP Projects. + ### External Service Jira (`external-service-jira`) - **New module** for checking project existance in Jira (Server) - Caching for the client @@ -22,6 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Health Monitoring & Observability - **Spring Boot Actuator** health indicators for all external services: +#### Persistance to keep project lifecycle + ### Changed - Updated all external service implementations to extend `ExternalService` interface - Use of lombok.extern.slf4j.Slf4j to remove boilerplate code. @@ -32,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated Spring Boot version - Added health indicator dependencies across external service modules -## [0.0.2] - 2026-03-03 +## [0.0.3] - 2026-03-03 ### Changed ##### Projects Info Service diff --git a/Makefile b/Makefile index 069095b..ceea7c8 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ # Supports building Spring Boot JAR and Native applications # Project variables -PROJECT_NAME := devstack-api-service -VERSION := 0.0.2 +PROJECT_NAME := opendevstack-api-service +VERSION := 0.0.3 JAVA_VERSION := 21 MAIN_CLASS := org.opendevstack.apiservice.core.DevstackApiServiceApplication @@ -44,7 +44,8 @@ YELLOW := \033[1;33m BLUE := \033[0;34m NC := \033[0m # No Color -.PHONY: help clean compile test package jar native docker docker-native run-jar run-native run-docker run-docker-native install verify lint format check-java check-maven check-config +.PHONY: help clean compile test package jar native docker docker-native run-jar run-native run-docker run-docker-native install verify lint format check-java check-maven check-config \ + db-check-env db-validate db-status db-migrate db-rollback db-tag db-port-forward db-docker-build-db # Default target .DEFAULT_GOAL := help @@ -90,8 +91,25 @@ check-maven: clean: check-maven @echo "$(BLUE)Cleaning build artifacts...$(NC)" $(MAVEN_WRAPPER) clean + @echo "$(BLUE)Removing all target directories across modules...$(NC)" + @find . -type d -name target -prune -exec rm -rf {} + + @echo "$(BLUE)Removing generated OpenAPI model directories...$(NC)" + @find . -type d -path "*/src/main/java/*/model" \ + ! -path "./external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/model" \ + ! -path "./service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model" \ + ! -path "./external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/model" \ + ! -path "./external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model" \ + ! -path "./external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/model" \ + -prune -exec rm -rf {} + @echo "$(GREEN)✓ Clean complete$(NC)" +## Remove local Maven cache for org.opendevstack.apiservice +clean-cache: + @echo "$(BLUE)Removing local Maven cache for org.opendevstack.apiservice...$(NC)" + @rm -rf "$$HOME/.m2/repository/org/opendevstack/apiservice" + @rm -rf "$$HOME/.m2/repositories/org/opendevstack/apiservice" + @echo "$(GREEN)✓ Maven cache clean complete$(NC)" + ## Compile the project compile: check-java check-maven @echo "$(BLUE)Compiling project...$(NC)" @@ -270,6 +288,145 @@ all: jar docker ## Quick start for development quick-start: jar run-jar +# ============================================================================= +# Database targets (Liquibase / PostgreSQL) +# +# Required environment variables (export them or source persistence/.env): +# ODS_API_SERVICE_DB_HOST PostgreSQL hostname or IP +# ODS_API_SERVICE_DB_PORT PostgreSQL port (default: 5432) +# ODS_API_SERVICE_DB_NAME Database name (ods_api_service) +# ODS_API_SERVICE_DB_USER Application user (ods_api_service) +# ODS_API_SERVICE_DB_PASSWORD Application user password +# +# Quick start: +# cp persistence/liquibase.properties.example persistence/.env +# # edit persistence/.env with real values +# source persistence/.env && make db-migrate +# +# Port-forward variables (can be overridden on the command line): +# NAMESPACE Kubernetes/OpenShift namespace where PostgreSQL runs (REQUIRED) +# DB_K8S_SERVICE Name of the PostgreSQL Service in the cluster (default: ods-api-service-postgresql) +# DB_PF_LOCAL_PORT Local port to bind on the developer machine (default: 5432) +# DB_PF_REMOTE_PORT PostgreSQL port exposed by the Service in-cluster (default: 5432) +# +# Example: +# make db-port-forward NAMESPACE=ods-dev +# make db-port-forward NAMESPACE=ods-prod DB_K8S_SERVICE=postgresql DB_PF_LOCAL_PORT=15432 +# ============================================================================= + +# Build the JDBC URL from individual host/port/name parts. +# ODS_API_SERVICE_DB_PORT defaults to 5432 when not set. +DB_PORT ?= $(or $(ODS_API_SERVICE_DB_PORT),5432) +DB_JDBC_URL = jdbc:postgresql://$(ODS_API_SERVICE_DB_HOST):$(DB_PORT)/$(ODS_API_SERVICE_DB_NAME) + +# Kubernetes port-forward defaults (all overridable on the command line) +DB_K8S_SERVICE ?= ods-api-service-postgresql +DB_PF_LOCAL_PORT ?= 5432 +DB_PF_REMOTE_PORT ?= 5432 + +DB_MAVEN_ARGS = -pl persistence \ + -Dliquibase.url=$(DB_JDBC_URL) \ + -Dliquibase.username=$(ODS_API_SERVICE_DB_USER) \ + -Dliquibase.password=$(ODS_API_SERVICE_DB_PASSWORD) + +## [DB] Verify required DB environment variables are set +db-check-env: + @missing=; \ + for var in ODS_API_SERVICE_DB_HOST ODS_API_SERVICE_DB_NAME ODS_API_SERVICE_DB_USER ODS_API_SERVICE_DB_PASSWORD; do \ + if [ -z "$$(eval echo \$$$$var)" ]; then missing="$$missing $$var"; fi; \ + done; \ + if [ -n "$$missing" ]; then \ + echo "$(RED)Error: the following required environment variables are not set:$(NC)"; \ + for v in $$missing; do echo " $$v"; done; \ + echo ""; \ + echo "$(YELLOW)Hint: copy persistence/liquibase.properties.example to persistence/.env,"; \ + echo " fill in the values, then run: source persistence/.env$(NC)"; \ + exit 1; \ + fi + @echo "$(GREEN)✓ DB environment variables OK$(NC)" + +## [DB] Validate the Liquibase changelog syntax (no DB connection required) +db-validate: check-maven + @echo "$(BLUE)Validating Liquibase changelog...$(NC)" + $(MAVEN_WRAPPER) -pl persistence liquibase:validate + @echo "$(GREEN)✓ Changelog validation complete$(NC)" + +## [DB] Show pending changesets that have not yet been applied +db-status: db-check-env check-maven + @echo "$(BLUE)Checking pending Liquibase changesets...$(NC)" + @echo "$(YELLOW)Target: $(DB_JDBC_URL)$(NC)" + $(MAVEN_WRAPPER) $(DB_MAVEN_ARGS) liquibase:status + +## [DB] Apply all pending changesets to the database +db-migrate: db-check-env check-maven + @echo "$(BLUE)Running Liquibase migrations...$(NC)" + @echo "$(YELLOW)Target: $(DB_JDBC_URL)$(NC)" + $(MAVEN_WRAPPER) $(DB_MAVEN_ARGS) liquibase:update + @echo "$(GREEN)✓ Migrations applied$(NC)" + +## [DB] Roll back the last applied changeset (usage: make db-rollback) +db-rollback: db-check-env check-maven + @echo "$(YELLOW)Rolling back last changeset on: $(DB_JDBC_URL)$(NC)" + $(MAVEN_WRAPPER) $(DB_MAVEN_ARGS) liquibase:rollback -Dliquibase.rollbackCount=1 + @echo "$(GREEN)✓ Rollback complete$(NC)" + +## [DB] Tag the current database state (usage: make db-tag TAG=v1.0.0) +db-tag: db-check-env check-maven + @if [ -z "$(TAG)" ]; then \ + echo "$(RED)Error: TAG is required. Usage: make db-tag TAG=v1.0.0$(NC)"; \ + exit 1; \ + fi + @echo "$(BLUE)Tagging database state as '$(TAG)'...$(NC)" + $(MAVEN_WRAPPER) $(DB_MAVEN_ARGS) liquibase:tag -Dliquibase.tag=$(TAG) + @echo "$(GREEN)✓ Database tagged as '$(TAG)'$(NC)" + +## [DB] Port-forward the cluster PostgreSQL Service to localhost (usage: make db-port-forward NAMESPACE=) +db-port-forward: + @if [ -z "$(NAMESPACE)" ]; then \ + echo "$(RED)Error: NAMESPACE is required.$(NC)"; \ + echo "$(YELLOW)Usage: make db-port-forward NAMESPACE=$(NC)"; \ + echo "$(YELLOW)Optionally override DB_K8S_SERVICE (default: $(DB_K8S_SERVICE)),$(NC)"; \ + echo "$(YELLOW) DB_PF_LOCAL_PORT (default: $(DB_PF_LOCAL_PORT)) or DB_PF_REMOTE_PORT (default: $(DB_PF_REMOTE_PORT)).$(NC)"; \ + exit 1; \ + fi + @if ! command -v kubectl >/dev/null 2>&1; then \ + echo "$(RED)Error: kubectl not found in PATH$(NC)"; \ + exit 1; \ + fi + @echo "$(BLUE)Port-forwarding PostgreSQL service...$(NC)" + @echo "$(YELLOW) Namespace : $(NAMESPACE)$(NC)" + @echo "$(YELLOW) Service : $(DB_K8S_SERVICE)$(NC)" + @echo "$(YELLOW) Mapping : localhost:$(DB_PF_LOCAL_PORT) → $(DB_K8S_SERVICE):$(DB_PF_REMOTE_PORT)$(NC)" + @echo "" + @echo "$(YELLOW)Once forwarding is active, connect with:$(NC)" + @echo " psql -h localhost -p $(DB_PF_LOCAL_PORT) -U \$$ODS_API_SERVICE_DB_USER \$$ODS_API_SERVICE_DB_NAME" + @echo " # or run migrations against the forwarded port:" + @echo " ODS_API_SERVICE_DB_HOST=localhost ODS_API_SERVICE_DB_PORT=$(DB_PF_LOCAL_PORT) make db-migrate" + @echo "" + @echo "$(YELLOW)Press Ctrl+C to stop the tunnel$(NC)" + kubectl port-forward \ + --namespace $(NAMESPACE) \ + --address 0.0.0.0 \ + service/$(DB_K8S_SERVICE) \ + $(DB_PF_LOCAL_PORT):$(DB_PF_REMOTE_PORT) + +db-docker-build-db: + @echo "$(BLUE)Building Docker image for database...$(NC)" + @docker build --file ../ods-core/ods-api-service/docker/Dockerfile.database --tag $(PROJECT_NAME)-db:18 ../ods-core/ods-api-service/docker/ + @echo "$(GREEN)✓ Database Docker image built: $(PROJECT_NAME)-db:$(DOCKER_TAG)$(NC)" + +db-docker-run-db: db-docker-build-db + @echo "$(BLUE)Running PostgreSQL database in Docker...$(NC)" + @echo "$(YELLOW)Access the database at: localhost:5432$(NC)" + @echo "$(YELLOW)PostgreSQL credentials: user=ods_api_service, db=ods_api_service$(NC)" + @echo "$(YELLOW)Press Ctrl+C to stop$(NC)" + docker run -p 5432:5432 \ + -e POSTGRES_USER=ods_api_service \ + -e POSTGRES_PASSWORD=ods_api_service \ + -e POSTGRES_DB=ods_api_service \ + $(PROJECT_NAME)-db:18 + + ## Clean everything including Docker images clean-all: clean @echo "$(BLUE)Cleaning Docker images...$(NC)" diff --git a/README.md b/README.md index c3e6c89..bfda216 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ The system will expose RESTful APIs for third-party client applications and futu - [Quick Start](#quick-start) - [Build Options](#build-options) - [Running the Application](#running-the-application) +- [Security & Authentication](#security--authentication) - [Development Workflow](#development-workflow) - [Docker Support](#docker-support) - [Advanced Usage](#advanced-usage) @@ -19,7 +20,7 @@ The system will expose RESTful APIs for third-party client applications and futu Before using the Makefile, ensure you have the following installed: ### Required -- **Java 17+** - The project uses Java 21 +- **Java 21+** - The project uses Java 21 - **Maven** - Maven wrapper (`mvnw`) is included in the project - **Make** - For running Makefile commands @@ -54,7 +55,7 @@ Build a traditional Spring Boot JAR file: ```bash make jar ``` -- Output: `core/target/core-0.0.2-SNAPSHOT.jar` +- Output: `core/target/core-0.0.3.jar` - Includes all dependencies - Standard Spring Boot startup time @@ -112,7 +113,48 @@ Once running, the application is available at: - **Swagger UI**: http://localhost:8080/swagger-ui.html - **Actuator Health**: http://localhost:8080/actuator/health -## 💻 Development Workflow +## � Security & Authentication + +The service is an OAuth2 Resource Server that validates incoming JWT access tokens. +Authentication is configured entirely through environment variables. + +### Multiple JWT Issuers + +The API accepts tokens from more than one trusted identity provider. Each issuer +pairs an issuer URI (the token `iss` claim) with the JWK set URI used to validate +the token signature. The incoming token's `iss` claim selects the matching issuer. + +| Variable | Description | +|----------|-------------| +| `OAUTH2_ISSUER_V1` | Issuer URI (`iss` claim) of the first trusted identity provider | +| `OAUTH2_JWK_SET_URI_V1` | JWK set URI used to verify signatures for issuer V1 | +| `OAUTH2_ISSUER_V2` | Issuer URI (`iss` claim) of the second trusted identity provider | +| `OAUTH2_JWK_SET_URI_V2` | JWK set URI used to verify signatures for issuer V2 | + +### Allowed Audiences + +Audiences are shared across all configured issuers. A valid token must carry at +least one of these values in its `aud` claim. + +| Variable | Description | +|----------|-------------| +| `OAUTH2_AUDIENCE` | First accepted audience value | +| `OAUTH2_AUDIENCE2` | Second accepted audience value | + +### Marketplace OBO Bypass + +Calls to the Marketplace service normally perform an On-Behalf-Of (OBO) token +exchange. When the incoming token already targets the configured bypass audience +and scope, the OBO exchange is skipped and the token is forwarded as-is. + +| Variable | Description | +|----------|-------------| +| `MARKETPLACE_BYPASS_AUDIENCE` | Audience that must be present in the token `aud` claim to skip the OBO exchange | +| `MARKETPLACE_BYPASS_SCOPE` | Scope that must be present in the token `scp` claim to skip the OBO exchange | + +> If either bypass value is unset, the OBO exchange is always performed. + +## �💻 Development Workflow ### Quick Development Cycle Clean, compile, and test in one command: diff --git a/api-project-component-v0/openapi/api-project-component-v0-internal.yaml b/api-project-component-v0/openapi/api-project-component-v0-internal.yaml new file mode 100644 index 0000000..1dd7134 --- /dev/null +++ b/api-project-component-v0/openapi/api-project-component-v0-internal.yaml @@ -0,0 +1,66 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v1 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: + - name: Project Components Internal + description: API for managing project components with extended methods + +paths: + /projects/{projectId}/components/{componentId}: + delete: + tags: + - Project Components Internal + summary: Delete component information + operationId: deleteProjectComponent + description: Deletes a specific component + parameters: + - name: projectId + in: path + required: true + description: Project key + schema: + type: string + pattern: "^[A-Z][A-Z0-9]{2,9}$" + minLength: 3 + maxLength: 10 + - name: componentId + in: path + required: true + description: Component id + schema: + type: string + minLength: 2 + maxLength: 52 + pattern: "^[a-z]+(-?[a-z0-9]+)*$" + responses: + "204": + description: Project component properly deleted or no component found for the provided componentId. + "400": + description: Bad request. + "401": + description: Invalid credentials on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + diff --git a/api-project-component-v0/openapi/api-project-component-v0.yaml b/api-project-component-v0/openapi/api-project-component-v0.yaml new file mode 100644 index 0000000..4e331b4 --- /dev/null +++ b/api-project-component-v0/openapi/api-project-component-v0.yaml @@ -0,0 +1,226 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v0 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: + - name: Project Components + description: API for managing project components + +paths: + /projects/{projectId}/components/: + post: + tags: + - Project Components + summary: Create a component in a project + operationId: createProjectComponent + description: Retrieves information about a specific component + parameters: + - name: projectId + in: path + required: true + description: Project key + schema: + type: string + requestBody: + description: Component data + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentRequest' + responses: + '200': + description: OK + headers: + Location: + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + description: Forbidden + '404': + description: Endpoint not found / Project not found / Product not found + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' + + /projects/{projectId}/components/{componentId}: + get: + tags: + - Project Components + summary: Get component information + operationId: getProjectComponent + description: Retrieves information about a specific component + parameters: + - name: projectId + in: path + required: true + description: Project key + schema: + type: string + - name: componentId + in: path + required: true + description: Component id + schema: + type: string + responses: + '200': + description: Component information + content: + application/json: + schema: + $ref: '#/components/schemas/Component' + +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + responses: + UnauthorizedError: + description: Authentication information is missing or invalid + headers: + WWW_Authenticate: + schema: + type: string + schemas: + Component: + type: object + x-class-extra-annotation: | + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + required: + - params + properties: + id: + type: string + description: Component ID + name: + type: string + description: Name of the component + productDescription: + type: string + description: Description of the product + productName: + type: string + description: Name of the product + productId: + type: string + description: Product ID + environment: + $ref: '#/components/schemas/EnvironmentsDTO' + description: Environment + status: + $ref: '#/components/schemas/ComponentsStatusDTO' + description: Status of the component + resultTraceback: + type: object + nullable: true + description: Traceback information in case of error + x-field-extra-annotation: | + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY) + repositoryURL: + type: string + description: URL of the repository (for ODS products) + params: + type: object + description: Additional parameters (key-value pairs) + additionalProperties: true + component-type: + type: string + description: Type of component (ods|awx) + CreateComponentResponse: + type: object + required: + - timestamp + - httpStatus + - errorKey + - message + - path + properties: + timestamp: + type: integer + format: int64 + httpStatus: + type: string + errorKey: + type: string + error: + type: string + message: + type: string + path: + type: string + CreateComponentRequest: + type: object + required: + - name + - productId + - params + properties: + name: + type: string + description: Component name + minLength: 2 + maxLength: 52 + pattern: "^[a-z]+(-?[a-z0-9]+)*$" + x-field-extra-annotation: | + @Size(min=2, message = "The component name must contain more than 1 character.") + @Size(max=52, message = "The component name must contain less than 53 characters.") + @Pattern(regexp = "^[a-z]+(-?[a-z0-9]+)*$", message = "Only lowercase letters and numbers are allowed (example: componentname8), with optional dashes in between (example: my-component). The first character must be a letter.") + productId: + type: string + description: Product id + registerOnly: + type: boolean + description: Register only flag (no provisioning) + default: false + params: + type: object + description: Additional parameters (key-value pairs) + additionalProperties: true + EnvironmentsDTO: + type: string + enum: + - DEV + - QA + - PROD + ComponentsStatusDTO: + type: string + enum: + - READY + - RUNNING + - FAILED + - DELETING + - DELETED + - UNKNOWN diff --git a/api-project-component-v0/pom.xml b/api-project-component-v0/pom.xml new file mode 100644 index 0000000..495e438 --- /dev/null +++ b/api-project-component-v0/pom.xml @@ -0,0 +1,205 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + api-project-component-v0 + API Project Components V0 + API module for managing EDP project components + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.opendevstack.apiservice + service-projects + ${project.version} + + + + org.opendevstack.apiservice + external-service-marketplace + ${project.version} + + + + org.opendevstack.apiservice + core-security + ${project.version} + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + org.projectlombok + lombok + provided + + + + org.mapstruct + mapstruct + 1.6.3 + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-core + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + + + + org.openapitools + openapi-generator-maven-plugin + + + generate-api-project-component-v0 + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project-component-v0.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + + + generate-api-project-component-extended + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project-component-v0-internal.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + + \ No newline at end of file diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java new file mode 100644 index 0000000..5230803 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.openapitools.jackson.nullable.JsonNullable; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JsonNullableConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonNullableCustomizer() { + return builder -> { + builder.modulesToInstall(JsonNullableModule.class); + builder.postConfigurer(this::configureJsonNullableInclusion); + }; + } + + private void configureJsonNullableInclusion(ObjectMapper objectMapper) { + objectMapper.configOverride(JsonNullable.class) + .setInclude(JsonInclude.Value.construct(JsonInclude.Include.NON_ABSENT, JsonInclude.Include.NON_ABSENT)); + } +} 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/ComponentsResponseFactory.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java new file mode 100644 index 0000000..0feb0ad --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.project.controller; + +import org.opendevstack.apiservice.project.exception.ComponentErrorKey; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; + +public final class ComponentsResponseFactory { + + private ComponentsResponseFactory() { + } + + public static CreateComponentResponse entityCreated(String projectId, String componentId) { + String path = String.format("/api/pub/v0/projects/%s/components/%s", projectId, componentId); + return ok(path, "Component created"); + } + + public static CreateComponentResponse badRequest(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.BAD_REQUEST, errorKey, path, message); + } + + public static CreateComponentResponse forbidden(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.FORBIDDEN, errorKey, path, message); + } + + public static CreateComponentResponse conflict(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.CONFLICT, errorKey, path, message); + } + + public static CreateComponentResponse notFound(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.NOT_FOUND, errorKey, path, message); + } + + public static CreateComponentResponse unprocessableEntity(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, errorKey, path, message); + } + + public static CreateComponentResponse internalError(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorKey, path, message); + } + + private static CreateComponentResponse buildResponse(HttpStatus httpStatus, ComponentErrorKey errorKey, String path, + String message) { + CreateComponentResponse response = new CreateComponentResponse(); + response.setTimestamp(System.currentTimeMillis()); + response.setHttpStatus(httpStatus.name()); + response.setErrorKey(errorKey.getKey()); + response.setError(errorKey.getMessage()); + response.setMessage(message); + response.setPath(path); + return response; + } + + public static CreateComponentResponse ok(String path, String message) { + CreateComponentResponse response = new CreateComponentResponse(); + response.setTimestamp(System.currentTimeMillis()); + response.setHttpStatus(HttpStatus.OK.name()); + response.setErrorKey(ComponentErrorKey.OK.getKey()); + response.setMessage(message); + response.setPath(path); + return response; + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java new file mode 100644 index 0000000..2f2e9c8 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java @@ -0,0 +1,59 @@ +package org.opendevstack.apiservice.project.controller; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.api.ProjectComponentsApi; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; +import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@Slf4j +@RequestMapping(ProjectComponentsController.API_BASE_PATH) +public class ProjectComponentsController implements ProjectComponentsApi { + + public static final String API_BASE_PATH = "/api/pub/v0"; + + private final ComponentsFacade componentsFacade; + + private final ComponentResponseMapper componentResponseMapper; + + @Override + public ResponseEntity createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + + if (Boolean.TRUE.equals(createComponentRequest.getRegisterOnly())) { + componentsFacade.registerProjectComponent(projectId, createComponentRequest); + log.info("Registered component '{}' for project '{}'", createComponentRequest.getName(), projectId); + } else { + componentsFacade.provisionProjectComponent(projectId, createComponentRequest); + log.info("Created component '{}' for project '{}'", createComponentRequest.getName(), projectId); + } + + return componentResponseMapper.toResponseEntity( + ComponentsResponseFactory.entityCreated(projectId, createComponentRequest.getName()) + ); + + } + + @Override + public ResponseEntity getProjectComponent(String projectId, String componentId) { + Component component = componentsFacade.getProjectComponent(projectId, componentId); + if (component == null) { + throw new ComponentNotFoundException( + String.format("Component '%s' not found for project '%s'", componentId, projectId) + ); + } + + log.info("Retrieved component '{}' for project '{}': {}", componentId, projectId, component); + return ResponseEntity.status(HttpStatus.OK).body(component); + } + +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalController.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalController.java new file mode 100644 index 0000000..63c1f3d --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalController.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.project.controller; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.api.ProjectComponentsInternalApi; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@Slf4j +@RequestMapping(ProjectComponentsInternalController.API_BASE_PATH) +public class ProjectComponentsInternalController implements ProjectComponentsInternalApi { + + public static final String API_BASE_PATH = "/api/pub/v1"; + + private final ComponentsFacade componentsFacade; + + @Override + public ResponseEntity deleteProjectComponent(String projectId, String componentId) { + componentsFacade.deleteProjectComponent(projectId, componentId); + log.info("Deleted component with id '{}' for project '{}'", componentId, projectId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + +} 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 new file mode 100644 index 0000000..b09dd64 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java @@ -0,0 +1,252 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.controller.ComponentsResponseFactory; +import org.opendevstack.apiservice.project.controller.ProjectComponentsController; +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.ComponentErrorKey; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; +import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; +import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.Map; + +@RestControllerAdvice(assignableTypes = ProjectComponentsController.class) +@Slf4j +public class ProjectComponentsExceptionHandler { + + private static final Map FIELD_ERROR_MAP = Map.of( + "name", ComponentErrorKey.COMPONENT_PARAM_NOT_MEET_REGEX, + "productId", ComponentErrorKey.INVALID_PARAMETERS, + "params", ComponentErrorKey.INVALID_PARAMETERS + ); + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex, + HttpServletRequest request) { + + log.warn("Request body validation error: {}", ex.getMessage()); + + FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .orElse(null); + + ComponentErrorKey errorKey = ComponentErrorKey.INVALID_PARAMETERS; + String message = ComponentErrorKey.INVALID_PARAMETERS.getMessage(); + + if (fieldError != null) { + errorKey = FIELD_ERROR_MAP.getOrDefault(fieldError.getField(), ComponentErrorKey.INVALID_PARAMETERS); + message = String.format("Field: %s %s", fieldError.getField(), fieldError.getDefaultMessage()); + } + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + message, + errorKey + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException ex, + HttpServletRequest request) { + + log.warn("Request parameter validation error: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ComponentNotFoundException.class) + public ResponseEntity handleComponentNotFoundException( + ComponentNotFoundException ex, + HttpServletRequest request) { + + log.warn("Component not found: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.notFound( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.COMPONENT_NOT_FOUND + ); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException( + AccessDeniedException ex, + HttpServletRequest request) { + + log.warn("Access denied: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.forbidden( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.ACCESS_DENIED + ); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex, + HttpServletRequest request) { + + log.warn("Request body format error: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + "Params property should be a valid json.", + ComponentErrorKey.COMPONENT_PARAM_INVALID_FORMAT + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ComponentCreationException.class) + public ResponseEntity handleComponentCreationException( + ComponentCreationException ex, + HttpServletRequest request) { + + log.error("Component creation failed: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(ComponentRegistrationException.class) + public ResponseEntity handleComponentRegisterException( + ComponentRegistrationException ex, + HttpServletRequest request) { + + log.error("Component registration failed: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(ComponentDeletionException.class) + public ResponseEntity handleComponentDeletionException( + ComponentDeletionException ex, + HttpServletRequest request) { + + log.error("Component deletion failed: {}", ex.getMessage(), ex); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); + } + + @ExceptionHandler(ComponentRetrievalException.class) + public ResponseEntity handleComponentRetrievalException( + ComponentRetrievalException ex, + HttpServletRequest request) { + + log.error("Component retrieval failed: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(ComponentAlreadyExistsException.class) + public ResponseEntity handleComponentAlreadyExistsException( + ComponentAlreadyExistsException ex, + HttpServletRequest request) { + + log.warn("Component already exists: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.conflict( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + } + + @ExceptionHandler(ComponentBadRequestException.class) + public ResponseEntity handleComponentBadRequestException( + ComponentBadRequestException ex, + HttpServletRequest request) { + + log.warn("Bad request from downstream service: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.unprocessableEntity( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + 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, + HttpServletRequest request) { + + log.error("Unexpected error: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + "An error occurred while processing the request.", + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java new file mode 100644 index 0000000..9b66377 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class CatalogItemNotFoundException extends RuntimeException { + + public CatalogItemNotFoundException(String message) { + super(message); + } + + public CatalogItemNotFoundException(String message, Exception e) { + super(message, e); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java new file mode 100644 index 0000000..8435586 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentAlreadyExistsException extends RuntimeException { + + public ComponentAlreadyExistsException(String message) { + super(message); + } + + public ComponentAlreadyExistsException(String message, Exception e) { + super(message, e); + } +} \ No newline at end of file diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java new file mode 100644 index 0000000..10deaec --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentBadRequestException extends RuntimeException { + + public ComponentBadRequestException(String message) { + super(message); + } + + public ComponentBadRequestException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java new file mode 100644 index 0000000..dce8484 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentCreationException extends RuntimeException { + + public ComponentCreationException(String message) { + super(message); + } + + public ComponentCreationException(String message, Exception e) { + super(message, e); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentDeletionException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentDeletionException.java new file mode 100644 index 0000000..f1eeffa --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentDeletionException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentDeletionException extends RuntimeException { + + public ComponentDeletionException(String message) { + super(message); + } + + public ComponentDeletionException(String message, Exception e) { + super(message, e); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java new file mode 100644 index 0000000..9af565b --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java @@ -0,0 +1,30 @@ +package org.opendevstack.apiservice.project.exception; + +public enum ComponentErrorKey { + + OK("000", ComponentErrorMessage.SUCCESS), + ACCESS_DENIED("002", ComponentErrorMessage.FORBIDDEN), + INTERNAL_ERROR("003", ComponentErrorMessage.INTERNAL_ERROR), + INVALID_PARAMETERS("006", ComponentErrorMessage.BAD_REQUEST), + COMPONENT_PARAM_NOT_MEET_REGEX("010", ComponentErrorMessage.BAD_REQUEST), + PROJECT_NOT_FOUND("012", ComponentErrorMessage.NOT_FOUND), + COMPONENT_NOT_FOUND("013", ComponentErrorMessage.NOT_FOUND), + BAD_REQUEST_BODY("014", ComponentErrorMessage.BAD_REQUEST), + COMPONENT_PARAM_INVALID_FORMAT("017", ComponentErrorMessage.BAD_REQUEST); + + private final String key; + private final String message; + + ComponentErrorKey(String key, String message) { + this.key = key; + this.message = message; + } + + public String getKey() { + return key; + } + + public String getMessage() { + return message; + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java new file mode 100644 index 0000000..116d439 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentErrorMessage { + + public static final String BAD_REQUEST = "Bad Request"; + public static final String NOT_FOUND = "Not Found"; + public static final String FORBIDDEN = "Forbidden"; + public static final String INTERNAL_ERROR = "Internal error"; + public static final String SUCCESS = "Success"; + + private ComponentErrorMessage() { + // prevent instantiation + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java new file mode 100644 index 0000000..38caa87 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentNotFoundException extends RuntimeException { + + public ComponentNotFoundException(String message) { + super(message); + } + + public ComponentNotFoundException(String message, Exception e) { + super(message, e); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRegistrationException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRegistrationException.java new file mode 100644 index 0000000..8ae8245 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRegistrationException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentRegistrationException extends RuntimeException { + + public ComponentRegistrationException(String message) { + super(message); + } + + public ComponentRegistrationException(String message, Exception e) { + super(message, e); + } +} 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/exception/ComponentRetrievalException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRetrievalException.java new file mode 100644 index 0000000..655c5be --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRetrievalException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentRetrievalException extends RuntimeException { + + public ComponentRetrievalException(String message) { + super(message); + } + + public ComponentRetrievalException(String message, Exception e) { + super(message, e); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..80bce52 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -0,0 +1,227 @@ +package org.opendevstack.apiservice.project.facade; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; +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.ComponentRegistrationException; +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; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.springframework.stereotype.Service; +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 +@Slf4j +public class ComponentsFacade { + + private final MarketplaceService marketplaceExternalService; + + private final MarketplaceMapper marketplaceMapper; + + private final ProjectComponentsCreateProperties createProperties; + + public Component getProjectComponent(String projectId, String componentId) { + try { + ProjectComponentProvisionStatus marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); + if (marketplaceComponent == null) { + log.info("Marketplace component with id {} not found", componentId); + throw new ComponentNotFoundException( + String.format("Component '%s' not found for project '%s'", componentId, projectId) + ); + } + String catalogItemId = CatalogItemOperations.buildCatalogItemId(marketplaceComponent); + CatalogItem catalogItem = marketplaceExternalService.getCatalogItem(catalogItemId); + if (catalogItem == null) { + log.info("Catalog item with id {} not found", catalogItemId); + throw new CatalogItemNotFoundException( + String.format("Catalog item with id '%s' not found", catalogItemId) + ); + } + Component component = marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent, catalogItem); + log.info("Marketplace v0 component retrieved: {}", component); + return component; + } catch (MarketplaceException e) { + log.error("Failed to retrieve component with id {} for project with id {}: {}", componentId, projectId, e.getMessage(), e); + throw new ComponentRetrievalException( + String.format("Failed to retrieve component '%s' for project '%s': %s", componentId, projectId, e.getMessage()), e + ); + } + } + + public void provisionProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + validateReservedParams(createComponentRequest); + try { + CatalogItem catalogItem = resolveCatalogItem(createComponentRequest); + List createComponentParameterList = marketplaceMapper + .mapCreateComponentRequestToCreateComponentParameterList(createComponentRequest, catalogItem); + boolean success = marketplaceExternalService.provisionProjectComponent(projectId, createComponentParameterList); + if (!success) { + log.error("Failed to create component in marketplace for project with id {}", projectId); + throw new ComponentCreationException( + String.format("Failed to create component for project '%s'", projectId) + ); + } + } catch (MarketplaceException e) { + if (isConflictCause(e)) { + throw new ComponentAlreadyExistsException(e.getMessage(), e); + } + if (isBadRequestCause(e)) { + String downstreamMessage = extractHttpErrorMessage(e); + throw new ComponentBadRequestException(downstreamMessage, e); + } + throw new ComponentCreationException( + String.format("Failed to create component for project '%s': %s", projectId, e.getMessage()), e + ); + } + } + + 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, + * when the catalog item cannot be found, or when the lookup fails – callers must tolerate a + * missing catalog item and fall back to default parameter handling. + */ + private CatalogItem resolveCatalogItem(CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null || createComponentRequest.getProductId() == null + || createComponentRequest.getProductId().isBlank()) { + return null; + } + String slug = createComponentRequest.getProductId(); + try { + CatalogItem catalogItem = marketplaceExternalService.getCatalogItemBySlug(slug); + if (catalogItem == null) { + log.warn("No catalog item found for slug '{}'; provisioning will fall back to default parameter types", slug); + } + return catalogItem; + } catch (MarketplaceException e) { + log.warn("Failed to retrieve catalog item for slug '{}': {}. Provisioning will fall back to default parameter types", + slug, e.getMessage()); + return null; + } + } + + private boolean isConflictCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException.Conflict) { + return true; + } + current = current.getCause(); + } + return false; + } + + private boolean isBadRequestCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException.UnprocessableEntity + || current instanceof HttpClientErrorException.BadRequest) { + return true; + } + current = current.getCause(); + } + return false; + } + + private String extractHttpErrorMessage(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException httpError) { + return httpError.getResponseBodyAsString(); + } + current = current.getCause(); + } + return throwable.getMessage(); + } + + public void deleteProjectComponent(String projectId, String componentId) { + try { + marketplaceExternalService.deleteProjectComponent(projectId, componentId); + log.info("Successfully deleted component '{}' for project '{}'", componentId, projectId); + } catch (MarketplaceException e) { + log.error("Failed to delete component '{}' for project '{}': {}", componentId, projectId, e.getMessage(), e); + // Check if it's an access denied error + if (isAccessDeniedCause(e)) { + throw new ComponentDeletionException( + String.format("Access denied when deleting component '%s' from project '%s'", componentId, projectId), e); + } + // Generic deletion failure + throw new ComponentDeletionException( + String.format("Failed to delete component '%s' for project '%s': %s", componentId, projectId, e.getMessage()), e + ); + } + } + + private boolean isAccessDeniedCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException.Unauthorized + || current instanceof HttpClientErrorException.Forbidden) { + return true; + } + current = current.getCause(); + } + return false; + } + + public void registerProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + String componentId = createComponentRequest.getName(); + String productId = createComponentRequest.getProductId(); + try { + List parameters = marketplaceMapper + .mapCreateComponentRequestToRegisterComponentParameterList(createComponentRequest); + marketplaceExternalService.registerProjectComponent(projectId, componentId, productId, parameters); + log.info("Successfully registered component '{}' for project '{}'", componentId, projectId); + } catch (MarketplaceException e) { + log.error("Failed to register component '{}' for project '{}': {}", componentId, projectId, e.getMessage(), e); + throw new ComponentRegistrationException( + String.format("Failed to register component '%s' for project '%s': %s", componentId, projectId, e.getMessage()), e + ); + } + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java new file mode 100644 index 0000000..846f299 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@Mapper(componentModel = "spring") +public interface ComponentResponseMapper { + + default ResponseEntity toResponseEntity(CreateComponentResponse response) { + return new ResponseEntity<>(response, HttpStatus.valueOf(response.getHttpStatus())); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java new file mode 100644 index 0000000..718a939 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java @@ -0,0 +1,128 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserAction; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.opendevstack.apiservice.project.model.EnvironmentsDTO; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Mapper(componentModel = "spring") +public interface MarketplaceMapper { + + String DEFAULT_PARAMETER_TYPE = "string"; + + default Component mapMarketplaceComponentToV0Component(ProjectComponentProvisionStatus source, CatalogItem catalogItem) throws MarketplaceException { + Component component = new Component(); + component.setId(source.getComponentId()); + component.setEnvironment(EnvironmentsDTO.DEV); // Env is always DEV so we hardcode it as such + component.setRepositoryURL(source.getComponentUrl()); + component.setComponentType(""); // We agreed to hardcode the type as empty + component.setStatus(StatusMap.toOldStatus(source.getStatus())); + if (component.getStatus() == null) { + throw new MarketplaceException("No status mapping found for status " + source.getStatus()); + } + if (catalogItem != null) { + component.setProductId(catalogItem.getId()); + component.setProductName(catalogItem.getTitle()); + component.setProductDescription(catalogItem.getShortDescription()); + } + + if (source.getParameters() != null) { + source.getParameters().forEach( + param -> component.putParamsItem(param.getName(), param.getValues()) + ); + } + return component; + } + + default List mapCreateComponentRequestToCreateComponentParameterList( + CreateComponentRequest createComponentRequest, CatalogItem catalogItem) { + if (createComponentRequest == null) { + return List.of(); + } + + Map parameterTypesByName = buildParameterTypeIndex(catalogItem); + + List parameters = new ArrayList<>(); + parameters.add(createParameter("component_id", createComponentRequest.getName(), DEFAULT_PARAMETER_TYPE)); + parameters.add(createParameter("catalog_item_slug", createComponentRequest.getProductId(), DEFAULT_PARAMETER_TYPE)); + + if (createComponentRequest.getParams() != null && !createComponentRequest.getParams().isEmpty()) { + createComponentRequest.getParams().forEach((name, value) -> { + String type = parameterTypesByName.getOrDefault(name, DEFAULT_PARAMETER_TYPE); + parameters.add(createParameter(name, value, type)); + }); + } + + return parameters; + } + + default List mapCreateComponentRequestToRegisterComponentParameterList( + CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null) { + return List.of(); + } + + List parameters = new ArrayList<>(); + if (createComponentRequest.getParams() != null && !createComponentRequest.getParams().isEmpty()) { + createComponentRequest.getParams().forEach((name, value) -> { + parameters.add(createRegisterParameter(name, value)); + }); + } + + return parameters; + } + + default ProvisionActionParameter createParameter(String name, Object value, String type) { + return new ProvisionActionParameter().name(name).type(type).value(value); + } + + default ProvisioningStatusUpdateRequestAllOfParameters createRegisterParameter(String name, Object value) { + List values; + if (value == null) { + values = List.of(""); + } else if (value instanceof List list) { + values = list.stream() + .map(Object::toString) + .toList(); + } else { + values = List.of(value.toString()); + } + return new ProvisioningStatusUpdateRequestAllOfParameters().name(name).values(values); + } + + /** + * Builds a map of parameter name -> type from the catalog item user actions. + * The type comes from the {@link CatalogItemUserActionParameter#getType()} value + * (e.g. {@code string}, {@code boolean}, {@code multiplelist}, {@code singlelist}, ...). + */ + private static Map buildParameterTypeIndex(CatalogItem catalogItem) { + Map index = new HashMap<>(); + if (catalogItem == null || catalogItem.getUserActions() == null) { + return index; + } + for (CatalogItemUserAction action : catalogItem.getUserActions()) { + if (action == null || action.getParameters() == null) { + continue; + } + for (CatalogItemUserActionParameter param : action.getParameters()) { + if (param == null || param.getName() == null || param.getType() == null) { + continue; + } + index.putIfAbsent(param.getName(), param.getType()); + } + } + return index; + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java new file mode 100644 index 0000000..cf9816b --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; + +import java.util.HashMap; +import java.util.Map; + +public class StatusMap { + + private StatusMap() { + } + + static final Map STATUS_MAP = new HashMap<>(); + + static { + STATUS_MAP.put("CREATING", ComponentsStatusDTO.RUNNING); + STATUS_MAP.put("CREATED", ComponentsStatusDTO.READY); + STATUS_MAP.put("FAILED", ComponentsStatusDTO.FAILED); + STATUS_MAP.put("DELETING", ComponentsStatusDTO.DELETING); + STATUS_MAP.put("UNKNOWN", ComponentsStatusDTO.UNKNOWN); + } + + static ComponentsStatusDTO toOldStatus(String status) { + return STATUS_MAP.get(status); + } +} diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java new file mode 100644 index 0000000..9c5b200 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java @@ -0,0 +1,162 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; +import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestComponent; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCreateComponentRequest; + +class ProjectComponentsControllerTest { + + @Mock + private ComponentsFacade componentsFacade; + + private final ComponentResponseMapper componentResponseMapper = Mappers.getMapper(ComponentResponseMapper.class); + + private ProjectComponentsController projectComponentsController; + + private AutoCloseable openMocks; + + @BeforeEach + void setup() { + openMocks = MockitoAnnotations.openMocks(this); + projectComponentsController = new ProjectComponentsController(componentsFacade, componentResponseMapper); + } + + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + + @Test + void create_project_component_returns_ok_with_component_name_in_path() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); // name = "testcomponent" + + doNothing().when(componentsFacade).provisionProjectComponent(eq(projectId), any(CreateComponentRequest.class)); + + ResponseEntity response = projectComponentsController.createProjectComponent(projectId, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.OK.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("000"); + assertThat(response.getBody().getMessage()).isEqualTo("Component created"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/testcomponent"); + verify(componentsFacade).provisionProjectComponent(projectId, request); + } + + @Test + void create_project_component_with_register_only_returns_ok_with_component_name_in_path() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); // name = "testcomponent" + request.setRegisterOnly(Boolean.TRUE); + + doNothing().when(componentsFacade).registerProjectComponent(eq(projectId), eq(request)); + + ResponseEntity response = projectComponentsController.createProjectComponent(projectId, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.OK.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("000"); + assertThat(response.getBody().getMessage()).isEqualTo("Component created"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/testcomponent"); + verify(componentsFacade).registerProjectComponent(projectId, request); + } + + @Test + void create_project_component_propagates_exception_when_facade_throws_exception() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); + + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(componentsFacade).provisionProjectComponent(eq(projectId), any(CreateComponentRequest.class)); + + assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("boom"); + verify(componentsFacade).provisionProjectComponent(projectId, request); + } + + @Test + void create_project_component_with_register_only_propagates_exception_when_facade_throws_exception() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); + request.setRegisterOnly(Boolean.TRUE); + + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(componentsFacade).registerProjectComponent(eq(projectId), eq(request)); + + assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("boom"); + verify(componentsFacade).registerProjectComponent(projectId, request); + } + + @Test + void create_project_component_with_register_only_false_delegates_to_provision_project_component() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); + request.setRegisterOnly(Boolean.FALSE); + + doNothing().when(componentsFacade).provisionProjectComponent(eq(projectId), any(CreateComponentRequest.class)); + + ResponseEntity response = projectComponentsController.createProjectComponent(projectId, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Component created"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/testcomponent"); + verify(componentsFacade).provisionProjectComponent(projectId, request); + } + + @Test + void get_project_component_returns_ok_when_component_exists() throws MarketplaceException { + String projectId = "projectId"; + String componentId = "test-component-one"; + Component testComponent = buildTestComponent(); + + when(componentsFacade.getProjectComponent(projectId, componentId)).thenReturn(testComponent); + + ResponseEntity response = projectComponentsController.getProjectComponent(projectId, componentId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(testComponent); + verify(componentsFacade).getProjectComponent(projectId, componentId); + } + + @Test + void get_project_component_throws_not_found_when_component_does_not_exist() throws MarketplaceException { + String projectId = "projectId"; + String componentId = "test-component-one"; + + when(componentsFacade.getProjectComponent(projectId, componentId)).thenReturn(null); + + assertThatThrownBy(() -> projectComponentsController.getProjectComponent(projectId, componentId)) + .isInstanceOf(ComponentNotFoundException.class) + .hasMessage("Component '" + componentId + "' not found for project 'projectId'"); + verify(componentsFacade).getProjectComponent(projectId, componentId); + } + +} \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalControllerTest.java new file mode 100644 index 0000000..4554359 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalControllerTest.java @@ -0,0 +1,64 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ComponentDeletionException; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +class ProjectComponentsInternalControllerTest { + + @Mock + private ComponentsFacade componentsFacade; + + private ProjectComponentsInternalController projectComponentsInternalController; + + private AutoCloseable openMocks; + + @BeforeEach + void setup() { + openMocks = MockitoAnnotations.openMocks(this); + projectComponentsInternalController = new ProjectComponentsInternalController(componentsFacade); + } + + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + + @Test + void delete_project_component_returns_no_content_when_component_delete_works() { + String projectId = "projectId"; + String componentId = "test-component-delete"; + + ResponseEntity response = projectComponentsInternalController.deleteProjectComponent(projectId, componentId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(componentsFacade).deleteProjectComponent(projectId, componentId); + } + + @Test + void delete_project_component_throws_exception_when_component_delete_api_throws_exception() { + String projectId = "projectId"; + String componentId = "test-component-delete"; + + doThrow(new ComponentDeletionException("test exception")) + .when(componentsFacade).deleteProjectComponent(anyString(), anyString()); + + assertThrows(ComponentDeletionException.class, () -> + projectComponentsInternalController.deleteProjectComponent(projectId, componentId) + ); + verify(componentsFacade).deleteProjectComponent(projectId, componentId); + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..70eb4a3 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java @@ -0,0 +1,213 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +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.exception.ComponentRegistrationException; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +class ProjectComponentsExceptionHandlerTest { + + @Mock + private HttpServletRequest request; + + private ProjectComponentsExceptionHandler handler; + + private AutoCloseable openMocks; + + @BeforeEach + void setup() { + openMocks = MockitoAnnotations.openMocks(this); + handler = new ProjectComponentsExceptionHandler(); + when(request.getRequestURI()).thenReturn("/api/pub/v0/projects/test-project/components/"); + } + + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + + @Test + void handle_method_argument_not_valid_exception_returns_bad_request_with_regex_error_key() throws Exception { + DummyRequest dummyRequest = new DummyRequest(); + BindingResult bindingResult = new BeanPropertyBindingResult(dummyRequest, "createComponentRequest"); + bindingResult.rejectValue("name", "Pattern", "has invalid format"); + + Method method = DummyController.class.getDeclaredMethod("dummyMethod", DummyRequest.class); + MethodParameter methodParameter = new MethodParameter(method, 0); + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); + + ResponseEntity response = handler.handleMethodArgumentNotValidException(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("010"); + assertThat(response.getBody().getMessage()).isEqualTo("Field: name has invalid format"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/test-project/components/"); + } + + @Test + void handle_method_argument_type_mismatch_exception_returns_bad_request() throws Exception { + Method method = DummyController.class.getDeclaredMethod("dummyMethod", DummyRequest.class); + MethodParameter methodParameter = new MethodParameter(method, 0); + MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException( + "not-a-uuid", + String.class, + "componentId", + methodParameter, + new IllegalArgumentException("Invalid UUID") + ); + + ResponseEntity response = handler.handleMethodArgumentTypeMismatchException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("006"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/test-project/components/"); + } + + @Test + void handle_component_not_found_exception_returns_not_found() { + ComponentNotFoundException exception = new ComponentNotFoundException("Component not found"); + + ResponseEntity response = handler.handleComponentNotFoundException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("013"); + assertThat(response.getBody().getMessage()).isEqualTo("Component not found"); + } + + @Test + void handle_access_denied_exception_returns_forbidden() { + AccessDeniedException exception = new AccessDeniedException("Forbidden"); + + ResponseEntity response = handler.handleAccessDeniedException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("002"); + assertThat(response.getBody().getMessage()).isEqualTo("Forbidden"); + } + + @Test + void handle_http_message_not_readable_exception_returns_bad_request() { + HttpMessageNotReadableException exception = new HttpMessageNotReadableException("Malformed JSON"); + + ResponseEntity response = handler.handleHttpMessageNotReadableException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("017"); + assertThat(response.getBody().getMessage()).isEqualTo("Params property should be a valid json."); + } + + @Test + void handle_component_creation_exception_returns_internal_server_error() { + ComponentCreationException exception = new ComponentCreationException("Creation failed"); + + ResponseEntity response = handler.handleComponentCreationException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("003"); + assertThat(response.getBody().getMessage()).isEqualTo("Creation failed"); + } + + @Test + void handle_component_already_exists_exception_returns_conflict() { + ComponentAlreadyExistsException exception = new ComponentAlreadyExistsException( + "This component name already exists, please choose another name."); + + ResponseEntity response = handler.handleComponentAlreadyExistsException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.CONFLICT.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("006"); + assertThat(response.getBody().getMessage()).isEqualTo("This component name already exists, please choose another name."); + } + + @Test + void handle_component_registration_exception_returns_internal_server_error() { + ComponentRegistrationException exception = new ComponentRegistrationException("Registration failed"); + + ResponseEntity response = handler.handleComponentRegisterException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("003"); + assertThat(response.getBody().getMessage()).isEqualTo("Registration failed"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/test-project/components/"); + } + + @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"); + + ResponseEntity response = handler.handleGenericException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("003"); + assertThat(response.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); + } + + private static class DummyRequest { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @SuppressWarnings("unused") + private static class DummyController { + public void dummyMethod(@ModelAttribute DummyRequest request) { + // no implementation needed for testing purposes + } + } +} 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 new file mode 100644 index 0000000..3af0405 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -0,0 +1,208 @@ +package org.opendevstack.apiservice.project.facade; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; +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.ComponentRegistrationException; +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; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCatalogItem; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCreateComponentRequest; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestMarketplaceComponent; + +@ExtendWith(MockitoExtension.class) +class ComponentsFacadeTest { + + private final MarketplaceMapper marketplaceMapper = Mappers.getMapper(MarketplaceMapper.class); + + @Mock + private MarketplaceService marketplaceExternalService; + + private ProjectComponentsCreateProperties createProperties; + + private ComponentsFacade componentsFacade; + + @BeforeEach + void setup() { + createProperties = new ProjectComponentsCreateProperties(); + createProperties.setReservedParams(List.of("workflow", "ods_namespace", "component_type", "quickstarter_repo")); + componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper, createProperties); + } + + @Test + void get_project_component_returns_mapped_component_when_marketplace_returns_data() throws MarketplaceException { + ProjectComponentProvisionStatus marketplaceComponent = buildTestMarketplaceComponent(); + CatalogItem testCatalogItem = buildTestCatalogItem(); + + when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) + .thenReturn(marketplaceComponent); + when(marketplaceExternalService.getCatalogItem(anyString())) + .thenReturn(testCatalogItem); + + Component retrievedComponent = componentsFacade.getProjectComponent("testProject", "testComponent"); + + assertThat(retrievedComponent).isNotNull(); + assertThat(retrievedComponent.getId()).isNotNull(); + assertThat(retrievedComponent.getStatus()).isEqualTo(ComponentsStatusDTO.RUNNING); + verify(marketplaceExternalService).getProjectComponent("testProject", "testComponent"); + } + + @Test + void get_project_component_throws_not_found_when_marketplace_returns_null() throws MarketplaceException { + when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) + .thenReturn(null); + + assertThatThrownBy(() -> componentsFacade.getProjectComponent("testProject", "testComponent")) + .isInstanceOf(ComponentNotFoundException.class) + .hasMessage("Component 'testComponent' not found for project 'testProject'"); + verify(marketplaceExternalService).getProjectComponent("testProject", "testComponent"); + } + + @Test + void create_project_component_returns_mapped_component_when_marketplace_creates_component() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + + when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) + .thenReturn(true); + + componentsFacade.provisionProjectComponent("testProject", request); + + verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); + } + + @Test + void create_project_component_throws_creation_exception_when_marketplace_returns_null() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + + when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) + .thenReturn(false); + + assertThatThrownBy(() -> componentsFacade.provisionProjectComponent("testProject", request)) + .isInstanceOf(ComponentCreationException.class) + .hasMessage("Failed to create component for project 'testProject'"); + verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); + } + + @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 + ); + + when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) + .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."); + verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); + } + + @Test + void register_project_component_ends_successfully_when_marketplace_registration_succeeds() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + String projectId = "testProjectId"; + + doNothing().when(marketplaceExternalService).registerProjectComponent(eq(projectId), anyString(), anyString(), anyList()); + + componentsFacade.registerProjectComponent(projectId, request); + + verify(marketplaceExternalService).registerProjectComponent(eq(projectId), anyString(), anyString(), anyList()); + } + + @Test + void register_project_component_passes_correct_component_id_and_product_id_to_marketplace() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + // name = "testcomponent", productId = "testProductId" + String projectId = "testProjectId"; + + doNothing().when(marketplaceExternalService).registerProjectComponent( + eq(projectId), eq("testcomponent"), eq("testProductId"), anyList()); + + componentsFacade.registerProjectComponent(projectId, request); + + verify(marketplaceExternalService).registerProjectComponent( + eq(projectId), eq("testcomponent"), eq("testProductId"), anyList()); + } + + @Test + void register_project_component_throws_registration_exception_wrapping_original_cause() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + String projectId = "testProjectId"; + MarketplaceException originalCause = new MarketplaceException("downstream error", new RuntimeException("root")); + + doThrow(originalCause) + .when(marketplaceExternalService) + .registerProjectComponent(eq(projectId), anyString(), anyString(), anyList()); + + assertThatThrownBy(() -> componentsFacade.registerProjectComponent(projectId, request)) + .isInstanceOf(ComponentRegistrationException.class) + .hasMessage("Failed to register component 'testcomponent' for project 'testProjectId': downstream error") + .hasCause(originalCause); + + verify(marketplaceExternalService).registerProjectComponent(eq(projectId), anyString(), anyString(), anyList()); + } + + @Test + void delete_project_component_ends_successfully_for_existing_component() throws MarketplaceException { + componentsFacade.deleteProjectComponent("testProject", "testComponent"); + + verify(marketplaceExternalService).deleteProjectComponent("testProject", "testComponent"); + } + + @Test + void delete_project_component_throws_component_deletion_exception_when_marketplace_exception_is_thrown() throws MarketplaceException { + doThrow(new MarketplaceException("Test exception")) + .when(marketplaceExternalService).deleteProjectComponent("testProject", "testComponent"); + + assertThatThrownBy(() -> componentsFacade.deleteProjectComponent("testProject", "testComponent")) + .isInstanceOf(ComponentDeletionException.class) + .hasMessageContaining("Test exception"); + verify(marketplaceExternalService).deleteProjectComponent("testProject", "testComponent"); + } +} \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java new file mode 100644 index 0000000..1adf2f3 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java @@ -0,0 +1,196 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserAction; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MarketplaceMapperTest { + + private final MarketplaceMapper mapper = Mappers.getMapper(MarketplaceMapper.class); + + @Test + void mapCreateComponentRequestToCreateComponentParameterList_resolvesTypeFromCatalogUserActions() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-amp-test-four"); + request.setProductId("catest_external-lists-item"); + + List projectGroup = List.of( + "CN=BI-AS-ATLASSIAN-P-EDPI-STAKEHOLDER,OU=BIDS-managed,DC=eu,DC=boehringer,DC=com", + "CN=BI-AS-ATLASSIAN-P-EDPI-MANAGER,OU=BIDS-managed,DC=eu,DC=boehringer,DC=com"); + request.setParams(Map.of( + "requestor_email", "user@example.com", + "Project_Group", projectGroup, + "enable_feature", true + )); + + CatalogItem catalogItem = new CatalogItem(); + catalogItem.setUserActions(List.of(buildProvisionAction( + param("requestor_email", "string"), + param("Project_Group", "multiplelist"), + param("enable_feature", "boolean") + ))); + + List result = + mapper.mapCreateComponentRequestToCreateComponentParameterList(request, catalogItem); + + assertThat(result).extracting(ProvisionActionParameter::getName) + .containsExactlyInAnyOrder("component_id", "catalog_item_slug", + "requestor_email", "Project_Group", "enable_feature"); + + ProvisionActionParameter projectGroupParam = findByName(result, "Project_Group"); + assertThat(projectGroupParam.getType()).isEqualTo("multiplelist"); + assertThat(projectGroupParam.getValue()).isEqualTo(projectGroup); + + ProvisionActionParameter emailParam = findByName(result, "requestor_email"); + assertThat(emailParam.getType()).isEqualTo("string"); + assertThat(emailParam.getValue()).isEqualTo("user@example.com"); + + ProvisionActionParameter booleanParam = findByName(result, "enable_feature"); + assertThat(booleanParam.getType()).isEqualTo("boolean"); + assertThat(booleanParam.getValue()).isEqualTo(true); + + ProvisionActionParameter componentId = findByName(result, "component_id"); + assertThat(componentId.getType()).isEqualTo("string"); + assertThat(componentId.getValue()).isEqualTo("test-amp-test-four"); + + ProvisionActionParameter slug = findByName(result, "catalog_item_slug"); + assertThat(slug.getType()).isEqualTo("string"); + assertThat(slug.getValue()).isEqualTo("catest_external-lists-item"); + } + + @Test + void mapCreateComponentRequestToCreateComponentParameterList_fallsBackToStringWhenCatalogItemMissing() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + request.setParams(Map.of("anything", "value")); + + List result = + mapper.mapCreateComponentRequestToCreateComponentParameterList(request, null); + + ProvisionActionParameter anything = findByName(result, "anything"); + assertThat(anything.getType()).isEqualTo("string"); + assertThat(anything.getValue()).isEqualTo("value"); + } + + @Test + void mapCreateComponentRequestToCreateComponentParameterList_returnsEmptyWhenRequestNull() { + assertThat(mapper.mapCreateComponentRequestToCreateComponentParameterList(null, null)).isEmpty(); + } + + // ------------------------------------------------------------------------- + // mapCreateComponentRequestToRegisterComponentParameterList + // ------------------------------------------------------------------------- + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_maps_string_values_correctly() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + request.setParams(Map.of("my_param", "my_value")); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).hasSize(1); + ProvisioningStatusUpdateRequestAllOfParameters param = + result.stream().filter(p -> "my_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(param.getValues()).containsExactly("my_value"); + } + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_maps_list_values_correctly() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + List listValue = List.of("value1", "value2", "value3"); + request.setParams(Map.of("list_param", listValue)); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).hasSize(1); + ProvisioningStatusUpdateRequestAllOfParameters param = + result.stream().filter(p -> "list_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(param.getValues()).containsExactlyInAnyOrder("value1", "value2", "value3"); + } + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_maps_non_string_value_to_string() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + request.setParams(Map.of("bool_param", true, "int_param", 42)); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).hasSize(2); + ProvisioningStatusUpdateRequestAllOfParameters boolParam = + result.stream().filter(p -> "bool_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(boolParam.getValues()).containsExactly("true"); + + ProvisioningStatusUpdateRequestAllOfParameters intParam = + result.stream().filter(p -> "int_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(intParam.getValues()).containsExactly("42"); + } + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_maps_null_value_to_empty_string_list() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + java.util.Map params = new java.util.HashMap<>(); + params.put("null_param", null); + request.setParams(params); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).hasSize(1); + ProvisioningStatusUpdateRequestAllOfParameters param = + result.stream().filter(p -> "null_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(param.getValues()).containsExactly(""); + } + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_returns_empty_list_when_params_null() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + request.setParams(null); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).isEmpty(); + } + + private static CatalogItemUserAction buildProvisionAction(CatalogItemUserActionParameter... params) { + CatalogItemUserAction action = new CatalogItemUserAction(); + action.setId("PROVISION"); + action.setParameters(List.of(params)); + return action; + } + + private static CatalogItemUserActionParameter param(String name, String type) { + CatalogItemUserActionParameter p = new CatalogItemUserActionParameter(); + p.setName(name); + p.setType(type); + return p; + } + + private static ProvisionActionParameter findByName(List list, String name) { + return list.stream().filter(p -> name.equals(p.getName())).findFirst().orElseThrow(); + } +} diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java new file mode 100644 index 0000000..b0ed351 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java @@ -0,0 +1,54 @@ +package org.opendevstack.apiservice.project.util; + +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.opendevstack.apiservice.project.model.EnvironmentsDTO; + +import java.util.HashMap; +import java.util.UUID; + +public class TestObjectsBuilder { + + private TestObjectsBuilder() { + } + + public static Component buildTestComponent() { + Component component = new Component(); + component.setId("testId"); + component.setName("testComponentName"); + component.setEnvironment(EnvironmentsDTO.DEV); + component.setStatus(ComponentsStatusDTO.RUNNING); + component.setComponentType("testComponentType"); + component.setParams(new HashMap<>()); + return component; + } + + public static ProjectComponentProvisionStatus buildTestMarketplaceComponent() { + ProjectComponentProvisionStatus component = new ProjectComponentProvisionStatus(); + component.setComponentId(UUID.randomUUID().toString()); + component.setStatus("CREATING"); + component.setComponentUrl("http://test.component.url"); + component.setCatalogItemId("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1s"); + component.setCatalogItemRef("P2F0PXJlZnMvaGVhZHMvbWFzdGVy"); + return component; + } + + public static CatalogItem buildTestCatalogItem() { + CatalogItem catalogItem = new CatalogItem(); + catalogItem.setId(UUID.randomUUID().toString()); + catalogItem.setTitle("Test Catalog Item"); + catalogItem.setShortDescription("This is a test catalog item"); + return catalogItem; + } + + public static CreateComponentRequest buildTestCreateComponentRequest() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("testcomponent"); + request.setProductId("testProductId"); + request.setParams(new HashMap<>()); + return request; + } +} diff --git a/api-project-platform/pom.xml b/api-project-platform/pom.xml index 52d3186..abf2834 100644 --- a/api-project-platform/pom.xml +++ b/api-project-platform/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 api-project-platform diff --git a/api-project-users/openapi/api-project-users.yaml b/api-project-users/openapi/api-project-users.yaml index 2d0ba51..a78d1ef 100644 --- a/api-project-users/openapi/api-project-users.yaml +++ b/api-project-users/openapi/api-project-users.yaml @@ -16,7 +16,7 @@ tags: - name: Project Users description: API for managing project users and their roles paths: - /project/{projectKey}/users: + /projects/{projectKey}/users: post: tags: - Project Users @@ -89,7 +89,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ApiResponseMembershipRequestResponse" - /project/{projectKey}/users/{userid}/status: + /projects/{projectKey}/users/{userid}/status: get: tags: - Project Users diff --git a/api-project-users/pom.xml b/api-project-users/pom.xml index fbd989c..c75bf70 100644 --- a/api-project-users/pom.xml +++ b/api-project-users/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 api-project-users diff --git a/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java b/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java index c53ea14..0cae6b7 100644 --- a/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java +++ b/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package org.opendevstack.apiservice.projectusers.exception; +import org.opendevstack.apiservice.projectusers.controller.ProjectUserController; import org.opendevstack.apiservice.projectusers.model.ValidationErrorResponse; import org.opendevstack.apiservice.projectusers.model.BaseApiResponse; import org.opendevstack.apiservice.projectusers.model.FieldError; @@ -32,7 +33,7 @@ * messages. */ @Slf4j -@ControllerAdvice +@ControllerAdvice(assignableTypes = ProjectUserController.class) public class GlobalExceptionHandler { /** @@ -69,17 +70,17 @@ public ResponseEntity handleMethodArgumentNotValidExcep fieldErrors.add(fieldError); }); - String errorMessage = String.format( - ErrorMessages.REQUEST_VALIDATION_FAILED, - fieldErrors.size()); - - ValidationErrorResponse errorResponse = new ValidationErrorResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setErrorCode(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setFieldErrors(fieldErrors); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.REQUEST_VALIDATION_FAILED, + fieldErrors.size()); + + ValidationErrorResponse errorResponse = new ValidationErrorResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setErrorCode(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setFieldErrors(fieldErrors); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -96,13 +97,13 @@ public ResponseEntity handleConstraintViolationException( .map(this::formatConstraintViolation) .toList(); - String errorMessage = String.format(ErrorMessages.PARAMETER_VALIDATION_FAILED, String.join("; ", errors)); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format(ErrorMessages.PARAMETER_VALIDATION_FAILED, String.join("; ", errors)); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -115,8 +116,8 @@ public ResponseEntity handleHttpMessageNotReadableException( log.warn("Invalid request body: {}", ex.getMessage()); - String errorMessage = ErrorMessages.INVALID_REQUEST_BODY; - String errorCode = ErrorCodes.PROJECT_USER_ERROR; + String errorMessage = ErrorMessages.INVALID_REQUEST_BODY; + String errorCode = ErrorCodes.PROJECT_USER_ERROR; Throwable cause = ex.getCause(); @@ -172,15 +173,15 @@ public ResponseEntity handleMissingPathVariableException( log.warn("Missing path variable: {}", ex.getMessage()); - String errorMessage = String.format( - ErrorMessages.REQUIRED_PATH_PARAMETER_MISSING, - ex.getVariableName()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.REQUIRED_PATH_PARAMETER_MISSING, + ex.getVariableName()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -192,15 +193,15 @@ public ResponseEntity handleMissingServletRequestParameterExcep log.warn("Missing request parameter: {}", ex.getMessage()); - String errorMessage = String.format( - ErrorMessages.REQUIRED_REQUEST_PARAMETER_MISSING, - ex.getParameterName(), ex.getParameterType()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.REQUIRED_REQUEST_PARAMETER_MISSING, + ex.getParameterName(), ex.getParameterType()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -212,17 +213,17 @@ public ResponseEntity handleMethodArgumentTypeMismatchException log.warn("Method argument type mismatch: {}", ex.getMessage()); - String errorMessage = String.format( - ErrorMessages.PARAMETER_TYPE_CONVERSION_FAILED, - ex.getName(), - ex.getValue(), - ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown"); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.INVALID_ROLE); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.PARAMETER_TYPE_CONVERSION_FAILED, + ex.getName(), + ex.getValue(), + ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown"); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.INVALID_ROLE); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -233,11 +234,11 @@ public ResponseEntity handleProjectNotFoundException( ProjectNotFoundException ex) { log.warn("Project not found: {}", ex.getMessage()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ex.getMessage()); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ex.getMessage()); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } @@ -249,11 +250,11 @@ public ResponseEntity handleUserNotFoundException( UserNotFoundException ex) { log.warn("User not found: {}", ex.getMessage()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ex.getMessage()); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ex.getMessage()); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } @@ -297,11 +298,11 @@ public ResponseEntity handleInvalidRoleException( InvalidRoleException ex) { log.warn("Invalid role: {}", ex.getMessage()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ex.getMessage()); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ex.getMessage()); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } @@ -313,12 +314,12 @@ public ResponseEntity handleAutomationPlatformException( AutomationPlatformException ex) { log.error("Automation platform error: {}", ex.getMessage(), ex); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(String.format(ErrorMessages.EXTERNAL_SERVICE_ERROR, ex.getMessage())); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(errorResponse); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(String.format(ErrorMessages.EXTERNAL_SERVICE_ERROR, ex.getMessage())); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(errorResponse); } /** @@ -327,12 +328,12 @@ public ResponseEntity handleAutomationPlatformException( @ExceptionHandler(ProjectUserException.class) public ResponseEntity handleProjectUserException(ProjectUserException ex) { log.error("Project user operation failed: {}", ex.getMessage(), ex); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(String.format(ErrorMessages.OPERATION_FAILED, ex.getMessage())); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(String.format(ErrorMessages.OPERATION_FAILED, ex.getMessage())); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } /** @@ -341,12 +342,12 @@ public ResponseEntity handleProjectUserException(ProjectUserExc @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(Exception ex) { log.error("Unexpected error occurred: {}", ex.getMessage(), ex); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ErrorMessages.UNEXPECTED_ERROR); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ErrorMessages.UNEXPECTED_ERROR); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } /** diff --git a/api-project/openapi/api-project-internal.yaml b/api-project/openapi/api-project-internal.yaml new file mode 100644 index 0000000..74a1bb1 --- /dev/null +++ b/api-project/openapi/api-project-internal.yaml @@ -0,0 +1,63 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v0 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: +- name: Projects Internal + description: API for manage EDP projects with extended methods +paths: + /projects/{projectKey}: + patch: + tags: + - Projects Internal + summary: Updates projects by project key. + description: Updates the current status and details of the project identified by the given project key. + operationId: updateProject + parameters: + - name: projectKey + in: path + required: true + schema: + type: string + description: Project key to retrieve information. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProjectRequest' + responses: + '204': + description: Project information updated successfully or no project found for the provided projectKey. + "400": + description: Bad Request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + '500': + description: Internal server error. +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + UpdateProjectRequest: + type: object + properties: + status: + type: string + description: Status of the project. If not in the allowed list, error INVALID_STATUS (030) is returned. + additionalProperties: false \ No newline at end of file diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml new file mode 100644 index 0000000..07cbb7e --- /dev/null +++ b/api-project/openapi/api-project.yaml @@ -0,0 +1,184 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v0 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: +- name: Projects + description: API for manage EDP projects. +paths: + /projects: + post: + tags: + - Projects + summary: Create a new project. + description: Creates a new project with the provided configuration. Generates a unique project key if not provided. + operationId: createProject + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectRequest' + responses: + '200': + description: Project creation initiated successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + '400': + description: Invalid request body or validation error. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '409': + description: A project with the specified key already exists. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + '500': + description: Internal server error during project creation. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + /projects/{projectKey}: + get: + tags: + - Projects + summary: Get project status by project key. + description: Returns the current status and details of the project identified by the given project key. + operationId: getProject + parameters: + - name: projectKey + in: path + required: true + schema: + type: string + description: Project key to retrieve information. + responses: + '200': + description: Project information retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + '404': + description: Project not found. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + CreateProjectRequest: + type: object + properties: + projectKey: + type: string + description: Optional project key. If not provided, a unique key will be generated. + pattern: "^[A-Z][A-Z0-9]{2,9}$" + minLength: 3 + maxLength: 10 + projectName: + type: string + description: Name of the project. + pattern: "^[A-Za-z0-9 ]{0,80}$" + maxLength: 80 + projectDescription: + type: string + description: Description of the project. + maxLength: 255 + projectFlavor: + type: string + description: Project flavor. Must be provided if configurationItem is not present. If both projectFlavor and configurationItem are missing, error BAD_REQUEST_FLAVOR_CONFIG_ITEM (023) is returned. + configurationItem: + type: string + description: Configuration item. Must be present if projectFlavor is not provided. If both projectFlavor and configurationItem are missing, error BAD_REQUEST_FLAVOR_CONFIG_ITEM (023) is returned. + location: + type: string + description: Location of the project. Must be one of the allowed locations. If not in the allowed list, error INVALID_LOCATION (011) is returned. + x2OdsAccount: + type: string + description: Technical account of the project. + pattern: "^x2[a-zA-Z0-9]{0,13}$" + owner: + type: string + description: Owner of the project. If x2OdsAccount is provided but owner is missing, error MANDATORY_OWNER (024) is returned. + pattern: "^[a-z]{1,10}$" + registrationOnly: + type: boolean + description: Indicates whether the project should be only registrated or a proper initialization is required. (Default false) + default: false + oneOf: + - required: [projectFlavor] + - required: [configurationItem] + additionalProperties: false + CreateProjectResponse: + type: object + properties: + projectKey: + type: string + httpStatus: + type: string + status: + type: string + projectFlavor: + type: string + message: + type: string + error: + type: string + errorKey: + type: string + errorDescription: + type: string + location: + type: string \ No newline at end of file diff --git a/api-project/pom.xml b/api-project/pom.xml new file mode 100644 index 0000000..799043f --- /dev/null +++ b/api-project/pom.xml @@ -0,0 +1,211 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + api-project + API Projects + API module for managing EDP projects + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.opendevstack.apiservice + service-projects + ${project.version} + + + + org.opendevstack.apiservice + persistence + ${project.version} + + + + org.opendevstack.apiservice + core-security + ${project.version} + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + org.projectlombok + lombok + provided + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-core + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + org.openapitools + openapi-generator-maven-plugin + + + generate-api-project + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + + + generate-api-project-extended + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project-internal.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java new file mode 100644 index 0000000..beea15b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -0,0 +1,65 @@ +package org.opendevstack.apiservice.project.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtUtils; +import org.opendevstack.apiservice.project.api.ProjectsApi; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping(ProjectController.API_BASE_PATH) +@AllArgsConstructor +@Slf4j +public class ProjectController implements ProjectsApi { + + public static final String API_BASE_PATH = "/api/pub/v0/projects"; + + private static final String HTTP_HEADER_LOCATION = "Location"; + + private final ProjectsFacade projectsFacade; + + private final ProjectRequestValidator projectRequestValidator; + + @PostMapping + @Override + public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { + projectRequestValidator.validateCreateRequest(createProjectRequest); + UUID clientId = JwtUtils.getClientId(); + + CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); + projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); + return ResponseEntity + .status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(projectResponse); + } + + @GetMapping("/{projectKey}") + @Override + public ResponseEntity getProject(@PathVariable String projectKey) { + String location = API_BASE_PATH + "/" + projectKey; + CreateProjectResponse response = projectsFacade.getProject(projectKey); + if (response == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .header(HTTP_HEADER_LOCATION, location) + .body(ProjectResponseFactory.notFound(projectKey, location)); + } + return ResponseEntity.status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, location) + .body(response); + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java new file mode 100644 index 0000000..af68367 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java @@ -0,0 +1,50 @@ +package org.opendevstack.apiservice.project.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtUtils; +import org.opendevstack.apiservice.project.api.ProjectsInternalApi; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping(ProjectInternalController.API_BASE_PATH) +@AllArgsConstructor +@Slf4j +public class ProjectInternalController implements ProjectsInternalApi { + + public static final String API_BASE_PATH = "/api/pub/v1/projects"; + + private static final String HTTP_HEADER_LOCATION = "Location"; + + private final ProjectsFacade projectsFacade; + + private final ProjectRequestValidator projectRequestValidator; + + @PatchMapping("/{projectKey}") + @Override + public ResponseEntity updateProject(@PathVariable String projectKey, + @Valid @RequestBody UpdateProjectRequest updateProjectRequest) { + String location = API_BASE_PATH + "/" + projectKey; + projectRequestValidator.validateUpdateRequest(updateProjectRequest); + + projectsFacade.updateProject(projectKey, updateProjectRequest); + + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .header(HTTP_HEADER_LOCATION, location) + .build(); + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java new file mode 100644 index 0000000..b28ca79 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.project.controller; + +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; + +public final class ProjectResponseFactory { + + private ProjectResponseFactory() { + } + + public static CreateProjectResponse notFound(String projectKey, String location) { + return error( + ErrorKey.PROJECT_NOT_FOUND.getMessage(), + ErrorKey.PROJECT_NOT_FOUND.getKey(), + String.format("Project with key '%s' not found", projectKey), + location + ); + } + + private static CreateProjectResponse error(String error, String errorKey, String message, String location) { + CreateProjectResponse response = new CreateProjectResponse(); + response.setError(error); + response.setErrorKey(errorKey); + response.setMessage(message); + response.setLocation(location); + return response; + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java new file mode 100644 index 0000000..e917006 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -0,0 +1,150 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectAlreadyExistsException; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice(assignableTypes = ProjectController.class) +@Slf4j +public class ProjectExceptionHandler { + + private static final Map FIELD_ERROR_MAP = Map.of( + "projectKey", ErrorKey.PROJECT_KEY_INVALID_FORMAT, + "projectName", ErrorKey.PROJECT_NAME_INVALID_FORMAT, + "projectDescription", ErrorKey.PROJECT_DESCRIPTION_INVALID_FORMAT, + "projectFlavor", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, + "configurationItem", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, + "x2OdsAccount", ErrorKey.PROJECT_X2ACCOUNT_INVALID_FORMAT, + "owner", ErrorKey.PROJECT_OWNER_INVALID_FORMAT + ); + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + + log.warn("Request body validation error: {}", ex.getMessage()); + + FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .orElse(null); + + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + + if (fieldError != null) { + String field = fieldError.getField(); + + ErrorKey key = FIELD_ERROR_MAP.getOrDefault(field, ErrorKey.BAD_REQUEST_BODY); + + response.setErrorKey(key.getKey()); + response.setMessage(key.getMessage()); + } else { + response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey()); + response.setMessage(ErrorKey.BAD_REQUEST_BODY.getMessage()); + } + + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(ClientAppNotRegisteredException.class) + public ResponseEntity handleClientAppNotRegisteredException( + ClientAppNotRegisteredException ex) { + log.warn("ClientApp registration error: {}", ex.getMessage()); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.FORBIDDEN.getReasonPhrase()); + response.setErrorKey(ErrorKey.CLIENT_APP_NOT_REGISTERED.getKey()); + response.setMessage(ErrorKey.CLIENT_APP_NOT_REGISTERED.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + + @ExceptionHandler(ProjectValidationException.class) + public ResponseEntity handleValidationException(ProjectValidationException ex) { + log.warn("Validation error: {}", ex.getMessage()); + ErrorKey errorKey = ex.getErrorKey(); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + response.setErrorKey(errorKey.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ProjectAlreadyExistsException.class) + public ResponseEntity handleProjectAlreadyExistsException(ProjectAlreadyExistsException ex) { + log.warn("Validation error: {}", ex.getMessage()); + ErrorKey errorKey = ex.getErrorKey() != null + ? ex.getErrorKey() + : ErrorKey.PROJECT_ALREADY_EXISTS; + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.CONFLICT.getReasonPhrase()); + response.setErrorKey(errorKey.getKey()); + response.setMessage(errorKey.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + } + + @ExceptionHandler(ProjectCreationException.class) + public ResponseEntity handleProjectCreationException( + ProjectCreationException ex) { + log.error("Project creation error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(AutomationPlatformException.class) + public ResponseEntity handleAutomationPlatformException( + AutomationPlatformException ex) { + log.error("Failed to execute automated job: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.COMPONENT_PARAM_INVALID_FORMAT.getMessage()); + response.setErrorKey(ErrorKey.COMPONENT_PARAM_INVALID_FORMAT.getKey()); + response.setMessage("Request body should be a valid json."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage("An error occurred while processing the request."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java new file mode 100644 index 0000000..ab8faab --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java @@ -0,0 +1,22 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.controller.ProjectInternalController; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = ProjectInternalController.class) +@Slf4j +public class ProjectInternalExceptionHandler { + + @ExceptionHandler(ProjectUpdateValidationException.class) + public ResponseEntity handleUpdateValidationException(ProjectUpdateValidationException ex) { + log.warn("Validation error: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + + +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java new file mode 100644 index 0000000..a3b8c75 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ClientAppNotRegisteredException extends RuntimeException { + + public ClientAppNotRegisteredException(String clientId) { + super(String.format("ClientApp with clientId '%s' is not registered", clientId)); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java new file mode 100644 index 0000000..a7755a7 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -0,0 +1,52 @@ +package org.opendevstack.apiservice.project.exception; + +public enum ErrorKey { + + OK("000", ErrorMessage.SUCCESS), + PRODUCT_NOT_FOUND("001", ErrorMessage.NOT_FOUND), + ACCESS_DENIED("002", ErrorMessage.FORBIDDEN), + INTERNAL_ERROR("003", ErrorMessage.INTERNAL_ERROR), + INVALID_AUTH_HEADER("004", ErrorMessage.BAD_REQUEST), + MISSING_AUTH_HEADER("005", ErrorMessage.BAD_REQUEST), + INVALID_PARAMETERS("006", ErrorMessage.BAD_REQUEST), + X2_ACCOUNT_MISSING_GROUPS("007", ErrorMessage.NOT_FOUND), + ONLY_INVITED_PROJECT("008", ErrorMessage.FORBIDDEN), + ONCE_PER_PROJECT("009", ErrorMessage.FORBIDDEN), + COMPONENT_PARAM_NOT_MEET_REGEX("010", ErrorMessage.BAD_REQUEST), + INVALID_LOCATION("011", ErrorMessage.INVALID_LOCATION), + PROJECT_NOT_FOUND("012", ErrorMessage.NOT_FOUND), + COMPONENT_NOT_FOUND("013", ErrorMessage.NOT_FOUND), + BAD_REQUEST_BODY("014", ErrorMessage.BAD_REQUEST), + FORBIDDEN("015", ErrorMessage.FORBIDDEN), + DUPLICATE_RECORD("016", ErrorMessage.RECORD_ALREADY_EXISTS), + COMPONENT_PARAM_INVALID_FORMAT("017", ErrorMessage.BAD_REQUEST), + PROJECT_KEY_INVALID_FORMAT("018", ErrorMessage.PROJECT_KEY_NOT_MET_THE_PATTERN), + PROJECT_NAME_INVALID_FORMAT("019", ErrorMessage.PROJECT_NAME_NOT_MET_THE_PATTERN), + PROJECT_DESCRIPTION_INVALID_FORMAT("020", ErrorMessage.PROJECT_DESCRIPTION_NOT_MET_THE_PATTERN), + PROJECT_OWNER_INVALID_FORMAT("021", ErrorMessage.PROJECT_OWNER_NOT_MET_THE_PATTERN), + PROJECT_X2ACCOUNT_INVALID_FORMAT("022", ErrorMessage.PROJECT_X_2_ACCOUNT_NOT_MET_THE_PATTERN), + BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", ErrorMessage.PROJECT_FLAVOUR_AND_CONFIG_ITEM_CANNOT_BE_BOTH_NULL), + MANDATORY_OWNER("024", ErrorMessage.OWNER_MUST_BE_PRESENT_IF_THE_X_2_ACCOUNT_IS_PRESENT), + PROJECT_ALREADY_EXISTS("025", ErrorMessage.PROJECT_ALREADY_EXISTS), + PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", ErrorMessage.PROJECT_WITH_SAME_PROJECT_NAME_ALREADY_EXISTS), + CLIENT_APP_NOT_REGISTERED("027", ErrorMessage.CLIENT_APP_NOT_REGISTERED_MANUAL_REGISTRATION_REQUIRED), + INVALID_PROJECT_FLAVOR("028", ErrorMessage.BAD_REQUEST), + INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST), + INVALID_STATUS("030", ErrorMessage.INVALID_STATUS); + + private String key; + private String message; + + ErrorKey(String key, String message) { + this.key = key; + this.message = message; + } + + public String getKey() { + return this.key; + } + + public String getMessage() { + return this.message; + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java new file mode 100644 index 0000000..8c8ed27 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.project.exception; + +public class ErrorMessage { + + public static final String NOT_FOUND = "Not Found"; + public static final String FORBIDDEN = "Forbidden"; + public static final String BAD_REQUEST = "Bad Request"; + public static final String INTERNAL_ERROR = "Internal error"; + public static final String SUCCESS = "Success"; + + public static final String INVALID_LOCATION = "Incorrect location. Valid locations are:"; + public static final String INVALID_STATUS = "Incorrect status. Valid status are:"; + public static final String RECORD_ALREADY_EXISTS = "Record already exists"; + public static final String PROJECT_KEY_NOT_MET_THE_PATTERN = "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"; + public static final String PROJECT_NAME_NOT_MET_THE_PATTERN = "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"; + public static final String PROJECT_DESCRIPTION_NOT_MET_THE_PATTERN = "projectDescription not met the pattern ^.{0,255}$"; + public static final String PROJECT_OWNER_NOT_MET_THE_PATTERN = "projectOwner not met the pattern ^[a-z]{1,10}$"; + public static final String PROJECT_X_2_ACCOUNT_NOT_MET_THE_PATTERN = "projectX2Account not met the pattern ^x2[a-zA-Z0-9]{0,13}$"; + public static final String PROJECT_FLAVOUR_AND_CONFIG_ITEM_CANNOT_BE_BOTH_NULL = "Project flavour and config item cannot be both null"; + public static final String OWNER_MUST_BE_PRESENT_IF_THE_X_2_ACCOUNT_IS_PRESENT = "Owner must be present if the X2 account is present"; + public static final String PROJECT_ALREADY_EXISTS = "Project already exists"; + public static final String PROJECT_WITH_SAME_PROJECT_NAME_ALREADY_EXISTS = "Project with same project name already exists"; + public static final String CLIENT_APP_NOT_REGISTERED_MANUAL_REGISTRATION_REQUIRED = "ClientApp not registered, manual registration required"; + + private ErrorMessage() { + // prevent instantiation + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectAlreadyExistsException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectAlreadyExistsException.java new file mode 100644 index 0000000..f99bdd0 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectAlreadyExistsException.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.project.exception; + +import lombok.Getter; + +@Getter +public class ProjectAlreadyExistsException extends RuntimeException { + + private ErrorKey errorKey; + + public ProjectAlreadyExistsException() { + super(); + } + + public ProjectAlreadyExistsException(ErrorKey errorKey) { + super(errorKey.getMessage()); + this.errorKey = errorKey; + } +} + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java new file mode 100644 index 0000000..8189cdc --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ProjectCreationException extends RuntimeException { + + public ProjectCreationException(String message) { + super(message); + } + + public ProjectCreationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java new file mode 100644 index 0000000..e9d8e8b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ProjectException extends Exception { + + public ProjectException(String message) { + super(message); + } + + public ProjectException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java new file mode 100644 index 0000000..56f0b5b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.project.exception; + +import lombok.Getter; + +@Getter +public class ProjectUpdateValidationException extends RuntimeException { + + private final ErrorKey errorKey; + + public ProjectUpdateValidationException(ErrorKey errorKey) { + super(errorKey.getMessage()); + this.errorKey = errorKey; + } + + public ProjectUpdateValidationException(ErrorKey errorKey, String additionalMessage) { + super(errorKey.getMessage() + " " + additionalMessage); + this.errorKey = errorKey; + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java new file mode 100644 index 0000000..2694cfa --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java @@ -0,0 +1,20 @@ +package org.opendevstack.apiservice.project.exception; + +import lombok.Getter; + +@Getter +public class ProjectValidationException extends RuntimeException { + + private final ErrorKey errorKey; + + public ProjectValidationException(ErrorKey errorKey) { + super(errorKey.getMessage()); + this.errorKey = errorKey; + } + + public ProjectValidationException(ErrorKey errorKey, String additionalMessage) { + super(errorKey.getMessage() + " " + additionalMessage); + this.errorKey = errorKey; + } +} + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java new file mode 100644 index 0000000..f36aa8d --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -0,0 +1,16 @@ +package org.opendevstack.apiservice.project.facade; + +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; + +import java.util.UUID; + +public interface ProjectsFacade { + + CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId); + + CreateProjectResponse getProject(String projectKey); + + void updateProject(String projectKey, UpdateProjectRequest request); +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java new file mode 100644 index 0000000..2267c0b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java @@ -0,0 +1,31 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ProjectCreationCommand { + + private String projectKey; + + private String projectName; + + private String projectDescription; + + private String projectFlavor; + + private String configurationItem; + + private String location; + + private String x2OdsAccount; + + private String owner; + + private UUID clientId; +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java new file mode 100644 index 0000000..1597897 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -0,0 +1,193 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectAlreadyExistsException; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; +import org.springframework.stereotype.Component; + +import java.text.MessageFormat; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ProjectCreationCommandBuilder { + + private final GenerateProjectKeyService generateProjectKeyService; + + private final ProjectExistenceService projectExistenceService; + + public ProjectCreationCommand buildForCreation(CreateProjectRequest request, ClientAppEntity clientApp) { + ClientAppProjectFlavorEntity flavor = resolveFlavor(request, clientApp); + String projectKey = resolveProjectKey(request.getProjectKey(), flavor); + + return build(request, flavor, projectKey, clientApp); + } + + public ProjectCreationCommand buildForRegistration(CreateProjectRequest request, ClientAppEntity clientApp) { + ClientAppProjectFlavorEntity flavor = resolveFlavor(request, clientApp); + String projectKey = resolveProjectKeyForRegistration(request.getProjectKey(), flavor); + + return build(request, flavor, projectKey, clientApp); + } + + private ProjectCreationCommand build(CreateProjectRequest request, ClientAppProjectFlavorEntity flavor, String projectKey, ClientAppEntity clientApp) { + String projectFlavor = firstNonBlank(request.getProjectFlavor(), flavor.getName()); + String configurationItem = firstNonBlank(request.getConfigurationItem(), flavor.getConfigItem()); + String owner = firstNonBlank(request.getOwner(), flavor.getProjectOwner()); + String x2account = firstNonBlank(request.getX2OdsAccount(), flavor.getServiceAccount()); + String location = firstNonBlank(request.getLocation(), flavor.getLocation()); + String projectName = resolveProjectName(request.getProjectName(), projectKey); + String projectDescription = firstNonBlank(request.getProjectDescription(), "project " + projectFlavor); + + return new ProjectCreationCommand( + projectKey, + projectName, + projectDescription, + projectFlavor, + configurationItem, + location, + x2account, + owner, + clientApp.getId()); + } + + private ClientAppProjectFlavorEntity resolveFlavor(CreateProjectRequest request, ClientAppEntity clientApp) { + List flavors = clientApp.getProjectFlavors(); + + if (flavors == null || flavors.isEmpty()) { + log.warn("ClientApp '{}' has no project flavors configured", clientApp.getClientId()); + throw new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + } + + if (Strings.isNotEmpty(request.getProjectFlavor())) { + return resolveByFlavorName(request.getProjectFlavor(), flavors, clientApp.getClientId()); + } + + if (Strings.isNotEmpty(request.getConfigurationItem())) { + return resolveByConfigurationItem(request.getConfigurationItem(), flavors, clientApp.getClientId()); + } + + throw new ProjectValidationException(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM); + } + + private ClientAppProjectFlavorEntity resolveByFlavorName( + String flavorName, List flavors, UUID clientId) { + return flavors.stream() + .filter(f -> flavorName.equals(f.getName())) + .findFirst() + .orElseThrow(() -> { + log.warn("Flavor '{}' is not configured for clientApp '{}'", flavorName, clientId); + return new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + }); + } + + private ClientAppProjectFlavorEntity resolveByConfigurationItem( + String configurationItem, List flavors, UUID clientId) { + List matchingFlavors = flavors.stream() + .filter(f -> configurationItem.equals(f.getConfigItem())) + .toList(); + + if (matchingFlavors.size() != 1) { + log.warn("ConfigItem '{}' does not match exactly one flavor for clientApp '{}'", + configurationItem, clientId); + String message = MessageFormat. + format("Not exists a project flavor configured for the Config Item {0}. " + + "To create a project under {0} the projectFlavor parameter is mandatory.", + configurationItem); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM, message); + } + + ClientAppProjectFlavorEntity matchedFlavor = matchingFlavors.getFirst(); + + if (!isAllowedConfigItem(configurationItem, matchedFlavor)) { + log.warn("ConfigItem '{}' is not in the allowed list for flavor '{}' of clientApp '{}'", + configurationItem, matchedFlavor.getName(), clientId); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + } + + return matchedFlavor; + } + + private String resolveProjectKey(String existingProjectKey, ClientAppProjectFlavorEntity flavor) { + try { + if (Strings.isNotEmpty(existingProjectKey)) { + if (!projectExistenceService.isProjectFound(existingProjectKey)) { + return existingProjectKey; + } + + throw new ProjectAlreadyExistsException(ErrorKey.PROJECT_ALREADY_EXISTS); + } + + String pattern = flavor.getProjectKeyPattern(); + + return generateProjectKeyService.generateProjectKey(pattern); + } catch (ProjectKeyGenerationException e) { + throw new ProjectCreationException("Error generating the project key", e); + } catch (ProjectExistenceServiceException e) { + throw new ProjectCreationException("Error checking if the generated key exists: " + e.getMessage(), e); + } + } + + private String resolveProjectKeyForRegistration(String existingProjectKey, ClientAppProjectFlavorEntity flavor) { + try { + if (Strings.isNotEmpty(existingProjectKey)) { + if (!projectExistenceService.isProjectFoundInCollection(existingProjectKey)) { + return existingProjectKey; + } + + throw new ProjectAlreadyExistsException(ErrorKey.PROJECT_ALREADY_EXISTS); + } + + String pattern = flavor.getProjectKeyPattern(); + + return generateProjectKeyService.generateProjectKey(pattern); + } catch (ProjectKeyGenerationException e) { + throw new ProjectCreationException("Error generating the project key", e); + } catch (ProjectExistenceServiceException e) { + throw new ProjectCreationException("Error checking if the generated key exists: " + e.getMessage(), e); + } + } + + private boolean isAllowedConfigItem(String configurationItem, ClientAppProjectFlavorEntity flavor) { + String[] allowedConfigItems = flavor.getAllowedConfigItems(); + + if (allowedConfigItems == null || allowedConfigItems.length == 0) { + return true; + } + + return Arrays.asList(allowedConfigItems).contains(configurationItem); + } + + private String firstNonBlank(String preferred, String fallback) { + return Strings.isNotEmpty(preferred) ? preferred : fallback; + } + + private String resolveProjectName(String preferred, String fallback) { + if (!Strings.isEmpty(preferred)) { + try { + if (projectExistenceService.isProjectFoundByName(preferred)) { + throw new ProjectAlreadyExistsException(ErrorKey.PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS); + } + } catch (ProjectExistenceServiceException e) { + throw new ProjectCreationException("Error checking if project name already exists: " + e.getMessage(), e); + } + } + + return firstNonBlank(preferred, fallback); + } +} + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java new file mode 100644 index 0000000..afa845d --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -0,0 +1,129 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; +import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.mapper.AutomationParametersMapper; +import org.opendevstack.apiservice.project.mapper.ProjectCreationResponseMapper; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.project.service.ClientAppService; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.UUID; + +@Component("apiProjectFacadeImpl") +@Slf4j +public class ProjectsFacadeImpl implements ProjectsFacade { + + @Value("${apis.projects.ansible-workflow-name}") + private String createProjectWorkflow; + + private final ProjectService projectService; + + private final ProjectMapper projectMapper; + + private final AutomationParametersMapper automationParametersMapper; + + private final ProjectCreationResponseMapper projectCreationResponseMapper; + + private final ProjectCreationCommandBuilder projectCreationCommandBuilder; + + private final ClientAppService clientAppService; + + private final AutomationPlatformService automationPlatformService; + + public ProjectsFacadeImpl( + ProjectService projectService, + ProjectMapper projectMapper, + AutomationParametersMapper automationParametersMapper, + ProjectCreationResponseMapper projectCreationResponseMapper, + ProjectCreationCommandBuilder projectCreationCommandBuilder, + ClientAppService clientAppService, + AutomationPlatformService automationPlatformService) { + this.projectService = projectService; + this.projectMapper = projectMapper; + this.automationParametersMapper = automationParametersMapper; + this.projectCreationResponseMapper = projectCreationResponseMapper; + this.projectCreationCommandBuilder = projectCreationCommandBuilder; + this.clientAppService = clientAppService; + this.automationPlatformService = automationPlatformService; + } + + @Override + public CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId) { + + ClientAppEntity clientApp = clientAppService.findByClientId(clientId); + + ProjectCreationCommand command; + ProjectResponse project; + + if (Boolean.TRUE.equals(request.getRegistrationOnly())) { + command = projectCreationCommandBuilder.buildForRegistration(request, clientApp); + project = registerProject(command); + } else { + command = projectCreationCommandBuilder.buildForCreation(request, clientApp); + project = createNewProject(command); + } + + return projectCreationResponseMapper.toSuccessResponse(command, project); + } + + @Override + public CreateProjectResponse getProject(String projectKey) { + return projectMapper.toApiResponse(projectService.getProject(projectKey)); + } + + @Override + public void updateProject(String projectKey, UpdateProjectRequest request) { + projectService.updateProjectStatus(projectKey, request.getStatus()); + } + + private ProjectResponse registerProject(ProjectCreationCommand command) { + + ProjectRequest projectRequest = projectMapper.toServiceRequest(command); + projectRequest.setStatus(Status.RUNNING); + + return projectService.saveProject(projectRequest); + } + + private ProjectResponse createNewProject(ProjectCreationCommand command) { + + ProjectRequest projectRequest = projectMapper.toServiceRequest(command); + projectRequest.setStatus(Status.PENDING); + + ProjectResponse project = projectService.saveProject(projectRequest); + + initializeProject(command, projectRequest, project); + + return project; + } + + private void initializeProject( + ProjectCreationCommand command, ProjectRequest projectRequest, ProjectResponse project) { + String projectId = project.getProjectId().toString(); + Map workflowParameters = automationParametersMapper.toWorkflowParameters(command, projectId); + + AutomationExecutionResult automationExecutionResult = automationPlatformService + .executeWorkflow(createProjectWorkflow, workflowParameters); + + if (!automationExecutionResult.isSuccessful()) { + projectRequest.setProjectId(project.getProjectId()); + projectRequest.setStatus(Status.FAILED); + projectService.saveProject(projectRequest); + throw new ProjectCreationException("Failed to create project: " + + automationExecutionResult.getErrorDetails()); + } + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java new file mode 100644 index 0000000..fa1ea7e --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; + +import java.util.HashMap; +import java.util.Map; + +@Mapper(componentModel = "spring") +public interface AutomationParametersMapper { + + default Map toWorkflowParameters(ProjectCreationCommand command, String projectId) { + Map parameters = new HashMap<>(); + parameters.put("geographic_region", command.getLocation()); + parameters.put("project_flavor", command.getProjectFlavor()); + parameters.put("project_owner", command.getOwner()); + parameters.put("project_id", projectId); + parameters.put("configuration_item", command.getConfigurationItem()); + parameters.put("project_key", command.getProjectKey()); + parameters.put("special_account", command.getX2OdsAccount()); + parameters.put("description", command.getProjectDescription()); + parameters.put("project_name", command.getProjectName()); + parameters.put("client_id", command.getClientId().toString()); + return parameters; + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java new file mode 100644 index 0000000..1f13f35 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; + +@Mapper(componentModel = "spring") +public interface ProjectCreationResponseMapper { + + @Mapping(target = "message", constant = "The project creation process has been successfully initiated.") + @Mapping(target = "status", ignore = true) + @Mapping(target = "httpStatus", constant = "OK") + @Mapping(target = "errorKey", constant = "000") + @Mapping(target = "projectKey", source = "project.projectKey") + @Mapping(target = "projectFlavor", ignore = true) + @Mapping(target = "location", ignore = true) + @Mapping(target = "error", ignore = true) + @Mapping(target = "errorDescription", ignore = true) + CreateProjectResponse toSuccessResponse(ProjectCreationCommand command, ProjectResponse project); +} + + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java new file mode 100644 index 0000000..adb2032 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java @@ -0,0 +1,52 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +import java.text.MessageFormat; + +@Mapper(componentModel = "spring") +public interface ProjectMapper { + + ProjectRequest toServiceRequest(CreateProjectRequest apiRequest); + + ProjectRequest toServiceRequest(ProjectCreationCommand command); + + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Mapping(source = "projectKey", target = "location", qualifiedByName = "mapLocation") + @Mapping(source = ".", target = "errorDescription", qualifiedByName = "mapErrorDescription") + CreateProjectResponse toApiResponse(ProjectResponse serviceResponse); + + @Named("mapStatus") + default String mapStatus(Status status) { + if (status == null) { + return null; + } + return status.getDbValue(); + } + + @Named("mapErrorDescription") + default String mapErrorDescription(ProjectResponse serviceResponse) { + return (serviceResponse.getStatus() == Status.FAILED) + ? MessageFormat.format( + "There was an error when creating the project {0}.\n\n " + + "The error has been reported to our Support team as an incident. " + + "You will be informed about the incident via email.", serviceResponse.getProjectKey()) + : null; + } + + @Named("mapLocation") + default String mapLocation(String projectKey) { + if (projectKey == null || projectKey.isEmpty()) { + return null; + } + return "/api/pub/v0/projects/" + projectKey; + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java new file mode 100644 index 0000000..8c15318 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.project.service; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ClientAppService { + + private final ClientAppRepository clientAppRepository; + + @Transactional(readOnly = true) + public ClientAppEntity findByClientId(UUID clientId) { + log.debug("Looking up ClientApp for clientId={}", clientId); + return clientAppRepository.findDetailedByClientId(clientId) + .orElseThrow(() -> { + log.warn("ClientApp not found for clientId={}", clientId); + return new ClientAppNotRegisteredException(clientId.toString()); + }); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java new file mode 100644 index 0000000..c96d32f --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java @@ -0,0 +1,87 @@ +package org.opendevstack.apiservice.project.validation; + +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +@Component +public class ProjectRequestValidator { + + @Value("${apis.projects.locations}") + private List locations; + + public void validateCreateRequest(CreateProjectRequest request) { + validateFlavorOrConfigItem(request); + validateOwnerIfX2AccountIsPresent(request); + validateLocation(request); + } + + public void validateUpdateRequest(UpdateProjectRequest request) { + validateStatus(request); + } + + private void validateFlavorOrConfigItem(CreateProjectRequest request) { + String projectFlavor = request.getProjectFlavor(); + String configurationItem = request.getConfigurationItem(); + + boolean hasFlavor = projectFlavor != null && !projectFlavor.trim().isEmpty(); + boolean hasConfigItem = configurationItem != null && !configurationItem.trim().isEmpty(); + + if (!hasFlavor && !hasConfigItem) { + throw new ProjectValidationException( + ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM + ); + } + } + + private void validateOwnerIfX2AccountIsPresent(CreateProjectRequest request) { + String x2Account = request.getX2OdsAccount(); + String owner = request.getOwner(); + + boolean hasX2Account = x2Account != null && !x2Account.trim().isEmpty(); + boolean hasOwner = owner != null && !owner.trim().isEmpty(); + + if(hasX2Account && !hasOwner) { + throw new ProjectValidationException( + ErrorKey.MANDATORY_OWNER + ); + } + } + + private void validateLocation(CreateProjectRequest request) { + String location = request.getLocation(); + + if (location == null || location.trim().isEmpty()) { + return; + } + + if (!locations.contains(location)) { + throw new ProjectValidationException(ErrorKey.INVALID_LOCATION, + String.join(", ", locations)); + } + } + + private void validateStatus(UpdateProjectRequest request) { + String status = request.getStatus(); + + if (status == null || status.trim().isEmpty()) { + return; + } + + List statusList = Arrays.stream(Status.values()) + .map(e -> e.getDbValue()) + .toList(); + if (!statusList.contains(status)) { + throw new ProjectUpdateValidationException(ErrorKey.INVALID_STATUS, + String.join(", ", statusList)); + } + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java new file mode 100644 index 0000000..2e1d53f --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -0,0 +1,117 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectControllerTest { + + @Mock + private ProjectsFacade projectsFacade; + + @Mock + private ProjectRequestValidator projectRequestValidator; + + private ProjectController sut; + private AutoCloseable mocks; + + @BeforeEach + void setup() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectController(projectsFacade, projectRequestValidator); + + Jwt jwtToken = Jwt.withTokenValue("dummy-token") + .claim("appid", UUID.randomUUID().toString()) + .claim("sub", "test-user") + .header("alg", "none") + .build(); + JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void create_project_returns_ok_when_creation_succeeds() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectKey("PROJ01"); + + CreateProjectResponse serviceResponse = new CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + serviceResponse.setErrorKey("000"); + serviceResponse.setMessage("The project creation process has been successfully initiated."); + + when(projectsFacade.createProject(any(CreateProjectRequest.class), any(UUID.class))) + .thenReturn(serviceResponse); + + ResponseEntity result = sut.createProject(request); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getBody().getStatus()).isEqualTo("Initiated"); + assertThat(result.getBody().getError()).isNull(); + assertThat(result.getBody().getErrorKey()).isEqualTo("000"); + assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectRequestValidator).validateCreateRequest(request); + verify(projectsFacade).createProject(any(CreateProjectRequest.class), any(UUID.class)); + } + + @Test + void get_project_returns_ok_when_project_exists() { + CreateProjectResponse serviceResponse = new CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + + when(projectsFacade.getProject("PROJ01")).thenReturn(serviceResponse); + + ResponseEntity result = sut.getProject("PROJ01"); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getBody().getError()).isNull(); + assertThat(result.getBody().getErrorKey()).isNull(); + verify(projectsFacade).getProject("PROJ01"); + } + + @Test + void get_project_returns_not_found_when_project_does_not_exist() { + when(projectsFacade.getProject("UNKNOWN")).thenReturn(null); + + ResponseEntity result = sut.getProject("UNKNOWN"); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo("Not Found"); + assertThat(result.getBody().getErrorKey()).isEqualTo("012"); + assertThat(result.getBody().getMessage()).contains("UNKNOWN"); + assertThat(result.getBody().getProjectKey()).isNull(); + assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectsFacade).getProject("UNKNOWN"); + } + +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java new file mode 100644 index 0000000..efcfc84 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java @@ -0,0 +1,86 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectInternalControllerTest { + + @Mock + private ProjectsFacade projectsFacade; + + @Mock + private ProjectRequestValidator projectRequestValidator; + + private ProjectInternalController sut; + private AutoCloseable mocks; + + @BeforeEach + void setup() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectInternalController(projectsFacade, projectRequestValidator); + + Jwt jwtToken = Jwt.withTokenValue("dummy-token") + .claim("appid", UUID.randomUUID().toString()) + .claim("sub", "test-user") + .header("alg", "none") + .build(); + JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void update_project_returns_no_content() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("Running"); + + ResponseEntity result = sut.updateProject("PROJ01", request); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(result.getBody()).isNull(); + assertThat(result.getHeaders().getFirst("Location")) + .isEqualTo(ProjectInternalController.API_BASE_PATH + "/PROJ01"); + verify(projectRequestValidator).validateUpdateRequest(request); + verify(projectsFacade).updateProject("PROJ01", request); + } + + @Test + void update_project_propagates_validation_exception_when_validator_throws() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("INVALID_STATUS"); + + doThrow(new ProjectValidationException(ErrorKey.INVALID_STATUS)) + .when(projectRequestValidator).validateUpdateRequest(request); + + assertThrows(ProjectValidationException.class, () -> sut.updateProject("PROJ01", request)); + + verify(projectRequestValidator).validateUpdateRequest(request); + verify(projectsFacade, never()).updateProject("PROJ01", request); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java new file mode 100644 index 0000000..e9db30a --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -0,0 +1,320 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectAlreadyExistsException; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.fail; + +class ProjectExceptionHandlerTest { + + private ProjectExceptionHandler sut; + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectExceptionHandler(); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void handle_validation_exception_returns_bad_request_response_for_project_key_invalid_format() { + ProjectValidationException exception = new ProjectValidationException(ErrorKey.PROJECT_KEY_INVALID_FORMAT); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("018", result.getBody().getErrorKey()); + assertEquals("projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_validation_exception_returns_bad_request_response_for_project_name_invalid_format() { + ProjectValidationException exception = new ProjectValidationException(ErrorKey.PROJECT_NAME_INVALID_FORMAT); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("019", result.getBody().getErrorKey()); + assertEquals("projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_validation_exception_returns_bad_request_response_for_missing_flavor_and_config_item() { + ProjectValidationException exception = new ProjectValidationException(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("023", result.getBody().getErrorKey()); + assertEquals("Project flavour and config item cannot be both null", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_validation_exception_preserves_additional_message_content() { + List validLocations = List.of("MADRID", "BARCELONA", "SANT_CUGAT"); + ProjectValidationException exception = new ProjectValidationException( + ErrorKey.INVALID_LOCATION, + String.join(",", validLocations) + ); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals("011", result.getBody().getErrorKey()); + assertEquals( + "Incorrect location. Valid locations are: MADRID,BARCELONA,SANT_CUGAT", + result.getBody().getMessage() + ); + } + + @Test + void handle_method_argument_not_valid_exception_returns_bad_request_response_for_request_body_validation_errors() { + CreateProjectRequest target = new CreateProjectRequest(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "createProjectRequest"); + bindingResult.addError(new FieldError("createProjectRequest", "projectName", null, false, null, null, + "must not be null")); + + MethodParameter methodParameter; + try { + methodParameter = new MethodParameter( + ProjectController.class.getMethod("createProject", CreateProjectRequest.class), + 0); + } catch (NoSuchMethodException e) { + fail("Failed to reflect controller method for test: " + e.getMessage()); + return; + } + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); + + ResponseEntity result = sut.handleMethodArgumentNotValidException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("019", result.getBody().getErrorKey()); + assertEquals("projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @ParameterizedTest + @MethodSource("provideValidationCases") + void handle_method_argument_not_valid_exception_parameterized( + String failingField, + String expectedErrorKey, + String expectedMessage + ) { + CreateProjectRequest target = new CreateProjectRequest(); + BeanPropertyBindingResult bindingResult = + new BeanPropertyBindingResult(target, "createProjectRequest"); + + bindingResult.addError(new FieldError( + "createProjectRequest", + failingField, + null, + false, + null, + null, + "invalid" + )); + + MethodParameter methodParameter; + try { + methodParameter = new MethodParameter( + ProjectController.class.getMethod("createProject", CreateProjectRequest.class), + 0); + } catch (NoSuchMethodException e) { + fail("Reflection error: " + e.getMessage()); + return; + } + + MethodArgumentNotValidException exception = + new MethodArgumentNotValidException(methodParameter, bindingResult); + + ResponseEntity result = + sut.handleMethodArgumentNotValidException(exception); + + CreateProjectResponse body = result.getBody(); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(body); + + assertEquals(ProjectController.API_BASE_PATH, body.getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), body.getError()); + + assertEquals(expectedErrorKey, body.getErrorKey()); + assertEquals(expectedMessage, body.getMessage()); + } + + private static Stream provideValidationCases() { + return Stream.of( + Arguments.of( + "projectName", + "019", + "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$" + ), + Arguments.of( + "projectKey", + "018", + "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$" + ), + Arguments.of( + "projectDescription", + "020", + "projectDescription not met the pattern ^.{0,255}$" + ), + Arguments.of( + "unknownField", + "014", + "Bad Request" + ) + ); + } + + @Test + void handle_project_creation_exception_returns_conflict() { + ProjectCreationException exception = new ProjectCreationException("error message"); + + ResponseEntity result = sut.handleProjectCreationException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("error message", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_client_app_not_registered_exception_returns_forbidden() { + ClientAppNotRegisteredException exception = new ClientAppNotRegisteredException("client-123"); + + ResponseEntity result = sut.handleClientAppNotRegisteredException(exception); + + assertEquals(HttpStatus.FORBIDDEN, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.FORBIDDEN.getReasonPhrase(), result.getBody().getError()); + assertEquals("027", result.getBody().getErrorKey()); + assertEquals("ClientApp not registered, manual registration required", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + } + + @Test + void handle_automation_platform_exception_returns_internal_server_error() { + AutomationPlatformException exception = new AutomationPlatformException("AAP job failed"); + + ResponseEntity result = sut.handleAutomationPlatformException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("AAP job failed", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + } + + @Test + void handle_generic_exception_returns_internal_server_error() { + RuntimeException exception = new RuntimeException("Database error"); + + ResponseEntity result = sut.handleGenericException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("An error occurred while processing the request.", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_http_message_not_readable_exception_returns_bad_request_with_error_key_017() { + HttpMessageNotReadableException exception = new HttpMessageNotReadableException("Malformed JSON", new RuntimeException("cause")); + + ResponseEntity result = sut.handleHttpMessageNotReadableException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Bad Request", result.getBody().getError()); + assertEquals("017", result.getBody().getErrorKey()); + assertEquals("Request body should be a valid json.", result.getBody().getMessage()); + } + + @Test + void handle_project_already_exists_exception_returns_conflict() { + ProjectAlreadyExistsException exception = new ProjectAlreadyExistsException(); + + ResponseEntity result = sut.handleProjectAlreadyExistsException(exception); + + assertEquals(HttpStatus.CONFLICT, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Conflict", result.getBody().getError()); + assertEquals("025", result.getBody().getErrorKey()); + assertEquals("Project already exists", result.getBody().getMessage()); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java new file mode 100644 index 0000000..968c767 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java @@ -0,0 +1,53 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class ProjectInternalExceptionHandlerTest { + + private ProjectInternalExceptionHandler sut; + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectInternalExceptionHandler(); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void handle_update_validation_exception_returns_bad_request_with_no_body() { + ProjectUpdateValidationException exception = + new ProjectUpdateValidationException(ErrorKey.INVALID_STATUS); + + ResponseEntity result = sut.handleUpdateValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNull(result.getBody()); + } + + @Test + void handle_update_validation_exception_with_additional_message_returns_bad_request_with_no_body() { + ProjectUpdateValidationException exception = + new ProjectUpdateValidationException(ErrorKey.INVALID_STATUS, "Pending,Running,Failed"); + + ResponseEntity result = sut.handleUpdateValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNull(result.getBody()); + } + +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java new file mode 100644 index 0000000..cef7943 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -0,0 +1,240 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectAlreadyExistsException; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectCreationCommandBuilderTest { + + private static final UUID CLIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Mock + private GenerateProjectKeyService generateProjectKeyService; + + @Mock + private ProjectExistenceService projectExistenceService; + + private ProjectCreationCommandBuilder sut; + + private AutoCloseable mocks; + + @BeforeEach + void set_up() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectCreationCommandBuilder(generateProjectKeyService, projectExistenceService); + } + + @AfterEach + void tear_down() throws Exception { + mocks.close(); + } + + @Test + void build_for_creation_resolves_defaults_from_flavor_when_request_fields_are_missing() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setOwner(null); + request.setLocation(null); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + + ProjectCreationCommand result = sut.buildForCreation(request, clientApp); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + assertEquals("owner1", result.getOwner()); + assertEquals("eu", result.getLocation()); + assertEquals("KEY01", result.getProjectKey()); + } + + @Test + void build_for_creation_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + + ProjectCreationCommand result = sut.buildForCreation(request, clientApp); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + } + + @Test + void build_for_creation_generates_project_key_when_request_project_key_is_null() throws ProjectExistenceServiceException, org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, null); + + when(generateProjectKeyService.generateProjectKey("DLSS%06d")).thenReturn("DLSS000001"); + when(projectExistenceService.isProjectFound("DLSS000001")).thenReturn(false); + + ProjectCreationCommand result = sut.buildForCreation(request, clientApp); + + assertEquals("DLSS000001", result.getProjectKey()); + verify(generateProjectKeyService).generateProjectKey("DLSS%06d"); + } + + @Test + void build_for_creation_throws_project_already_exists_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(true); + + ProjectAlreadyExistsException ex = assertThrows(ProjectAlreadyExistsException.class, + () -> sut.buildForCreation(request, clientApp)); + assertEquals(ErrorKey.PROJECT_ALREADY_EXISTS, ex.getErrorKey()); + } + + @Test + void build_for_creation_throws_validation_exception_when_flavor_and_config_item_are_missing() { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request(null, null, "KEY01"); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.buildForCreation(request, clientApp)); + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, ex.getErrorKey()); + } + + @Test + void build_for_creation_throws_validation_exception_when_configuration_item_matches_multiple_flavors() { + ClientAppEntity clientApp = build_client_app(List.of( + build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"), + build_flavor("AMP", "CI-001", new String[] {}, "eu", "owner2"))); + CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.buildForCreation(request, clientApp)); + assertEquals(ErrorKey.INVALID_CONFIG_ITEM, ex.getErrorKey()); + } + + @Test + void build_for_creation_throws_project_key_generation_exception_when_generation_fails() throws Exception { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, null); + + when(generateProjectKeyService.generateProjectKey("DLSS%06d")) + .thenThrow(new org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException("fail")); + + assertThrows(ProjectCreationException.class, () -> sut.buildForCreation(request, clientApp)); + } + + @Test + void build_for_creation_throws_project_already_exists_exception_when_project_name_already_exists() throws Exception { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setProjectName("Existing Project"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + when(projectExistenceService.isProjectFoundByName("Existing Project")).thenReturn(true); + + assertThrows(ProjectAlreadyExistsException.class, () -> sut.buildForCreation(request, clientApp)); + } + + @Test + void build_for_creation_throws_project_creation_exception_when_project_name_check_fails() throws Exception { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setProjectName("Any Project"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + when(projectExistenceService.isProjectFoundByName("Any Project")) + .thenThrow(new ProjectExistenceServiceException("lookup failed")); + + assertThrows(ProjectCreationException.class, () -> sut.buildForCreation(request, clientApp)); + } + + @Test + void build_for_registration_resolves_defaults_from_flavor_when_request_fields_are_missing() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setOwner(null); + request.setLocation(null); + + when(projectExistenceService.isProjectFoundInCollection("KEY01")).thenReturn(false); + + ProjectCreationCommand result = sut.buildForRegistration(request, clientApp); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + assertEquals("owner1", result.getOwner()); + assertEquals("eu", result.getLocation()); + assertEquals("KEY01", result.getProjectKey()); + } + + @Test + void build_for_registration_throws_project_already_exists_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + + when(projectExistenceService.isProjectFoundInCollection("KEY01")).thenReturn(true); + + ProjectAlreadyExistsException ex = assertThrows(ProjectAlreadyExistsException.class, + () -> sut.buildForRegistration(request, clientApp)); + assertEquals(ErrorKey.PROJECT_ALREADY_EXISTS, ex.getErrorKey()); + } + + private CreateProjectRequest build_request(String flavor, String configItem, String projectKey) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectKey(projectKey); + request.setProjectFlavor(flavor); + request.setConfigurationItem(configItem); + request.setProjectName("Test Project"); + request.setProjectDescription("A test project"); + request.setOwner("testowner"); + request.setLocation("eu"); + request.setX2OdsAccount("x2test"); + return request; + } + + private ClientAppEntity build_client_app(List flavors) { + ClientAppEntity entity = ClientAppEntity.builder() + .clientId(CLIENT_ID) + .clientName("Test App") + .build(); + entity.setProjectFlavors(flavors); + return entity; + } + + private ClientAppProjectFlavorEntity build_flavor( + String name, String configItem, String[] allowedConfigItems, String location, String projectOwner) { + return ClientAppProjectFlavorEntity.builder() + .name(name) + .configItem(configItem) + .allowedConfigItems(allowedConfigItems) + .projectKeyPattern(name + "%06d") + .location(location) + .projectOwner(projectOwner) + .build(); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java new file mode 100644 index 0000000..abf00d3 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -0,0 +1,228 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; +import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.mapper.AutomationParametersMapper; +import org.opendevstack.apiservice.project.mapper.ProjectCreationResponseMapper; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.project.service.ClientAppService; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +import java.lang.reflect.Field; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectsFacadeImplTest { + + private static final UUID CLIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Mock + private ProjectService projectService; + + @Mock + private ProjectMapper projectMapper; + + @Mock + private AutomationParametersMapper automationParametersMapper; + + @Mock + private ProjectCreationResponseMapper projectCreationResponseMapper; + + @Mock + private ProjectCreationCommandBuilder projectCreationCommandBuilder; + + @Mock + private ClientAppService clientAppService; + + @Mock + private AutomationPlatformService automationPlatformService; + + private ProjectsFacadeImpl sut; + private AutoCloseable mocks; + + @BeforeEach + void set_up() throws Exception { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectsFacadeImpl( + projectService, + projectMapper, + automationParametersMapper, + projectCreationResponseMapper, + projectCreationCommandBuilder, + clientAppService, + automationPlatformService); + + Field workflowField = ProjectsFacadeImpl.class.getDeclaredField("createProjectWorkflow"); + workflowField.setAccessible(true); + workflowField.set(sut, "create-project-workflow"); + } + + @AfterEach + void tear_down() throws Exception { + mocks.close(); + } + + @Test + void create_project_returns_success_response_when_automation_is_successful() { + CreateProjectRequest request = new CreateProjectRequest(); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); + ProjectRequest serviceRequest = new ProjectRequest(); + ProjectResponse projectResponse = ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("DLSS01") + .status(Status.PENDING) + .build(); + CreateProjectResponse apiResponse = new CreateProjectResponse(); + apiResponse.setStatus("Pending"); + apiResponse.setProjectFlavor("DLSS"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectCreationCommandBuilder.buildForCreation(request, clientApp)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); + when(projectService.saveProject(serviceRequest)).thenReturn(projectResponse); + when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) + .thenReturn(Map.of("project_key", "DLSS01")); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job-1", "ok")); + when(projectCreationResponseMapper.toSuccessResponse(command, projectResponse)).thenReturn(apiResponse); + + CreateProjectResponse result = sut.createProject(request, CLIENT_ID); + + assertEquals("Pending", result.getStatus()); + assertEquals("DLSS", result.getProjectFlavor()); + verify(projectCreationCommandBuilder).buildForCreation(request, clientApp); + verify(projectMapper).toServiceRequest(command); + verify(automationParametersMapper) + .toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111"); + verify(projectCreationResponseMapper).toSuccessResponse(command, projectResponse); + } + + @Test + void create_project_with_registrationOnly_returns_success_response_when_automation_is_not_executed() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setRegistrationOnly(true); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "P3RO01", "name", "desc", null, "CI-001", "eu", "x2test", "owner", CLIENT_ID); + ProjectRequest serviceRequest = new ProjectRequest(); + ProjectResponse projectResponse = ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("P3RO01") + .status(Status.RUNNING) + .build(); + CreateProjectResponse apiResponse = new CreateProjectResponse(); + apiResponse.setStatus("Running"); + apiResponse.setProjectFlavor("REGULAR"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectCreationCommandBuilder.buildForRegistration(request, clientApp)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); + when(projectService.saveProject(serviceRequest)).thenReturn(projectResponse); + when(projectCreationResponseMapper.toSuccessResponse(command, projectResponse)).thenReturn(apiResponse); + + CreateProjectResponse result = sut.createProject(request, CLIENT_ID); + + assertEquals("Running", result.getStatus()); + assertEquals("REGULAR", result.getProjectFlavor()); + verify(projectCreationCommandBuilder).buildForRegistration(request, clientApp); + verify(projectMapper).toServiceRequest(command); + verify(automationParametersMapper, never()) + .toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111"); + verify(automationPlatformService, never()).executeWorkflow(anyString(), anyMap()); + verify(projectCreationResponseMapper).toSuccessResponse(command, projectResponse); + } + + @Test + void create_project_throws_project_creation_exception_when_automation_is_not_successful() { + CreateProjectRequest request = new CreateProjectRequest(); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectCreationCommandBuilder.buildForCreation(request, clientApp)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(new ProjectRequest()); + when(projectService.saveProject(any(ProjectRequest.class))) + .thenReturn(ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("DLSS01") + .status(Status.PENDING) + .build()); + when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) + .thenReturn(Map.of()); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.failure("job-1", "error", "workflow failed")); + + assertThrows(ProjectCreationException.class, () -> sut.createProject(request, CLIENT_ID)); + } + + @Test + void get_project_returns_mapped_response_when_service_returns_project() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.RUNNING) + .build(); + CreateProjectResponse mappedResponse = new CreateProjectResponse(); + mappedResponse.setProjectKey("PROJ01"); + mappedResponse.setStatus("Running"); + + when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); + when(projectMapper.toApiResponse(serviceResponse)).thenReturn(mappedResponse); + + CreateProjectResponse result = sut.getProject("PROJ01"); + + assertEquals("PROJ01", result.getProjectKey()); + assertEquals("Running", result.getStatus()); + } + + @Test + void get_project_returns_null_when_service_returns_null() { + when(projectService.getProject("UNKNOWN")).thenReturn(null); + when(projectMapper.toApiResponse(null)).thenReturn(null); + + CreateProjectResponse result = sut.getProject("UNKNOWN"); + + assertNull(result); + } + + @Test + void update_project_calls_update_status() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("Running"); + ProjectResponse projectResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.PENDING) + .build(); + + when(projectService.getProject("PROJ01")).thenReturn(projectResponse); + + sut.updateProject("PROJ01", request); + + verify(projectService).updateProjectStatus("PROJ01", "Running"); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java new file mode 100644 index 0000000..7ecbd89 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java @@ -0,0 +1,191 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ProjectMapperTest { + + private ProjectMapper projectMapper; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + projectMapper = Mappers.getMapper(ProjectMapper.class); + } + + @Test + void to_service_request_maps_all_fields_correctly() { + CreateProjectRequest apiRequest = new CreateProjectRequest(); + apiRequest.setProjectKey("PROJ01"); + apiRequest.setProjectDescription("A test project"); + + ProjectRequest result = projectMapper.toServiceRequest(apiRequest); + + assertNotNull(result); + assertEquals("PROJ01", result.getProjectKey()); + assertEquals("A test project", result.getProjectDescription()); + } + + @Test + void to_service_request_returns_null_when_input_is_null() { + CreateProjectRequest apiRequest = null; + + ProjectRequest result = projectMapper.toServiceRequest(apiRequest); + + assertNull(result); + } + + @Test + void to_api_response_maps_all_fields_correctly() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.PENDING) + .projectFlavor("AMP") + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("PROJ01", result.getProjectKey()); + assertEquals("Pending", result.getStatus()); + assertEquals("AMP", result.getProjectFlavor()); + assertEquals("/api/pub/v0/projects/PROJ01", result.getLocation()); + } + + @Test + void to_api_response_returns_null_when_input_is_null() { + ProjectResponse serviceResponse = null; + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNull(result); + } + + @Test + void to_api_response_maps_with_null_status() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ02") + .status(null) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("PROJ02", result.getProjectKey()); + assertNull(result.getStatus()); + assertEquals("/api/pub/v0/projects/PROJ02", result.getLocation()); + } + + @Test + void to_api_response_does_not_set_location_when_project_key_is_null() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey(null) + .status(Status.RUNNING) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertNull(result.getProjectKey()); + assertEquals("Running", result.getStatus()); + assertNull(result.getLocation()); + } + + @Test + void to_api_response_does_not_set_location_when_project_key_is_empty() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("") + .status(Status.FAILED) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("", result.getProjectKey()); + assertEquals("Failed", result.getStatus()); + assertNull(result.getLocation()); + } + + @Test + void to_api_response_maps_status_running_to_db_value() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("RUN01") + .status(Status.RUNNING) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("Running", result.getStatus()); + } + + @Test + void to_api_response_maps_status_failed_to_db_value() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("FAIL01") + .status(Status.FAILED) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("Failed", result.getStatus()); + } + + @Test + void to_api_response_maps_null_project_flavor() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ03") + .status(Status.PENDING) + .projectFlavor(null) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("PROJ03", result.getProjectKey()); + assertNull(result.getProjectFlavor()); + } + + @Test + void to_api_response_sets_error_description_when_status_is_failed() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("FAIL01") + .status(Status.FAILED) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals( + "There was an error when creating the project FAIL01.\n\n " + + "The error has been reported to our Support team as an incident. " + + "You will be informed about the incident via email.", + result.getErrorDescription() + ); + } + + @Test + void to_api_response_sets_null_error_description_when_status_is_not_failed() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.RUNNING) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertNull(result.getErrorDescription()); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java new file mode 100644 index 0000000..a0737fa --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java @@ -0,0 +1,58 @@ +package org.opendevstack.apiservice.project.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; + +class ClientAppServiceTest { + + @Mock + private ClientAppRepository clientAppRepository; + private ClientAppService sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + sut = new ClientAppService(clientAppRepository); + } + + @Test + void findByClientId_returns_entity_when_client_exists() { + + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + ClientAppEntity entity = ClientAppEntity.builder() + .clientId(clientId) + .clientName("Test App") + .build(); + when(clientAppRepository.findDetailedByClientId(clientId)).thenReturn(Optional.of(entity)); + + ClientAppEntity result = sut.findByClientId(clientId); + + assertThat(result).isNotNull(); + assertThat(result.getClientId()).isEqualTo(clientId); + assertThat(result.getClientName()).isEqualTo("Test App"); + verify(clientAppRepository).findDetailedByClientId(clientId); + } + + @Test + void findByClientId_throws_exception_when_client_not_found() { + + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + when(clientAppRepository.findDetailedByClientId(clientId)).thenReturn(Optional.empty()); + + assertThrows( + ClientAppNotRegisteredException.class, + () -> sut.findByClientId(clientId)); + verify(clientAppRepository).findDetailedByClientId(clientId); + } +} \ No newline at end of file diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java new file mode 100644 index 0000000..e2fed8c --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java @@ -0,0 +1,203 @@ +package org.opendevstack.apiservice.project.validation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProjectRequestValidatorTest { + + private ProjectRequestValidator sut; + + @BeforeEach + void setUp() throws NoSuchFieldException, IllegalAccessException { + sut = new ProjectRequestValidator(); + + Field locationsField = ProjectRequestValidator.class.getDeclaredField("locations"); + locationsField.setAccessible(true); + locationsField.set(sut, List.of("MADRID", "BARCELONA", "SANT_CUGAT")); + + } + + @Test + void validateCreateRequest_throws_exception_when_project_flavor_and_config_item_both_null() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor(null); + request.setConfigurationItem(null); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.validateCreateRequest(request) + ); + + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); + } + + @Test + void validateCreateRequest_throws_exception_when_project_flavor_and_config_item_both_empty() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor(""); + request.setConfigurationItem(" "); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.validateCreateRequest(request) + ); + + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); + } + + @ParameterizedTest + @CsvSource({ + "STANDARD, null", + "null, JIRA", + "STANDARD, JIRA" + }) + void validateCreateRequest_succeeds_when_flavor_or_config_item_provided(String projectFlavor, String configurationItem) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor("null".equals(projectFlavor) ? null : projectFlavor); + request.setConfigurationItem("null".equals(configurationItem) ? null : configurationItem); + + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); + } + + @Test + void validateCreateRequest_throws_exception_when_x2account_present_but_owner_missing() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + request.setX2OdsAccount("x2abc123"); + request.setOwner(null); + + ProjectValidationException exception = + assertThrows(ProjectValidationException.class, () -> sut.validateCreateRequest(request)); + + assertEquals(ErrorKey.MANDATORY_OWNER, exception.getErrorKey()); + } + + @ParameterizedTest + @CsvSource({ + "x2abc, owner1", + "x2x, xowner", + "null, owner1", + "x2valid, ownerX" + }) + void validateCreateRequest_succeeds_when_owner_present_or_x2account_missing(String x2, String owner) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + + request.setX2OdsAccount("null".equals(x2) ? null : x2); + request.setOwner(owner); + + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); + } + + @Test + void validateCreateRequest_succeeds_when_location_is_null_or_empty() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + + request.setLocation(null); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); + + request.setLocation(""); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); + + request.setLocation(" "); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); + } + + @Test + void validateCreateRequest_throws_exception_when_location_not_in_allowed_list() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + + request.setLocation("INVALID_LOCATION_NOT_ALLOWED"); + + ProjectValidationException exception = + assertThrows(ProjectValidationException.class, () -> sut.validateCreateRequest(request)); + + assertEquals(ErrorKey.INVALID_LOCATION, exception.getErrorKey()); + } + + @ParameterizedTest + @CsvSource({ + "MADRID", + "BARCELONA", + "SANT_CUGAT" + }) + void validateCreateRequest_succeeds_when_location_is_allowed(String location) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + request.setLocation(location); + + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); + } + + @Test + void validateUpdateRequest_succeeds_when_status_is_null() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(null); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @Test + void validateUpdateRequest_succeeds_when_status_is_empty_string() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(""); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @Test + void validateUpdateRequest_succeeds_when_status_is_blank() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(" "); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @ParameterizedTest + @CsvSource({ + "Pending", + "Running", + "Failed" + }) + void validateUpdateRequest_succeeds_when_status_is_valid(String status) { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(status); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @Test + void validateUpdateRequest_throws_exception_with_error_key_INVALID_STATUS_when_status_is_invalid() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("INVALID_STATUS"); + + ProjectUpdateValidationException exception = + assertThrows(ProjectUpdateValidationException.class, () -> sut.validateUpdateRequest(request)); + + assertEquals(ErrorKey.INVALID_STATUS, exception.getErrorKey()); + } +} \ No newline at end of file diff --git a/application.yaml b/application.yaml index e1d762a..f276531 100644 --- a/application.yaml +++ b/application.yaml @@ -1,22 +1,113 @@ +# Base HTTP server configuration. +# Override these values with environment variables when deploying to another environment. +server: + # TCP port used by the Spring Boot application. + port: 8080 + +# Application logging configuration. +# Raise levels temporarily for troubleshooting; keep them lower in shared environments. logging: level: + # Core Spring framework logs. org.springframework: INFO + # Security filter chain and JWT validation logs. org.springframework.security: TRACE + # Custom external service integration logs. org.opendevstack.apiservice.externalservice: DEBUG - + +# Spring Boot infrastructure configuration. +# Most values here should be supplied from environment variables or a secret store. +spring: + datasource: + # JDBC connection string for the PostgreSQL database. + # Example: jdbc:postgresql://localhost:5432/ods_api_service + url: ${ODS_API_SERVICE_DB_DATASOURCE_URL} + # Database user used by the application. + username: ${ODS_API_SERVICE_DB_USER:opendevstack} + # Database password. Use a secret manager or injected environment variable in non-local setups. + password: ${ODS_API_SERVICE_DB_PASSWORD:opendevstack} + # JDBC driver class. Keep this aligned with the database engine in use. + driver-class-name: org.postgresql.Driver + hikari: + # Maximum number of open connections in the pool. + maximum-pool-size: ${HIKARI_POOL_MAX_SIZE:10} + # Minimum number of idle connections kept ready. + minimum-idle: ${HIKARI_MIN_IDLE:2} + # Time to wait for a free connection before failing, in milliseconds. + connection-timeout: ${HIKARI_CONNECTION_TIMEOUT:30000} + # How long an idle connection may stay in the pool, in milliseconds. + idle-timeout: ${HIKARI_IDLE_TIMEOUT:600000} + # Maximum lifetime of a pooled connection, in milliseconds. + max-lifetime: ${HIKARI_MAX_LIFETIME:1800000} + jpa: + hibernate: + # Schema management mode. Use validate/update locally, avoid create/create-drop in shared environments. + ddl-auto: ${JPA_HIBERNATE_DDL_AUTO:validate} + properties: + hibernate: + # Enable Hibernate statistics only while investigating performance issues. + generate_statistics: ${JPA_HIBERNATE_GENERATE_STATISTICS:false} + # Disable the Open Session in View pattern by default. + open-in-view: ${JPA_OPEN_IN_VIEW:false} + # Log SQL statements only for debugging. + show-sql: ${JPA_SHOW_SQL:false} + management: + endpoints: + web: + exposure: + # Minimal actuator exposure under spring.*; the top-level management block below extends this further. + include: ${MANAGEMENT_ENDPOINTS_INCLUDE:health} + +# Custom application-level security switches. +app: + security: + # Master switch for application authentication and authorization. + enabled: true + public-endpoints: + # Endpoints listed here remain reachable without authentication. + - /actuator/health + - /actuator/info + - /api/v1/projects/*/platforms + obo: + # Azure AD token endpoint used for On-Behalf-Of token exchange. + token-url: ${OBO_TOKEN_URL:https://login.microsoftonline.com/${AZURE_TENANT_ID:}/oauth2/v2.0/token} + # Client ID of this application's Azure AD app registration. + client-id: ${OBO_CLIENT_ID} + # Client secret for OBO exchange. Must be injected via environment variable or secret store. + client-secret: ${OBO_CLIENT_SECRET} + # Audience values accepted by this API. A valid token must carry at least one of these. + # These are shared across all configured issuers. + audiences: + - ${OAUTH2_AUDIENCE:} + - ${OAUTH2_AUDIENCE2:} + # Trusted JWT issuers. Each entry pairs an issuer URI (iss claim) with the JWK set URI + # used to validate token signatures. The incoming token's iss claim selects the entry. + issuers: + - issuer-uri: ${OAUTH2_ISSUER_V1:} + jwk-set-uri: ${OAUTH2_JWK_SET_URI_V1:} + - issuer-uri: ${OAUTH2_ISSUER_V2:} + jwk-set-uri: ${OAUTH2_JWK_SET_URI_V2:} + +# Spring Boot Actuator configuration. +# Restrict these endpoints in production if they expose operational details. management: endpoints: web: exposure: + # Explicit list of actuator endpoints exposed over HTTP. include: openapi, swagger-ui, beans, caches, configprops, env, health, httpexchanges, info, loggers, mappings endpoint: configprops: + # Shows bound configuration values in actuator output. show-values: always env: + # Shows environment-derived values in actuator output. show-values: always loggers: + # Allows runtime log level inspection and updates. access: unrestricted health: + # Exposes full health details and individual contributors. show-details: always show-components: always info: @@ -27,52 +118,56 @@ management: recording: # Show all available info in /actuator/httpexchanges and also in Swagger include: request-headers, response-headers, authorization_header, cookie_headers, principal, remote_address, session_id, time_taken -springdoc: - show-actuator: true - swagger-ui: - doc-expansion: none - try-it-out-enabled: true - filter: true - tags-sorter: alpha - operations-sorter: alpha - -openapi: - servers: - - url: "https://localhost:8080" - description: "Development environment" + +# OpenTelemetry settings. +# Configure OTLP endpoint and sampling according to your observability platform. otel: - service: - name: devstack-api-service-dev - version: 0.0.2 - exporter: - otlp: - endpoint: http://opentelemetry.example.com - traces: - exporter: logging,otlp - sampler: parentbased_traceidratio - sampler_arg: 1.0 - metrics: - exporter: none - resource: - attributes: service.name=devstack-api-service,service.version=0.0.2,deployment.environment=development - instrumentation: - jdbc: - enabled: false - logback-appender: - enabled: true + service: + # Logical service name and version attached to telemetry data. + name: devstack-api-service-dev + version: 0.0.3 + exporter: + otlp: + # Endpoint of the OpenTelemetry collector. + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT} + traces: + # Send traces to both application logs and the OTLP collector. + exporter: logging,otlp + # Parent-based ratio sampling. sampler_arg=1.0 means sample all traces. + sampler: parentbased_traceidratio + sampler_arg: 1.0 + metrics: + # Metrics export is disabled here. + exporter: none + resource: + # Resource attributes attached to every exported span. + attributes: service.name=devstack-api-service,service.version=0.0.3,deployment.environment=development + instrumentation: + jdbc: + # JDBC instrumentation is disabled, likely to reduce noise or overhead. + enabled: false + logback-appender: + # Enables trace correlation through logback. + enabled: true # External Service Configuration automation: platform: ansible: + # Toggle the Ansible automation integration. enabled: true + # Base URL of the Ansible Automation Platform / AWX API. base-url: ${ANSIBLE_BASE_URL:http://localhost:8080/api/v2} + # Credentials used to authenticate against Ansible. username: ${ANSIBLE_USERNAME:admin} password: ${ANSIBLE_PASSWORD:password} + # Request timeout in milliseconds. timeout: ${ANSIBLE_TIMEOUT:30000} ssl: + # When false, TLS certificates are not validated. Keep true outside local development. verify-certificates: ${ANSIBLE_SSL_VERIFY:true} + # Optional custom trust store settings for private CA certificates. trust-store-path: ${ANSIBLE_SSL_TRUSTSTORE_PATH:} trust-store-password: ${ANSIBLE_SSL_TRUSTSTORE_PASSWORD:} trust-store-type: ${ANSIBLE_SSL_TRUSTSTORE_TYPE:JKS} @@ -80,11 +175,11 @@ automation: uipath: # Base URL of the UIPath Orchestrator instance host: ${UIPATH_HOST:https://orchestrator.example.com} - + # Authentication credentials clientId: ${UIPATH_CLIENT_ID:your-client-id} clientSecret: ${UIPATH_CLIENT_SECRET:your-client-secret} - + # Tenancy name (default: "default") tenancy-name: ${UIPATH_TENANCY_NAME:default} @@ -94,10 +189,10 @@ automation: # API endpoints (defaults shown, can be overridden) login-endpoint: /api/Account/Authenticate queue-items-endpoint: /odata/QueueItems - + # Request timeout in milliseconds timeout: 30000 - + # SSL Configuration ssl: # Set to false to disable certificate verification (DEV ONLY!) @@ -107,25 +202,42 @@ automation: trust-store-password: ${TRUSTSTORE_PASSWORD:changeit} 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} token: + # Secret used to sign internal tokens. Replace the default in every non-local environment. secret: ${API_PROJECT_USERS_TOKEN_SECRET:devstack-api-service-jwt-secret-key-256bit-change-in-production} + # Token lifetime in hours. expiration-hours: ${API_PROJECT_USERS_TOKEN_EXPIRATION_HOURS:24} + projects: + # Workflow name used for project provisioning automation. + ansible-workflow-name: ${API_PROJECTS_MINIEDP_PROVISION_WORKFLOW_NAME} + # Supported project locations, typically provided as a comma-separated environment variable. + locations: ${API_PROJECTS_LOCATIONS} externalservices: openshift: instances: # Development OpenShift instance dev: + # API URL of the target cluster. api-url: ${OPENSHIFT_US_TEST_API_URL:https://api.dev.ocp.example.com:6443} + # Service account or user token used to access the cluster API. token: ${OPENSHIFT_US_TEST_TOKEN:your-dev-token-here} + # Default namespace/project to operate in. namespace: ${OPENSHIFT_US_TEST_NAMESPACE:devstack-dev} + # HTTP client timeouts in milliseconds. connection-timeout: 30000 read-timeout: 30000 + # When true, the client accepts untrusted certificates. trust-all-certificates: ${OPENSHIFT_US_TEST_TRUST_ALL:true} # Test OpenShift instance @@ -141,7 +253,9 @@ externalservices: instances: # Development Bitbucket instance dev: + # Base REST URL of the Bitbucket server. base-url: ${BITBUCKET_DEV_BASE_REST_URL:https://bitbucket.dev.example.com} + # Preferred authentication method: bearer token. bearer-token: ${BITBUCKET_DEV_BEARER_TOKEN:} # OR use basic auth if bearer token is not available: # username: ${BITBUCKET_DEV_USERNAME:admin} @@ -149,7 +263,7 @@ externalservices: connection-timeout: 30000 read-timeout: 30000 trust-all-certificates: ${BITBUCKET_DEV_TRUST_ALL:true} - + # Production Bitbucket instance prod: base-url: ${BITBUCKET_PROD_BASE_REST_URL:https://bitbucket.prod.example.com} @@ -165,11 +279,39 @@ externalservices: clusters: # Test Cluster test: + # Base cluster domain used to derive webhook proxy routes. cluster-base: ${WEBHOOK_PROXY_TEST_CLUSTER_BASE:apps.cluster.ocp.com} connection-timeout: ${WEBHOOK_PROXY_TEST_CONNECTION_TIMEOUT:30000} read-timeout: ${WEBHOOK_PROXY_TEST_READ_TIMEOUT:30000} trust-all-certificates: ${WEBHOOK_PROXY_TEST_TRUST_ALL:false} + # Relative path to the Jenkinsfile used when none is supplied. default-jenkinsfile-path: ${WEBHOOK_PROXY_TEST_JENKINSFILE_PATH:Jenkinsfile} projects-info-service: - base-url: ${PROJECTS_INFO_SERVICE_BASE_URL:http://localhost:8081} \ No newline at end of file + # Base URL of the downstream Projects Info Service consumed by this application. + base-url: ${PROJECTS_INFO_SERVICE_BASE_URL:http://localhost:8081} + + jira: + # Name of the default Jira instance. + default-instance: ${JIRA_DEFAULT_INSTANCE:jira} + instances: + jira: + base-url: ${JIRA_JIRA_DEV_BASE_URL:https://jira.example.com} + bearer-token: ${JIRA_JIRA_DEV_BEARER_TOKEN:} + connection-timeout: ${JIRA_JIRA_DEV_CONNECTION_TIMEOUT:30000} + read-timeout: ${JIRA_JIRA_DEV_READ_TIMEOUT:30000} + trust-all-certificates: ${JIRA_JIRA_DEV_TRUST_ALL:false} + + marketplace: + instances: + default: + project-components-base-url: ${MARKETPLACE_PROJECT_COMPONENTS_BASE_URL} + provisioner-actions-base-url: ${MARKETPLACE_PROVISIONER_ACTIONS_BASE_URL:} + bitbucket-base-url: ${MARKETPLACE_BITBUCKET_BASE_URL:} + obo-scope: ${MARKETPLACE_OBO_SCOPE:} + bypass: + audience: ${MARKETPLACE_BYPASS_AUDIENCE:} + scope: ${MARKETPLACE_BYPASS_SCOPE:} + trust-all-certificates: ${MARKETPLACE_TRUST_ALL_CERTS:false} + username: ${MARKETPLACE_USERNAME:} + password: ${MARKETPLACE_PASSWORD:} diff --git a/core-contracts/pom.xml b/core-contracts/pom.xml new file mode 100644 index 0000000..1db3a6d --- /dev/null +++ b/core-contracts/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + core-contracts + core-contracts + + + + jakarta.servlet + jakarta.servlet-api + provided + + + org.springframework + spring-context + + + org.springframework + spring-web + + + com.fasterxml.jackson.core + jackson-databind + + + org.projectlombok + lombok + provided + + + diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/api/ApiModule.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/api/ApiModule.java new file mode 100644 index 0000000..ccbc516 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/api/ApiModule.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.core.contracts.api; + +import java.util.List; + +public interface ApiModule { + + String getName(); + + String getBasePath(); + + List getSupportedVersions(); + + boolean isLocal(); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthType.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthType.java new file mode 100644 index 0000000..4558b32 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthType.java @@ -0,0 +1,7 @@ +package org.opendevstack.apiservice.core.contracts.auth; + +public enum AuthType { + NONE, + OBO, + CLIENT_CREDENTIALS +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthorizationDecision.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthorizationDecision.java new file mode 100644 index 0000000..8a63ab3 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthorizationDecision.java @@ -0,0 +1,18 @@ +package org.opendevstack.apiservice.core.contracts.auth; + +public enum AuthorizationDecision { + + PERMIT, + DENY, + ABSTAIN; + + public static AuthorizationDecision combine(AuthorizationDecision a, AuthorizationDecision b) { + if (a == DENY || b == DENY) { + return DENY; + } + if (a == PERMIT || b == PERMIT) { + return PERMIT; + } + return ABSTAIN; + } +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ApiDefinitionDao.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ApiDefinitionDao.java new file mode 100644 index 0000000..c69a7af --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ApiDefinitionDao.java @@ -0,0 +1,21 @@ +package org.opendevstack.apiservice.core.contracts.persistence; + +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; + +import java.util.List; +import java.util.Optional; + +/** + * Data access contract for API definitions. + * Implementations are provided by the persistence module. + */ +public interface ApiDefinitionDao { + + Optional findByApiId(String apiId); + + Optional findByBasePathAndVersion(String basePath, String version); + + List findAllEnabled(); + + List findAll(); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientDao.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientDao.java new file mode 100644 index 0000000..16f68fe --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientDao.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.core.contracts.persistence; + +import java.util.Optional; + +/** + * Data access contract for registered clients. + * Implementations are provided by the persistence module. + */ +public interface ClientDao { + + Optional findByClientId(String clientId); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientInfo.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientInfo.java new file mode 100644 index 0000000..5c8d671 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientInfo.java @@ -0,0 +1,22 @@ +package org.opendevstack.apiservice.core.contracts.persistence; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.experimental.Accessors; + +import java.util.UUID; + +@Getter +@Accessors(fluent = true) +@RequiredArgsConstructor +@EqualsAndHashCode +@ToString +public final class ClientInfo { + + private final UUID id; + private final String clientId; + private final String name; + private final boolean enabled; +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/PolicyDao.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/PolicyDao.java new file mode 100644 index 0000000..bcd1a4c --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/PolicyDao.java @@ -0,0 +1,18 @@ +package org.opendevstack.apiservice.core.contracts.persistence; + +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; + +import java.util.List; + +/** + * Data access contract for authorization policies. + * Implementations are provided by the persistence module. + */ +public interface PolicyDao { + + List findByApiDefinitionId(String apiDefinitionId); + + List findByApiDefinitionIdAndClientId(String apiDefinitionId, String clientId); + + List findGlobalByApiDefinitionId(String apiDefinitionId); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyContext.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyContext.java new file mode 100644 index 0000000..91e5be9 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyContext.java @@ -0,0 +1,40 @@ +package org.opendevstack.apiservice.core.contracts.policy; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; + +import java.util.Map; + +@Getter +@AllArgsConstructor +public class PolicyContext { + + private final String clientId; + private final String subject; + private final Map claims; + private final ApiDefinition apiDefinition; + private final HttpServletRequest request; + private final PolicyRule activeRule; + private final Map requestBody; + + public PolicyContext(String clientId, String subject, Map claims, + ApiDefinition apiDefinition, HttpServletRequest request) { + this(clientId, subject, claims, apiDefinition, request, null, null); + } + + /** + * Returns a new context with the given rule set, leaving this instance unchanged. + */ + public PolicyContext withRule(PolicyRule rule) { + return new PolicyContext(clientId, subject, claims, apiDefinition, request, rule, requestBody); + } + + /** + * Returns a new context with the given request body, leaving this instance unchanged. + */ + public PolicyContext withRequestBody(Map body) { + return new PolicyContext(clientId, subject, claims, apiDefinition, request, activeRule, body); + } +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyEvaluator.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyEvaluator.java new file mode 100644 index 0000000..2994a19 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyEvaluator.java @@ -0,0 +1,10 @@ +package org.opendevstack.apiservice.core.contracts.policy; + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; + +public interface PolicyEvaluator { + + boolean supports(String policyType); + + AuthorizationDecision evaluate(PolicyContext context); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyRule.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyRule.java new file mode 100644 index 0000000..2abf7f1 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyRule.java @@ -0,0 +1,20 @@ +package org.opendevstack.apiservice.core.contracts.policy; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PolicyRule { + + private UUID id; + private String apiDefinitionId; + private String clientId; + private String policyType; + private Map config; +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java new file mode 100644 index 0000000..43e256b --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.core.contracts.policy; + +/** + * Well-known policy type constants used by core. + * Modules are free to define their own policy type strings + * without modifying this class. + */ +public final class PolicyTypes { + + private PolicyTypes() {} + + public static final String FLAVOR_RESTRICTION = "FLAVOR_RESTRICTION"; + public static final String ALLOWED_CLIENTS = "ALLOWED_CLIENTS"; + public static final String SCOPE_REQUIRED = "SCOPE_REQUIRED"; +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/registry/ApiDefinition.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/registry/ApiDefinition.java new file mode 100644 index 0000000..a3021e1 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/registry/ApiDefinition.java @@ -0,0 +1,32 @@ +package org.opendevstack.apiservice.core.contracts.registry; + +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiDefinition { + + private String id; + private String name; + private String basePath; + private String version; + private Set authTypes; + private boolean isPublic; + private String proxyUrl; + private boolean enabled; + + public boolean isLocal() { + return proxyUrl == null || proxyUrl.isBlank(); + } + + public boolean requiresAuth() { + return authTypes != null + && !authTypes.isEmpty() + && !(authTypes.size() == 1 && authTypes.contains(AuthType.NONE)); + } +} diff --git a/core-security/pom.xml b/core-security/pom.xml new file mode 100644 index 0000000..04bc8fb --- /dev/null +++ b/core-security/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + core-security + core-security + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + + org.opendevstack.apiservice + core-contracts + ${project.version} + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-test + test + + + \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java new file mode 100644 index 0000000..0a660af --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java @@ -0,0 +1,111 @@ +package org.opendevstack.apiservice.core.security.authorization; + + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.filter.AuthTypeEnforcementFilter; +import org.opendevstack.apiservice.core.security.registry.ApiDefinitionResolver; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Spring Security AuthorizationManager that delegates authorization decisions to the policy engine. + */ +@Component +public class PolicyAuthorizationManager implements AuthorizationManager { + + private final PolicyEngine policyEngine; + private final PolicyService policyService; + private final PolicyContextFactory contextFactory; + private final ApiDefinitionResolver resolver; + + private final ObjectMapper objectMapper; + + public PolicyAuthorizationManager(PolicyEngine policyEngine, + PolicyService policyService, + PolicyContextFactory contextFactory, + ApiDefinitionResolver resolver, + ObjectMapper objectMapper) { + this.policyEngine = policyEngine; + this.policyService = policyService; + this.contextFactory = contextFactory; + this.resolver = resolver; + this.objectMapper = objectMapper; + } + + @Override + public org.springframework.security.authorization.AuthorizationDecision check( + Supplier authentication, + RequestAuthorizationContext context) { + + HttpServletRequest request = context.getRequest(); + + Optional apiDef = this.resolver.resolve(request); + + if (apiDef.isPresent()) { + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef.get()); + } + + // Unknown routes are denied (fail-closed). Public API definitions are allowed. + if (apiDef.isEmpty()) { + return new org.springframework.security.authorization.AuthorizationDecision(false); + } + + if (apiDef.get().isPublic()) { + return new org.springframework.security.authorization.AuthorizationDecision(true); + } + + PolicyContext policyContext = contextFactory.create(apiDef.get(), request); + policyContext = policyContext.withRequestBody(extractRequestBody(request)); + + List rules = policyService.findPolicies(apiDef.get().getId(), policyContext.getClientId()); + + AuthorizationDecision decision = policyEngine.evaluate(policyContext, rules); + + return new org.springframework.security.authorization.AuthorizationDecision( + decision != AuthorizationDecision.DENY + ); + } + + private Map extractRequestBody(HttpServletRequest request) { + if (!isJsonWriteRequest(request)) { + return Map.of(); + } + + try { + byte[] bytes = request.getInputStream().readAllBytes(); + if (bytes.length == 0) { + return Map.of(); + } + return objectMapper.readValue(bytes, new TypeReference<>() { + }); + } catch (IOException ex) { + return Map.of(); + } + } + + private boolean isJsonWriteRequest(HttpServletRequest request) { + String method = request.getMethod(); + if (!"POST".equalsIgnoreCase(method) + && !"PUT".equalsIgnoreCase(method) + && !"PATCH".equalsIgnoreCase(method)) { + return false; + } + + String contentType = request.getContentType(); + return contentType != null && contentType.toLowerCase().startsWith("application/json"); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java new file mode 100644 index 0000000..120c1cd --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java @@ -0,0 +1,34 @@ +package org.opendevstack.apiservice.core.security.authorization; + + +import jakarta.servlet.http.HttpServletRequest; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class PolicyContextFactory { + + public PolicyContext create(ApiDefinition apiDefinition, HttpServletRequest request) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + + String clientId = null; + String subject = null; + Map claims = Map.of(); + + if (authentication instanceof JwtAuthenticationToken jwtAuth) { + Jwt jwt = jwtAuth.getToken(); + clientId = AzureJwtAuthenticationConverter.extractClientId(jwt); + subject = jwt.getClaimAsString("sub"); + claims = jwt.getClaims(); + } + + return new PolicyContext(clientId, subject, claims, apiDefinition, request); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java new file mode 100644 index 0000000..e1cec33 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java @@ -0,0 +1,49 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class PolicyEngine { + + private final List evaluators; + + public PolicyEngine(List evaluators) { + this.evaluators = evaluators; + } + + public AuthorizationDecision evaluate(PolicyContext context, List rules) { + if (rules == null || rules.isEmpty()) { + return AuthorizationDecision.DENY; + } + + AuthorizationDecision finalDecision = AuthorizationDecision.ABSTAIN; + + for (PolicyRule rule : rules) { + PolicyEvaluator evaluator = evaluators.stream() + .filter(e -> e.supports(rule.getPolicyType())) + .findFirst() + .orElse(null); + + if (evaluator == null) { + continue; + } + + AuthorizationDecision decision = evaluator.evaluate(context.withRule(rule)); + finalDecision = AuthorizationDecision.combine(finalDecision, decision); + + if (finalDecision == AuthorizationDecision.DENY) { + return AuthorizationDecision.DENY; + } + } + + return finalDecision == AuthorizationDecision.ABSTAIN + ? AuthorizationDecision.PERMIT + : finalDecision; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java new file mode 100644 index 0000000..316f2c0 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.opendevstack.apiservice.core.contracts.persistence.PolicyDao; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class PolicyService { + + private final PolicyDao policyDao; + + public PolicyService(PolicyDao policyDao) { + this.policyDao = policyDao; + } + + public List findPolicies(String apiDefinitionId, String clientId) { + if (clientId != null) { + List rules = new ArrayList<>(); + rules.addAll(policyDao.findGlobalByApiDefinitionId(apiDefinitionId)); + rules.addAll(policyDao.findByApiDefinitionIdAndClientId(apiDefinitionId, clientId)); + return rules; + } + return policyDao.findByApiDefinitionId(apiDefinitionId); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java new file mode 100644 index 0000000..cd24768 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java @@ -0,0 +1,47 @@ +package org.opendevstack.apiservice.core.security.authorization.evaluator; + + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.policy.PolicyTypes; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Evaluates ALLOWED_CLIENTS policy rules. + * Verifies that the clientId from the JWT is in the list of authorized clients. + */ +@Component +public class AllowedClientsEvaluator implements PolicyEvaluator { + + @Override + public boolean supports(String policyType) { + return PolicyTypes.ALLOWED_CLIENTS.equals(policyType); + } + + @Override + @SuppressWarnings("unchecked") + public AuthorizationDecision evaluate(PolicyContext context) { + PolicyRule rule = context.getActiveRule(); + if (rule == null || rule.getConfig() == null) { + return AuthorizationDecision.ABSTAIN; + } + + String clientId = context.getClientId(); + if (clientId == null) { + return AuthorizationDecision.DENY; + } + + List allowedClients = (List) rule.getConfig().get("allowedClients"); + if (allowedClients == null || allowedClients.isEmpty()) { + return AuthorizationDecision.ABSTAIN; + } + + return allowedClients.contains(clientId) + ? AuthorizationDecision.PERMIT + : AuthorizationDecision.DENY; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java new file mode 100644 index 0000000..9fd93c7 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java @@ -0,0 +1,53 @@ +package org.opendevstack.apiservice.core.security.authorization.evaluator; + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.policy.PolicyTypes; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Evaluates FLAVOR_RESTRICTION policy rules. + * Verifies that the requested flavor in the request body is in the list + * of allowed flavors for the client. + */ +@Component +public class FlavorRestrictionEvaluator implements PolicyEvaluator { + + @Override + public boolean supports(String policyType) { + return PolicyTypes.FLAVOR_RESTRICTION.equals(policyType); + } + + @Override + @SuppressWarnings("unchecked") + public AuthorizationDecision evaluate(PolicyContext context) { + PolicyRule rule = context.getActiveRule(); + if (rule == null || rule.getConfig() == null) { + return AuthorizationDecision.ABSTAIN; + } + + Map body = context.getRequestBody(); + if (body == null) { + return AuthorizationDecision.ABSTAIN; + } + + String requestedFlavor = (String) body.get("projectFlavor"); + if (requestedFlavor == null || requestedFlavor.trim().isEmpty()) { + return AuthorizationDecision.ABSTAIN; + } + + List allowedFlavors = (List) rule.getConfig().get("allowedFlavors"); + if (allowedFlavors == null || allowedFlavors.isEmpty()) { + return AuthorizationDecision.ABSTAIN; + } + + return allowedFlavors.contains(requestedFlavor) + ? AuthorizationDecision.PERMIT + : AuthorizationDecision.DENY; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java new file mode 100644 index 0000000..91afd02 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java @@ -0,0 +1,120 @@ +package org.opendevstack.apiservice.core.security.config; + +import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; +import org.opendevstack.apiservice.core.security.filter.CachedBodyRequestFilter; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; +import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.SecurityFilterChain; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final SecurityProperties securityProperties; + private final PolicyAuthorizationManager policyAuthorizationManager; + private final AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; + private final CachedBodyRequestFilter cachedBodyRequestFilter; + + + public SecurityConfig(SecurityProperties securityProperties, + PolicyAuthorizationManager policyAuthorizationManager, + AzureJwtAuthenticationConverter azureJwtAuthenticationConverter, + CachedBodyRequestFilter cachedBodyRequestFilter) { + this.securityProperties = securityProperties; + this.policyAuthorizationManager = policyAuthorizationManager; + this.azureJwtAuthenticationConverter = azureJwtAuthenticationConverter; + this.cachedBodyRequestFilter = cachedBodyRequestFilter; + } + + @Bean + public JwtIssuerAuthenticationManagerResolver jwtIssuerAuthenticationManagerResolver() { + List audiences = securityProperties.getAudiences(); + Map managers = new HashMap<>(); + + for (SecurityProperties.IssuerConfig issuerConfig : securityProperties.getIssuers()) { + String issuerUri = issuerConfig.getIssuerUri(); + AuthenticationManager manager = buildAuthenticationManager(issuerConfig, audiences); + managers.put(issuerUri, manager); + log.info("Registered JWT issuer: {}", issuerUri); + } + + return new JwtIssuerAuthenticationManagerResolver(managers::get); + } + + private AuthenticationManager buildAuthenticationManager( + SecurityProperties.IssuerConfig issuerConfig, List audiences) { + + NimbusJwtDecoder decoder = NimbusJwtDecoder + .withJwkSetUri(issuerConfig.getJwkSetUri()) + .build(); + + List> validators = new ArrayList<>(); + validators.add(JwtValidators.createDefaultWithIssuer(issuerConfig.getIssuerUri())); + if (audiences != null && !audiences.isEmpty()) { + validators.add(new JwtClaimValidator>( + JwtClaimNames.AUD, + aud -> aud != null && !Collections.disjoint(aud, audiences))); + } + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators)); + + JwtAuthenticationProvider provider = new JwtAuthenticationProvider(decoder); + provider.setJwtAuthenticationConverter(azureJwtAuthenticationConverter); + return provider::authenticate; + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + JwtIssuerAuthenticationManagerResolver jwtIssuerAuthenticationManagerResolver) throws Exception { + http + .authorizeHttpRequests(authz -> { + // Allow public endpoints from security properties + if (securityProperties.getPublicEndpoints() != null) { + Arrays.stream(securityProperties.getPublicEndpoints()) + .forEach(endpoint -> { + log.info("Public endpoint configured: {}", endpoint); + authz.requestMatchers(endpoint).permitAll(); + }); + } + authz.anyRequest().access(policyAuthorizationManager); + }) + .oauth2ResourceServer(oauth2 -> + oauth2.authenticationManagerResolver(jwtIssuerAuthenticationManagerResolver) + ) + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + .csrf(csrf -> csrf.disable()); + + // Body-caching filter must run before AuthorizationFilter so that + // PolicyAuthorizationManager can read the request body for policy evaluation, + // and the CachedBodyHttpServletRequest wrapper propagates to later servlet + // filters (including BypassRoutingFilter) for transparent body replay. + http.addFilterBefore(cachedBodyRequestFilter, AuthorizationFilter.class); + + return http.build(); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java new file mode 100644 index 0000000..f15e749 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java @@ -0,0 +1,45 @@ +package org.opendevstack.apiservice.core.security.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.security") +public class SecurityProperties { + + private boolean enabled = true; + + /** + * Endpoints that don't require authentication. + */ + private String[] publicEndpoints; + + /** + * Audience values accepted by this API. A valid token must carry at least one of these. + * Applies to every configured issuer. + */ + private List audiences = new ArrayList<>(); + + /** + * Trusted issuers. Each entry maps an issuer URI to a JWK set URI used for + * signature validation. The {@code iss} claim of the incoming token selects + * which entry is used. + */ + private List issuers = new ArrayList<>(); + + @Getter + @Setter + public static class IssuerConfig { + /** Expected value of the {@code iss} claim. */ + private String issuerUri; + /** URL of the JWK set used to verify token signatures for this issuer. */ + private String jwkSetUri; + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java new file mode 100644 index 0000000..0ac2ddc --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java @@ -0,0 +1,75 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.flow.AuthFlowResolver; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class AuthTypeEnforcementFilter extends OncePerRequestFilter { + + public static final String API_DEFINITION_ATTR = "oas.apiDefinition"; + + private final AuthFlowResolver flowResolver; + private final Map validators; + + public AuthTypeEnforcementFilter(AuthFlowResolver flowResolver, + List validatorList) { + this.flowResolver = flowResolver; + this.validators = validatorList.stream() + .collect(Collectors.toMap(AuthFlowValidator::getSupportedFlow, Function.identity())); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + ApiDefinition apiDef = (ApiDefinition) request.getAttribute(API_DEFINITION_ATTR); + + // If no API definition resolved or does not require auth, continue + if (apiDef == null || !apiDef.requiresAuth()) { + filterChain.doFilter(request, response); + return; + } + + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) { + throw new AuthenticationCredentialsNotFoundException("JWT required"); + } + + Jwt jwt = jwtAuth.getToken(); + AuthType detectedFlow = flowResolver.resolve(jwt); + + // Verify detected flow is in the set of allowed flows for this API + if (detectedFlow == null || !apiDef.getAuthTypes().contains(detectedFlow)) { + throw new AccessDeniedException( + "Flow '" + detectedFlow + "' is not allowed for this API. Allowed: " + apiDef.getAuthTypes()); + } + + // Validate the flow specifics + AuthFlowValidator validator = validators.get(detectedFlow); + if (validator != null && !validator.validate(jwt)) { + throw new AccessDeniedException("Token validation failed for flow: " + detectedFlow); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java new file mode 100644 index 0000000..3511ffd --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java @@ -0,0 +1,56 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Request wrapper that caches the body so it can be read multiple times. + */ +public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private final byte[] cachedBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + this.cachedBody = request.getInputStream().readAllBytes(); + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody); + return new ServletInputStream() { + @Override + public int read() { + return byteArrayInputStream.read(); + } + + @Override + public boolean isFinished() { + return byteArrayInputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + // no-op for synchronous request handling + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java new file mode 100644 index 0000000..43ea1ea --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java @@ -0,0 +1,38 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class CachedBodyRequestFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (shouldWrap(request)) { + filterChain.doFilter(new CachedBodyHttpServletRequest(request), response); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean shouldWrap(HttpServletRequest request) { + String method = request.getMethod(); + if (!"POST".equalsIgnoreCase(method) + && !"PUT".equalsIgnoreCase(method) + && !"PATCH".equalsIgnoreCase(method)) { + return false; + } + + String contentType = request.getContentType(); + return contentType != null && contentType.toLowerCase().startsWith("application/json"); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java new file mode 100644 index 0000000..0d5d53d --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java @@ -0,0 +1,22 @@ +package org.opendevstack.apiservice.core.security.flow; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class AuthFlowResolver { + + public AuthType resolve(Jwt jwt) { + if (jwt == null) { + return AuthType.NONE; + } + // In Azure AD, OBO tokens have a "scp" claim (delegated permissions). + // Client credentials tokens have a "roles" claim but no "scp". + String scp = jwt.getClaimAsString("scp"); + if (scp != null && !scp.isBlank()) { + return AuthType.OBO; + } + return AuthType.CLIENT_CREDENTIALS; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java new file mode 100644 index 0000000..8036b1c --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java @@ -0,0 +1,11 @@ +package org.opendevstack.apiservice.core.security.flow; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.springframework.security.oauth2.jwt.Jwt; + +public interface AuthFlowValidator { + + AuthType getSupportedFlow(); + + boolean validate(Jwt jwt); +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java new file mode 100644 index 0000000..6a5082d --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java @@ -0,0 +1,45 @@ +package org.opendevstack.apiservice.core.security.flow.validator; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class ClientCredentialFlowValidator implements AuthFlowValidator { + + @Override + public AuthType getSupportedFlow() { + return AuthType.CLIENT_CREDENTIALS; + } + + @Override + public boolean validate(Jwt jwt) { + // v1 tokens use "appid", v2 tokens use "azp"; either is acceptable + String appid = jwt.getClaimAsString("appid"); + String azp = jwt.getClaimAsString("azp"); + if ((appid == null || appid.isBlank()) && (azp == null || azp.isBlank())) { + return false; + } + + Object aud = jwt.getClaim("aud"); + if (aud == null) { + return false; + } + + String scp = jwt.getClaimAsString("scp"); + if (scp != null && !scp.isBlank()) { + return false; + } + + String upn = jwt.getClaimAsString("upn"); + String preferredUsername = jwt.getClaimAsString("preferred_username"); + if ((upn != null && !upn.isBlank()) || (preferredUsername != null && !preferredUsername.isBlank())) { + return false; + } + + String sub = jwt.getSubject(); + String oid = jwt.getClaimAsString("oid"); + return sub != null && oid != null && sub.equals(oid); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java new file mode 100644 index 0000000..9395f5e --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java @@ -0,0 +1,40 @@ +package org.opendevstack.apiservice.core.security.flow.validator; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class OboFlowValidator implements AuthFlowValidator { + + @Override + public AuthType getSupportedFlow() { + return AuthType.OBO; + } + + @Override + public boolean validate(Jwt jwt) { + String scp = jwt.getClaimAsString("scp"); + if (scp == null || scp.isBlank()) { + return false; + } + + String roles = jwt.getClaimAsString("roles"); + if (roles != null && !roles.isBlank()) { + return false; + } + + String user = jwt.getClaimAsString("upn"); + if (user == null || user.isBlank()) { + user = jwt.getClaimAsString("preferred_username"); + } + + if (user == null || user.isBlank()) { + user = jwt.getSubject(); + } + + return user != null && !user.isBlank(); + } + +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java new file mode 100644 index 0000000..7bf20c8 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +@Component +public class AzureJwtAuthenticationConverter implements Converter { + + @Override + public AbstractAuthenticationToken convert(Jwt jwt) { + Collection authorities = extractAuthorities(jwt); + return new JwtAuthenticationToken(jwt, authorities, jwt.getClaimAsString("sub")); + } + + private Collection extractAuthorities(Jwt jwt) { + List authorities = new ArrayList<>(); + + // App Roles from "roles" claim (client-credentials flow) + List roles = jwt.getClaimAsStringList("roles"); + if (roles != null) { + for (String role : roles) { + if (role != null && !role.isBlank()) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); + } + } + } + + // Delegated scopes from "scp" claim (authorization-code / OBO flows) + String scp = jwt.getClaimAsString("scp"); + if (scp != null && !scp.isBlank()) { + for (String scope : scp.split(" ")) { + if (!scope.isBlank()) { + authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); + } + } + } + + return Collections.unmodifiableList(authorities); + } + + /** + * Extracts the client application identifier from the JWT. + * Entra ID uses {@code azp} (v2 tokens) or {@code appid} (v1 tokens). + */ + public static String extractClientId(Jwt jwt) { + String clientId = jwt.getClaimAsString("azp"); + if (clientId == null || clientId.isBlank()) { + clientId = jwt.getClaimAsString("appid"); + } + return clientId; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java new file mode 100644 index 0000000..64c4ddf --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java @@ -0,0 +1,140 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public final class JwtUtils { + + private JwtUtils() { + } + + /** + * Extracts the raw JWT token value from the current SecurityContext. + * + * @return the JWT token string + * @throws InvalidBearerTokenException if the principal is not a JWT + */ + public static String getTokenValue() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return jwt.getTokenValue(); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + private static Object currentPrincipal() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new InvalidBearerTokenException("No authentication present in security context"); + } + Object principal = authentication.getPrincipal(); + if (principal == null) { + throw new InvalidBearerTokenException("No principal present in security context"); + } + return principal; + } + + /** + * Extracts the Azure AD client ID from the current SecurityContext JWT. + * Checks the {@code azp} claim first, then falls back to {@code appid}. + * + * @return the client ID as UUID + * @throws InvalidBearerTokenException if the principal is not a JWT or no client ID claim is found + */ + public static UUID getClientId() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return extractClientId(jwt); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + /** + * Extracts the Azure AD client ID from the given JWT. + */ + public static UUID extractClientId(Jwt jwt) { + String clientId = jwt.getClaimAsString("azp"); + if (clientId == null || clientId.isBlank()) { + clientId = jwt.getClaimAsString("appid"); + } + if (clientId == null || clientId.isBlank()) { + throw new InvalidBearerTokenException("Client ID not found in token claims"); + } + return UUID.fromString(clientId); + } + + public static List extractAudiences(Jwt jwt) { + List audiences = jwt.getAudience(); + if (audiences == null || audiences.isEmpty()) { + throw new InvalidBearerTokenException("Audience not found in token claims"); + } + return audiences; + } + + + public static List getAudiences() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return extractAudiences(jwt); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + /** + * Determines whether the current SecurityContext JWT already targets the configured bypass + * audience and scope, meaning the incoming token can be forwarded as-is without an OBO exchange. + * + * @param bypassAudience the configured bypass audience that must be present in the token {@code aud} claim + * @param bypassScope the configured bypass scope that must be present in the token {@code scp} claim + * @return {@code true} only when both the bypass audience and scope match the token claims + */ + public static boolean tokenMatchesScopeAudience(String bypassAudience, String bypassScope) { + if (bypassAudience == null || bypassAudience.isBlank() + || bypassScope == null || bypassScope.isBlank()) { + return false; + } + Set tokenAudiences = new LinkedHashSet<>(getAudiences()); + if (!tokenAudiences.contains(bypassAudience)) { + return false; + } + return getScopes().contains(bypassScope); + } + + /** + * Extracts the delegated scopes from the current SecurityContext JWT {@code scp} claim. + * + * @return the set of scopes, empty when the {@code scp} claim is absent + * @throws InvalidBearerTokenException if the principal is not a JWT + */ + public static Set getScopes() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return extractScopes(jwt); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + /** + * Extracts the delegated scopes from the given JWT {@code scp} claim. + * Entra ID exposes delegated permissions as a space-separated {@code scp} claim; + * there is no dedicated accessor for it in Spring's {@code JwtClaimAccessor}. + */ + public static Set extractScopes(Jwt jwt) { + String scp = jwt.getClaimAsString("scp"); + Set scopes = new LinkedHashSet<>(); + if (scp != null && !scp.isBlank()) { + for (String scope : scp.trim().split("\\s+")) { + if (!scope.isBlank()) { + scopes.add(scope); + } + } + } + return scopes; + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java new file mode 100644 index 0000000..bbb797a --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.core.security.obo; + +public class OboTokenException extends RuntimeException { + + public OboTokenException(String message) { + super(message); + } + + public OboTokenException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java new file mode 100644 index 0000000..0454b89 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java @@ -0,0 +1,17 @@ +package org.opendevstack.apiservice.core.security.obo; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "app.security.obo") +@Data +public class OboTokenProperties { + + private String tokenUrl; + + private String clientId; + + private String clientSecret; +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java new file mode 100644 index 0000000..5894738 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.core.security.obo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OboTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private int expiresIn; + + private String scope; +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java new file mode 100644 index 0000000..6a9a7a2 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java @@ -0,0 +1,68 @@ +package org.opendevstack.apiservice.core.security.obo; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@Service +@Slf4j +public class OboTokenService { + + private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + + private final OboTokenProperties properties; + private final RestTemplate restTemplate; + + @Autowired + public OboTokenService(OboTokenProperties properties, RestTemplate restTemplate) { + this.properties = properties; + this.restTemplate = restTemplate; + } + + /** + * Exchanges the given JWT assertion for an OBO (On-Behalf-Of) access token. + * + * @param assertion the incoming JWT token value (from the original request) + * @param scope the target API scope (e.g. {@code api:///Api.Access}) + * @return the OBO access token string + * @throws OboTokenException if the token exchange fails + */ + public String exchangeToken(String assertion, String scope) { + log.debug("Exchanging JWT for OBO token with scope '{}'", scope); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", GRANT_TYPE); + body.add("client_id", properties.getClientId()); + body.add("client_secret", properties.getClientSecret()); + body.add("assertion", assertion); + body.add("requested_token_use", "on_behalf_of"); + body.add("scope", scope); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> request = new HttpEntity<>(body, headers); + + try { + ResponseEntity response = + restTemplate.postForEntity(properties.getTokenUrl(), request, OboTokenResponse.class); + + if (response.getBody() == null || response.getBody().getAccessToken() == null) { + throw new OboTokenException("OBO token response body or access_token is null"); + } + + log.debug("OBO token obtained successfully, expires in {} seconds", response.getBody().getExpiresIn()); + return response.getBody().getAccessToken(); + } catch (RestClientException e) { + throw new OboTokenException("Failed to exchange JWT for OBO token: " + e.getMessage(), e); + } + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java new file mode 100644 index 0000000..5feb497 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java @@ -0,0 +1,32 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class ApiDefinitionResolver { + + private static final Pattern VERSION_PATH_PATTERN = Pattern.compile("^/(?:api/(?:pub/)?)?(v\\d+)/(.+)$"); + + private final CoreApiRegistry registry; + + public ApiDefinitionResolver(CoreApiRegistry registry) { + this.registry = registry; + } + + public Optional resolve(HttpServletRequest request) { + String uri = request.getRequestURI(); + Matcher matcher = VERSION_PATH_PATTERN.matcher(uri); + if (!matcher.matches()) { + return Optional.empty(); + } + String version = matcher.group(1); // e.g. "v0", "v1" + String pathAfterVersion = matcher.group(2); + return registry.resolveBestMatch(version, pathAfterVersion); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java new file mode 100644 index 0000000..7e22f3c --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java @@ -0,0 +1,79 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.opendevstack.apiservice.core.contracts.persistence.ApiDefinitionDao; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.stereotype.Service; +import org.springframework.util.AntPathMatcher; + +import jakarta.annotation.PostConstruct; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class CoreApiRegistry { + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + private final ApiDefinitionDao apiDefinitionDao; + + private final Map routeIndex = new ConcurrentHashMap<>(); + + public CoreApiRegistry(ApiDefinitionDao apiDefinitionDao) { + this.apiDefinitionDao = apiDefinitionDao; + } + + @PostConstruct + public void init() { + refreshIndex(); + } + + public void refreshIndex() { + routeIndex.clear(); + List definitions = apiDefinitionDao.findAllEnabled(); + for (ApiDefinition def : definitions) { + String key = buildKey(def.getVersion(), def.getBasePath()); + routeIndex.put(key, def); + } + } + + public Optional resolve(String version, String basePath) { + String key = buildKey(version, basePath); + return Optional.ofNullable(routeIndex.get(key)); + } + + public Optional resolveBestMatch(String version, String requestPathAfterVersion) { + String normalizedRequestPath = normalizePath(requestPathAfterVersion); + if (normalizedRequestPath.isBlank()) { + return Optional.empty(); + } + + return routeIndex.values().stream() + .filter(definition -> version.equals(definition.getVersion())) + .filter(definition -> matchesPattern(definition.getBasePath(), normalizedRequestPath)) + .max((left, right) -> Integer.compare(patternSpecificity(left.getBasePath()), patternSpecificity(right.getBasePath()))); + } + + private String buildKey(String version, String basePath) { + String normalizedPath = basePath.startsWith("/") ? basePath.substring(1) : basePath; + return version + "/" + normalizedPath; + } + + private boolean matchesPattern(String basePathPattern, String requestPath) { + String normalizedPattern = normalizePath(basePathPattern); + return pathMatcher.match(normalizedPattern, requestPath) + || pathMatcher.match(normalizedPattern + "/**", requestPath); + } + + private int patternSpecificity(String basePathPattern) { + return normalizePath(basePathPattern).length(); + } + + private String normalizePath(String rawPath) { + if (rawPath == null || rawPath.isBlank()) { + return ""; + } + return rawPath.startsWith("/") ? rawPath.substring(1) : rawPath; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/util/Base64Operations.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/util/Base64Operations.java new file mode 100644 index 0000000..41e4a62 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/util/Base64Operations.java @@ -0,0 +1,40 @@ +package org.opendevstack.apiservice.core.security.util; + +import java.util.Base64; + +public class Base64Operations { + + private Base64Operations() {} + + /** + * Encodes a string value using Base64 encoding. + * + * @param value the string value to encode + * @return the Base64 encoded string, or null if input is null + */ + public static String encode(String value) { + if (value == null) { + return null; + } + return Base64.getEncoder().encodeToString(value.getBytes()); + } + + /** + * Decodes a Base64 encoded string value. + * + * @param encodedValue the Base64 encoded string to decode + * @return the decoded string, or null if input is null + * @throws IllegalArgumentException if the input is not valid Base64 + */ + public static String decode(String encodedValue) { + if (encodedValue == null) { + return null; + } + try { + byte[] decodedBytes = Base64.getDecoder().decode(encodedValue); + return new String(decodedBytes); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid Base64 encoded value: " + encodedValue, e); + } + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java new file mode 100644 index 0000000..21fb51d --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java @@ -0,0 +1,160 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.registry.ApiDefinitionResolver; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyMap; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PolicyAuthorizationManagerTest { + + private PolicyEngine policyEngine; + private PolicyService policyService; + private PolicyContextFactory contextFactory; + private ApiDefinitionResolver resolver; + private PolicyAuthorizationManager manager; + + @BeforeEach + void setUp() { + policyEngine = mock(PolicyEngine.class); + policyService = mock(PolicyService.class); + contextFactory = mock(PolicyContextFactory.class); + resolver = mock(ApiDefinitionResolver.class); + manager = new PolicyAuthorizationManager(policyEngine, policyService, contextFactory, resolver, new ObjectMapper()); + } + + @Test + void check_unknownRoute_denied() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/unknown"); + when(resolver.resolve(request)).thenReturn(Optional.empty()); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertFalse(decision.isGranted()); + } + + @Test + void check_publicApi_permitted() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/health"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Health", "/health", "v0", + Set.of(AuthType.NONE), true, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + verifyNoInteractions(policyEngine); + } + + @Test + void check_securedApi_policyPermits() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-a"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "ALLOWED_CLIENTS", Map.of())); + when(policyService.findPolicies("api-1", "client-a")).thenReturn(rules); + when(policyEngine.evaluate(ctx, rules)).thenReturn(AuthorizationDecision.PERMIT); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + } + + @Test + void check_securedApi_policyDenies() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-b"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-b", "ALLOWED_CLIENTS", Map.of())); + when(policyService.findPolicies("api-1", "client-b")).thenReturn(rules); + when(policyEngine.evaluate(ctx, rules)).thenReturn(AuthorizationDecision.DENY); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertFalse(decision.isGranted()); + } + + @Test + void check_securedApi_abstainPermits() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-a"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + when(policyService.findPolicies(anyString(), anyString())).thenReturn(List.of()); + when(policyEngine.evaluate(any(), any())).thenReturn(AuthorizationDecision.ABSTAIN); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + } + + @Test + void check_securedPostRequest_populatesRequestBodyInContext() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v0/projects"); + request.setContentType("application/json"); + String requestBody = "{" + + "\"projectFlavor\":\"DLSS\"," + + "\"location\":\"UNKNOWN_REGION\"" + + "}"; + request.setContent(requestBody.getBytes(StandardCharsets.UTF_8)); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-a"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "FLAVOR_RESTRICTION", Map.of())); + when(policyService.findPolicies("api-1", "client-a")).thenReturn(rules); + when(policyEngine.evaluate(ctx, rules)).thenReturn(AuthorizationDecision.PERMIT); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java new file mode 100644 index 0000000..4f94057 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java @@ -0,0 +1,90 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PolicyContextFactoryTest { + + private final PolicyContextFactory factory = new PolicyContextFactory(); + + @Test + void create_withJwtAuthentication_extractsClientIdSubjectAndClaims() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "user-1") + .claim("azp", "client-app") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + JwtAuthenticationToken authToken = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authToken); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Test API", "/test", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/test"); + + PolicyContext ctx = factory.create(apiDef, request); + + assertEquals("client-app", ctx.getClientId()); + assertEquals("user-1", ctx.getSubject()); + assertSame(apiDef, ctx.getApiDefinition()); + assertSame(request, ctx.getRequest()); + assertFalse(ctx.getClaims().isEmpty()); + + SecurityContextHolder.clearContext(); + } + + @Test + void create_withNoAuthentication_returnsNullClientIdAndSubject() { + SecurityContextHolder.clearContext(); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Test API", "/test", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/test"); + + PolicyContext ctx = factory.create(apiDef, request); + + assertNull(ctx.getClientId()); + assertNull(ctx.getSubject()); + assertTrue(ctx.getClaims().isEmpty()); + } + + @Test + void create_withAzpBlank_fallsBackToAppid() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "user-1") + .claim("azp", "") + .claim("appid", "v1-client") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + JwtAuthenticationToken authToken = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authToken); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Test API", "/test", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/test"); + + PolicyContext ctx = factory.create(apiDef, request); + + assertEquals("v1-client", ctx.getClientId()); + + SecurityContextHolder.clearContext(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java new file mode 100644 index 0000000..81657e1 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java @@ -0,0 +1,118 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PolicyEngineTest { + + private PolicyEvaluator evaluatorA; + private PolicyEvaluator evaluatorB; + private PolicyEngine engine; + private PolicyContext context; + + @BeforeEach + void setUp() { + evaluatorA = mock(PolicyEvaluator.class); + evaluatorB = mock(PolicyEvaluator.class); + when(evaluatorA.supports("TYPE_A")).thenReturn(true); + when(evaluatorB.supports("TYPE_B")).thenReturn(true); + engine = new PolicyEngine(List.of(evaluatorA, evaluatorB)); + context = mock(PolicyContext.class); + when(context.withRule(any())).thenReturn(context); + } + + @Test + void evaluate_emptyRules_returnsDeny() { + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, Collections.emptyList())); + } + + @Test + void evaluate_nullRules_returnsDeny() { + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, null)); + } + + @Test + void evaluate_singleRulePermit_returnsPermit() { + PolicyRule rule = rule("TYPE_A"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.PERMIT); + + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(rule))); + } + + @Test + void evaluate_singleRuleDeny_returnsDeny() { + PolicyRule rule = rule("TYPE_A"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.DENY); + + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, List.of(rule))); + } + + @Test + void evaluate_twoRules_permitThenDeny_returnsDeny() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.PERMIT); + when(evaluatorB.evaluate(context)).thenReturn(AuthorizationDecision.DENY); + + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, List.of(ruleA, ruleB))); + } + + @Test + void evaluate_twoRules_permitThenAbstain_returnsPermit() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.PERMIT); + when(evaluatorB.evaluate(context)).thenReturn(AuthorizationDecision.ABSTAIN); + + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(ruleA, ruleB))); + } + + @Test + void evaluate_allAbstain_returnsPermit() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.ABSTAIN); + when(evaluatorB.evaluate(context)).thenReturn(AuthorizationDecision.ABSTAIN); + + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(ruleA, ruleB))); + } + + @Test + void evaluate_ruleWithNoMatchingEvaluator_isSkipped() { + PolicyRule unknownRule = rule("UNKNOWN_TYPE"); + + // No evaluator supports UNKNOWN_TYPE → skipped → finalDecision stays ABSTAIN → PERMIT + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(unknownRule))); + } + + @Test + void evaluate_denyShortCircuits_secondEvaluatorNotCalled() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.DENY); + + engine.evaluate(context, List.of(ruleA, ruleB)); + + verify(evaluatorB, never()).evaluate(any()); + } + + private PolicyRule rule(String type) { + return new PolicyRule(UUID.randomUUID(), "api-1", "client-1", type, Map.of()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java new file mode 100644 index 0000000..635a30e --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java @@ -0,0 +1,67 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.persistence.PolicyDao; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PolicyServiceTest { + + private PolicyDao policyDao; + private PolicyService policyService; + + @BeforeEach + void setUp() { + policyDao = mock(PolicyDao.class); + policyService = new PolicyService(policyDao); + } + + @Test + void findPolicies_withClientId_combinesGlobalAndClientSpecificRules() { + PolicyRule globalRule = new PolicyRule(UUID.randomUUID(), "api-1", null, "TYPE_A", Map.of()); + PolicyRule clientRule = new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "TYPE_B", Map.of()); + + when(policyDao.findGlobalByApiDefinitionId("api-1")).thenReturn(List.of(globalRule)); + when(policyDao.findByApiDefinitionIdAndClientId("api-1", "client-a")).thenReturn(List.of(clientRule)); + + List result = policyService.findPolicies("api-1", "client-a"); + + assertEquals(2, result.size()); + assertTrue(result.contains(globalRule)); + assertTrue(result.contains(clientRule)); + } + + @Test + void findPolicies_withNullClientId_usesApiDefinitionOnlyLookup() { + PolicyRule rule = new PolicyRule(UUID.randomUUID(), "api-1", null, "TYPE_A", Map.of()); + when(policyDao.findByApiDefinitionId("api-1")).thenReturn(List.of(rule)); + + List result = policyService.findPolicies("api-1", null); + + assertEquals(1, result.size()); + verify(policyDao, never()).findGlobalByApiDefinitionId(any()); + verify(policyDao, never()).findByApiDefinitionIdAndClientId(any(), any()); + } + + @Test + void findPolicies_withClientId_noRulesFound_returnsEmpty() { + when(policyDao.findGlobalByApiDefinitionId("api-1")).thenReturn(List.of()); + when(policyDao.findByApiDefinitionIdAndClientId("api-1", "client-x")).thenReturn(List.of()); + + List result = policyService.findPolicies("api-1", "client-x"); + + assertTrue(result.isEmpty()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java new file mode 100644 index 0000000..48f4a2a --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java @@ -0,0 +1,86 @@ +package org.opendevstack.apiservice.core.security.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; +import org.opendevstack.apiservice.core.security.filter.CachedBodyRequestFilter; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; +import org.springframework.security.web.SecurityFilterChain; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SecurityConfigTest { + + private SecurityProperties securityProperties; + private PolicyAuthorizationManager policyAuthorizationManager; + private AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; + private CachedBodyRequestFilter cachedBodyRequestFilter; + private JwtIssuerAuthenticationManagerResolver resolver; + private SecurityConfig securityConfig; + + @BeforeEach + void setUp() { + securityProperties = mock(SecurityProperties.class); + policyAuthorizationManager = mock(PolicyAuthorizationManager.class); + azureJwtAuthenticationConverter = new AzureJwtAuthenticationConverter(); + cachedBodyRequestFilter = mock(CachedBodyRequestFilter.class); + resolver = mock(JwtIssuerAuthenticationManagerResolver.class); + securityConfig = new SecurityConfig( + securityProperties, + policyAuthorizationManager, + azureJwtAuthenticationConverter, + cachedBodyRequestFilter + ); + } + + @Test + void securityFilterChain_withPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{"/health", "/info"}); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http, resolver); + assertNotNull(chain); + } + + @Test + void securityFilterChain_withNullPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(null); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http, resolver); + assertNotNull(chain); + } + + @Test + void securityFilterChain_withEmptyPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{}); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http, resolver); + assertNotNull(chain); + } + + @Test + void jwtIssuerAuthenticationManagerResolver_registersConfiguredIssuers() { + SecurityProperties.IssuerConfig v1 = new SecurityProperties.IssuerConfig(); + v1.setIssuerUri("https://sts.windows.net/tenant/"); + v1.setJwkSetUri("https://login.microsoftonline.com/common/discovery/keys"); + + SecurityProperties.IssuerConfig v2 = new SecurityProperties.IssuerConfig(); + v2.setIssuerUri("https://login.microsoftonline.com/tenant/v2.0"); + v2.setJwkSetUri("https://login.microsoftonline.com/tenant/discovery/v2.0/keys"); + + when(securityProperties.getIssuers()).thenReturn(List.of(v1, v2)); + when(securityProperties.getAudiences()).thenReturn(List.of("api://my-app")); + + JwtIssuerAuthenticationManagerResolver built = securityConfig.jwtIssuerAuthenticationManagerResolver(); + assertNotNull(built); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java new file mode 100644 index 0000000..6a3ee8d --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java @@ -0,0 +1,174 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.flow.AuthFlowResolver; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthTypeEnforcementFilterTest { + + private AuthFlowResolver flowResolver; + private AuthFlowValidator ccValidator; + private AuthFlowValidator oboValidator; + private AuthTypeEnforcementFilter filter; + private FilterChain filterChain; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + flowResolver = new AuthFlowResolver(); + ccValidator = mock(AuthFlowValidator.class); + when(ccValidator.getSupportedFlow()).thenReturn(AuthType.CLIENT_CREDENTIALS); + when(ccValidator.validate(any())).thenReturn(true); + + oboValidator = mock(AuthFlowValidator.class); + when(oboValidator.getSupportedFlow()).thenReturn(AuthType.OBO); + when(oboValidator.validate(any())).thenReturn(true); + + filter = new AuthTypeEnforcementFilter(flowResolver, List.of(ccValidator, oboValidator)); + filterChain = mock(FilterChain.class); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void noApiDefinition_continuesChain() throws Exception { + // No API_DEFINITION_ATTR set + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void publicApi_continuesChain() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Public", "/pub", "v0", + Set.of(AuthType.NONE), true, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_noJwtAuthentication_throwsCredentialsNotFound() { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + assertThrows(AuthenticationCredentialsNotFoundException.class, + () -> filter.doFilterInternal(request, response, filterChain)); + } + + @Test + void securedApi_clientCredentials_allowedFlow_continuesChain() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); // no scp → CLIENT_CREDENTIALS + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_obo_allowedFlow_continuesChain() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("scp", "api.read")); // scp present → OBO + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_wrongFlow_throwsAccessDenied() { + // API only allows OBO, but token is CLIENT_CREDENTIALS + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); // no scp → CLIENT_CREDENTIALS + + assertThrows(AccessDeniedException.class, + () -> filter.doFilterInternal(request, response, filterChain)); + } + + @Test + void securedApi_flowValidatorFails_throwsAccessDenied() { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); + when(ccValidator.validate(any())).thenReturn(false); + + assertThrows(AccessDeniedException.class, + () -> filter.doFilterInternal(request, response, filterChain)); + } + + @Test + void securedApi_bothFlowsAllowed_clientCredentialsToken_passes() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS, AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_bothFlowsAllowed_oboToken_passes() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS, AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("scp", "api.read")); + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + private void setJwtAuth(Map claims) { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "test-subject") + .claims(c -> c.putAll(claims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + JwtAuthenticationToken authToken = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authToken); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java new file mode 100644 index 0000000..d5821b7 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java @@ -0,0 +1,54 @@ +package org.opendevstack.apiservice.core.security.flow; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class AuthFlowResolverTest { + + private AuthFlowResolver resolver; + + @BeforeEach + void setUp() { + resolver = new AuthFlowResolver(); + } + + @Test + void resolve_withScpClaim_returnsObo() { + Jwt jwt = buildJwt(Map.of("scp", "api.read api.write")); + assertEquals(AuthType.OBO, resolver.resolve(jwt)); + } + + @Test + void resolve_withBlankScpClaim_returnsClientCredentials() { + Jwt jwt = buildJwt(Map.of("scp", " ")); + assertEquals(AuthType.CLIENT_CREDENTIALS, resolver.resolve(jwt)); + } + + @Test + void resolve_withoutScpClaim_returnsClientCredentials() { + Jwt jwt = buildJwt(Map.of("sub", "user-id")); + assertEquals(AuthType.CLIENT_CREDENTIALS, resolver.resolve(jwt)); + } + + @Test + void resolve_withNullJwt_returnsNone() { + assertEquals(AuthType.NONE, resolver.resolve(null)); + } + + private Jwt buildJwt(Map claims) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "test-subject") + .claims(c -> c.putAll(claims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidatorTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidatorTest.java new file mode 100644 index 0000000..1cc0b3c --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidatorTest.java @@ -0,0 +1,139 @@ +package org.opendevstack.apiservice.core.security.flow.validator; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ClientCredentialFlowValidatorTest { + + private ClientCredentialFlowValidator validator; + + @BeforeEach + void setUp() { + validator = new ClientCredentialFlowValidator(); + } + + // --- v1 token (appid) --- + + @Test + void validate_v1Token_valid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "aud", "api://my-app", + "oid", "same-as-sub" + ), "same-as-sub"); + assertTrue(validator.validate(jwt)); + } + + @Test + void validate_v1Token_withScp_invalid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "aud", "api://my-app", + "scp", "api.read", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_v1Token_withUpn_invalid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "aud", "api://my-app", + "upn", "user@example.com", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_v1Token_subNotEqualOid_invalid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "aud", "api://my-app", + "oid", "different-oid" + ), "subject"); + assertFalse(validator.validate(jwt)); + } + + // --- v2 token (azp, no appid) --- + + @Test + void validate_v2Token_valid() { + Jwt jwt = buildJwt(Map.of( + "azp", "client-id-v2", + "aud", "api://my-app", + "oid", "same-as-sub" + ), "same-as-sub"); + assertTrue(validator.validate(jwt)); + } + + @Test + void validate_v2Token_withScp_invalid() { + Jwt jwt = buildJwt(Map.of( + "azp", "client-id-v2", + "aud", "api://my-app", + "scp", "api.read", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_v2Token_withPreferredUsername_invalid() { + Jwt jwt = buildJwt(Map.of( + "azp", "client-id-v2", + "aud", "api://my-app", + "preferred_username", "user@example.com", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_v2Token_subNotEqualOid_invalid() { + Jwt jwt = buildJwt(Map.of( + "azp", "client-id-v2", + "aud", "api://my-app", + "oid", "different-oid" + ), "subject"); + assertFalse(validator.validate(jwt)); + } + + // --- no client ID at all --- + + @Test + void validate_noClientIdClaim_invalid() { + Jwt jwt = buildJwt(Map.of( + "aud", "api://my-app", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_missingAud_invalid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + private Jwt buildJwt(Map extraClaims, String subject) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", subject) + .claims(c -> c.putAll(extraClaims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java new file mode 100644 index 0000000..1fd8eca --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java @@ -0,0 +1,147 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AzureJwtAuthenticationConverterTest { + + private AzureJwtAuthenticationConverter converter; + + @BeforeEach + void setUp() { + converter = new AzureJwtAuthenticationConverter(); + } + + // ── Authority extraction ── + + @Test + void convert_withRolesOnly_mapsToRolePrefixedAuthorities() { + Jwt jwt = buildJwt(Map.of("roles", List.of("ADMIN", "READER"))); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertTrue(authorities.contains("ROLE_ADMIN")); + assertTrue(authorities.contains("ROLE_READER")); + assertEquals(2, authorities.size()); + } + + @Test + void convert_withScpOnly_mapsToScopePrefixedAuthorities() { + Jwt jwt = buildJwt(Map.of("scp", "api.read api.write")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertTrue(authorities.contains("SCOPE_api.read")); + assertTrue(authorities.contains("SCOPE_api.write")); + assertEquals(2, authorities.size()); + } + + @Test + void convert_withBothRolesAndScp_mapsBoth() { + Jwt jwt = buildJwt(Map.of("roles", List.of("ADMIN"), "scp", "api.read")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertTrue(authorities.contains("ROLE_ADMIN")); + assertTrue(authorities.contains("SCOPE_api.read")); + assertEquals(2, authorities.size()); + } + + @Test + void convert_withNeitherClaim_returnsEmptyAuthorities() { + Jwt jwt = buildJwt(Map.of()); + + AbstractAuthenticationToken token = converter.convert(jwt); + + assertTrue(token.getAuthorities().isEmpty()); + } + + @Test + void convert_withBlankRole_isIgnored() { + Jwt jwt = buildJwt(Map.of("roles", List.of("ADMIN", " ", ""))); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertEquals(1, authorities.size()); + assertTrue(authorities.contains("ROLE_ADMIN")); + } + + @Test + void convert_withBlankScp_returnsNoScopeAuthorities() { + Jwt jwt = buildJwt(Map.of("scp", " ")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + assertTrue(token.getAuthorities().isEmpty()); + } + + @Test + void convert_setsSubjectAsName() { + Jwt jwt = buildJwt(Map.of("sub", "user-123")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + assertEquals("user-123", token.getName()); + } + + // ── extractClientId ── + + @Test + void extractClientId_withAzp_returnsAzp() { + Jwt jwt = buildJwt(Map.of("azp", "client-a")); + assertEquals("client-a", AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + @Test + void extractClientId_withAzpBlank_fallsBackToAppid() { + Jwt jwt = buildJwt(Map.of("azp", "", "appid", "client-b")); + assertEquals("client-b", AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + @Test + void extractClientId_withAzpNull_fallsBackToAppid() { + Jwt jwt = buildJwt(Map.of("appid", "client-b")); + assertEquals("client-b", AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + @Test + void extractClientId_withNeitherClaim_returnsNull() { + Jwt jwt = buildJwt(Map.of()); + assertNull(AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + // ── helpers ── + + private Collection authorityStrings(AbstractAuthenticationToken token) { + return token.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + } + + private Jwt buildJwt(Map extraClaims) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "test-subject") + .claims(c -> c.putAll(extraClaims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java new file mode 100644 index 0000000..386edd9 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java @@ -0,0 +1,263 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JwtUtilsTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void get_token_value_returns_jwt_token_string() { + // GIVEN + Jwt jwt = buildJwt("eyJhbGciOiJSUzI1NiJ9.test", Map.of("azp", "client-a")); + setSecurityContext(jwt); + + // WHEN + String token = JwtUtils.getTokenValue(); + + // THEN + assertEquals("eyJhbGciOiJSUzI1NiJ9.test", token); + } + + @Test + void get_token_value_throws_when_principal_is_not_jwt() { + // GIVEN + SecurityContext ctx = mock(SecurityContext.class); + var auth = mock(org.springframework.security.core.Authentication.class); + when(auth.getPrincipal()).thenReturn("not-a-jwt"); + when(ctx.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(ctx); + + // WHEN / THEN + assertThrows(InvalidBearerTokenException.class, JwtUtils::getTokenValue); + } + + @Test + void get_client_id_returns_azp_claim() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000001"; + Jwt jwt = buildJwt("token", Map.of("azp", clientId)); + setSecurityContext(jwt); + + // WHEN + UUID result = JwtUtils.getClientId(); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void get_client_id_falls_back_to_appid_when_azp_is_blank() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000002"; + Jwt jwt = buildJwt("token", Map.of("azp", "", "appid", clientId)); + setSecurityContext(jwt); + + // WHEN + UUID result = JwtUtils.getClientId(); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void get_client_id_throws_when_no_client_claim() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("sub", "user")); + setSecurityContext(jwt); + + // WHEN / THEN + assertThrows(InvalidBearerTokenException.class, JwtUtils::getClientId); + } + + @Test + void extract_client_id_from_jwt_with_azp() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000003"; + Jwt jwt = buildJwt("token", Map.of("azp", clientId)); + + // WHEN + UUID result = JwtUtils.extractClientId(jwt); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void extract_client_id_from_jwt_with_appid_fallback() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000004"; + Jwt jwt = buildJwt("token", Map.of("appid", clientId)); + + // WHEN + UUID result = JwtUtils.extractClientId(jwt); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void extract_audiences_supports_single_string_claim() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("aud", "aud-1")); + + // WHEN + List audiences = JwtUtils.extractAudiences(jwt); + + // THEN + assertEquals(List.of("aud-1"), audiences); + } + + @Test + void extract_audiences_supports_list_claim() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("aud", List.of("aud-1", "aud-2"))); + + // WHEN + List audiences = JwtUtils.extractAudiences(jwt); + + // THEN + assertEquals(List.of("aud-1", "aud-2"), audiences); + } + + @Test + void token_matches_scope_audience_when_audience_and_scope_match_bypass() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "6d81fd84-9b38-4aed-9403-ed95f6395cd7", + "scp", "Api.Access")); + setSecurityContext(jwt); + + // WHEN + boolean result = JwtUtils.tokenMatchesScopeAudience( + "6d81fd84-9b38-4aed-9403-ed95f6395cd7", "Api.Access"); + + // THEN + assertTrue(result); + } + + @Test + void token_matches_scope_audience_when_scope_is_one_of_several_scp_values() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "api://6d81fd84-9b38-4aed-9403-ed95f6395cd7", + "scp", "User.Read Api.Access Files.Read")); + setSecurityContext(jwt); + + // WHEN + boolean result = JwtUtils.tokenMatchesScopeAudience( + "api://6d81fd84-9b38-4aed-9403-ed95f6395cd7", "Api.Access"); + + // THEN + assertTrue(result); + } + + @Test + void token_matches_scope_audience_returns_false_when_audience_does_not_match() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "api://another-app", + "scp", "Api.Access")); + setSecurityContext(jwt); + + // WHEN + boolean result = JwtUtils.tokenMatchesScopeAudience( + "6d81fd84-9b38-4aed-9403-ed95f6395cd7", "Api.Access"); + + // THEN + assertFalse(result); + } + + @Test + void token_matches_scope_audience_returns_false_when_scope_does_not_match() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "6d81fd84-9b38-4aed-9403-ed95f6395cd7", + "scp", "User.Read")); + setSecurityContext(jwt); + + // WHEN + boolean result = JwtUtils.tokenMatchesScopeAudience( + "6d81fd84-9b38-4aed-9403-ed95f6395cd7", "Api.Access"); + + // THEN + assertFalse(result); + } + + @Test + void token_matches_scope_audience_returns_false_when_bypass_values_are_blank() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "6d81fd84-9b38-4aed-9403-ed95f6395cd7", + "scp", "Api.Access")); + setSecurityContext(jwt); + + // WHEN / THEN + assertFalse(JwtUtils.tokenMatchesScopeAudience("", "Api.Access")); + assertFalse(JwtUtils.tokenMatchesScopeAudience("6d81fd84-9b38-4aed-9403-ed95f6395cd7", "")); + assertFalse(JwtUtils.tokenMatchesScopeAudience(null, null)); + } + + @Test + void extract_scopes_splits_scp_claim_by_whitespace() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("scp", "User.Read Api.Access")); + + // WHEN + var scopes = JwtUtils.extractScopes(jwt); + + // THEN + assertTrue(scopes.contains("User.Read")); + assertTrue(scopes.contains("Api.Access")); + assertEquals(2, scopes.size()); + } + + @Test + void extract_scopes_returns_empty_set_when_scp_claim_absent() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("aud", "some-audience")); + + // WHEN + var scopes = JwtUtils.extractScopes(jwt); + + // THEN + assertTrue(scopes.isEmpty()); + } + + private Jwt buildJwt(String tokenValue, Map claims) { + Jwt.Builder builder = Jwt.withTokenValue(tokenValue) + .header("alg", "RS256") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)); + claims.forEach(builder::claim); + return builder.build(); + } + + private void setSecurityContext(Jwt jwt) { + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication(auth); + SecurityContextHolder.setContext(ctx); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java new file mode 100644 index 0000000..ad9d0ca --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java @@ -0,0 +1,129 @@ +package org.opendevstack.apiservice.core.security.obo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OboTokenServiceTest { + + @Mock + private RestTemplate restTemplate; + + private OboTokenProperties properties; + + private OboTokenService sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + properties = new OboTokenProperties(); + properties.setTokenUrl("https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token"); + properties.setClientId("test-client-id"); + properties.setClientSecret("test-client-secret"); + sut = new OboTokenService(properties, restTemplate); + } + + @Test + void exchange_token_returns_access_token_on_success() { + // GIVEN + String assertion = "jwt-assertion-value"; + String scope = "api://target-app/Api.Access"; + OboTokenResponse tokenResponse = new OboTokenResponse(); + tokenResponse.setAccessToken("obo-access-token"); + tokenResponse.setTokenType("Bearer"); + tokenResponse.setExpiresIn(3600); + tokenResponse.setScope(scope); + + when(restTemplate.postForEntity(eq(properties.getTokenUrl()), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); + + // WHEN + String result = sut.exchangeToken(assertion, scope); + + // THEN + assertEquals("obo-access-token", result); + verify(restTemplate).postForEntity(eq(properties.getTokenUrl()), any(HttpEntity.class), eq(OboTokenResponse.class)); + } + + @Test + @SuppressWarnings("unchecked") + void exchange_token_sends_correct_form_parameters() { + // GIVEN + String assertion = "my-jwt"; + String scope = "api://app/scope"; + OboTokenResponse tokenResponse = new OboTokenResponse(); + tokenResponse.setAccessToken("token"); + + ArgumentCaptor>> captor = ArgumentCaptor.forClass(HttpEntity.class); + when(restTemplate.postForEntity(anyString(), captor.capture(), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); + + // WHEN + sut.exchangeToken(assertion, scope); + + // THEN + MultiValueMap body = captor.getValue().getBody(); + assertNotNull(body); + assertEquals("urn:ietf:params:oauth:grant-type:jwt-bearer", body.getFirst("grant_type")); + assertEquals("test-client-id", body.getFirst("client_id")); + assertEquals("test-client-secret", body.getFirst("client_secret")); + assertEquals("my-jwt", body.getFirst("assertion")); + assertEquals("on_behalf_of", body.getFirst("requested_token_use")); + assertEquals("api://app/scope", body.getFirst("scope")); + } + + @Test + void exchange_token_throws_obo_exception_when_response_body_is_null() { + // GIVEN + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(null, HttpStatus.OK)); + + // WHEN / THEN + OboTokenException ex = assertThrows(OboTokenException.class, + () -> sut.exchangeToken("jwt", "scope")); + assertTrue(ex.getMessage().contains("null")); + } + + @Test + void exchange_token_throws_obo_exception_when_access_token_is_null() { + // GIVEN + OboTokenResponse response = new OboTokenResponse(); + response.setAccessToken(null); + + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + + // WHEN / THEN + assertThrows(OboTokenException.class, () -> sut.exchangeToken("jwt", "scope")); + } + + @Test + void exchange_token_throws_obo_exception_on_rest_client_error() { + // GIVEN + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenThrow(new RestClientException("Connection refused")); + + // WHEN / THEN + OboTokenException ex = assertThrows(OboTokenException.class, + () -> sut.exchangeToken("jwt", "scope")); + assertTrue(ex.getMessage().contains("Connection refused")); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java new file mode 100644 index 0000000..a0f6c77 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java @@ -0,0 +1,100 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ApiDefinitionResolverTest { + + private CoreApiRegistry registry; + private ApiDefinitionResolver resolver; + + @BeforeEach + void setUp() { + registry = mock(CoreApiRegistry.class); + resolver = new ApiDefinitionResolver(registry); + } + + @Test + void resolve_standardVersionedPath_delegatesToRegistry() { + ApiDefinition expected = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(registry.resolveBestMatch("v0", "projects")).thenReturn(Optional.of(expected)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + void resolve_publicApiPath_delegatesToRegistry() { + ApiDefinition expected = new ApiDefinition("api-pub", "Health", "health", "v0", + Set.of(AuthType.NONE), true, null, true); + when(registry.resolveBestMatch("v0", "health")).thenReturn(Optional.of(expected)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/pub/v0/health"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + void resolve_directVersionPath_delegatesToRegistry() { + ApiDefinition expected = new ApiDefinition("api-1", "Projects", "projects", "v1", + Set.of(AuthType.OBO), false, null, true); + when(registry.resolveBestMatch("v1", "projects")).thenReturn(Optional.of(expected)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/projects"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + void resolve_noVersionInPath_returnsEmpty() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/health"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isEmpty()); + verifyNoInteractions(registry); + } + + @Test + void resolve_rootPath_returnsEmpty() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isEmpty()); + } + + @Test + void resolve_registryReturnsEmpty_returnsEmpty() { + when(registry.resolveBestMatch("v0", "unknown")).thenReturn(Optional.empty()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/unknown"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isEmpty()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java new file mode 100644 index 0000000..ed27df9 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java @@ -0,0 +1,144 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.persistence.ApiDefinitionDao; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CoreApiRegistryTest { + + private ApiDefinitionDao apiDefinitionDao; + private CoreApiRegistry registry; + + @BeforeEach + void setUp() { + apiDefinitionDao = mock(ApiDefinitionDao.class); + registry = new CoreApiRegistry(apiDefinitionDao); + } + + @Test + void init_loadsDefinitionsFromDao() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + + registry.init(); + + Optional result = registry.resolve("v0", "projects"); + assertTrue(result.isPresent()); + assertEquals("api-1", result.get().getId()); + } + + @Test + void resolve_exactMatch_returnsDefinition() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolve("v0", "projects").isPresent()); + } + + @Test + void resolve_noMatch_returnsEmpty() { + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of()); + registry.init(); + + assertTrue(registry.resolve("v0", "nonexistent").isEmpty()); + } + + @Test + void resolveBestMatch_exactPath_returnsDefinition() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + Optional result = registry.resolveBestMatch("v0", "projects"); + assertTrue(result.isPresent()); + assertEquals("api-1", result.get().getId()); + } + + @Test + void resolveBestMatch_subPath_matchesViaAntPattern() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + Optional result = registry.resolveBestMatch("v0", "projects/123/tasks"); + assertTrue(result.isPresent()); + assertEquals("api-1", result.get().getId()); + } + + @Test + void resolveBestMatch_wrongVersion_returnsEmpty() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolveBestMatch("v1", "projects").isEmpty()); + } + + @Test + void resolveBestMatch_picksMostSpecific() { + ApiDefinition broad = new ApiDefinition("api-broad", "Broad", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + ApiDefinition specific = new ApiDefinition("api-specific", "Specific", "projects/tasks", "v0", + Set.of(AuthType.OBO), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(broad, specific)); + registry.init(); + + Optional result = registry.resolveBestMatch("v0", "projects/tasks/42"); + assertTrue(result.isPresent()); + assertEquals("api-specific", result.get().getId()); + } + + @Test + void resolveBestMatch_blankPath_returnsEmpty() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolveBestMatch("v0", "").isEmpty()); + } + + @Test + void refreshIndex_clearsOldEntriesAndReloads() { + ApiDefinition old = new ApiDefinition("api-old", "Old", "old-path", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(old)); + registry.init(); + assertTrue(registry.resolve("v0", "old-path").isPresent()); + + ApiDefinition updated = new ApiDefinition("api-new", "New", "new-path", "v0", + Set.of(AuthType.OBO), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(updated)); + registry.refreshIndex(); + + assertTrue(registry.resolve("v0", "old-path").isEmpty()); + assertTrue(registry.resolve("v0", "new-path").isPresent()); + } + + @Test + void resolve_basePathWithLeadingSlash_matches() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolve("v0", "projects").isPresent()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/util/Base64OperationsTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/util/Base64OperationsTest.java new file mode 100644 index 0000000..5b08a04 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/util/Base64OperationsTest.java @@ -0,0 +1,51 @@ +package org.opendevstack.apiservice.core.security.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class Base64OperationsTest { + + @Test + void tectEncodeWhenNullInputReturnNull() { + String testString = null; + + String encodedString = Base64Operations.encode(testString); + + assertEquals(null, encodedString); + } + + @Test + void tectEncodeWhenCorrectInputReturnEncodedResult() { + String testString = "test;string"; + + String encodedString = Base64Operations.encode(testString); + + assertEquals("dGVzdDtzdHJpbmc=", encodedString); + } + + @Test + void testDecodeWhenNullInputReturnNull() { + String testString = null; + + String dencodedString = Base64Operations.decode(testString); + + assertEquals(null, dencodedString); + } + + @Test + void testDecodeWhenCorrectInputReturnDecodedResult() { + String testString = "dGVzdDtzdHJpbmc="; + + String dencodedString = Base64Operations.decode(testString); + + assertEquals("test;string", dencodedString); + } + + @Test + void testDecodeWhenBadInputThrowException() { + String testString = "NOT AN ENCODED STRING"; + + assertThrows(IllegalArgumentException.class, () -> Base64Operations.decode(testString)); + } +} \ No newline at end of file diff --git a/core/pom.xml b/core/pom.xml index 76bb484..a0ba531 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 core @@ -30,6 +30,13 @@ spring-boot-starter-oauth2-resource-server + + + org.opendevstack.apiservice + core-security + ${project.version} + + org.springframework.boot spring-boot-starter-aop @@ -41,6 +48,14 @@ spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-devtools + runtime + true + + org.opendevstack.apiservice @@ -101,17 +116,56 @@ ${project.version} + + org.opendevstack.apiservice + external-service-marketplace + ${project.version} + + org.opendevstack.apiservice api-project-users ${project.version} + + org.opendevstack.apiservice + api-project + ${project.version} + + + + org.opendevstack.apiservice + api-project-component-v0 + ${project.version} + + org.opendevstack.apiservice api-project-platform ${project.version} + + + + org.opendevstack.apiservice + service-projects + ${project.version} + + + + + org.opendevstack.apiservice + persistence + ${project.version} + + + + + org.postgresql + postgresql + runtime + @@ -145,6 +199,7 @@ org.opendevstack.apiservice.core.DevstackApiServiceApplication ../docker + ${project.parent.basedir} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java b/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java index bcb4866..f13e57a 100644 --- a/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java +++ b/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java @@ -2,10 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication(scanBasePackages = { "org.opendevstack.apiservice" }) @EnableCaching +@EntityScan(basePackages = "org.opendevstack.apiservice.persistence.entity") +@EnableJpaRepositories(basePackages = "org.opendevstack.apiservice.persistence.repository") public class DevstackApiServiceApplication { public static void main(String[] args) { diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java deleted file mode 100644 index 90d5d5d..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.EnableAspectJAutoProxy; - -@Configuration -@EnableAspectJAutoProxy -public class AspectConfig { - // Enables AspectJ auto-proxy for flow enforcement -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java b/core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java deleted file mode 100644 index a705ffb..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -public class CustomRoleConverter implements Converter> { - - private static final String ROLES_CLAIM = "roles"; - - @Override - @SuppressWarnings("nullness") - public Collection convert(Jwt jwt) { - if (jwt == null) { - return List.of(); - } - // Extract realm roles (Keycloak/Auth0 standard) - List realmRoles = getRealmRoles(jwt); - - // Extract resource roles (client-specific roles) - List resourceRoles = getResourceRoles(jwt); - - // Combine all roles - List allRoles = combineRoles(realmRoles, resourceRoles); - - // Convert to GrantedAuthority with ROLE_ prefix - return allRoles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .map(GrantedAuthority.class::cast) - .toList(); - } - - private List getRealmRoles(Jwt jwt) { - Map realmAccess = jwt.getClaimAsMap("realm_access"); - if (realmAccess != null && realmAccess.containsKey(ROLES_CLAIM)) { - @SuppressWarnings("unchecked") - List roles = (List) realmAccess.get(ROLES_CLAIM); - return roles != null ? roles : List.of(); - } - return List.of(); - } - - private List getResourceRoles(Jwt jwt) { - Map resourceAccess = jwt.getClaimAsMap("resource_access"); - if (resourceAccess == null) { - return List.of(); - } - - return resourceAccess.values().stream() - .map(this::extractRolesFromResource) - .flatMap(Collection::stream) - .toList(); - } - - @SuppressWarnings("unchecked") - private List extractRolesFromResource(Object resource) { - if (resource instanceof Map) { - Map resourceMap = (Map) resource; - Object roles = resourceMap.get(ROLES_CLAIM); - if (roles instanceof List) { - return (List) roles; - } - } - return List.of(); - } - - private List combineRoles(List realmRoles, List resourceRoles) { - return java.util.stream.Stream.concat( - realmRoles.stream(), - resourceRoles.stream() - ).toList(); - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java b/core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java deleted file mode 100644 index e367e65..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "app.security.flows") -public class FlowProperties { - - private Global global = new Global(); - - private Map apis; - - @Getter - @Setter - public static class Global { - private List enabledFlows; - private String defaultFlow = "client-credentials"; - } - - @Getter - @Setter - public static class ApiFlows { - private String defaultFlow; - private List endpoints; - } - - @Getter - @Setter - public static class EndpointFlow { - private String pattern; // Now as a property instead of map key - private List flows; - private List roles; - private boolean permitAll = false; - private boolean requireActor = false; - private boolean requireAuthentication = true; - private int requireDelegationDepth = 0; - private List requiredScopes; - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java new file mode 100644 index 0000000..da25ff0 --- /dev/null +++ b/core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java @@ -0,0 +1,21 @@ +package org.opendevstack.apiservice.core.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Global Jackson configuration. + * Ensures that null properties are omitted from all JSON responses. + */ +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> builder.serializationInclusion(JsonInclude.Include.NON_NULL); + } +} + + diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java deleted file mode 100644 index 0c72cba..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.opendevstack.apiservice.core.config; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties(OnBehalfOfProperties.class) -public class OnBehalfOfConfig { - // This class enables binding of OnBehalfOfProperties to configuration - // No additional code needed - just enables the configuration properties -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java b/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java deleted file mode 100644 index ea43459..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java +++ /dev/null @@ -1,111 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "app.on-behalf-of") -public class OnBehalfOfProperties { - - private boolean enabled = true; - - private TokenExchange tokenExchange = new TokenExchange(); - - private Flow flow = new Flow(); - - private Delegation delegation = new Delegation(); - - private Security security = new Security(); - - private Provider provider = new Provider(); - - @Getter - @Setter - public static class TokenExchange { - private String grantType = "urn:ietf:params:oauth:grant-type:token-exchange"; - private List supportedSubjectTokenTypes; - private String requestedTokenType = "urn:ietf:params:oauth:token-type:access_token"; - private String defaultAudience; - } - - @Getter - @Setter - public static class Flow { - private boolean validateActor = false; - private List allowedActorClients; - private List requiredScopes; - private int delegatedTokenLifetime = 3600; - } - - @Getter - @Setter - public static class Delegation { - private Map rules; - - @Getter - @Setter - public static class ServiceDelegation { - private List canDelegateTo; - private List allowedScopes; - private int maxDelegationDepth = 1; - } - } - - @Getter - @Setter - public static class Security { - private boolean requireActorClaim = false; - private String introspectionEndpoint; - private AuditLogging auditLogging = new AuditLogging(); - - @Getter - @Setter - public static class AuditLogging { - private boolean enabled = true; - private boolean logSuccessfulExchanges = true; - private boolean logFailedExchanges = true; - private boolean includeTokenScope = false; - } - } - - @Getter - @Setter - public static class Provider { - private String type = "keycloak"; - private Settings settings = new Settings(); - - @Getter - @Setter - public static class Settings { - private Keycloak keycloak = new Keycloak(); - private Auth0 auth0 = new Auth0(); - private Generic generic = new Generic(); - - @Getter - @Setter - public static class Keycloak { - private String tokenExchangePolicy = "on-behalf-of"; - } - - @Getter - @Setter - public static class Auth0 { - private String organization; - } - - @Getter - @Setter - public static class Generic { - private String tokenExchangeUrl; - private String clientAuthMethod = "client_secret_basic"; - } - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java deleted file mode 100644 index 4069616..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java +++ /dev/null @@ -1,99 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.servers.Server; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; -import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.boot.context.properties.bind.Bindable; - -import java.util.List; - -@Configuration -public class OpenApiConfig { - - @Value("${openapi.info.title:'API Documentation'}") - private String title; - - @Value("${openapi.info.description:'API description not provided'}") - private String description; - - @Value("${openapi.info.version:'1.0.0'}") - private String version; - - @Value("${openapi.info.contact.name:'API Support'}") - private String contactName; - - @Value("${openapi.info.contact.email:'support@example.com'}") - private String contactEmail; - - private final Environment environment; - - public OpenApiConfig(Environment environment) { - this.environment = environment; - } - - @Bean - public OpenAPI customOpenAPI() { - // bind the YAML list under openapi.servers to the nested ServerProperties class - List configured = Binder.get(environment) - .bind("openapi.servers", Bindable.listOf(ServerProperties.class)) - .orElse(List.of()); - - List servers = configured.stream() - .map(s -> new Server().url(s.getUrl()).description(s.getDescription())) - .toList(); - - // fallback to a sensible default if no servers configured - if (servers.isEmpty()) { - servers = List.of(new Server().url("http://localhost:8080").description("Development server")); - } - - final String securitySchemeName = "bearerAuth"; - return new OpenAPI() - .info(new Info() - .title(title) - .description(description) - .version(version) - .contact(new Contact().name(contactName).email(contactEmail))) - .servers(servers) - .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) - .components( - new Components() - .addSecuritySchemes(securitySchemeName, - new SecurityScheme() - .name(securitySchemeName) - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))); - - } - - public static class ServerProperties { - private String url; - private String description; - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java deleted file mode 100644 index cd8b73e..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java +++ /dev/null @@ -1,177 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.web.SecurityFilterChain; -import lombok.extern.slf4j.Slf4j; -import java.util.Arrays; - -@Slf4j -@Configuration -@EnableWebSecurity -@EnableMethodSecurity(prePostEnabled = true) -public class SecurityConfig { - - private final SecurityProperties securityProperties; - private final FlowProperties flowProperties; - - public SecurityConfig(SecurityProperties securityProperties, FlowProperties flowProperties) { - this.securityProperties = securityProperties; - this.flowProperties = flowProperties; - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(authz -> { - // Allow public endpoints from security properties - if (securityProperties.getPublicEndpoints() != null) { - Arrays.stream(securityProperties.getPublicEndpoints()) - .forEach(endpoint -> authz.requestMatchers(endpoint).permitAll()); - } - - // Apply flow-based security rules - applyFlowBasedSecurity(authz); - - // All other requests require authentication - authz.anyRequest().authenticated(); - }) - .oauth2ResourceServer(oauth2 -> - oauth2.jwt(jwt -> jwt - .jwtAuthenticationConverter(jwtAuthenticationConverter()) - ) - ) - .headers(headers -> headers.frameOptions(frame -> frame.disable())) - .csrf(csrf -> csrf.disable()); - - return http.build(); - } - - private void applyFlowBasedSecurity(org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authz) { - if (flowProperties.getApis() == null) { - return; - } - - // Apply security rules based on flow configuration - flowProperties.getApis().forEach((apiName, apiFlows) -> { - if (apiFlows.getEndpoints() != null) { - apiFlows.getEndpoints().forEach(endpointFlow -> { - String pattern = endpointFlow.getPattern(); - - // Debug logging to see what patterns we're receiving - log.info("Processing security pattern: '{}' for API: {}", pattern, apiName); - - // Permit all if specified - if (endpointFlow.isPermitAll()) { - authz.requestMatchers(pattern).permitAll(); - } - // Apply role-based security - else if (endpointFlow.getRoles() != null && !endpointFlow.getRoles().isEmpty()) { - String[] rolesArray = endpointFlow.getRoles().toArray(new String[0]); - authz.requestMatchers(pattern).hasAnyRole(rolesArray); - } - // Otherwise require authentication - else if (endpointFlow.isRequireAuthentication()) { - authz.requestMatchers(pattern).authenticated(); - } - }); - } - }); - } - - @Bean - public JwtAuthenticationConverter jwtAuthenticationConverter() { - JwtAuthenticationConverter authenticationConverter = new JwtAuthenticationConverter(); - authenticationConverter.setJwtGrantedAuthoritiesConverter(new CustomRoleConverter()); - return authenticationConverter; - } - - @Bean - public CustomRoleConverter customRoleConverter() { - return new CustomRoleConverter(); - } - - @Bean - public JwtDecoder jwtDecoder() { - // For development: use a lenient decoder when JWT validation is disabled - if (!securityProperties.isJwtValidationEnabled()) { - return createLenientJwtDecoder(); - } - - // For production: use proper JWT validation with JWK Set URI - return createValidatingJwtDecoder(); - } - - private JwtDecoder createLenientJwtDecoder() { - return token -> { - // Parse JWT without validation - String[] parts = token.split("\\."); - if (parts.length != 3) { - throw new org.springframework.security.oauth2.jwt.BadJwtException("Invalid JWT token"); - } - - // Decode payload - java.util.Base64.Decoder decoder = java.util.Base64.getUrlDecoder(); - String payload = new String(decoder.decode(parts[1])); - - return parseJwtPayload(token, payload); - }; - } - - private org.springframework.security.oauth2.jwt.Jwt parseJwtPayload(String token, String payload) { - org.springframework.security.oauth2.jwt.Jwt.Builder builder = - org.springframework.security.oauth2.jwt.Jwt.withTokenValue(token); - - try { - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - @SuppressWarnings("unchecked") - java.util.Map claims = mapper.readValue(payload, java.util.Map.class); - - builder.claims(c -> c.putAll(claims)); - - // Set standard claims if present - if (claims.containsKey("iss")) { - builder.issuer(claims.get("iss").toString()); - } - if (claims.containsKey("sub")) { - builder.subject(claims.get("sub").toString()); - } - if (claims.containsKey("exp")) { - Number exp = (Number) claims.get("exp"); - builder.expiresAt(java.time.Instant.ofEpochSecond(exp.longValue())); - } else { - // Set a far future expiration if not present - builder.expiresAt(java.time.Instant.now().plusSeconds(3600)); - } - if (claims.containsKey("iat")) { - Number iat = (Number) claims.get("iat"); - builder.issuedAt(java.time.Instant.ofEpochSecond(iat.longValue())); - } else { - builder.issuedAt(java.time.Instant.now()); - } - - builder.header("alg", "none"); - - return builder.build(); - } catch (Exception e) { - throw new org.springframework.security.oauth2.jwt.BadJwtException("Failed to parse JWT", e); - } - } - - private JwtDecoder createValidatingJwtDecoder() { - if (securityProperties.getJwkSetUri() != null && !securityProperties.getJwkSetUri().isEmpty()) { - return NimbusJwtDecoder.withJwkSetUri(securityProperties.getJwkSetUri()).build(); - } - - throw new IllegalStateException( - "JWT validation is enabled but no JWK Set URI is configured. " + - "Please set app.security.jwk-set-uri in your configuration." - ); - } -} \ No newline at end of file diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java b/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java deleted file mode 100644 index 5d21689..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Map; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "app.security") -public class SecurityProperties { - - private boolean enabled = true; - private boolean jwtValidationEnabled = false; - private String issuer; - private String audience; - private String jwkSetUri; - - /** - * Map of endpoint patterns to required roles - * Format: pattern -> list of roles - */ - private Map endpointRoles = new HashMap<>(); - - /** - * Map of endpoints that don't require authentication - */ - private String[] publicEndpoints = { - "/api/public/**", - "/actuator/health", - "/actuator/info", - "/h2-console/**" - }; -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java b/core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java deleted file mode 100644 index 29a459b..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; - -/** - * Custom method security expression handler for custom security expressions. - * - * In Spring Security 6.x, custom methods in SecurityExpressionRoot are automatically - * recognized without needing to override createSecurityExpressionRoot(). - * - * This class extends DefaultMethodSecurityExpressionHandler to ensure our - * custom SecurityExpressionRoot is used for method security expressions. - * - * Note: In Spring Security 6.x, the createSecurityExpressionRoot() method signature - * changed and MethodInvocation was removed. Custom expression methods defined in - * SecurityExpressionRoot are automatically available in SpEL expressions. - */ -public class CustomMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler { - // No overrides needed in Spring Security 6.x - custom methods in - // SecurityExpressionRoot are automatically recognized -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java b/core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java deleted file mode 100644 index bc8679d..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.util.Optional; - -/** - * Custom security expression root with additional methods - * Provides custom security expressions like isAdmin(), isUser(), etc. - * In Spring Security 6.x, we extend Spring's SecurityExpressionRoot and add custom methods - */ -public class CustomSecurityExpressionRoot extends org.springframework.security.access.expression.SecurityExpressionRoot { - - private final Authentication authentication; - - public CustomSecurityExpressionRoot(Authentication authentication) { - super(authentication); - this.authentication = authentication; - } - - /** - * Check if user has all of the specified roles - * Uses the inherited hasRole() method from Spring Security 6.x - * Note: hasAnyRole is final in Spring Security 6.x and cannot be overridden - */ - public boolean hasAllRoles(String... roles) { - for (String role : roles) { - if (!hasRole(role)) { - return false; - } - } - return true; - } - - public boolean isOwner() { - // Simplified for example: owner if authenticated principal is a Jwt - return authentication != null && authentication.getPrincipal() instanceof Jwt; - } - - public Optional getCurrentUserEmail() { - if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsString("email")); - } - return Optional.empty(); - } - - public Optional getCurrentUserId() { - if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsString("sub")); - } - return Optional.empty(); - } - - public Optional getCurrentUserName() { - if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsString("preferred_username")); - } - return Optional.empty(); - } - - public boolean isAdmin() { - return hasRole("admin") || hasRole("super-admin"); - } - - public boolean isUser() { - return hasRole("user") || isAdmin(); - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java b/core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java deleted file mode 100644 index a1f48b8..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java +++ /dev/null @@ -1,165 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.core.annotation.Order; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; - -/** - * Aspect to enforce OAuth2 flow requirements - * Intercepts methods annotated with @RequireFlow - */ -@Aspect -@Component -@Order(100) // Run after Spring Security's authorization -public class FlowEnforcementAspect { - - @Around("@annotation(requireFlow)") - public Object enforceFlowRequirement(ProceedingJoinPoint joinPoint, RequireFlow requireFlow) throws Throwable { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication == null) { - throw new InsufficientFlowException("Authentication required"); - } - - if (!(authentication.getPrincipal() instanceof Jwt)) { - throw new InsufficientFlowException("JWT token required"); - } - - Jwt jwt = (Jwt) authentication.getPrincipal(); - FlowValidator.ValidationResult result = validateFlowRequirements(requireFlow, jwt); - - if (!result.isValid()) { - return ResponseEntity.status(403).body( - new org.opendevstack.apiservice.core.dto.ApiResponse<>( - false, null, "Flow validation failed: " + result.getErrorMessage() - ) - ); - } - - return joinPoint.proceed(); - } - - private FlowValidator.ValidationResult validateFlowRequirements(RequireFlow requireFlow, Jwt jwt) { - FlowValidator.ValidationResult result = new FlowValidator.ValidationResult(); - - validateRequiredFlows(requireFlow, jwt, result); - validateActorRequirement(requireFlow, jwt, result); - validateDelegationDepth(requireFlow, jwt, result); - validateRequiredScopes(requireFlow, jwt, result); - - return result; - } - - private void validateRequiredFlows(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.value().length > 0) { - boolean hasRequiredFlow = validateFlows(requireFlow.value(), jwt); - if (!hasRequiredFlow) { - result.addError("Token does not match required flows: " + - String.join(", ", requireFlow.value())); - } - } - } - - private void validateActorRequirement(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.requireActor()) { - String actor = jwt.getClaimAsString("actor"); - if (actor == null || actor.isEmpty()) { - result.addError("Actor claim required (On-Behalf-Of flow)"); - } - } - } - - private void validateDelegationDepth(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.requireDelegationDepth() > 0) { - Number depth = jwt.getClaim("delegation_depth"); - int currentDepth = depth != null ? depth.intValue() : 0; - if (currentDepth < requireFlow.requireDelegationDepth()) { - result.addError("Insufficient delegation depth. Required: " + - requireFlow.requireDelegationDepth() + ", Actual: " + currentDepth); - } - } - } - - private void validateRequiredScopes(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.scopes().length > 0) { - String scope = jwt.getClaimAsString("scope"); - if (scope == null || scope.isEmpty()) { - result.addError("Token has no scopes"); - } else { - validateEachScope(requireFlow.scopes(), scope.split(" "), result); - } - } - } - - private void validateEachScope(String[] requiredScopes, String[] tokenScopes, FlowValidator.ValidationResult result) { - for (String requiredScope : requiredScopes) { - if (!isScopePresent(requiredScope, tokenScopes)) { - result.addError("Missing required scope: " + requiredScope); - } - } - } - - private boolean isScopePresent(String requiredScope, String[] tokenScopes) { - for (String tokenScope : tokenScopes) { - if (requiredScope.equals(tokenScope)) { - return true; - } - } - return false; - } - - private boolean validateFlows(String[] requiredFlows, Jwt jwt) { - for (String requiredFlow : requiredFlows) { - if (isFlowValid(requiredFlow, jwt)) { - return true; - } - } - return false; - } - - private boolean isFlowValid(String requiredFlow, Jwt jwt) { - switch (requiredFlow) { - case "authorization-code": - return isAuthorizationCodeFlow(jwt); - case "client-credentials": - return isClientCredentialsFlow(jwt); - case "on-behalf-of": - return isOnBehalfOfFlow(jwt); - default: - return false; - } - } - - private boolean isAuthorizationCodeFlow(Jwt jwt) { - return "Bearer".equals(jwt.getClaimAsString("token_type")); - } - - private boolean isClientCredentialsFlow(Jwt jwt) { - String grantType = jwt.getClaimAsString("grant_type"); - return "client_credentials".equals(grantType); - } - - private boolean isOnBehalfOfFlow(Jwt jwt) { - String actor = jwt.getClaimAsString("actor"); - if (actor != null && !actor.isEmpty()) { - return true; - } - String scope = jwt.getClaimAsString("scope"); - return scope != null && (scope.contains("on-behalf-of") || scope.contains("delegated_access")); - } - - /** - * Exception thrown when flow requirements are not met - */ - public static class InsufficientFlowException extends RuntimeException { - public InsufficientFlowException(String message) { - super(message); - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java b/core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java deleted file mode 100644 index f4c24aa..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java +++ /dev/null @@ -1,228 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.opendevstack.apiservice.core.config.FlowProperties; -import org.opendevstack.apiservice.core.config.FlowProperties.EndpointFlow; -import lombok.Getter; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -/** - * Validates OAuth2 flows for endpoints - * Checks if authenticated token meets the required flow criteria - */ -@Component -public class FlowValidator { - - private final FlowProperties flowProperties; - - public FlowValidator(FlowProperties flowProperties) { - this.flowProperties = flowProperties; - } - - /** - * Validate if current token meets flow requirements for an endpoint - */ - public ValidationResult validateEndpointFlow(String endpointPattern) { - EndpointFlow endpointFlow = findEndpointFlow(endpointPattern); - if (endpointFlow == null) { - // No specific flow configuration, use global defaults - return ValidationResult.success(); - } - - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null) { - return ValidationResult.failure("No authentication"); - } - - ValidationResult result = new ValidationResult(); - - // Check if authentication is required - if (!endpointFlow.isRequireAuthentication()) { - return ValidationResult.success(); - } - - // Check if token is present - if (authentication.getPrincipal() instanceof Jwt jwt) { - // Validate required flows - if (endpointFlow.getFlows() != null && !endpointFlow.getFlows().isEmpty()) { - boolean hasRequiredFlow = validateFlows(endpointFlow.getFlows(), jwt); - if (!hasRequiredFlow) { - result.addError("Token does not match required flows: " + endpointFlow.getFlows()); - } - } - - // Validate actor requirement (for On-Behalf-Of) - if (endpointFlow.isRequireActor()) { - boolean hasActor = validateActorClaim(jwt); - if (!hasActor) { - result.addError("Token must have actor claim (On-Behalf-Of flow required)"); - } - } - - // Validate delegation depth - if (endpointFlow.getRequireDelegationDepth() > 0) { - int currentDepth = getDelegationDepth(jwt); - if (currentDepth < endpointFlow.getRequireDelegationDepth()) { - result.addError("Insufficient delegation depth. Required: " + - endpointFlow.getRequireDelegationDepth() + ", Actual: " + currentDepth); - } - } - - // Validate required scopes - if (endpointFlow.getRequiredScopes() != null && !endpointFlow.getRequiredScopes().isEmpty()) { - boolean hasRequiredScopes = validateScopes(endpointFlow.getRequiredScopes(), jwt); - if (!hasRequiredScopes) { - result.addError("Token missing required scopes: " + endpointFlow.getRequiredScopes()); - } - } - } - - return result; - } - - /** - * Find flow configuration for an endpoint - */ - private EndpointFlow findEndpointFlow(String endpointPattern) { - if (flowProperties.getApis() == null) { - return null; - } - - // Check each API's endpoints - for (Map.Entry apiEntry : flowProperties.getApis().entrySet()) { - FlowProperties.ApiFlows apiFlows = apiEntry.getValue(); - if (apiFlows.getEndpoints() != null) { - // Check if pattern matches - for (EndpointFlow endpointFlow : apiFlows.getEndpoints()) { - String pattern = endpointFlow.getPattern(); - if (pattern != null && matchesPattern(endpointPattern, pattern)) { - return endpointFlow; - } - } - } - } - - return null; - } - - /** - * Simple pattern matching (supports wildcards like /**) - */ - private boolean matchesPattern(String endpoint, String pattern) { - if (pattern.endsWith("/**")) { - String basePattern = pattern.substring(0, pattern.length() - 3); - return endpoint.startsWith(basePattern); - } - return endpoint.equals(pattern); - } - - /** - * Validate if token has one of the required flows - */ - private boolean validateFlows(List requiredFlows, Jwt jwt) { - // Get flow from token claims or scopes - String scope = jwt.getClaimAsString("scope"); - String grantType = jwt.getClaimAsString("grant_type"); - - for (String requiredFlow : requiredFlows) { - // Check if flow matches - if ("authorization-code".equals(requiredFlow)) { - if ("Bearer".equals(jwt.getClaimAsString("token_type"))) { - return true; // Authorization code tokens appear as Bearer tokens - } - } else if ("client-credentials".equals(requiredFlow)) { - if ("client_credentials".equals(grantType)) { - return true; - } - } else if ("on-behalf-of".equals(requiredFlow)) { - // Check for actor claim (indicates OBO flow) - String actor = jwt.getClaimAsString("actor"); - if (actor != null && !actor.isEmpty()) { - return true; - } - // Or check scope - if (scope != null && (scope.contains("on-behalf-of") || scope.contains("delegated_access"))) { - return true; - } - } - } - - return false; - } - - /** - * Validate actor claim for On-Behalf-Of flow - */ - private boolean validateActorClaim(Jwt jwt) { - String actor = jwt.getClaimAsString("actor"); - return actor != null && !actor.isEmpty(); - } - - /** - * Get delegation depth from token - */ - private int getDelegationDepth(Jwt jwt) { - Number depth = jwt.getClaim("delegation_depth"); - return depth != null ? depth.intValue() : 0; - } - - /** - * Validate required scopes - */ - private boolean validateScopes(List requiredScopes, Jwt jwt) { - String scope = jwt.getClaimAsString("scope"); - if (scope == null || scope.isEmpty()) { - return false; - } - - String[] tokenScopes = scope.split(" "); - for (String requiredScope : requiredScopes) { - boolean found = false; - for (String tokenScope : tokenScopes) { - if (requiredScope.equals(tokenScope)) { - found = true; - break; - } - } - if (!found) { - return false; - } - } - - return true; - } - - /** - * Validation result for flow checks - */ - @Getter - public static class ValidationResult { - private boolean valid = true; - private java.util.List errors = new java.util.ArrayList<>(); - - public static ValidationResult success() { - return new ValidationResult(); - } - - public static ValidationResult failure(String error) { - ValidationResult result = new ValidationResult(); - result.valid = false; - result.errors.add(error); - return result; - } - - public void addError(String error) { - this.valid = false; - this.errors.add(error); - } - - public String getErrorMessage() { - return String.join("; ", errors); - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java b/core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java deleted file mode 100644 index f113e79..0000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Annotation to specify required OAuth2 flow for an endpoint - * Can be used on classes or methods - */ -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface RequireFlow { - - /** - * Required OAuth2 flow(s) - * Values: authorization-code, client-credentials, on-behalf-of - */ - String[] value() default {}; - - /** - * Whether actor claim is required (for On-Behalf-Of flow) - */ - boolean requireActor() default false; - - /** - * Required delegation depth - */ - int requireDelegationDepth() default 0; - - /** - * Required scopes - */ - String[] scopes() default {}; - - /** - * Error message if validation fails - */ - String message() default "Insufficient OAuth2 flow permissions"; -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/DevstackApiServiceApplicationTest.java b/core/src/test/java/org/opendevstack/apiservice/core/DevstackApiServiceApplicationTest.java deleted file mode 100644 index d8ada75..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/DevstackApiServiceApplicationTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.opendevstack.apiservice.core; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class DevstackApiServiceApplicationTest { - - @Test - void contextLoads() { - // This test ensures that the Spring context loads successfully - } - - @Test - void applicationStartsSuccessfully() { - // This test verifies that the application class can be instantiated - DevstackApiServiceApplication app = new DevstackApiServiceApplication(); - org.junit.jupiter.api.Assertions.assertNotNull(app, "Application instance should not be null"); - } -} \ No newline at end of file diff --git a/core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java b/core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java deleted file mode 100644 index dccedaf..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java +++ /dev/null @@ -1,264 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; - -class CustomRoleConverterTest { - - private CustomRoleConverter converter; - - @BeforeEach - void setUp() { - converter = new CustomRoleConverter(); - } - - @Test - void testConvertWithRealmRoles() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("admin", "user")); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(2, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_admin")); - assertTrue(containsAuthority(authorities, "ROLE_user")); - } - - @Test - void testConvertWithResourceRoles() { - // Given - Map clientRoles = new HashMap<>(); - clientRoles.put("roles", Arrays.asList("manager", "viewer")); - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("my-client", clientRoles); - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(2, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_manager")); - assertTrue(containsAuthority(authorities, "ROLE_viewer")); - } - - @Test - void testConvertWithBothRealmAndResourceRoles() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("admin")); - - Map clientRoles = new HashMap<>(); - clientRoles.put("roles", Arrays.asList("manager")); - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("my-client", clientRoles); - - Map claims = new HashMap<>(); - claims.put("realm_access", realmAccess); - claims.put("resource_access", resourceAccess); - - Jwt jwt = createJwtWithClaims(claims); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(2, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_admin")); - assertTrue(containsAuthority(authorities, "ROLE_manager")); - } - - @Test - void testConvertWithMultipleResourceClients() { - // Given - Map client1Roles = new HashMap<>(); - client1Roles.put("roles", Arrays.asList("role1", "role2")); - - Map client2Roles = new HashMap<>(); - client2Roles.put("roles", Arrays.asList("role3")); - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("client1", client1Roles); - resourceAccess.put("client2", client2Roles); - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(3, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_role1")); - assertTrue(containsAuthority(authorities, "ROLE_role2")); - assertTrue(containsAuthority(authorities, "ROLE_role3")); - } - - @Test - void testConvertWithNullRealmAccess() { - // Given - Jwt jwt = createJwtWithClaims(Map.of()); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithEmptyRealmRoles() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Collections.emptyList()); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithNullResourceAccess() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("admin")); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(1, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_admin")); - } - - @Test - void testConvertWithRealmAccessWithoutRoles() { - // Given - Map realmAccess = new HashMap<>(); - // No "roles" key - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithMalformedResourceAccess() { - // Given - resource_access without proper structure - Map resourceAccess = new HashMap<>(); - resourceAccess.put("client1", "not-a-map"); // Should be a Map - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithResourceAccessClientWithoutRoles() { - // Given - Map clientConfig = new HashMap<>(); - clientConfig.put("other-field", "value"); - // No "roles" key - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("my-client", clientConfig); - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithNullRolesInRealmAccess() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", null); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testRolePrefixIsAdded() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("test-role")); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(1, authorities.size()); - GrantedAuthority authority = authorities.iterator().next(); - assertTrue(authority.getAuthority().startsWith("ROLE_")); - assertEquals("ROLE_test-role", authority.getAuthority()); - } - - // Helper methods - - private Jwt createJwtWithClaims(Map claims) { - return Jwt.withTokenValue("token") - .header("alg", "none") - .subject("test-user") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)) - .claims(c -> c.putAll(claims)) - .build(); - } - - private boolean containsAuthority(Collection authorities, String authority) { - return authorities.stream() - .anyMatch(a -> a.getAuthority().equals(authority)); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java b/core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java deleted file mode 100644 index 70b3b0e..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java +++ /dev/null @@ -1,366 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class FlowPropertiesTest { - - private FlowProperties flowProperties; - - @BeforeEach - void setUp() { - flowProperties = new FlowProperties(); - } - - @Test - void testDefaultGlobalProperties() { - // When - FlowProperties.Global global = flowProperties.getGlobal(); - - // Then - assertNotNull(global); - assertEquals("client-credentials", global.getDefaultFlow()); - } - - @Test - void testSetAndGetGlobal() { - // Given - FlowProperties.Global global = new FlowProperties.Global(); - global.setDefaultFlow("authorization-code"); - global.setEnabledFlows(Arrays.asList("authorization-code", "client-credentials")); - - // When - flowProperties.setGlobal(global); - - // Then - assertEquals("authorization-code", flowProperties.getGlobal().getDefaultFlow()); - assertEquals(2, flowProperties.getGlobal().getEnabledFlows().size()); - assertTrue(flowProperties.getGlobal().getEnabledFlows().contains("authorization-code")); - } - - @Test - void testSetAndGetApis() { - // Given - Map apis = new HashMap<>(); - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setDefaultFlow("on-behalf-of"); - apis.put("test-api", apiFlows); - - // When - flowProperties.setApis(apis); - - // Then - assertNotNull(flowProperties.getApis()); - assertEquals(1, flowProperties.getApis().size()); - assertTrue(flowProperties.getApis().containsKey("test-api")); - assertEquals("on-behalf-of", flowProperties.getApis().get("test-api").getDefaultFlow()); - } - - @Test - void testGlobalEnabledFlows() { - // Given - FlowProperties.Global global = new FlowProperties.Global(); - List flows = Arrays.asList("authorization-code", "client-credentials", "on-behalf-of"); - - // When - global.setEnabledFlows(flows); - - // Then - assertEquals(3, global.getEnabledFlows().size()); - assertTrue(global.getEnabledFlows().contains("authorization-code")); - assertTrue(global.getEnabledFlows().contains("client-credentials")); - assertTrue(global.getEnabledFlows().contains("on-behalf-of")); - } - - @Test - void testGlobalDefaultFlow() { - // Given - FlowProperties.Global global = new FlowProperties.Global(); - - // When - global.setDefaultFlow("authorization-code"); - - // Then - assertEquals("authorization-code", global.getDefaultFlow()); - } - - @Test - void testApiFlowsDefaultFlow() { - // Given - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - - // When - apiFlows.setDefaultFlow("client-credentials"); - - // Then - assertEquals("client-credentials", apiFlows.getDefaultFlow()); - } - - @Test - void testApiFlowsEndpoints() { - // Given - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - FlowProperties.EndpointFlow endpoint1 = new FlowProperties.EndpointFlow(); - endpoint1.setPattern("/api/test"); - - FlowProperties.EndpointFlow endpoint2 = new FlowProperties.EndpointFlow(); - endpoint2.setPattern("/api/admin/**"); - - List endpoints = Arrays.asList(endpoint1, endpoint2); - - // When - apiFlows.setEndpoints(endpoints); - - // Then - assertNotNull(apiFlows.getEndpoints()); - assertEquals(2, apiFlows.getEndpoints().size()); - } - - @Test - void testEndpointFlowPattern() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setPattern("/api/users/**"); - - // Then - assertEquals("/api/users/**", endpoint.getPattern()); - } - - @Test - void testEndpointFlowFlows() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - List flows = Arrays.asList("authorization-code", "on-behalf-of"); - - // When - endpoint.setFlows(flows); - - // Then - assertEquals(2, endpoint.getFlows().size()); - assertTrue(endpoint.getFlows().contains("authorization-code")); - assertTrue(endpoint.getFlows().contains("on-behalf-of")); - } - - @Test - void testEndpointFlowRoles() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - List roles = Arrays.asList("admin", "user"); - - // When - endpoint.setRoles(roles); - - // Then - assertEquals(2, endpoint.getRoles().size()); - assertTrue(endpoint.getRoles().contains("admin")); - assertTrue(endpoint.getRoles().contains("user")); - } - - @Test - void testEndpointFlowPermitAll() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setPermitAll(true); - - // Then - assertTrue(endpoint.isPermitAll()); - } - - @Test - void testEndpointFlowPermitAllDefaultFalse() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertFalse(endpoint.isPermitAll()); - } - - @Test - void testEndpointFlowRequireActor() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setRequireActor(true); - - // Then - assertTrue(endpoint.isRequireActor()); - } - - @Test - void testEndpointFlowRequireActorDefaultFalse() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertFalse(endpoint.isRequireActor()); - } - - @Test - void testEndpointFlowRequireAuthentication() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setRequireAuthentication(false); - - // Then - assertFalse(endpoint.isRequireAuthentication()); - } - - @Test - void testEndpointFlowRequireAuthenticationDefaultTrue() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertTrue(endpoint.isRequireAuthentication()); - } - - @Test - void testEndpointFlowRequireDelegationDepth() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setRequireDelegationDepth(3); - - // Then - assertEquals(3, endpoint.getRequireDelegationDepth()); - } - - @Test - void testEndpointFlowRequireDelegationDepthDefaultZero() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertEquals(0, endpoint.getRequireDelegationDepth()); - } - - @Test - void testEndpointFlowRequiredScopes() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - List scopes = Arrays.asList("read:data", "write:data"); - - // When - endpoint.setRequiredScopes(scopes); - - // Then - assertEquals(2, endpoint.getRequiredScopes().size()); - assertTrue(endpoint.getRequiredScopes().contains("read:data")); - assertTrue(endpoint.getRequiredScopes().contains("write:data")); - } - - @Test - void testCompleteConfigurationStructure() { - // Given - FlowProperties config = new FlowProperties(); - - // Global configuration - FlowProperties.Global global = new FlowProperties.Global(); - global.setDefaultFlow("client-credentials"); - global.setEnabledFlows(Arrays.asList("authorization-code", "client-credentials")); - config.setGlobal(global); - - // API configuration - FlowProperties.ApiFlows userApi = new FlowProperties.ApiFlows(); - userApi.setDefaultFlow("authorization-code"); - - FlowProperties.EndpointFlow userEndpoint = new FlowProperties.EndpointFlow(); - userEndpoint.setPattern("/api/users/**"); - userEndpoint.setFlows(Arrays.asList("authorization-code")); - userEndpoint.setRoles(Arrays.asList("user", "admin")); - userEndpoint.setRequireAuthentication(true); - - userApi.setEndpoints(Arrays.asList(userEndpoint)); - - Map apis = new HashMap<>(); - apis.put("user-api", userApi); - config.setApis(apis); - - // Then - assertNotNull(config.getGlobal()); - assertEquals("client-credentials", config.getGlobal().getDefaultFlow()); - assertEquals(2, config.getGlobal().getEnabledFlows().size()); - - assertNotNull(config.getApis()); - assertEquals(1, config.getApis().size()); - - FlowProperties.ApiFlows retrievedApi = config.getApis().get("user-api"); - assertNotNull(retrievedApi); - assertEquals("authorization-code", retrievedApi.getDefaultFlow()); - assertEquals(1, retrievedApi.getEndpoints().size()); - - FlowProperties.EndpointFlow retrievedEndpoint = retrievedApi.getEndpoints().get(0); - assertEquals("/api/users/**", retrievedEndpoint.getPattern()); - assertEquals(1, retrievedEndpoint.getFlows().size()); - assertEquals(2, retrievedEndpoint.getRoles().size()); - assertTrue(retrievedEndpoint.isRequireAuthentication()); - } - - @Test - void testMultipleApisConfiguration() { - // Given - FlowProperties config = new FlowProperties(); - - FlowProperties.ApiFlows api1 = new FlowProperties.ApiFlows(); - api1.setDefaultFlow("authorization-code"); - - FlowProperties.ApiFlows api2 = new FlowProperties.ApiFlows(); - api2.setDefaultFlow("client-credentials"); - - Map apis = new HashMap<>(); - apis.put("api1", api1); - apis.put("api2", api2); - - // When - config.setApis(apis); - - // Then - assertEquals(2, config.getApis().size()); - assertEquals("authorization-code", config.getApis().get("api1").getDefaultFlow()); - assertEquals("client-credentials", config.getApis().get("api2").getDefaultFlow()); - } - - @Test - void testEndpointFlowWithAllProperties() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setPattern("/api/secure/**"); - endpoint.setFlows(Arrays.asList("on-behalf-of")); - endpoint.setRoles(Arrays.asList("admin")); - endpoint.setPermitAll(false); - endpoint.setRequireActor(true); - endpoint.setRequireAuthentication(true); - endpoint.setRequireDelegationDepth(2); - endpoint.setRequiredScopes(Arrays.asList("admin:all")); - - // Then - assertEquals("/api/secure/**", endpoint.getPattern()); - assertEquals(1, endpoint.getFlows().size()); - assertEquals("on-behalf-of", endpoint.getFlows().get(0)); - assertEquals(1, endpoint.getRoles().size()); - assertEquals("admin", endpoint.getRoles().get(0)); - assertFalse(endpoint.isPermitAll()); - assertTrue(endpoint.isRequireActor()); - assertTrue(endpoint.isRequireAuthentication()); - assertEquals(2, endpoint.getRequireDelegationDepth()); - assertEquals(1, endpoint.getRequiredScopes().size()); - assertEquals("admin:all", endpoint.getRequiredScopes().get(0)); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java b/core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java deleted file mode 100644 index 88634c2..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.web.SecurityFilterChain; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class SecurityConfigTest { - private SecurityProperties securityProperties; - private FlowProperties flowProperties; - private SecurityConfig securityConfig; - - @BeforeEach - void setUp() { - securityProperties = mock(SecurityProperties.class); - flowProperties = mock(FlowProperties.class); - securityConfig = new SecurityConfig(securityProperties, flowProperties); - } - - @Test - void testJwtAuthenticationConverterBean() { - JwtAuthenticationConverter converter = securityConfig.jwtAuthenticationConverter(); - assertNotNull(converter); - } - - @Test - void testCustomRoleConverterBean() { - CustomRoleConverter converter = securityConfig.customRoleConverter(); - assertNotNull(converter); - } - - @Test - void testJwtDecoderLenient() { - when(securityProperties.isJwtValidationEnabled()).thenReturn(false); - JwtDecoder decoder = securityConfig.jwtDecoder(); - assertNotNull(decoder); - } - - @Test - void testJwtDecoderValidating() { - when(securityProperties.isJwtValidationEnabled()).thenReturn(true); - when(securityProperties.getJwkSetUri()).thenReturn("http://localhost/jwk"); - JwtDecoder decoder = securityConfig.jwtDecoder(); - assertNotNull(decoder); - } - - @Test - void testJwtDecoderThrowsIfNoJwkSetUri() { - when(securityProperties.isJwtValidationEnabled()).thenReturn(true); - when(securityProperties.getJwkSetUri()).thenReturn(""); - Exception exception = assertThrows(IllegalStateException.class, () -> securityConfig.jwtDecoder()); - assertTrue(exception.getMessage().contains("no JWK Set URI")); - } - - @Test - void testSecurityFilterChainBean() throws Exception { - HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - // Just verify bean creation does not throw - SecurityFilterChain chain = securityConfig.securityFilterChain(http); - assertNotNull(chain); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java b/core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java deleted file mode 100644 index a46475e..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java +++ /dev/null @@ -1,370 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.opendevstack.apiservice.core.dto.ApiResponse; -import org.aspectj.lang.ProceedingJoinPoint; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.Map; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class FlowEnforcementAspectTest { - - @Mock - private FlowValidator flowValidator; - - @Mock - private ProceedingJoinPoint joinPoint; - - @Mock - private SecurityContext securityContext; - - @Mock - private Authentication authentication; - - private FlowEnforcementAspect aspect; - - @BeforeEach - void setUp() { - aspect = new FlowEnforcementAspect(); - SecurityContextHolder.setContext(securityContext); - } - - // Parameterized test for successful flow validations - static Stream successfulFlowScenarios() { - return Stream.of( - Arguments.of("client-credentials", Map.of("grant_type", "client_credentials"), "client-credentials flow"), - Arguments.of("authorization-code", Map.of("token_type", "Bearer"), "authorization-code flow"), - Arguments.of("on-behalf-of", Map.of("actor", "service-account"), "on-behalf-of with actor"), - Arguments.of("on-behalf-of", Map.of("scope", "on-behalf-of delegated_access"), "on-behalf-of with scope") - ); - } - - @ParameterizedTest(name = "{2}") - @MethodSource("successfulFlowScenarios") - void testSuccessfulFlowValidation(String flowType, Map claims, String description) throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{flowType}, false, 0, new String[]{}); - Jwt jwt = createJwt(claims); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testEnforceFlowRequirementWithNoAuthentication() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{"client-credentials"}, false, 0, new String[]{}); - when(securityContext.getAuthentication()).thenReturn(null); - - // When & Then - assertThrows(FlowEnforcementAspect.InsufficientFlowException.class, - () -> aspect.enforceFlowRequirement(joinPoint, requireFlow)); - - verify(joinPoint, never()).proceed(); - } - - @Test - void testEnforceFlowRequirementWithNonJwtAuthentication() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{"client-credentials"}, false, 0, new String[]{}); - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn("not-a-jwt"); - - // When & Then - assertThrows(FlowEnforcementAspect.InsufficientFlowException.class, - () -> aspect.enforceFlowRequirement(joinPoint, requireFlow)); - - verify(joinPoint, never()).proceed(); - } - - @Test - void testEnforceFlowRequirementFailureReturns403() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{"client-credentials"}, false, 0, new String[]{}); - Jwt jwt = createJwt(Map.of("grant_type", "authorization_code")); // Wrong flow - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertNotNull(result); - assertTrue(result instanceof ResponseEntity); - ResponseEntity response = (ResponseEntity) result; - assertEquals(403, response.getStatusCode().value()); - - ApiResponse apiResponse = (ApiResponse) response.getBody(); - assertNotNull(apiResponse); - assertFalse(apiResponse.isSuccess()); - assertTrue(apiResponse.getMessage().contains("Flow validation failed")); - - verify(joinPoint, never()).proceed(); - } - - // Parameterized test for successful actor validations - static Stream successfulActorScenarios() { - return Stream.of( - Arguments.of(Map.of("actor", "service-principal"), true, 0, "actor present"), - Arguments.of(Map.of("delegation_depth", 3), false, 2, "delegation depth sufficient") - ); - } - - @ParameterizedTest(name = "{3}") - @MethodSource("successfulActorScenarios") - void testSuccessfulActorValidation(Map claims, boolean requireActor, int delegationDepth, String description) throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{}, requireActor, delegationDepth, new String[]{}); - Jwt jwt = createJwt(claims); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - // Parameterized test for 403 failure scenarios - static Stream failureScenarios() { - return Stream.of( - Arguments.of(new String[]{}, true, 0, new String[]{}, Map.of(), "Actor claim required"), - Arguments.of(new String[]{}, false, 2, new String[]{}, Map.of("delegation_depth", 1), "Insufficient delegation depth"), - Arguments.of(new String[]{}, false, 0, new String[]{"read:data", "write:data"}, Map.of("scope", "read:data"), "Missing required scope"), - Arguments.of(new String[]{}, false, 0, new String[]{"read:data"}, Map.of(), "Token has no scopes") - ); - } - - @ParameterizedTest(name = "{5}") - @MethodSource("failureScenarios") - void testFlowValidationFailures(String[] flows, boolean requireActor, int delegationDepth, - String[] scopes, Map claims, String expectedError) throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(flows, requireActor, delegationDepth, scopes); - Jwt jwt = createJwt(claims); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertTrue(result instanceof ResponseEntity); - ResponseEntity response = (ResponseEntity) result; - assertEquals(403, response.getStatusCode().value()); - - ApiResponse apiResponse = (ApiResponse) response.getBody(); - assertTrue(apiResponse.getMessage().contains(expectedError)); - - verify(joinPoint, never()).proceed(); - } - - @Test - void testRequiredScopesSuccess() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{}, false, 0, new String[]{"read:data", "write:data"}); - Jwt jwt = createJwt(Map.of("scope", "read:data write:data admin:all")); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testMultipleFlowsOneMatches() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow( - new String[]{"authorization-code", "client-credentials"}, false, 0, new String[]{}); - Jwt jwt = createJwt(Map.of("grant_type", "client_credentials")); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testEmptyFlowRequirementAllowsAnyFlow() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{}, false, 0, new String[]{}); - Jwt jwt = createJwt(Map.of()); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testInsufficientFlowExceptionMessage() { - // When - FlowEnforcementAspect.InsufficientFlowException exception = - new FlowEnforcementAspect.InsufficientFlowException("Custom error message"); - - // Then - assertEquals("Custom error message", exception.getMessage()); - } - - // Helper methods - - private Jwt createJwt(Map claims) { - return Jwt.withTokenValue("token") - .header("alg", "none") - .subject("test-user") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)) - .claims(c -> c.putAll(claims)) - .build(); - } - - private RequireFlow createRequireFlow(String[] flows, boolean requireActor, - int requireDelegationDepth, String[] scopes) { - return new RequireFlow() { - @Override - public Class annotationType() { - return RequireFlow.class; - } - - @Override - public String[] value() { - return flows; - } - - @Override - public boolean requireActor() { - return requireActor; - } - - @Override - public int requireDelegationDepth() { - return requireDelegationDepth; - } - - @Override - public String[] scopes() { - return scopes; - } - - @Override - public String message() { - return "Insufficient OAuth2 flow permissions"; - } - }; - } - @Test - void testValidateFlowsWithSingleValidFlow() { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(Map.of("grant_type", "client_credentials")); - String[] requiredFlows = {"client-credentials"}; - boolean result = invokeValidateFlows(testAspect, requiredFlows, jwt); - assertTrue(result); - } - - @Test - void testValidateFlowsWithMultipleFlowsOneValid() { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(Map.of("token_type", "Bearer")); - String[] requiredFlows = {"client-credentials", "authorization-code"}; - boolean result = invokeValidateFlows(testAspect, requiredFlows, jwt); - assertTrue(result); - } - - @Test - void testValidateFlowsWithNoValidFlow() { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(Map.of("grant_type", "other")); - String[] requiredFlows = {"client-credentials"}; - boolean result = invokeValidateFlows(testAspect, requiredFlows, jwt); - assertFalse(result); - } - - static Stream isFlowValidScenarios() { - return Stream.of( - Arguments.of("authorization-code", Map.of("token_type", "Bearer"), true, "authorization-code with Bearer token"), - Arguments.of("client-credentials", Map.of("grant_type", "client_credentials"), true, "client-credentials flow"), - Arguments.of("on-behalf-of", Map.of("actor", "service-account"), true, "on-behalf-of with actor"), - Arguments.of("on-behalf-of", Map.of("scope", "on-behalf-of delegated_access"), true, "on-behalf-of with scope"), - Arguments.of("unknown-flow", Map.of(), false, "unknown flow type") - ); - } - - @ParameterizedTest(name = "{3}") - @MethodSource("isFlowValidScenarios") - void testIsFlowValid(String flow, Map claims, boolean expected, String description) { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(claims); - boolean result = invokeIsFlowValid(testAspect, flow, jwt); - assertEquals(expected, result); - } - - // Reflection helpers to access private methods - private boolean invokeValidateFlows(FlowEnforcementAspect aspect, String[] flows, Jwt jwt) { - try { - java.lang.reflect.Method m = FlowEnforcementAspect.class.getDeclaredMethod("validateFlows", String[].class, Jwt.class); - m.setAccessible(true); - return (boolean) m.invoke(aspect, flows, jwt); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private boolean invokeIsFlowValid(FlowEnforcementAspect aspect, String flow, Jwt jwt) { - try { - java.lang.reflect.Method m = FlowEnforcementAspect.class.getDeclaredMethod("isFlowValid", String.class, Jwt.class); - m.setAccessible(true); - return (boolean) m.invoke(aspect, flow, jwt); - } catch (Exception e) { - throw new RuntimeException(e); - } - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java b/core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java deleted file mode 100644 index f07e3ba..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java +++ /dev/null @@ -1,345 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.opendevstack.apiservice.core.config.FlowProperties; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.*; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class FlowValidatorTest { - - private FlowValidator flowValidator; - private FlowProperties flowProperties; - - @BeforeEach - void setUp() { - flowProperties = new FlowProperties(); - flowValidator = new FlowValidator(flowProperties); - - // Clear security context before each test - SecurityContextHolder.clearContext(); - } - - @Test - void testValidateEndpointFlowWithNoConfiguration() { - // Given - no flow configuration - setupAuthentication(createJwt(Map.of())); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/test"); - - // Then - assertTrue(result.isValid()); - assertTrue(result.getErrors().isEmpty()); - } - - @Test - void testValidateEndpointFlowWithNoAuthenticationAndConfiguration() { - // Given - no authentication but with endpoint configuration requiring it - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/test"); - endpointFlow.setRequireAuthentication(true); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - SecurityContextHolder.clearContext(); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/test"); - - // Then - assertFalse(result.isValid()); - assertEquals("No authentication", result.getErrorMessage()); - } - - @Test - void testValidateEndpointFlowWithAuthenticationNotRequired() { - // Given - endpoint that doesn't require authentication - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/public/**"); - endpointFlow.setRequireAuthentication(false); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - setupAuthentication(createJwt(Map.of())); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/public/test"); - - // Then - assertTrue(result.isValid()); - } - - // Parameterized test for successful flow validations - static Stream successfulFlowScenarios() { - return Stream.of( - Arguments.of("authorization-code", "/api/user/**", "/api/user/profile", Map.of("token_type", "Bearer")), - Arguments.of("client-credentials", "/api/service/**", "/api/service/data", Map.of("grant_type", "client_credentials")), - Arguments.of("on-behalf-of", "/api/delegated/**", "/api/delegated/action", Map.of("actor", "service-account")) - ); - } - - @ParameterizedTest(name = "{0} flow validation") - @MethodSource("successfulFlowScenarios") - void testSuccessfulFlowValidation(String flowType, String pattern, String endpoint, Map claims) { - // Given - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern(pattern); - endpointFlow.setFlows(Arrays.asList(flowType)); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(claims); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow(endpoint); - - // Then - assertTrue(result.isValid()); - } - - @Test - void testValidateFlowFailureWhenFlowDoesNotMatch() { - // Given - endpoint requiring client-credentials but token is authorization-code - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/service/**"); - endpointFlow.setFlows(Arrays.asList("client-credentials")); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("token_type", "Bearer")); // Not client-credentials - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/service/data"); - - // Then - assertFalse(result.isValid()); - assertTrue(result.getErrorMessage().contains("does not match required flows")); - } - - // Parameterized test for actor and delegation depth validations - static Stream actorAndDepthScenarios() { - return Stream.of( - Arguments.of(true, 0, Map.of("actor", "service-principal"), "/api/obo/**", "/api/obo/action", true, "actor present"), - Arguments.of(true, 0, Map.of(), "/api/obo/**", "/api/obo/action", false, "actor missing"), - Arguments.of(false, 2, Map.of("delegation_depth", 3), "/api/deep/**", "/api/deep/action", true, "delegation depth sufficient"), - Arguments.of(false, 2, Map.of("delegation_depth", 1), "/api/deep/**", "/api/deep/action", false, "delegation depth insufficient") - ); - } - - @ParameterizedTest(name = "{6}") - @MethodSource("actorAndDepthScenarios") - void testActorAndDelegationDepthValidation(boolean requireActor, int delegationDepth, Map claims, - String pattern, String endpoint, boolean shouldBeValid, String description) { - // Given - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern(pattern); - endpointFlow.setRequireActor(requireActor); - endpointFlow.setRequireDelegationDepth(delegationDepth); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(claims); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow(endpoint); - - // Then - assertEquals(shouldBeValid, result.isValid()); - if (!shouldBeValid) { - assertFalse(result.getErrorMessage().isEmpty()); - } - } - - // Parameterized test for scope validations - static Stream scopeValidationScenarios() { - return Stream.of( - Arguments.of(Arrays.asList("read:data", "write:data"), "read:data write:data admin:all", true, "all scopes present"), - Arguments.of(Arrays.asList("read:data", "write:data"), "read:data", false, "missing write:data scope") - ); - } - - @ParameterizedTest(name = "{3}") - @MethodSource("scopeValidationScenarios") - void testScopeValidation(List requiredScopes, String tokenScope, boolean shouldBeValid, String description) { - // Given - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/scoped/**"); - endpointFlow.setRequiredScopes(requiredScopes); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("scope", tokenScope)); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/scoped/action"); - - // Then - assertEquals(shouldBeValid, result.isValid()); - if (!shouldBeValid) { - assertTrue(result.getErrorMessage().contains("missing required scopes")); - } - } - - @Test - void testPatternMatchingExact() { - // Given - exact pattern match - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/exact"); - endpointFlow.setRequireActor(true); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("actor", "service")); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/exact"); - - // Then - assertTrue(result.isValid()); - } - - @Test - void testPatternMatchingWildcard() { - // Given - wildcard pattern match - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/wildcard/**"); - endpointFlow.setRequireActor(true); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("actor", "service")); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/wildcard/sub/path"); - - // Then - assertTrue(result.isValid()); - } - - @Test - void testValidationResultSuccess() { - // When - FlowValidator.ValidationResult result = FlowValidator.ValidationResult.success(); - - // Then - assertTrue(result.isValid()); - assertTrue(result.getErrors().isEmpty()); - } - - @Test - void testValidationResultFailure() { - // When - FlowValidator.ValidationResult result = FlowValidator.ValidationResult.failure("Error occurred"); - - // Then - assertFalse(result.isValid()); - assertEquals(1, result.getErrors().size()); - assertEquals("Error occurred", result.getErrorMessage()); - } - - @Test - void testValidationResultAddError() { - // Given - FlowValidator.ValidationResult result = new FlowValidator.ValidationResult(); - - // When - result.addError("First error"); - result.addError("Second error"); - - // Then - assertFalse(result.isValid()); - assertEquals(2, result.getErrors().size()); - assertTrue(result.getErrorMessage().contains("First error")); - assertTrue(result.getErrorMessage().contains("Second error")); - } - - @Test - void testMultipleValidationErrors() { - // Given - endpoint with multiple requirements - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/strict/**"); - endpointFlow.setFlows(Arrays.asList("on-behalf-of")); - endpointFlow.setRequireActor(true); - endpointFlow.setRequiredScopes(Arrays.asList("admin:all")); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("scope", "read:data")); // Missing actor and wrong flow - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/strict/action"); - - // Then - assertFalse(result.isValid()); - assertTrue(result.getErrors().size() >= 2); - } - - // Helper methods - - private Jwt createJwt(Map claims) { - return Jwt.withTokenValue("token") - .header("alg", "none") - .subject("test-user") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)) - .claims(c -> c.putAll(claims)) - .build(); - } - - private void setupAuthentication(Jwt jwt) { - SecurityContext securityContext = mock(SecurityContext.class); - Authentication authentication = mock(Authentication.class); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - - SecurityContextHolder.setContext(securityContext); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java b/core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java deleted file mode 100644 index 8760709..0000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java +++ /dev/null @@ -1,327 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.Arrays; -import java.util.Map; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; - -class SecurityExpressionRootTest { - - private CustomSecurityExpressionRoot expressionRoot; - private Authentication authentication; - - @BeforeEach - void setUp() { - // Default setup with basic authentication - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList( - new SimpleGrantedAuthority("ROLE_user"), - new SimpleGrantedAuthority("ROLE_admin") - )); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - } - - @Test - void testHasAllRolesWithAllRolesPresent() { - // Given - authentication has both ROLE_user and ROLE_admin - - // When - boolean result = expressionRoot.hasAllRoles("user", "admin"); - - // Then - assertTrue(result); - } - - @Test - void testHasAllRolesWithSomeRolesMissing() { - // Given - authentication has ROLE_user and ROLE_admin but not ROLE_super-admin - - // When - boolean result = expressionRoot.hasAllRoles("user", "super-admin"); - - // Then - assertFalse(result); - } - - @Test - void testHasAllRolesWithSingleRole() { - // Given - authentication has ROLE_user - - // When - boolean result = expressionRoot.hasAllRoles("user"); - - // Then - assertTrue(result); - } - - @Test - void testHasAllRolesWithNoRoles() { - // Given - authentication with no roles - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.hasAllRoles("user"); - - // Then - assertFalse(result); - } - - @Test - void testIsAdminWithAdminRole() { - // Given - authentication has ROLE_admin - - // When - boolean result = expressionRoot.isAdmin(); - - // Then - assertTrue(result); - } - - @Test - void testIsAdminWithSuperAdminRole() { - // Given - authentication has ROLE_super-admin - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_super-admin"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isAdmin(); - - // Then - assertTrue(result); - } - - @Test - void testIsAdminWithoutAdminRole() { - // Given - authentication without admin role - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_user"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isAdmin(); - - // Then - assertFalse(result); - } - - @Test - void testIsUserWithUserRole() { - // Given - authentication has ROLE_user - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_user"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isUser(); - - // Then - assertTrue(result); - } - - @Test - void testIsUserWithAdminRole() { - // Given - authentication has ROLE_admin (admin is also a user) - authentication = new TestingAuthenticationToken("admin", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_admin"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isUser(); - - // Then - assertTrue(result); - } - - @Test - void testIsUserWithoutUserOrAdminRole() { - // Given - authentication without user or admin role - authentication = new TestingAuthenticationToken("guest", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_guest"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isUser(); - - // Then - assertFalse(result); - } - - @Test - void testGetCurrentUserEmailFromJwt() { - // Given - JWT with email claim - Jwt jwt = createJwtWithClaims(Map.of("email", "test@example.com")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertTrue(email.isPresent()); - assertEquals("test@example.com", email.get()); - } - - @Test - void testGetCurrentUserEmailWithoutJwt() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertFalse(email.isPresent()); - } - - @Test - void testGetCurrentUserEmailWhenNullAuthentication() { - // Given - null authentication - expressionRoot = new CustomSecurityExpressionRoot(null); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertFalse(email.isPresent()); - } - - @Test - void testGetCurrentUserIdFromJwt() { - // Given - JWT with subject - Jwt jwt = createJwtWithClaims(Map.of("sub", "user-123")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional userId = expressionRoot.getCurrentUserId(); - - // Then - assertTrue(userId.isPresent()); - assertEquals("user-123", userId.get()); - } - - @Test - void testGetCurrentUserIdWithoutJwt() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional userId = expressionRoot.getCurrentUserId(); - - // Then - assertFalse(userId.isPresent()); - } - - @Test - void testGetCurrentUserNameFromJwt() { - // Given - JWT with preferred_username claim - Jwt jwt = createJwtWithClaims(Map.of("preferred_username", "john.doe")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional username = expressionRoot.getCurrentUserName(); - - // Then - assertTrue(username.isPresent()); - assertEquals("john.doe", username.get()); - } - - @Test - void testGetCurrentUserNameWithoutJwt() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional username = expressionRoot.getCurrentUserName(); - - // Then - assertFalse(username.isPresent()); - } - - @Test - void testIsOwnerReturnsTrue() { - // Given - JWT authentication - Jwt jwt = createJwtWithClaims(Map.of("sub", "user-123")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isOwner(); - - // Then - // Note: Current implementation is simplified and returns true - assertTrue(result); - } - - @Test - void testIsOwnerWithNullAuthentication() { - // Given - null authentication - expressionRoot = new CustomSecurityExpressionRoot(null); - - // When - boolean result = expressionRoot.isOwner(); - - // Then - assertFalse(result); - } - - @Test - void testIsOwnerWithNonJwtAuthentication() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isOwner(); - - // Then - assertFalse(result); - } - - @Test - void testGetCurrentUserEmailWithMissingEmailClaim() { - // Given - JWT without email claim - Jwt jwt = createJwtWithClaims(Map.of("sub", "user-123")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertFalse(email.isPresent()); - } - - // Helper methods - - private Jwt createJwtWithClaims(Map claims) { - Jwt.Builder builder = Jwt.withTokenValue("token") - .header("alg", "none") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)); - - // Add subject if present in claims - if (claims.containsKey("sub")) { - builder.subject((String) claims.get("sub")); - } else { - builder.subject("test-user"); - } - - return builder.claims(c -> c.putAll(claims)).build(); - } -} diff --git a/doc/core-security/tests.md b/doc/core-security/tests.md new file mode 100644 index 0000000..2117fe7 --- /dev/null +++ b/doc/core-security/tests.md @@ -0,0 +1,195 @@ +# core-security — Test Combinations + +This document enumerates the test scenarios for the `core-security` module. +Each scenario is defined by the combination of: **API visibility** × **Auth flow** × **Client type** × **Policy outcome**. + +--- + +## Dimensions + +| Dimension | Values | +|---|---| +| API visibility | `PUBLIC`, `SECURED` | +| Auth flow | `CLIENT_CREDENTIALS`, `OBO`, `NONE` (no token) | +| Client type | Internal service, External / third-party app, UI SPA (on behalf of a user) | +| Policy | No rules, `ALLOWED_CLIENTS`, `SCOPE_REQUIRED`, multiple rules | +| Token state | Valid, Expired, Malformed, Missing | + +--- + +## 1. Public API + +Public APIs (`ApiDefinition.isPublic = true`) must be accessible regardless of authentication state. + +| # | Flow | Token | Client type | Expected | +|---|---|---|---|---| +| P-01 | NONE | Missing | Any | `200 OK` — no token required | +| P-02 | CLIENT_CREDENTIALS | Valid | Internal service | `200 OK` — token accepted but not required | +| P-03 | OBO | Valid | UI SPA | `200 OK` — token accepted but not required | +| P-04 | NONE | Malformed / expired | Any | `200 OK` — public path bypasses JWT validation | + +> `PolicyAuthorizationManager` returns `PERMIT` immediately when `apiDef.isPublic() == true`. +> `AuthTypeEnforcementFilter` skips the flow check when `!apiDef.requiresAuth()`. + +--- + +## 2. Secured API — No Token + +| # | API auth types | Token | Expected | +|---|---|---|---| +| S-00-01 | `[CLIENT_CREDENTIALS]` | Missing | `401 Unauthorized` | +| S-00-02 | `[OBO]` | Missing | `401 Unauthorized` | +| S-00-03 | `[CLIENT_CREDENTIALS, OBO]` | Missing | `401 Unauthorized` | +| S-00-04 | `[CLIENT_CREDENTIALS]` | Malformed JWT | `401 Unauthorized` | +| S-00-05 | `[CLIENT_CREDENTIALS]` | Expired JWT | `401 Unauthorized` | + +--- + +## 3. Secured API — CLIENT_CREDENTIALS flow + +Token characteristics: has `roles` claim, **no** `scp` claim. +`AuthFlowResolver` resolves to `CLIENT_CREDENTIALS`. + +### 3.1 Flow enforcement (`AuthTypeEnforcementFilter`) + +| # | API auth types | Token flow | Expected | +|---|---|---|---| +| CC-F-01 | `[CLIENT_CREDENTIALS]` | CLIENT_CREDENTIALS | passes filter | +| CC-F-02 | `[OBO]` | CLIENT_CREDENTIALS | `403 Forbidden` — wrong flow | +| CC-F-03 | `[CLIENT_CREDENTIALS, OBO]` | CLIENT_CREDENTIALS | passes filter | +| CC-F-04 | `[NONE]` | CLIENT_CREDENTIALS | passes filter (no auth required) | + +### 3.2 Policy — ALLOWED_CLIENTS + +Config: `{ "clientIds": ["app-a", "app-b"] }` + +| # | Client (azp/appid) | Policy | Expected | +|---|---|---|---| +| CC-P-01 | `app-a` (internal service) | ALLOWED_CLIENTS: [app-a, app-b] | `PERMIT` | +| CC-P-02 | `app-c` (external / unknown app) | ALLOWED_CLIENTS: [app-a, app-b] | `DENY` → `403` | +| CC-P-03 | `app-a` | No rules configured | `PERMIT` (empty rules → `ABSTAIN` → `PERMIT`) | +| CC-P-04 | `app-a` | Rules empty list | `DENY` (rules == null or empty → explicit `DENY`) | + +### 3.3 Policy — SCOPE_REQUIRED + +Config: `{ "scopes": ["api.read"] }` +CLIENT_CREDENTIALS tokens carry app-level roles, not delegated scopes. + +| # | Token scopes (scp) | Policy | Expected | +|---|---|---|---| +| CC-S-01 | none (no scp claim) | SCOPE_REQUIRED: api.read | `DENY` → `403` | +| CC-S-02 | `api.read api.write` | SCOPE_REQUIRED: api.read | `PERMIT` | + +### 3.4 Multiple rules (ALLOWED_CLIENTS + SCOPE_REQUIRED) + +| # | Client | Scopes | Rules | Expected combined decision | +|---|---|---|---|---| +| CC-M-01 | allowed | required scope present | both PERMIT | `PERMIT` | +| CC-M-02 | allowed | scope missing | PERMIT + DENY | `DENY` → `403` | +| CC-M-03 | not allowed | required scope present | DENY + PERMIT | `DENY` → `403` | +| CC-M-04 | not allowed | scope missing | both DENY | `DENY` → `403` | + +--- + +## 4. Secured API — OBO flow (On-Behalf-Of / delegated) + +Token characteristics: has `scp` claim with delegated scopes, identifies an end user via `sub`. +`AuthFlowResolver` resolves to `OBO`. + +### 4.1 Flow enforcement (`AuthTypeEnforcementFilter`) + +| # | API auth types | Token flow | Expected | +|---|---|---|---| +| OBO-F-01 | `[OBO]` | OBO | passes filter | +| OBO-F-02 | `[CLIENT_CREDENTIALS]` | OBO | `403 Forbidden` — wrong flow | +| OBO-F-03 | `[CLIENT_CREDENTIALS, OBO]` | OBO | passes filter | + +### 4.2 Policy — ALLOWED_CLIENTS + +| # | Client (azp) | Policy | Expected | +|---|---|---|---| +| OBO-P-01 | `spa-frontend` (UI SPA) | ALLOWED_CLIENTS: [spa-frontend] | `PERMIT` | +| OBO-P-02 | `mobile-app` (external app) | ALLOWED_CLIENTS: [spa-frontend] | `DENY` → `403` | +| OBO-P-03 | `spa-frontend` | No rules | `PERMIT` | + +### 4.3 Policy — SCOPE_REQUIRED + +| # | Token scp | Policy | Expected | +|---|---|---|---| +| OBO-S-01 | `api.read api.write` | SCOPE_REQUIRED: api.read | `PERMIT` | +| OBO-S-02 | `api.write` | SCOPE_REQUIRED: api.read | `DENY` → `403` | +| OBO-S-03 | empty / blank scp | SCOPE_REQUIRED: api.read | `DENY` → `403` | + +### 4.4 Multiple rules + +| # | Client | Scopes | Rules | Expected | +|---|---|---|---|---| +| OBO-M-01 | allowed | required scope present | both PERMIT | `PERMIT` | +| OBO-M-02 | allowed | scope missing | PERMIT + DENY | `DENY` | +| OBO-M-03 | not allowed | required scope present | DENY + PERMIT | `DENY` | + +--- + +## 5. Route not registered (unknown API definition) + +`ApiDefinitionResolver` returns empty — `PolicyAuthorizationManager` denies immediately (fail-closed). + +| # | Token | Expected | +|---|---|---| +| U-01 | Missing | `403 Forbidden` | +| U-02 | Valid CLIENT_CREDENTIALS | `403 Forbidden` | +| U-03 | Valid OBO | `403 Forbidden` | + +--- + +## 6. JWT claim extraction (`AzureJwtAuthenticationConverter`) + +Unit tests for authority and client-id extraction — no HTTP stack needed. + +| # | Token claims | Expected authorities | Expected clientId | +|---|---|---|---| +| J-01 | `roles: [ADMIN]`, no scp | `ROLE_ADMIN` | `azp` value | +| J-02 | `scp: "api.read api.write"`, no roles | `SCOPE_api.read`, `SCOPE_api.write` | `azp` value | +| J-03 | both `roles` and `scp` | `ROLE_*` + `SCOPE_*` | `azp` value | +| J-04 | neither claim | empty collection | `azp` value | +| J-05 | `azp` absent, `appid` present | — | `appid` value (v1 token fallback) | +| J-06 | both `azp` and `appid` absent | — | `null` | + +--- + +## 7. `AuthFlowResolver` unit tests + +| # | JWT claims | Expected `AuthType` | +|---|---|---| +| R-01 | `scp` present and non-blank | `OBO` | +| R-02 | `scp` blank string | `CLIENT_CREDENTIALS` | +| R-03 | `scp` null / absent | `CLIENT_CREDENTIALS` | +| R-04 | JWT is null | `NONE` | + +--- + +## 8. `PolicyEngine` unit tests + +| # | Rules | Evaluator outcomes | Expected decision | +|---|---|---|---| +| E-01 | empty list | — | `DENY` | +| E-02 | one rule | `PERMIT` | `PERMIT` | +| E-03 | one rule | `DENY` | `DENY` | +| E-04 | two rules | `PERMIT`, `DENY` | `DENY` (short-circuit) | +| E-05 | two rules | `PERMIT`, `ABSTAIN` | `PERMIT` | +| E-06 | two rules | `ABSTAIN`, `ABSTAIN` | `PERMIT` (all-abstain fallback) | +| E-07 | rule with no matching evaluator | — | skipped → `PERMIT` (falls through to abstain default) | + +--- + +## Summary matrix + +``` + │ PUBLIC │ SECURED / CC │ SECURED / OBO │ UNKNOWN ROUTE +─────────────────────┼────────┼──────────────┼───────────────┼────────────── +No token │ 200 │ 401 │ 401 │ 403 +Valid, allowed │ 200 │ 200 │ 200 │ 403 +Valid, wrong flow │ 200 │ 403 │ 403 │ 403 +Valid, not in policy │ 200 │ 403 │ 403 │ 403 +Expired / malformed │ 200 │ 401 │ 401 │ 401/403 +``` diff --git a/docker/install-certs.sh b/docker/install-certs.sh index bfd62c3..bbd3f32 100755 --- a/docker/install-certs.sh +++ b/docker/install-certs.sh @@ -19,10 +19,12 @@ fi CERT_DIR=$(mktemp -d) echo "Created temporary directory: $CERT_DIR" -echo "Creating new custom truststore from scratch at: $CUSTOM_TRUSTSTORE" +echo "Creating custom truststore based on Java cacerts at: $CUSTOM_TRUSTSTORE" -# Remove existing truststore if it exists +# Copy Java cacerts as the base truststore rm -f "$CUSTOM_TRUSTSTORE" +cp "$JAVA_HOME/lib/security/cacerts" "$CUSTOM_TRUSTSTORE" +chmod 600 "$CUSTOM_TRUSTSTORE" # Split CERT_URLS by comma and process each URL IFS=',' read -ra URLS <<< "$CERT_URLS" diff --git a/external-service-aap/pom.xml b/external-service-aap/pom.xml index 8250a95..0c80ffe 100644 --- a/external-service-aap/pom.xml +++ b/external-service-aap/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-aap diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java index 2c7686f..6a0805b 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java @@ -3,7 +3,7 @@ /** * Exception thrown when there are issues with automation platform operations. */ -public class AutomationPlatformException extends Exception { +public class AutomationPlatformException extends RuntimeException { private final String errorCode; diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java index 57cecb7..57b042f 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java @@ -22,7 +22,7 @@ public interface AutomationPlatformService extends ExternalService { * @return the execution result * @throws AutomationPlatformException if workflow execution fails */ - AutomationExecutionResult executeWorkflow(String workflowName, Map parameters) throws AutomationPlatformException; + AutomationExecutionResult executeWorkflow(String workflowName, Map parameters); /** * Executes a workflow on the automation platform asynchronously. @@ -50,7 +50,7 @@ public interface AutomationPlatformService extends ExternalService { * @return the workflow job status and result * @throws AutomationPlatformException if status check fails */ - AutomationJobStatus getWorkflowJobStatus(String workflowId) throws AutomationPlatformException; + AutomationJobStatus getWorkflowJobStatus(String workflowId); /** diff --git a/external-service-api/pom.xml b/external-service-api/pom.xml index af29c09..b695737 100644 --- a/external-service-api/pom.xml +++ b/external-service-api/pom.xml @@ -7,7 +7,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2-SNAPSHOT + 0.0.3 external-service-api diff --git a/external-service-bitbucket/pom.xml b/external-service-bitbucket/pom.xml index efa5d4b..39467b7 100644 --- a/external-service-bitbucket/pom.xml +++ b/external-service-bitbucket/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-bitbucket diff --git a/external-service-jira/pom.xml b/external-service-jira/pom.xml index c1ef1bd..f89d456 100644 --- a/external-service-jira/pom.xml +++ b/external-service-jira/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2-SNAPSHOT + 0.0.3 external-service-jira diff --git a/external-service-marketplace/.openapi-generator-ignore b/external-service-marketplace/.openapi-generator-ignore new file mode 100644 index 0000000..82ac4f7 --- /dev/null +++ b/external-service-marketplace/.openapi-generator-ignore @@ -0,0 +1,43 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md + +api +api/** +gradle +gradle/** +.github +.github/** +pom.xml +**/AndroidManifest.xml +.gitignore +.openapi-generator-ignore +.travis.yml +build.gradle +build.sbt +git_push.sh +gradle.properties +gradlew +gradlew.bat +settings.gradle + diff --git a/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml new file mode 100644 index 0000000..6bf9eb3 --- /dev/null +++ b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml @@ -0,0 +1,1366 @@ +openapi: 3.0.3 +info: + title: Component Catalog REST API + version: '1.0.0' + description: > + The Component Catalog API allows clients to retrieve information about CatalogItems, CatalogFilters and Files entities. + + Catalog and File entities also exist internally, but only referenced by id's on requests and not returned on responses. + + **NOTES**: + - The OpenAPI specification file is also used to [generate](https://openapi-generator.tech/) REST client(s) and a server REST API. + - Clients and servers generated from the same OpenAPI specification version are guaranteed to be **compatible**. + contact: + name: EDPCore Team + url: https://confluence.biscrum.com/pages/viewpage.action?spaceKey=EDP&title=Welcome +servers: + - url: http://{baseurl}/v1 + variables: + baseurl: + default: localhost:8080 + description: Default address for a Component Catalog's backend REST API instance. +security: + - bearerAuth: [ ] +tags: + - name: CatalogHealth + description: Public actuator health endpoint. + - name: CatalogItems + description: CatalogItems operations. + - name: CatalogFilters + description: CatalogFilters operations. + - name: CatalogItemUserActionMessageDefinitions + description: User actions standardized messages definitions + - name: Files + description: File operations. + - name: SchemaValidations + description: Schema Validations operations. + - name: ProvisionerActions + description: Provisioning notifications from AWX/Provisioner +paths: + /actuator/health: + get: + tags: + - CatalogHealth + summary: Health check endpoint + description: Public actuator health endpoint. + operationId: getCatalogHealth + security: [] + responses: + "200": + description: Service health status. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: UP + groups: + type: array + items: + type: string + required: + - status + + /project/{projectKey}/components: + get: + tags: + - Project-components + summary: Returns the information of the project's components in the Bitbucket repository. + operationId: getProjectComponents + parameters: + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + responses: + "200": + description: A list of Project Component Information + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ProjectComponentInfo' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /project/{projectKey}/component/{componentId}: + get: + tags: + - Project-components + summary: Returns the extended information of a project component given both its project key and component ID in the Bitbucket repository. + operationId: getProjectComponentById + parameters: + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + - name: componentId + in: path + description: component ID. + required: true + schema: + type: string + responses: + "200": + description: The extended information of a project component. + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectComponentExtendedInfo' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-descriptors: + get: + tags: + - Catalog-descriptors + summary: List of all available Catalog Descriptors. + description: > + Returns a list of all available Catalog Descriptors.
+ operationId: getCatalogDescriptors + responses: + "200": + description: A list of Catalog Descriptors. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogDescriptor' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalogs/{catalogId}: + get: + tags: + - Catalogs + summary: Get a catalog by id. + description: > + Returns a valid catalog. + operationId: getCatalog + parameters: + - name: catalogId + in: path + description: id for the Catalog. + required: true + schema: + type: string + responses: + "200": + description: A Single catalog. + content: + application/json: + schema: + $ref: '#/components/schemas/Catalog' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: Catalog not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/slug/{slug}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided slug. + description: > + Returns the CatalogItem identified by a composite slug with format `{project-key}_{catalog-item-repository-name}`.
+ The separator is the first underscore (`_`); everything after it (the repo name) may itself contain underscores.
+ The project-key is the normalised (lowercase) Bitbucket project key that owns the item's repository. + The catalog-item-repository-name is matched against the Bitbucket repository slug of the item.
+ Returns 404 if no catalog item matches the provided slug. + operationId: getCatalogItemBySlug + parameters: + - name: slug + in: path + description: > + Composite slug with format `{project-key}_{catalog-item-repository-name}`. + The separator is the first underscore; the repo name may contain additional underscores. + Example: `myproject_my-component-repo` + required: true + schema: + type: string + example: 'myproject_my-component-repo' + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid or malformed slug provided. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: No CatalogItem associated to the provided slug. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided slug. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items: + get: + tags: + - CatalogItems + summary: List of all CatalogItems. + description: > + Returns a list of all CatalogItems for the given Catalog identified by catalogId.
+ CatalogItems referenced on a Catalog that are either invalid or non-existent are **excluded** from the response. + operationId: getCatalogItems + parameters: + - name: catalogId + in: query + description: id for the Catalog. + required: true + schema: + type: string + - name: sortByTitle + in: query + description: Sort the returned CatalogItems by title, either in ascending or descending order. + required: true + schema: + $ref: '#/components/schemas/SortOrder' + example: 'asc' + responses: + "200": + description: A list of valid CatalogItems. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid parameters provided on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/{id}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided id. + description: > + Returns the CatalogItem associated to the provided id, unless: +
    +
  • The id is not associated to any CatalogItem.
  • +
  • Or the associated CatalogItem is invalid and can't be processed to create a response.
  • +
+ operationId: getCatalogItemById + parameters: + - name: id + in: path + description: id for the CatalogItem. + required: true + schema: + type: string + example: 'aSdFam...yCg==' + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: No CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /{projectKey}/catalog-items: + get: + tags: + - CatalogItems + summary: List of all CatalogItems given a project key. + description: > + Returns a list of all CatalogItems for the given Catalog identified by catalogId given a project key.
+ CatalogItems referenced on a Catalog that are either invalid or non-existent are **excluded** from the response. + operationId: getCatalogItemsForProjectKey + parameters: + - name: catalogId + in: query + description: id for the Catalog. + required: true + schema: + type: string + - name: sortByTitle + in: query + description: Sort the returned CatalogItems by title, either in ascending or descending order. + required: true + schema: + $ref: '#/components/schemas/SortOrder' + example: 'asc' + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + responses: + "200": + description: A list of valid CatalogItems. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid parameters provided on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /{projectKey}/catalog-items/{id}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided id, given a project key. + description: > + Returns the CatalogItem associated to the provided id, given a project key, unless: +
    +
  • The id is not associated to any CatalogItem.
  • +
  • Or the associated CatalogItem is invalid and can't be processed to create a response.
  • +
  • Project key does not exist or user has no visibility over it
  • +
+ operationId: getCatalogItemByIdForProjectKey + parameters: + - name: id + in: path + description: id for the CatalogItem. + required: true + schema: + type: string + example: 'aSdFam...yCg==' + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: No CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-filters: + get: + tags: + - CatalogFilters + summary: List of all CatalogItemFilters. + description: > + Returns the list of all CatalogItemFilters for the CatalogItems on the Catalog identified by catalogId.
+ CatalogItemFilters are built based on the contents of the Catalog and its CatalogItems.
+ Catalog or CatalogItems **with errors** will affect the number and/or contents of the returned CatalogItemFilters. + operationId: getCatalogFilters + parameters: + - name: catalogId + in: query + description: id for the Catalog. + required: true + schema: + type: string + example: 'cHJvam...yCg==' + responses: + "200": + description: A list of CatalogItemFilters. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItemFilter' + "400": + description: Invalid parameters provided on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /files/{id}/contents: + get: + tags: + - Files + summary: Returns the contents of a File. + description: > + Returns the contents of a File entity associated to the provided id, unless: +
    +
  • The id is not associated to any File.
  • +
  • Or the associated File is invalid (e.g. corrupted) and can't be processed to create a response.
  • +
+ operationId: getFileById + parameters: + - name: id + in: path + description: id for the File. + required: true + schema: + type: string + example: 'cHJvam...yCg==' + - name: format + in: query + description: desired format for the returned File contents, **must** match the actual format. + required: true + schema: + $ref: '#/components/schemas/FileFormat' + example: image + responses: + "200": + description: File contents, either in binary or text format. + content: + "application/octet-stream": + schema: + type: string + format: byte + description: binary file contents. + example: '' + "text/*": + schema: + type: string + description: text file contents. + example: '# About\nThis repository contains the source code for...' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: No File associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid File associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/{catalogItemId}/user-actions/{userActionId}/messages-definitions/{messageDefinitionId}: + post: + tags: + - CatalogItemUserActionMessageDefinitions + summary: Get a message definition by id. + description: > + Returns an standard message definition + operationId: getMessageDefinitionByCatalogItemIdAndMessageId + parameters: + - name: catalogItemId + in: path + description: id for the CatalogItem + required: true + schema: + type: string + - name: userActionId + in: path + description: id for the CatalogItemUserAction + required: true + schema: + type: string + - name: messageDefinitionId + in: path + description: id for the CatalogItemUserActionMessageDefinition + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: string + responses: + "200": + description: A single message definition. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItemUserActionMessageDefinition' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: Catalog not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /schema-validation/{className}: + post: + tags: + - SchemaValidations + summary: Validate a yaml against a proper schema. + description: > + Validates the provided Catalog schema against the expected format and structure.
+ Returns a 200 OK response if the schema is valid, otherwise returns a 400 Bad Request with details about the validation errors. + operationId: validateCatalogSchema + parameters: + - name: className + in: path + description: ClassName for the uploaded file, so we can get proper schema for validation. + required: true + schema: + type: string + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + "200": + description: Validation resul. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationResult' + '400': + description: Invalid input or validation failed + + /provision/{project-key}/{status}: + put: + tags: + - ProvisionerActions + summary: Create new project component + description: > + This endpoint will create a new project component. + operationId: notifyProvisioningStatusUpdate + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Provisioning status for the component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusUpdateRequest' + responses: + "200": + description: Provisioning status update completed. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + patch: + tags: + - ProvisionerActions + summary: Update an existing project component + description: > + This endpoint receives provisioning status update notifications from AWX. + operationId: notifyProvisioningStatusUpdatePartially + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Provisioning status for the component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusUpdateRequest' + responses: + "200": + description: Provisioning status update completed. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /provision/{project-key}: + delete: + security: + - basicAuth: [] # Enable ONLY basicAuth + tags: + - ProvisionerActions + summary: Delete provision status component from the file + description: > + This endpoint receives provisioning status delete notifications from Component Provisioner. + operationId: deleteProvisioningStatus + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningDeleteRequest' + responses: + "200": + description: Project component properly deleted. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + +components: + securitySchemes: + bearerAuth: + type: http + description: > + Authorization via bearer token. + scheme: bearer + bearerFormat: JWT + basicAuth: + type: http + scheme: basic + description: Basic authentication for internal provisioning endpoint + schemas: + Catalog: + properties: + name: + type: string + example: 'catalog-name' + description: + type: string + example: 'A brief description for a catalog' + communityPageId: + type: string + example: 'aSdFam...yCg==' + links: + type: array + items: + $ref: '#/components/schemas/CatalogLink' + tags: + type: array + items: + type: string + example: + - 'tasks' + - 'technologies' + ProjectComponentInfo: + properties: + componentId: + type: string + example: 'edpc-4132-v2' + status: + type: string + example: 'CREATING' + canBeDeleted: + type: boolean + example: true + logoUrl: + type: string + example: https://somepic.jpg + componentUrl: + type: string + example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + ProjectComponentParameter: + properties: + name: + type: string + example: 'environment' + values: + type: array + items: + type: string + example: + - 'dev' + - 'test' + ProjectComponentExtendedInfo: + properties: + componentId: + type: string + example: 'nextjs-basic-app' + catalogItemId: + type: string + example: 'some-encoded-info' + catalogItemRef: + type: string + example: 'more-encoded-info' + status: + type: string + example: 'CREATING' + componentUrl: + type: string + example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + parameters: + type: array + items: + $ref: '#/components/schemas/ProjectComponentParameter' + CatalogDescriptor: + properties: + id: + type: string + example: 'aSdFam...yCg==' + slug: + type: string + example: 'aSdFam...yCg==' + CatalogLink: + properties: + url: + type: string + example: 'http://some-link.com' + name: + type: string + example: 'whatever name' + CatalogItem: + properties: + id: + type: string + example: 'aSdFam...yCg==' + slug: + type: string + description: > + Composite slug computed from the normalised Bitbucket project key and the repository slug of the item, + in the format `{project-key}_{repo-name}`. Calculated at mapping time; not retrieved from Bitbucket. + example: 'myproject_my-component-repo' + path: + type: string + example: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master + title: + type: string + example: An item title + shortDescription: + type: string + example: This is a short description for the item + descriptionFileId: + type: string + example: cHJvam...0ZXIK + imageFileId: + type: string + example: cHJvam...YXN0Z + itemSrc: + type: string + example: 'https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master' + tags: + type: array + items: + $ref: '#/components/schemas/CatalogItemTag' + authors: + type: array + items: + type: string + example: + - '@SomeAuthor' + - '@SomeOtherAuthor' + date: + type: string + format: date-time + example: '2021-07-01T00:00:00Z' + userActions: + type: array + items: + $ref: '#/components/schemas/CatalogItemUserAction' + restrictions: + $ref: '#/components/schemas/CatalogItemRestriction' + required: + - id + - title + - shortDescription + - description + - image + - authors + - date + example: + id: aSdFam...yCg== + slug: myproject_some-repo + path: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master + title: An item title + shortDescription: This is a short description for the item + descriptionFileId: cHJvam...0ZXIK + imageFileId: cHJvam...YXN0Z + itemSrc: https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master + tags: + - label: data + options: + - 'some-data-option' + - 'some-other-data-option' + authors: + - '@SomeAuthor' + - '@SomeOtherAuthor' + date: "2021-07-01T00:00:00Z" + CatalogItemUserAction: + properties: + id: + type: string + nullable: false + example: 'CODE' + displayName: + type: string + nullable: false + example: 'View Code' + url: + type: string + nullable: true + example: 'https://quickstarter' + triggerMessage: + type: string + nullable: true + example: 'Provisioning a component' + requestable: + type: boolean + nullable: true + example: true + restrictionMessage: + type: string + nullable: true + example: 'You do not have permissions to provision this component.' + parameters: + type: array + items: + $ref: '#/components/schemas/CatalogItemUserActionParameter' + example: + id: "PROVISION" + triggerMessage: "Provisioning a component custom message" + parameters: + - name: "workflow" + type: "string" + required: true + defaultValue: "9987" + description: "Workflow to execute." + visible: false + CatalogItemUserActionMessageDefinition: + properties: + id: + type: string + nullable: false + example: 'OPENSHIFT_CONNECTION_ERROR' + type: + $ref: '#/components/schemas/CatalogItemUserActionMessageType' + title: + type: string + nullable: false + example: 'An error occurred while connecting to OpenShift' + message: + type: string + nullable: false + example: > + Authorization error: please check your user credentials for deployment + and try again later. + createsIncident: + type: boolean + nullable: false + example: + id: "OPENSHIFT_CONNECTION_ERROR" + title: "An error occurred while connecting to OpenShift" + message: > + Authorization error: please check your user credentials for deployment + and try again later. + CatalogItemUserActionMessageType: + type: string + enum: + - success + - error + example: error + CatalogItemUserActionParameter: + properties: + name: + type: string + nullable: false + example: 'workflow' + type: + type: string + nullable: false + example: 'string' + required: + type: boolean + example: 'true' + defaultValue: + type: string + nullable: true + example: '123' + defaultValues: + nullable: true + type: array + items: + type: string + example: + - 'some-data-option' + - 'some-other-data-option' + options: + nullable: true + type: array + items: + type: string + example: + - 'some-data-option' + - 'some-other-data-option' + locations: + type: array + nullable: true + items: + $ref: '#/components/schemas/CatalogItemUserActionParameterLocation' + label: + type: string + nullable: false + example: 'Workflow to execute.' + placeholder: + type: string + nullable: true + example: 'some placeholder for a workflow' + hint: + type: string + nullable: true + example: 'some hint for a workflow' + sendOnDeletion: + type: boolean + nullable: true + example: false + visible: + type: boolean + example: 'true' + validations: + type: array + nullable: true + items: + $ref: '#/components/schemas/CatalogItemUserActionParameterValidation' + example: + name: "workflow" + type: "string" + required: true + defaultValue: "9987" + description: "Workflow to execute." + visible: false + CatalogItemUserActionParameterValidation: + properties: + regex: + type: string + example: '/^[a-z\s]{0,255}$/i' + errorMessage: + type: string + example: 'There is an error in the provided value, please check it and try again.' + CatalogItemUserActionParameterLocation: + properties: + location: + type: string + example: 'EU' + value: + type: string + example: '1234' + CatalogItemTag: + properties: + label: + type: string + example: data + options: + type: array + uniqueItems: true + items: + type: string + example: + - 'some-data-option' + - 'some-other-data-option' + CatalogItemFilter: + properties: + label: + type: string + example: business + options: + type: array + uniqueItems: true + items: + type: string + example: + - 'some-business-option' + - 'some-other-business-option' + example: + label: business + options: + - 'some-business-option' + - 'some-other-business-option' + CatalogItemRestriction: + properties: + projects: + type: array + uniqueItems: true + items: + type: string + example: + - 'project-key-1' + - 'project-key-2' + SortOrder: + type: string + enum: + - asc + - desc + example: asc + FileFormat: + type: string + enum: + - image + - markdown + - yaml + example: markdown + RestErrorMessage: + properties: + message: + type: string + required: + - message + ValidationResult: + type: object + properties: + valid: + type: boolean + errors: + type: array + items: + $ref: '#/components/schemas/ValidationMessage' + ValidationMessage: + type: object + properties: + type: + type: string + code: + type: string + message: + type: string + required: + - message + + # === New, explicit request models to avoid sharing === + ProvisioningStatusUpdateRequest: + type: object + properties: + componentId: + type: string + minLength: 1 # disallows empty string "" + pattern: '^(?!\s*$).+' # reject whitespace-only + description: The componentId set by the user. + example: "any-component-id-from-backend" + + catalogItemId: + type: string + description: The base64 encoded path for the catalogItem. It may include branch reference. + example: "cHJvamVjdHMvQ0FURVNUL3JlcG9zL3VzZXItYWN0aW9ucy1pdGVtL3Jhdy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy" + + componentUrl: + type: string + description: the repository url where the component was provisioned + example: "https://bitbucket.com/projects/DEVSTACK/repos/devstack-component-catalog" + nullable: true + + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - value + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" + + ProvisioningDeleteRequest: + type: object + properties: + componentId: + type: string + minLength: 1 # disallows empty string "" + pattern: '^(?!\s*$).+' # reject whitespace-only + description: The componentId set by the user. + example: "any-component-id-from-backend" + + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - value + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" \ No newline at end of file diff --git a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml new file mode 100644 index 0000000..2cf2681 --- /dev/null +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -0,0 +1,613 @@ +openapi: 3.0.3 +info: + title: Component Provisioner REST API + version: '1.0.0' + description: > + The Component Provisioner API allows clients to trigger Ansible Automation Platform (AWX) workflows. + + **NOTES**: + - The OpenAPI specification file is also used to [generate](https://openapi-generator.tech/) REST client(s) and a server REST API. + - Clients and servers generated from the same OpenAPI specification version are guaranteed to be **compatible**. + contact: + name: EDPCore Team + url: https://confluence.biscrum.com/pages/viewpage.action?spaceKey=EDP&title=Welcome +servers: + - url: http://{baseurl}/v1 + variables: + baseurl: + default: localhost:8080 + description: Default address for a Component Provisioner's backend REST API instance. +security: + - bearerAuth: [] +tags: + - name: ProvisionerHealth + description: Public actuator health endpoint. + - name: ProvisionerActions + description: ProvisionerAction operations. + - name: ProvisionerMessagesDefinitions + description: Provisioner standardized messages definitions + - name: ProvisionResults + description: Work with project components statuses +paths: + /actuator/health: + get: + tags: + - ProvisionerHealth + summary: Health check endpoint + description: Public actuator health endpoint. + operationId: getProvisionerHealth + security: [] + responses: + "200": + description: Service health status. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: UP + groups: + type: array + items: + type: string + required: + - status + + /provision-actions: + post: + tags: + - ProvisionerActions + summary: Execute a provisioning action with parameters + description: > + This endpoint receives ProvisionerActions from clients and triggers them in AWX. + operationId: triggerProvisionAction + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionAction' + responses: + "201": + description: Provisioning created. + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionActionResponse' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + + /catalog-items/{catalogItemId}/user-actions/{action}/message-definitions/{id}: + post: + tags: + - ProvisionerMessagesDefinitions + summary: Get a message definition by catalogItemId and id. + description: > + Returns an standard message definition + operationId: getMessageDefinitionByCatalogItemIdAndMessageId + parameters: + - name: catalogItemId + in: path + description: id for the Catalog Item where Message is defined. + required: true + schema: + type: string + - name: action + in: path + description: Action for the MessageDefinition. + required: true + schema: + type: string + - name: id + in: path + description: id for the MessageDefinition. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: string + responses: + "200": + description: A single message definition. + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionerMessageDefinition' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: Catalog not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + + /provision/{projectKey}/{status}: + put: + security: + - basicAuth: [ ] # Enable ONLY basicAuth + tags: + - ProvisionResults + summary: Notify provisioning Status Update + description: > + This endpoint receives provisioning updates. + operationId: notifyProvisioningStatusUpdate + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Project key of the provisioned component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusUpdateRequest' + responses: + "200": + description: Provisioning completion notified. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + patch: + tags: + - ProvisionResults + summary: Notify provisioning Status Update + description: > + This endpoint receives provisioning status update notifications from AAP. + operationId: notifyProvisioningStatusUpdatePartially + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Project key of the provisioned component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusPartialUpdateRequest' + responses: + "200": + description: Provisioning completion notified. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /provision/{projectKey}: + delete: + security: + - basicAuth: [ ] # Enable ONLY basicAuth + tags: + - ProvisionResults + summary: Delete project component from the file + description: > + This endpoint receives provisioning status delete notifications from Project Component. + operationId: deleteProjectComponent + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningDeleteRequest' + responses: + "204": + description: Project component properly deleted. + "400": + description: Bad request. + "401": + description: Invalid credentials on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /provision/delete/{projectKey}/{componentId}: + post: + tags: + - ProvisionResults + summary: Request App Support to do operations to delete provision status component (and dependencies) from the file + description: > + This endpoint receives project key and componentId and send an create an incident to app support. + operationId: requestDeletion + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: componentId + in: path + description: Component id of the provisioned component. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateIncidentAction' + responses: + "201": + description: Incident properly created. + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionActionResponse' + "400": + description: Bad request. + "401": + description: Invalid credentials on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /project/{projectKey}/component/{componentId}: + get: + tags: + - Project-components-with-provision-status + summary: Returns the provision status of a project component given both its project key and component ID. + operationId: getProjectComponentProvisionStatusById + parameters: + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + - name: componentId + in: path + description: component ID. + required: true + schema: + type: string + responses: + "200": + description: The provision status information of a project component, including fail reason if exists. + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectComponentProvisionStatus' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' +components: + securitySchemes: + bearerAuth: + type: http + description: > + Authorization via bearer token. + scheme: bearer + bearerFormat: JWT + basicAuth: + type: http + scheme: basic + description: Basic authentication for internal provisioning endpoint + schemas: + ProvisionAction: + properties: + id: + type: string + nullable: false + example: 'PROVISION' + parameters: + type: array + items: + $ref: '#/components/schemas/ProvisionActionParameter' + example: + id: "PROVISION" + triggerMessage: "Provisioning a component custom message" + parameters: + - name: "workflow" + type: "string" + required: true + defaultValue: "2558" + description: "Workflow to execute." + visible: false + CreateIncidentAction: + properties: + parameters: + type: array + items: + $ref: '#/components/schemas/CreateIncidentParameter' + example: + parameters: + - name: "cluster_location" + type: "string" + value: "eu" + ProvisionActionParameter: + properties: + name: + type: string + nullable: false + example: 'workflow' + type: + type: string + nullable: false + example: 'string' + value: + type: object + nullable: false + example: '2558' + example: + name: "workflow" + type: "string" + value: "2558" + ProvisionActionResponse: + properties: + failed: + title: Job failed to execute + type: boolean + id: + title: Job ID + type: integer + created: + title: Job created timestamp + type: string + format: date-time + modified: + title: Job modified timestamp + type: string + format: date-time + ProvisionerMessageDefinition: + properties: + id: + type: string + nullable: false + example: 'OPENSHIFT_CONNECTION_ERROR' + type: + $ref: '#/components/schemas/ProvisionerMessageDefinitionType' + title: + type: string + nullable: false + example: 'An error occurred while connecting to OpenShift' + message: + type: string + nullable: false + example: > + Authorization error: please check your user credentials for deployment + and try again later. + createsIncident: + type: boolean + nullable: false + example: + id: "OPENSHIFT_CONNECTION_ERROR" + title: "An error occurred while connecting to OpenShift" + message: > + Authorization error: please check your user credentials for deployment + and try again later. + ProvisionerMessageDefinitionType: + type: string + enum: + - success + - error + example: error + RestErrorMessage: + properties: + message: + type: string + required: + - message + + ProvisioningStatusPartialUpdateRequest: + type: object + properties: + componentId: + type: string + minLength: 1 # disallows empty string "" + pattern: '^(?!\s*$).+' # reject whitespace-only + description: The componentId set by the user. + example: "any-component-id-from-backend" + catalogItemId: + type: string + description: The base64 encoded path for the catalogItem. It may include branch reference. + example: "cHJvamVjdHMvQ0FURVNUL3JlcG9zL3VzZXItYWN0aW9ucy1pdGVtL3Jhdy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy" + catalogItemSlug: + type: string + description: The slug for the provisioned component. + example: "myproject_repo_name" + componentUrl: + type: string + description: the repository url where the component was provisioned + example: "https://bitbucket.com/projects/DEVSTACK/repos/devstack-component-catalog" + nullable: true + + ProvisioningStatusUpdateRequest: + allOf: + - $ref: '#/components/schemas/ProvisioningStatusPartialUpdateRequest' + - type: object + properties: + workflowJobId: + type: string + description: the workflow job id from AWX to correlate provisioning status with AWX job status updates + example: "123456" + nullable: true + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - values + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" + + ProvisioningDeleteRequest: + type: object + properties: + componentId: + type: string + description: The componentId set by the user. + example: "any-component-id-from-backend" + + CreateIncidentParameter: + properties: + name: + type: string + nullable: false + example: 'workflow' + type: + type: string + nullable: false + example: 'string' + value: + type: object + nullable: false + example: '2558' + example: + name: "workflow" + type: "string" + value: "2558" + + ProjectComponentStatusParameter: + properties: + name: + type: string + example: 'environment' + values: + type: array + items: + type: string + example: + - 'dev' + - 'test' + ProjectComponentProvisionStatus: + properties: + projectKey: + type: string + example: 'simple-project-sample' + componentId: + type: string + example: 'nextjs-basic-app' + catalogItemId: + type: string + example: 'some-encoded-info' + catalogItemRef: + type: string + example: 'more-encoded-info' + status: + type: string + example: 'CREATING' + componentUrl: + type: string + example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + workflowJobId: + type: string + example: '1316315' + errorTask: + type: string + example: '08-01-fail if validations or checks did not pass' + errorMessage: + type: string + example: 'JIRA_ERROR' + parameters: + type: array + items: + $ref: '#/components/schemas/ProjectComponentStatusParameter' \ No newline at end of file diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml new file mode 100644 index 0000000..d3b1991 --- /dev/null +++ b/external-service-marketplace/pom.xml @@ -0,0 +1,251 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + external-service-marketplace + External Service Marketplace 2.0 + Service module for integrating with Marketplace 2.0 + + + + + org.opendevstack.apiservice + external-service-api + ${project.version} + + + + + org.opendevstack.apiservice + core-security + ${project.version} + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.projectlombok + lombok + provided + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.junit.jupiter + junit-jupiter + test + + + + + org.wiremock.integrations + wiremock-spring-boot + 3.10.0 + test + + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + jakarta.annotation + jakarta.annotation-api + + + + jakarta.validation + jakarta.validation-api + + + + io.swagger.core.v3 + swagger-annotations + 2.2.21 + + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + + org.apache.httpcomponents + httpclient + 4.5.14 + provided + + + + + + org.springframework.boot + spring-boot-starter-cache + + + javax.annotation + javax.annotation-api + 1.3.2 + compile + + + org.jetbrains + annotations + 17.0.0 + compile + + + + + + + org.openapitools + openapi-generator-maven-plugin + + + generate-marketplace-catalog-client + + generate + + + FILTER=operationId:getCatalogItemById|getCatalogItemBySlug|getCatalogHealth + java + ${project.basedir}/target/generated-sources/openapi + resttemplate + ${project.basedir}/openapi/openapi-component_catalog-v1.0.0.yaml + org.opendevstack.apiservice.externalservice.marketplace.client.api + org.opendevstack.apiservice.externalservice.marketplace.client.model + org.opendevstack.apiservice.externalservice.marketplace.client + false + true + true + true + false + false + false + false + + false + true + true + true + true + + + java8 + jackson + true + + + + + generate-marketplace-provisioner-client + + generate + + + FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProjectComponentProvisionStatusById|getProvisionerHealth + java + ${project.basedir}/target/generated-sources/openapi + resttemplate + ${project.basedir}/openapi/openapi-component_provisioner-v1.0.0.yaml + org.opendevstack.apiservice.externalservice.marketplace.client.api + org.opendevstack.apiservice.externalservice.marketplace.client.model + org.opendevstack.apiservice.externalservice.marketplace.client + false + true + true + true + false + false + false + false + + false + true + true + true + true + + + java8 + jackson + true + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/openapi/src/main/java + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java new file mode 100644 index 0000000..6443f56 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java @@ -0,0 +1,77 @@ +package org.opendevstack.apiservice.externalservice.marketplace.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.opendevstack.apiservice.core.security.util.Base64Operations; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.auth.HttpBearerAuth; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +@Getter +@Slf4j +public class MarketplaceApiClient { + + + private final String instanceName; + private final MarketplaceInstanceConfig config; + private final ApiClient apiClient; + + /** + * Constructor for MarketplaceApiClient. + * + * @param instanceName Name of the Marketplace instance + * @param config Configuration for this instance + * @param restTemplate RestTemplate configured with appropriate timeouts and SSL settings + */ + public MarketplaceApiClient(String instanceName, MarketplaceInstanceConfig config, RestTemplate restTemplate) { + this.instanceName = instanceName; + this.config = config; + + // Configure ObjectMapper with JsonNullableModule for the RestTemplate + configureRestTemplateWithJsonNullable(restTemplate); + + // Initialize the generated ApiClient + this.apiClient = new ApiClient(restTemplate); + + if (config.getUsername() != null && config.getPassword() != null) { + this.apiClient.setUsername(config.getUsername()); + this.apiClient.setPassword(Base64Operations.decode(config.getPassword())); + log.info("MarketplaceApiClient for instance '{}' uses basic authentication", instanceName); + } + + log.info("MarketplaceApiClient initialized for instance '{}'", instanceName); + } + + /** + * Configure RestTemplate's ObjectMapper to handle JsonNullable types. + * + * @param restTemplate RestTemplate to configure + */ + private void configureRestTemplateWithJsonNullable(RestTemplate restTemplate) { + for (HttpMessageConverter converter : restTemplate.getMessageConverters()) { + if (converter instanceof MappingJackson2HttpMessageConverter jacksonConverter) { + ObjectMapper objectMapper = jacksonConverter.getObjectMapper(); + objectMapper.registerModule(new JsonNullableModule()); + log.debug("Registered JsonNullableModule with ObjectMapper for instance '{}'", instanceName); + return; + } + } + log.warn("No MappingJackson2HttpMessageConverter found in RestTemplate for instance '{}'", instanceName); + } + + /** + * Sets the bearer token for authentication on this client. + * This replaces any previously configured bearer token. + * + * @param bearerToken the bearer token to use for subsequent API calls + */ + public void setBearerToken(String bearerToken) { + HttpBearerAuth auth = (HttpBearerAuth) this.apiClient.getAuthentication("bearerAuth"); + auth.setBearerToken(bearerToken); + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java new file mode 100644 index 0000000..3b6fed5 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java @@ -0,0 +1,215 @@ +package org.opendevstack.apiservice.externalservice.marketplace.client; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceServiceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.net.HttpURLConnection; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.Map; +import java.util.Set; + +@Component +@Slf4j +public class MarketplaceApiClientFactory { + + private final MarketplaceServiceConfig configuration; + private final RestTemplateBuilder restTemplateBuilder; + + /** + * Constructor with dependency injection. + * + * @param configuration Marketplace service configuration + * @param restTemplateBuilder RestTemplate builder for creating HTTP clients + */ + public MarketplaceApiClientFactory(MarketplaceServiceConfig configuration, + RestTemplateBuilder restTemplateBuilder) { + this.configuration = configuration; + this.restTemplateBuilder = restTemplateBuilder; + + log.info("MarketplaceApiClientFactory initialized with {} instance(s)", + configuration.getInstances().size()); + } + + /** + * Resolve the effective instance name. + *
    + *
  • If the default instance is configured via {@code externalservices.marketplace.default-instance}, it is returned.
  • + *
  • Otherwise the first entry of the instances map is returned (insertion order).
  • + *
  • If no instances are configured at all, a {@link MarketplaceException} is thrown.
  • + *
+ * + * @return The resolved instance name (never {@code null}/blank) + * @throws MarketplaceException if no Marketplace instances are configured + */ + public String getDefaultInstanceName() throws MarketplaceException { + + String defaultInstance = configuration.getDefaultInstance(); + if (defaultInstance != null && !defaultInstance.isBlank()) { + return defaultInstance; + } + + Map instances = configuration.getInstances(); + if (instances == null || instances.isEmpty()) { + throw new MarketplaceException("No Marketplace instances configured"); + } + + return instances.keySet().iterator().next(); + } + + /** + * Get a {@link MarketplaceApiClient} for a specific instance. + * If {@code instanceName} is {@code null} or blank, this method will throw a {@link MarketplaceException} + * to avoid ambiguity. The caller should explicitly call {@link #getClient()} to get the default instance client + * in that case. + * + * @param instanceName Name of the Marketplace instance, or {@code null}/{@code ""} for the default + * @return Configured MarketplaceApiClient + * @throws MarketplaceException if the instance is not configured + */ + public MarketplaceApiClient getClient(String instanceName) throws MarketplaceException { + if (instanceName == null || instanceName.isBlank()) { + throw new MarketplaceException( + String.format("Provide instance name. Available instances: %s", + configuration.getInstances().keySet())); + } + + MarketplaceInstanceConfig instanceConfig = configuration.getInstances().get(instanceName); + + if (instanceConfig == null) { + throw new MarketplaceException( + String.format("Marketplace instance '%s' is not configured. Available instances: %s", + instanceName, configuration.getInstances().keySet())); + } + + log.info("Creating new MarketplaceApiClient for instance '{}'", instanceName); + + RestTemplate restTemplate = createRestTemplate(instanceConfig); + return new MarketplaceApiClient(instanceName, instanceConfig, restTemplate); + } + + /** + * Get a {@link MarketplaceApiClient} for a specific instance with the given bearer token. + * + * @param instanceName Name of the Marketplace instance + * @param bearerToken Bearer token to use for authentication + * @return Configured MarketplaceApiClient with the given bearer token + * @throws MarketplaceException if the instance is not configured + */ + public MarketplaceApiClient getClient(String instanceName, String bearerToken) throws MarketplaceException { + MarketplaceApiClient client = getClient(instanceName); + client.setBearerToken(bearerToken); + return client; + } + + /** + * Get the default client, as determined by {@code externalservices.marketplace.default-instance}. + * Falls back to the first configured instance when {@code default-instance} is not set. + * + * @return MarketplaceApiClient for the default instance + * @throws MarketplaceException if no instances are configured + */ + public MarketplaceApiClient getClient() throws MarketplaceException { + String defaultInstanceName = getDefaultInstanceName(); + MarketplaceInstanceConfig instanceConfig = configuration.getInstances().get(defaultInstanceName); + RestTemplate restTemplate = createRestTemplate(instanceConfig); + + return new MarketplaceApiClient(defaultInstanceName, instanceConfig, restTemplate); + } + + /** + * Get all available instance names. + * + * @return Set of configured instance names + */ + public Set getAvailableInstances() { + return configuration.getInstances().keySet(); + } + + /** + * Check if an instance is configured. + * + * @param instanceName Name of the instance to check + * @return true if configured, false otherwise + */ + public boolean hasInstance(String instanceName) { + return configuration.getInstances().containsKey(instanceName); + } + + /** + * Create a configured RestTemplate for a Marketplace instance. + * + * @param config Configuration for the instance + * @return Configured RestTemplate + */ + private RestTemplate createRestTemplate(MarketplaceInstanceConfig config) { + RestTemplate restTemplate = restTemplateBuilder.build(); + + SimpleClientHttpRequestFactory requestFactory; + if (config.isTrustAllCertificates()) { + log.warn("Trust all certificates is enabled for Marketplace API connection. " + + "This should only be used in development environments!"); + requestFactory = createTrustAllRequestFactory(); + } else { + requestFactory = new SimpleClientHttpRequestFactory(); + } + requestFactory.setConnectTimeout(config.getConnectionTimeout()); + requestFactory.setReadTimeout(config.getReadTimeout()); + restTemplate.setRequestFactory(requestFactory); + + return restTemplate; + } + + /** + * Builds a {@link SimpleClientHttpRequestFactory} that disables certificate and hostname + * verification only for the connections opened by this factory (no JVM-global side effects). + * WARNING: should only be used in development environments. + */ + @SuppressWarnings({"java:S4830", "java:S5527"}) + private SimpleClientHttpRequestFactory createTrustAllRequestFactory() { + final javax.net.ssl.SSLSocketFactory socketFactory; + try { + TrustManager[] trustAllCertificates = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + public void checkClientTrusted(X509Certificate[] certs, String authType) { + // Intentionally empty - dev only + } + public void checkServerTrusted(X509Certificate[] certs, String authType) { + // Intentionally empty - dev only + } + } + }; + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustAllCertificates, new java.security.SecureRandom()); + socketFactory = context.getSocketFactory(); + } catch (NoSuchAlgorithmException | KeyManagementException ex) { + log.error("Failed to configure SSL trust all certificates for Marketplace API", ex); + return new SimpleClientHttpRequestFactory(); + } + + return new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws java.io.IOException { + if (connection instanceof HttpsURLConnection httpsConnection) { + httpsConnection.setSSLSocketFactory(socketFactory); + httpsConnection.setHostnameVerifier((hostname, session) -> true); + } + super.prepareConnection(connection, httpMethod); + } + }; + } +} \ No newline at end of file diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java new file mode 100644 index 0000000..5f0302f --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -0,0 +1,76 @@ +package org.opendevstack.apiservice.externalservice.marketplace.config; + +import lombok.Data; + +@Data +public class MarketplaceInstanceConfig { + /** + * The project components base URL of the Marketplace + */ + private String projectComponentsBaseUrl; + + /** + * The provisioner actions base URL of the Marketplace + */ + private String provisionerActionsBaseUrl; + + /** + * The Bitbucket base URL of the Marketplace project + */ + private String bitbucketBaseUrl; + + /** + * The username used for basic auth + */ + private String username; + + /** + * The password used for basic auth + */ + private String password; + + /** + * Connection timeout in milliseconds (default: 30000) + */ + private int connectionTimeout = 30000; + + /** + * Read timeout in milliseconds (default: 30000). + */ + private int readTimeout = 30000; + + /** + * Whether to trust all SSL certificates (default: false). + * WARNING: Should only be used in development environments. + */ + private boolean trustAllCertificates = false; + + /** + * OAuth2 scope used for OBO token exchange when calling this Marketplace instance. + * Example: {@code api:///Api.Access} + */ + private String oboScope; + + /** + * Bypass configuration. When the incoming token already targets the configured bypass + * audience and scope, the OBO token exchange is skipped and the token is forwarded as-is. + */ + private Bypass bypass = new Bypass(); + + /** + * Audience and scope used to decide whether the incoming token can be forwarded without + * an OBO exchange. + */ + @Data + public static class Bypass { + /** + * Audience that must be present in the token {@code aud} claim to allow the bypass. + */ + private String audience; + + /** + * Scope that must be present in the token {@code scp} claim to allow the bypass. + */ + private String scope; + } +} \ No newline at end of file diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java new file mode 100644 index 0000000..bbaec73 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.externalservice.marketplace.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@ConfigurationProperties(prefix = "externalservices.marketplace") +@Data +public class MarketplaceServiceConfig { + + /** + * Name of the default Marketplace instance to use when no instance name is provided. + * If not set, the first configured instance is used as default. + */ + private String defaultInstance; + + /** + * Map of Marketplace instances with the instance name as the key and the configuration as the value. + */ + private Map instances = new HashMap<>(); +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java new file mode 100644 index 0000000..f31934c --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.externalservice.marketplace.exception; + +public class MarketplaceException extends Exception { + + public MarketplaceException(String message) { + super(message); + } + + public MarketplaceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java new file mode 100644 index 0000000..5ca9c00 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.externalservice.marketplace.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class ProjectComponent { + + private UUID componentId; + private String name; + private String productDescription; + private String productName; + private String productId; + private String environment; + private String status; + private String resultTraceback; + private String repositoryURL; + private String componentType; + private boolean canBeDeleted; + private String logoUrl; + private String componentUrl; + +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java new file mode 100644 index 0000000..d4d14e9 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class CatalogItemOperations { + + private CatalogItemOperations() { + } + + public static byte[] encodeId(String id) { + return Base64.getUrlEncoder().encode(id.getBytes(StandardCharsets.UTF_8)); + } + + public static byte[] decodeId(String id) { + return Base64.getUrlDecoder().decode(id); + } + + public static String buildCatalogItemId(ProjectComponentProvisionStatus component) { + if (component == null || component.getCatalogItemId() == null || component.getCatalogItemRef() == null) { + return null; + } + String decodedId = new String(decodeId(component.getCatalogItemId()), StandardCharsets.UTF_8); + String decodedRef = new String(decodeId(component.getCatalogItemRef()), StandardCharsets.UTF_8); + return new String(encodeId(decodedId + decodedRef), StandardCharsets.UTF_8); + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java new file mode 100644 index 0000000..98fc934 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java @@ -0,0 +1,45 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.opendevstack.apiservice.externalservice.api.ExternalService; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; + +import java.util.List; +import java.util.Set; + +public interface MarketplaceService extends ExternalService { + + Set getAvailableInstances(); + + boolean hasInstance(String instanceName); + + String getDefaultInstance() throws MarketplaceException; + + ProjectComponentProvisionStatus getProjectComponent(String projectId, String componentId) throws MarketplaceException; + + ProjectComponentProvisionStatus getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + + CatalogItem getCatalogItem(String catalogItemId) throws MarketplaceException; + + CatalogItem getCatalogItem(String instanceName, String catalogItemId) throws MarketplaceException; + + CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException; + + CatalogItem getCatalogItemBySlug(String instanceName, String slug) throws MarketplaceException; + + boolean provisionProjectComponent(String projectId, List params) throws MarketplaceException; + + boolean provisionProjectComponent(String instanceName, String projectId, List params) throws MarketplaceException; + + void deleteProjectComponent(String projectId, String componentId) throws MarketplaceException; + + void deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + + void registerProjectComponent(String projectId, String componentId, String catalogItemSlug, List params) throws MarketplaceException; + + void registerProjectComponent(String instanceName, String projectId, String componentId, String catalogItemSlug, List params) throws MarketplaceException; + +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java new file mode 100644 index 0000000..74464ae --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -0,0 +1,374 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtUtils; +import org.opendevstack.apiservice.core.security.obo.OboTokenService; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.CatalogHealthApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.CatalogItemsApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProjectComponentsWithProvisionStatusApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionResultsApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerActionsApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerHealthApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetCatalogHealth200Response; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetProvisionerHealth200Response; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionAction; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningDeleteRequest; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequest; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; +import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; + +import java.util.List; +import java.util.Set; + +@Service +@Slf4j +public class MarketplaceServiceImpl implements MarketplaceService { + private static final String HEALTH_STATUS_UP = "UP"; + + private final MarketplaceApiClientFactory clientFactory; + private final OboTokenService oboTokenService; + + public MarketplaceServiceImpl(MarketplaceApiClientFactory clientFactory, + OboTokenService oboTokenService) { + this.clientFactory = clientFactory; + this.oboTokenService = oboTokenService; + log.info("MarketplaceServiceImpl initialized"); + } + + + @Override + public CatalogItem getCatalogItem(String catalogItemId) throws MarketplaceException { + return getCatalogItem(getDefaultInstance(), catalogItemId); + } + + @Override + public CatalogItem getCatalogItem(String instanceName, String catalogItemId) throws MarketplaceException { + log.debug("Marketplace service GET catalog item with id {} in instance {} ", catalogItemId, instanceName); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + CatalogItemsApi catalogItemsApi = new CatalogItemsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return catalogItemsApi.getCatalogItemById(catalogItemId); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Catalog item with id '{}' not found in Marketplace instance '{}'", + catalogItemId, instanceName); + return null; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting catalog item '%s' in instance '%s'", + catalogItemId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve catalog item '%s' in instance '%s'", + catalogItemId, instanceName), e); + } + } + + @Override + public CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException { + return getCatalogItemBySlug(getDefaultInstance(), slug); + } + + @Override + public CatalogItem getCatalogItemBySlug(String instanceName, String slug) throws MarketplaceException { + log.debug("Marketplace service GET catalog item with slug {} in instance {} ", slug, instanceName); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + CatalogItemsApi catalogItemsApi = new CatalogItemsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return catalogItemsApi.getCatalogItemBySlug(slug); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Catalog item with slug '{}' not found in Marketplace instance '{}'", + slug, instanceName); + return null; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting catalog item with slug '%s' in instance '%s'", + slug, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve catalog item with slug '%s' in instance '%s'", + slug, instanceName), e); + } + } + + @Override + public ProjectComponentProvisionStatus getProjectComponent(String projectId, String componentId) throws MarketplaceException { + return getProjectComponent(getDefaultInstance(), projectId, componentId); + } + + @Override + public ProjectComponentProvisionStatus getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + log.debug("Marketplace service GET component with id {} for project {} in instance {} ", componentId, projectId, instanceName); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + ProjectComponentsWithProvisionStatusApi projectComponentsApi = new ProjectComponentsWithProvisionStatusApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return projectComponentsApi.getProjectComponentProvisionStatusById(projectId, componentId); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Component with id '{}' not found in Marketplace instance '{}' for project '{}'", + componentId, instanceName, projectId); + return null; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } + } + + @Override + public boolean provisionProjectComponent(String projectId, List params) throws MarketplaceException { + return provisionProjectComponent(getDefaultInstance(), projectId, params); + } + + @Override + public boolean provisionProjectComponent(String instanceName, + String projectId, + List params) + throws MarketplaceException { + log.debug("Marketplace service PROVISION component for project {}: ", projectId); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + MarketplaceInstanceConfig config = marketplaceClient.getConfig(); + + String provisionerActionsBaseUrl = config.getProvisionerActionsBaseUrl(); + + ProvisionAction provisionAction = new ProvisionAction(); + provisionAction.setId("PROVISION"); + provisionAction.addParametersItem(new ProvisionActionParameter().name("project_key").type("string").value(projectId)); + + params.forEach(provisionAction::addParametersItem); + + ProvisionerActionsApi provisionerActionsApi = new ProvisionerActionsApi(apiClient); + apiClient.setBasePath(provisionerActionsBaseUrl); + + ProvisionActionResponse response = provisionerActionsApi.triggerProvisionAction(provisionAction); + return !Boolean.TRUE.equals(response.getFailed()); + } catch (HttpClientErrorException.Conflict e) { + throw new MarketplaceException("This component name already exists, please choose another name.", e); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when provisioning project component in project '%s' and instance '%s'", + projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to provision project component in project '%s' and instance '%s'", + projectId, instanceName), e); + } + } + + @Override + public void deleteProjectComponent(String projectId, String componentId) throws MarketplaceException { + deleteProjectComponent(getDefaultInstance(), projectId, componentId); + } + + @Override + public void deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + log.debug("Marketplace service DELETE component {} for project {}: ", componentId, projectId); + try { + MarketplaceApiClient marketplaceClient = clientFactory.getClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + + ProvisionResultsApi provisionResultsApi = new ProvisionResultsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); + log.debug("Api client base path: {}", apiClient.getBasePath()); + + ProvisioningDeleteRequest provisioningDeleteRequest = new ProvisioningDeleteRequest(); + provisioningDeleteRequest.setComponentId(componentId); + provisionResultsApi.deleteProjectComponent(projectId, provisioningDeleteRequest); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when deleting project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to delete project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } + } + + @Override + public void registerProjectComponent(String projectId, + String componentId, + String catalogItemSlug, + List params) + throws MarketplaceException { + registerProjectComponent(getDefaultInstance(), projectId, componentId, catalogItemSlug, params); + } + + @Override + public void registerProjectComponent(String instanceName, + String projectId, + String componentId, + String catalogItemSlug, + List params) + throws MarketplaceException { + log.debug("Marketplace service REGISTER component {} for project {}: ", componentId, projectId); + try { + MarketplaceApiClient marketplaceClient = clientFactory.getClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + + ProvisionResultsApi provisionResultsApi = new ProvisionResultsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); + log.debug("Api client base path: {}", apiClient.getBasePath()); + + ProvisioningStatusUpdateRequest registerRequest = new ProvisioningStatusUpdateRequest() + .componentId(componentId) + .catalogItemSlug(catalogItemSlug) + .componentUrl(String.format("%s/projects/%s/repos/%s-%s/browse", + marketplaceClient.getConfig().getBitbucketBaseUrl(), + projectId.toUpperCase(), projectId.toLowerCase(), componentId)) + .parameters(params); + provisionResultsApi.notifyProvisioningStatusUpdate(projectId, "CREATED", registerRequest); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when registering project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to register project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } + } + + /** + * {@inheritDoc} + */ + @Override + public String getDefaultInstance() throws MarketplaceException { + return clientFactory.getDefaultInstanceName(); + } + + /** + * {@inheritDoc} + * + * Returns {@code false} (without throwing) if no instances are configured or + * if either public Marketplace health endpoint is not UP. + */ + @Override + public boolean isHealthy() { + Set instances = getAvailableInstances(); + if (instances.isEmpty()) { + log.warn("No Marketplace instances configured - reporting unhealthy"); + return false; + } + + final MarketplaceApiClient marketplaceClient; + try { + marketplaceClient = clientFactory.getClient(getDefaultInstance()); + } catch (MarketplaceException ex) { + log.warn("Could not resolve Marketplace client for health check", ex); + return false; + } + + boolean provisionerHealthy = isProvisionerEndpointUp(marketplaceClient); + if (!provisionerHealthy) { + return false; + } + + return isCatalogEndpointUp(marketplaceClient); + } + + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + try { + String provisionerBaseUrl = marketplaceClient.getConfig().getProvisionerActionsBaseUrl(); + ApiClient healthApiClient = marketplaceClient.getApiClient(); + healthApiClient.setBasePath(provisionerBaseUrl); + + ProvisionerHealthApi healthApi = new ProvisionerHealthApi(healthApiClient); + GetProvisionerHealth200Response response = healthApi.getProvisionerHealth(); + return isStatusUp(response != null ? response.getStatus() : null, + "provisionerActionsBaseUrl", provisionerBaseUrl); + } catch (RestClientException ex) { + log.warn("Health check via provisionerActionsBaseUrl failed", ex); + return false; + } + } + + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + try { + String catalogBaseUrl = marketplaceClient.getConfig().getProjectComponentsBaseUrl(); + ApiClient healthApiClient = marketplaceClient.getApiClient(); + healthApiClient.setBasePath(catalogBaseUrl); + + CatalogHealthApi healthApi = new CatalogHealthApi(healthApiClient); + GetCatalogHealth200Response response = healthApi.getCatalogHealth(); + return isStatusUp(response != null ? response.getStatus() : null, + "projectComponentsBaseUrl", catalogBaseUrl); + } catch (RestClientException ex) { + log.warn("Health check via projectComponentsBaseUrl failed", ex); + return false; + } + } + + private boolean isStatusUp(String status, String endpointName, String baseUrl) { + boolean healthy = HEALTH_STATUS_UP.equals(status); + if (!healthy) { + log.warn("Health endpoint from {}='{}' returned status '{}'", endpointName, baseUrl, status); + } + return healthy; + } + + @Override + public Set getAvailableInstances() { + return clientFactory.getAvailableInstances(); + } + + @Override + public boolean hasInstance(String instanceName) { + return clientFactory.hasInstance(instanceName); + } + + /** + * Creates a {@link MarketplaceApiClient} authenticated with an OBO token + * obtained from the current request's JWT. + */ + private MarketplaceApiClient getOboAuthenticatedClient(String instanceName) throws MarketplaceException { + MarketplaceApiClient client = clientFactory.getClient(instanceName); + String oboScope = client.getConfig().getOboScope(); + String assertion = JwtUtils.getTokenValue(); + if (oboScope == null || oboScope.isBlank()) { + throw new MarketplaceException( + String.format("OBO scope not configured for Marketplace instance '%s'", instanceName)); + } + MarketplaceInstanceConfig.Bypass bypass = client.getConfig().getBypass(); + if (bypass != null && JwtUtils.tokenMatchesScopeAudience(bypass.getAudience(), bypass.getScope())) { + client.setBearerToken(assertion); + } else { + client.setBearerToken(getOboToken(instanceName, assertion, oboScope)); + } + return client; + } + + private String getOboToken(String instanceName, String assertion, String oboScope) throws MarketplaceException { + try { + return oboTokenService.exchangeToken(assertion, oboScope); + } catch (RuntimeException ex) { + throw new MarketplaceException( + String.format( + "Failed to exchange OBO token for Marketplace instance '%s' with scope '%s'", + instanceName, oboScope), + ex); + } + } +} diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java new file mode 100644 index 0000000..d8bdbfe --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java @@ -0,0 +1,163 @@ +package org.opendevstack.apiservice.externalservice.marketplace.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceServiceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.web.client.RestTemplate; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MarketplaceApiClientFactory}. + * Focuses on the default-instance resolution logic introduced in {@code resolveInstanceName}. + */ +@ExtendWith(MockitoExtension.class) +class MarketplaceApiClientFactoryTest { + + @Mock + private RestTemplateBuilder restTemplateBuilder; + + @Mock + private RestTemplate restTemplate; + + private MarketplaceServiceConfig configuration; + + @BeforeEach + void setUp() { + configuration = new MarketplaceServiceConfig(); + lenient().when(restTemplateBuilder.build()).thenReturn(restTemplate); + } + + private MarketplaceApiClientFactory factory() { + return new MarketplaceApiClientFactory(configuration, restTemplateBuilder); + } + + // ------------------------------------------------------------------------- + // resolveInstanceName → configured default + // ------------------------------------------------------------------------- + + @Test + void resolveInstanceName_null_returnsConfiguredDefaultInstance() throws MarketplaceException { + configuration.setDefaultInstance("prod"); + + assertEquals("prod", factory().getDefaultInstanceName()); + } + + // ------------------------------------------------------------------------- + // resolveInstanceName – without default → fallback to first instance + // ------------------------------------------------------------------------- + + @Test + void resolveInstanceName_null_noDefaultConfigured_returnsFirstInstance() throws MarketplaceException { + // LinkedHashMap preserves insertion order → "alpha" is first + Map instances = new LinkedHashMap<>(); + instances.put("alpha", config("https://marketplace.example.com")); + instances.put("beta", config("https://marketplace-beta.example.com")); + configuration.setInstances(instances); + + assertEquals("alpha", factory().getDefaultInstanceName()); + } + + // ------------------------------------------------------------------------- + // resolveInstanceName – no instances at all → exception + // ------------------------------------------------------------------------- + + @Test + void resolveInstanceName_null_noInstancesConfigured_throwsMarketplaceException() { + // no instances set → empty map + MarketplaceApiClientFactory f = factory(); + + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> f.getDefaultInstanceName()); + assertTrue(ex.getMessage().toLowerCase().contains("no marketplace instances configured"), + "Expected 'no marketplace instances configured' in: " + ex.getMessage()); + } + + @Test + void getClient_null_throwsMarketplaceException() throws MarketplaceException { + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> factory().getClient(null)); + assertTrue(ex.getMessage().toLowerCase().contains("provide instance name"), + "Expected 'provide instance name' in: " + ex.getMessage()); + } + + @Test + void getClient_blank_throwsMarketplaceException() throws MarketplaceException { + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> factory().getClient("")); + assertTrue(ex.getMessage().toLowerCase().contains("provide instance name"), + "Expected 'provide instance name' in: " + ex.getMessage()); + } + + @Test + void getClient_unknownInstance_throwsMarketplaceException() { + configuration.setInstances(Map.of("dev", config("https://marketplace.dev.example.com"))); + + MarketplaceException ex = assertThrows(MarketplaceException.class, + () -> factory().getClient("nonexistent")); + assertTrue(ex.getMessage().contains("not configured")); + assertTrue(ex.getMessage().contains("nonexistent")); + } + + @Test + void getClient_returnsClientForConfiguredDefaultInstance() throws MarketplaceException { + when(restTemplateBuilder.build()).thenReturn(restTemplate); + configuration.setDefaultInstance("prod"); + configuration.setInstances(orderedMap("dev", "prod")); + + MarketplaceApiClient client = factory().getClient(); + + assertNotNull(client); + assertEquals("prod", client.getInstanceName()); + } + + @Test + void getClient_noDefaultConfigured_returnsFirstInstance() throws MarketplaceException { + when(restTemplateBuilder.build()).thenReturn(restTemplate); + Map instances = new LinkedHashMap<>(); + instances.put("alpha", config("https://marketplace.example.com")); + instances.put("beta", config("https://marketplace-beta.example.com")); + configuration.setInstances(instances); + + MarketplaceApiClient client = factory().getClient(); + + assertNotNull(client); + assertEquals("alpha", client.getInstanceName()); + } + + @Test + void getClient_noInstancesConfigured_throwsMarketplaceException() { + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> factory().getClient()); + assertTrue(ex.getMessage().toLowerCase().contains("no marketplace instances configured")); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static MarketplaceInstanceConfig config(String baseUrl) { + MarketplaceInstanceConfig c = new MarketplaceInstanceConfig(); + c.setProjectComponentsBaseUrl(baseUrl); + c.setProvisionerActionsBaseUrl(baseUrl); + return c; + } + + /** Creates a LinkedHashMap with two configs using their names as base-url stems. */ + private static Map orderedMap(String first, String second) { + Map m = new LinkedHashMap<>(); + m.put(first, config("https://" + first + ".example.com")); + m.put(second, config("https://" + second + ".example.com")); + return m; + } +} \ No newline at end of file diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java new file mode 100644 index 0000000..54b849a --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java @@ -0,0 +1,39 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class CatalogItemOperationsTest { + + @Test + void testBuildCatalogItemId_whenNullComponent_ReturnNull() { + String catalogItemId = CatalogItemOperations.buildCatalogItemId(null); + assertNull(catalogItemId); + } + + @Test + void testBuildCatalogItemId_whenNullValues_ReturnNull() { + ProjectComponentProvisionStatus testComponentProvisionStatus = new ProjectComponentProvisionStatus(); + testComponentProvisionStatus.setCatalogItemId(null); + testComponentProvisionStatus.setCatalogItemRef(null); + + String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentProvisionStatus); + assertNull(catalogItemId); + } + + @Test + void testBuildCatalogItemId_whenCorrectValues_ReturnCorrectlyBuiltId() { + ProjectComponentProvisionStatus testComponentProvisionStatus = new ProjectComponentProvisionStatus(); + testComponentProvisionStatus.setCatalogItemId("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1s"); + testComponentProvisionStatus.setCatalogItemRef("P2F0PXJlZnMvaGVhZHMvbWFzdGVy"); + + String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentProvisionStatus); + assertNotNull(catalogItemId); + assertEquals("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy", catalogItemId); + } + +} \ No newline at end of file diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java new file mode 100644 index 0000000..417cb8f --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -0,0 +1,918 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.OngoingStubbing; +import org.opendevstack.apiservice.core.security.obo.OboTokenService; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequest; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse; +import org.opendevstack.apiservice.externalservice.marketplace.service.impl.MarketplaceServiceImpl; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MarketplaceService}. + * These tests use mocks and do not require actual Marketplace connectivity. + */ +@ExtendWith(MockitoExtension.class) +class MarketplaceServiceImplTest { + + // Generated-API endpoint paths invoked by ApiClient#invokeAPI. These mirror the + // values produced by the OpenAPI generator so tests fail loudly if a path or + // HTTP method changes unexpectedly. + private static final String PATH_CATALOG_ITEM_BY_ID = "/catalog-items/{id}"; + private static final String PATH_PROJECT_COMPONENT = "/project/{projectKey}/component/{componentId}"; + private static final String PATH_PROVISION_ACTIONS = "/provision-actions"; + private static final String PATH_DELETE_COMPONENT = "/support/delete/{projectKey}/{componentId}"; + private static final String PATH_NOTIFY_PROVISIONING = "/provision/{projectKey}/{status}"; + + @Mock + private MarketplaceApiClientFactory clientFactory; + + @Mock + private MarketplaceApiClient marketplaceApiClient; + + @Mock + private ApiClient apiClient; + + @Mock + private OboTokenService oboTokenService; + + private MarketplaceService marketplaceService; + + @BeforeEach + void setUp() { + marketplaceService = new MarketplaceServiceImpl(clientFactory, oboTokenService); + + // Set up a fake SecurityContext so JwtUtils.getTokenValue() works + Jwt jwt = Jwt.withTokenValue("test-jwt-assertion") + .header("alg", "RS256") + .claim("azp", "test-client-id") + .claim("aud", "api://different-app") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication(new JwtAuthenticationToken(jwt)); + SecurityContextHolder.setContext(ctx); + + // Default OBO stub — tests that fail before OBO won't reach this + lenient().when(oboTokenService.exchangeToken(anyString(), anyString())) + .thenReturn("obo-test-token"); + + // Stub ApiClient utility methods used by the generated ProjectApi / ServerInfoApi + // before invokeAPI is reached. Without these, putAll(null) causes NullPointerException. + lenient().when(apiClient.parameterToMultiValueMap(any(), anyString(), any())) + .thenReturn(new LinkedMultiValueMap<>()); + lenient().when(apiClient.selectHeaderAccept(any())) + .thenReturn(List.of(MediaType.APPLICATION_JSON)); + lenient().when(apiClient.selectHeaderContentType(any())) + .thenReturn(MediaType.APPLICATION_JSON); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + /** + * Stubs {@code apiClient.invokeAPI(...)} for a specific endpoint path and HTTP method. + * Using {@code eq(path)} and {@code eq(method)} (instead of bare {@code any()} for every + * argument) makes the test assert that the service is calling the expected generated-API + * operation, while still being tolerant of the surrounding boilerplate arguments. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private OngoingStubbing> whenInvokeAPI(String path, HttpMethod method) { + return (OngoingStubbing) when(apiClient.invokeAPI( + eq(path), + eq(method), + any(), + any(), + any(), + any(HttpHeaders.class), + any(), + any(), + any(), + any(), + any(), + any(ParameterizedTypeReference.class))); + } + + // ------------------------------------------------------------------------- + // getProjectComponent + // ------------------------------------------------------------------------- + + @Test + void testGetProjectComponent_InstanceNotConfigured() throws MarketplaceException { + // Arrange + String instanceName = "nonexistent"; + String projectKey = "PROJ"; + String componentId = "test-component"; + + when(clientFactory.getClient(instanceName)) + .thenThrow(new MarketplaceException("Marketplace instance 'nonexistent' is not configured")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("not configured")); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testGetProjectComponent_RestClientException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET) + .thenThrow(new RestClientException("Connection failed")); + + // Act & Assert + assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(instanceName, projectKey, componentId)); + + verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); + } + + @Test + void testGetProjectComponent_NotFound_ReturnsNull() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "UNKNOWN"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + ProjectComponentProvisionStatus result = marketplaceService.getProjectComponent(instanceName, projectKey, componentId); + + // Assert + assertNull(result); + verify(clientFactory).getClient(instanceName); + } + + // ------------------------------------------------------------------------- + // isHealthy + // ------------------------------------------------------------------------- + + @Test + void testIsHealthy_NoInstancesConfigured_ReturnsFalse() { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of()); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + }; + + boolean result = service.isHealthy(); + + assertFalse(result); + } + + @Test + void testIsHealthy_BothEndpointsUp_ReturnsTrue() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + }; + + boolean result = service.isHealthy(); + + assertTrue(result); + } + + @Test + void testIsHealthy_ProvisionerDown_ReturnsFalse() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return false; + } + }; + + boolean result = service.isHealthy(); + + assertFalse(result); + } + + @Test + void testIsHealthy_CatalogDown_ReturnsFalse() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return false; + } + }; + + boolean result = service.isHealthy(); + + assertFalse(result); + } + + // ------------------------------------------------------------------------- + // getAvailableInstances / hasInstance + // ------------------------------------------------------------------------- + + @Test + void testGetAvailableInstances() { + // Arrange + Set expected = Set.of("dev", "prod"); + when(clientFactory.getAvailableInstances()).thenReturn(expected); + + // Act + Set result = marketplaceService.getAvailableInstances(); + + // Assert + assertEquals(expected, result); + verify(clientFactory).getAvailableInstances(); + } + + @Test + void testHasInstance_Existing_ReturnsTrue() { + // Arrange + when(clientFactory.hasInstance("dev")).thenReturn(true); + + // Act + Assert + assertTrue(marketplaceService.hasInstance("dev")); + } + + @Test + void testHasInstance_NonExistent_ReturnsFalse() { + // Arrange + when(clientFactory.hasInstance("nope")).thenReturn(false); + + // Act + Assert + assertFalse(marketplaceService.hasInstance("nope")); + } + + // ------------------------------------------------------------------------- + // Default-instance support + // ------------------------------------------------------------------------- + + @Test + void testGetProjectComponent_NoInstanceArg_UsesDefaultClient() throws MarketplaceException { + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenReturn(ResponseEntity.ok(null)); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentProvisionStatus result = marketplaceService.getProjectComponent(projectKey, componentId); + + assertNull(result); + verify(clientFactory).getClient("default"); + } + + @Test + void testGetProjectComponent_NullInstanceName_PropagatesFactoryException() throws MarketplaceException { + // The factory rejects null/blank instance names; the service must surface that. + String projectKey = "PROJ"; + String componentId = "test-component"; + + when(clientFactory.getClient(null)) + .thenThrow(new MarketplaceException("Provide instance name. Available instances: []")); + + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(null, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Provide instance name")); + verify(clientFactory).getClient(null); + } + + @Test + void testGetProjectComponent_BlankInstanceName_PropagatesFactoryException() throws MarketplaceException { + // The factory rejects null/blank instance names; the service must surface that. + String projectKey = "PROJ"; + String componentId = "test-component"; + + when(clientFactory.getClient("")) + .thenThrow(new MarketplaceException("Provide instance name. Available instances: []")); + + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent("", projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Provide instance name")); + verify(clientFactory).getClient(""); + } + + @Test + void testGetProjectComponent_NoInstanceArg_NotFound_ReturnsFalse() throws MarketplaceException { + String projectKey = "ZZZNOPE"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentProvisionStatus result = marketplaceService.getProjectComponent(projectKey, componentId); + + assertNull(result); + } + + @Test + void testGetProjectComponent_NoInstanceArg_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(new RestClientException("timeout")); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + assertThrows(MarketplaceException.class, () -> marketplaceService.getProjectComponent("PROJ", + "test-component")); + } + + @Test + void testGetDefaultInstance_DelegatesToFactory() throws MarketplaceException { + when(clientFactory.getDefaultInstanceName()).thenReturn("prod"); + + String result = marketplaceService.getDefaultInstance(); + + assertEquals("prod", result); + verify(clientFactory).getDefaultInstanceName(); + } + + @Test + void testGetDefaultInstance_FactoryThrows_PropagatesException() throws MarketplaceException { + when(clientFactory.getDefaultInstanceName()) + .thenThrow(new MarketplaceException("No Marketplace instances configured")); + + assertThrows(MarketplaceException.class, () -> marketplaceService.getDefaultInstance()); + } + + @Test + void testProvisionProjectComponent_Conflict_ThrowsMarketplaceExceptionWithDuplicateMessage() throws MarketplaceException { + String instanceName = "dev"; + String projectKey = "EDPC"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + HttpClientErrorException conflictEx = HttpClientErrorException.create( + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + "{\"message\":\"This component name already exists, please choose another name.\"}".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8 + ); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST).thenThrow(conflictEx); + + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertEquals("This component name already exists, please choose another name.", exception.getMessage()); + } + + @Test + void testGetCatalogItem_RestClientException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET) + .thenThrow(new RestClientException("Connection failed")); + + // Act & Assert + assertThrows(MarketplaceException.class, () -> + marketplaceService.getCatalogItem(instanceName, catalogItemId)); + + verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); + } + + @Test + void testGetCatalogItem_NotFound_ReturnsNull() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + CatalogItem result = marketplaceService.getCatalogItem(instanceName, catalogItemId); + + // Assert + assertNull(result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testGetCatalogItem_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET).thenThrow(forbiddenEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getCatalogItem(instanceName, catalogItemId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testGetCatalogItem_DefaultInstance() throws MarketplaceException { + // Arrange + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + CatalogItem result = marketplaceService.getCatalogItem(catalogItemId); + + // Assert + assertNull(result); + verify(clientFactory).getClient("default"); + } + + // ------------------------------------------------------------------------- + // getProjectComponent — auth error + // ------------------------------------------------------------------------- + + @Test + void testGetProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(forbiddenEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + // ------------------------------------------------------------------------- + // provisionProjectComponent — success, auth error, REST error, default instance + // ------------------------------------------------------------------------- + + @Test + void testProvisionProjectComponent_Success_ReturnsTrue() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of()); + + // Assert + assertTrue(result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testProvisionProjectComponent_Failed_ReturnsFalse() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(true); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of()); + + // Assert + assertFalse(result); + } + + @Test + void testProvisionProjectComponent_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenThrow(new RestClientException("Connection refused")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertTrue(exception.getMessage().contains("Failed to provision")); + } + + @Test + void testProvisionProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException unauthorizedEx = HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST).thenThrow(unauthorizedEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testProvisionProjectComponent_DefaultInstance_Success() throws MarketplaceException { + // Arrange + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.provisionProjectComponent(projectKey, List.of()); + + // Assert + assertTrue(result); + verify(clientFactory).getClient("default"); + } + + @Test + void testProvisionProjectComponent_OboScopeNotConfigured_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + // OBO scope is null + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertTrue(exception.getMessage().contains("OBO scope not configured")); + } + + // ------------------------------------------------------------------------- + // deleteProjectComponent + // ------------------------------------------------------------------------- + @Test + void testDeleteProjectComponent_RestClientException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String componentId = "test-component-id"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new RestClientException("Connection failed")); + + // Act & Assert + assertThrows(MarketplaceException.class, () -> + marketplaceService.deleteProjectComponent(instanceName, componentId)); + + verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); + } + + @Test + void testDeleteProjectComponent_Unauthorized_ThrowsException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String componentId = "test-component-id"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + HttpClientErrorException unauthorizedException = HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(unauthorizedException); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + assertThrows(MarketplaceException.class, () -> + marketplaceService.deleteProjectComponent(instanceName, componentId)); + + // Assert + verify(clientFactory).getClient(instanceName); + } + + @Test + void testDeleteProjectComponent_ComponentExists_NoExceptionThrown() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String componentId = "test-component-id"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(ResponseEntity.status(HttpStatus.NO_CONTENT).build()); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + marketplaceService.deleteProjectComponent(instanceName, componentId); + + // Assert + verify(clientFactory).getClient(instanceName); + } + + // ------------------------------------------------------------------------- + // registerProjectComponent + // ------------------------------------------------------------------------- + + @Test + void testRegisterProjectComponent_RestClientException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component-id"; + String catalogItemSlug = "test-catalog-item-slug"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenThrow(new RestClientException("Connection failed")); + + // Act — should not throw + assertThrows(MarketplaceException.class, () -> + marketplaceService.registerProjectComponent(projectKey, componentId, catalogItemSlug, List.of())); + + // Assert + verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); + } + + @Test + void testRegisterProjectComponent_Unauthorized_ThrowsException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component-id"; + String catalogItemSlug = "test-catalog-item-slug"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + HttpClientErrorException unauthorizedException = HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenThrow(unauthorizedException); + + // Act & Assert + assertThrows(MarketplaceException.class, () -> + marketplaceService.registerProjectComponent(projectKey, componentId, catalogItemSlug, List.of())); + + verify(clientFactory).getClient(instanceName); + } + + @Test + void testRegisterProjectComponent_BuildsCorrectComponentUrl_UsingBitbucketBaseUrl() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "my-component"; + String catalogItemSlug = "test-slug"; + String bitbucketBaseUrl = "https://bitbucket.example.com"; + + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setBitbucketBaseUrl(bitbucketBaseUrl); + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(Object.class); + + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenReturn(ResponseEntity.ok(null)); + + // Act + marketplaceService.registerProjectComponent(projectKey, componentId, catalogItemSlug, List.of()); + + // Assert — capture the body passed to invokeAPI and verify its componentUrl + verify(apiClient).invokeAPI( + eq(PATH_NOTIFY_PROVISIONING), + eq(HttpMethod.PUT), + any(), + any(), + bodyCaptor.capture(), + any(HttpHeaders.class), + any(), any(), any(), any(), any(), any()); + + ProvisioningStatusUpdateRequest capturedRequest = + (ProvisioningStatusUpdateRequest) bodyCaptor.getValue(); + + String expectedUrl = bitbucketBaseUrl + "/projects/" + projectKey.toUpperCase() + "/repos/" + + projectKey.toLowerCase() + "-" + componentId + "/browse"; + assertEquals(expectedUrl, capturedRequest.getComponentUrl()); + } + + @Test + void testRegisterProjectComponent_ComponentIsRegistered_NoExceptionThrown() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + String catalogItemSlug = "test-catalog-item-slug"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenReturn(ResponseEntity.ok(null)); + + // Act + marketplaceService.registerProjectComponent(projectKey, componentId, catalogItemSlug, List.of()); + + // Assert + verify(clientFactory).getClient(instanceName); + } + +} \ No newline at end of file diff --git a/external-service-ocp/pom.xml b/external-service-ocp/pom.xml index a2f7df9..befcf6f 100644 --- a/external-service-ocp/pom.xml +++ b/external-service-ocp/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-ocp diff --git a/external-service-projects-info-service/pom.xml b/external-service-projects-info-service/pom.xml index d444710..76a7779 100644 --- a/external-service-projects-info-service/pom.xml +++ b/external-service-projects-info-service/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-projects-info-service diff --git a/external-service-uipath/pom.xml b/external-service-uipath/pom.xml index aa7962c..b2a9925 100644 --- a/external-service-uipath/pom.xml +++ b/external-service-uipath/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-uipath diff --git a/external-service-webhookproxy/pom.xml b/external-service-webhookproxy/pom.xml index d4ed9a8..94c8ec1 100644 --- a/external-service-webhookproxy/pom.xml +++ b/external-service-webhookproxy/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-webhookproxy diff --git a/persistence/liquibase.properties.example b/persistence/liquibase.properties.example new file mode 100644 index 0000000..59629be --- /dev/null +++ b/persistence/liquibase.properties.example @@ -0,0 +1,28 @@ +# Liquibase connection properties — LOCAL DEVELOPMENT TEMPLATE +# +# Copy this file to liquibase.properties and fill in real values. +# cp liquibase.properties.example liquibase.properties +# +# The file liquibase.properties is git-ignored and MUST NOT be committed. +# In CI/CD pipelines the connection is passed via environment variables +# that the Makefile forwards as -D flags to Maven (see 'make help' for details). +# +# Required environment variables used by the Makefile db-* targets: +# ODS_API_SERVICE_DB_HOST PostgreSQL hostname or IP +# ODS_API_SERVICE_DB_PORT PostgreSQL port (default: 5432) +# ODS_API_SERVICE_DB_NAME Database name → ods_api_service +# ODS_API_SERVICE_DB_USER Application user → ods_api_service +# ODS_API_SERVICE_DB_PASSWORD Application user password (plain text) +# +# The Kubernetes Secret holds the Base64-encoded equivalents: +# ODS_API_SERVICE_DB_NAME_B64 +# ODS_API_SERVICE_DB_USER_B64 +# ODS_API_SERVICE_DB_PASSWORD_B64 +# ODS_API_SERVICE_DB_SUPER_PASSWORD_B64 +# --------------------------------------------------------------------------- + +export ODS_API_SERVICE_DB_HOST=localhost +export ODS_API_SERVICE_DB_PORT=5432 +export ODS_API_SERVICE_DB_NAME=ods_api_service +export ODS_API_SERVICE_DB_USER=ods_api_service +export ODS_API_SERVICE_DB_PASSWORD=changeme diff --git a/persistence/pom.xml b/persistence/pom.xml new file mode 100644 index 0000000..92dc1e5 --- /dev/null +++ b/persistence/pom.xml @@ -0,0 +1,118 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + persistence + Persistence + JPA entities, Spring Data repositories, database schema migrations and persistence utilities for the Devstack API Service + + + 5.0.1 + 42.7.10 + + jdbc:postgresql://localhost:5432/ods_api_service + ods_api_service + + org.postgresql.Driver + src/main/resources/db/changelog/db.changelog-master.xml + src/main/resources/db/changelog/db.changelog-generated.xml + true + + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.projectlombok + lombok + true + + + + org.opendevstack.apiservice + core-contracts + ${project.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.testcontainers + postgresql + test + + + + + org.testcontainers + junit-jupiter + test + + + + + org.postgresql + postgresql + test + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + + org.liquibase + liquibase-maven-plugin + ${liquibase.version} + + ${liquibase.driver} + ${liquibase.url} + ${liquibase.username} + ${liquibase.password} + ${liquibase.changeLogFile} + ${liquibase.outputChangeLogFile} + ${liquibase.verbose} + + + + + org.postgresql + postgresql + ${postgresql.version} + + + + + + + diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ApiDefinitionDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ApiDefinitionDaoImpl.java new file mode 100644 index 0000000..a95a85f --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ApiDefinitionDaoImpl.java @@ -0,0 +1,77 @@ +package org.opendevstack.apiservice.persistence.dao; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.persistence.ApiDefinitionDao; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.persistence.entity.ApiDefinitionEntity; +import org.opendevstack.apiservice.persistence.repository.ApiDefinitionJpaRepository; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class ApiDefinitionDaoImpl implements ApiDefinitionDao { + + private final ApiDefinitionJpaRepository repository; + + public ApiDefinitionDaoImpl(ApiDefinitionJpaRepository repository) { + this.repository = repository; + } + + @Override + public Optional findByApiId(String apiId) { + return repository.findByApiId(apiId) + .map(this::toDto); + } + + @Override + public Optional findByBasePathAndVersion(String basePath, String version) { + return repository.findByBasePathAndVersion(basePath, version) + .map(this::toDto); + } + + @Override + public List findAllEnabled() { + return repository.findByEnabledTrue().stream() + .map(this::toDto) + .toList(); + } + + @Override + public List findAll() { + return repository.findAll().stream() + .map(this::toDto) + .toList(); + } + + private ApiDefinition toDto(ApiDefinitionEntity entity) { + Set authTypes = Arrays.stream(entity.getAuthTypes()) + .map(s -> { + try { + return AuthType.valueOf(s); + } catch (IllegalArgumentException e) { + log.warn("Unknown AuthType '{}' in api_definitions row {}", s, entity.getApiId()); + return null; + } + }) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); + + return new ApiDefinition( + entity.getApiId(), + entity.getName(), + entity.getBasePath(), + entity.getVersion(), + authTypes, + entity.isPublic(), + entity.getProxyUrl(), + entity.isEnabled() + ); + } +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java new file mode 100644 index 0000000..d539c21 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java @@ -0,0 +1,41 @@ +package org.opendevstack.apiservice.persistence.dao; + +import org.opendevstack.apiservice.core.contracts.persistence.ClientDao; +import org.opendevstack.apiservice.core.contracts.persistence.ClientInfo; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.UUID; + +@Service +public class ClientDaoImpl implements ClientDao { + + private final ClientAppRepository repository; + + public ClientDaoImpl(ClientAppRepository repository) { + this.repository = repository; + } + + @Override + public Optional findByClientId(String clientId) { + if (clientId == null || clientId.isBlank()) { + return Optional.empty(); + } + + final UUID clientUuid; + try { + clientUuid = UUID.fromString(clientId); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + + return repository.findByClientId(clientUuid) + .map(entity -> new ClientInfo( + entity.getId(), + entity.getClientId().toString(), + entity.getClientName(), + entity.isEnabled() + )); + } +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java new file mode 100644 index 0000000..9b85c97 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java @@ -0,0 +1,71 @@ +package org.opendevstack.apiservice.persistence.dao; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.contracts.persistence.PolicyDao; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.persistence.entity.AuthorizationPolicyEntity; +import org.opendevstack.apiservice.persistence.repository.AuthorizationPolicyJpaRepository; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +public class PolicyDaoImpl implements PolicyDao { + + private final AuthorizationPolicyJpaRepository repository; + private final ObjectMapper objectMapper; + + PolicyDaoImpl(AuthorizationPolicyJpaRepository repository, ObjectMapper objectMapper) { + this.repository = repository; + this.objectMapper = objectMapper; + } + + @Override + public List findByApiDefinitionId(String apiDefinitionId) { + return repository.findByApiDefinitionId(apiDefinitionId).stream() + .map(this::toDto) + .toList(); + } + + @Override + public List findByApiDefinitionIdAndClientId(String apiDefinitionId, String clientId) { + return repository.findByApiDefinitionIdAndClientId(apiDefinitionId, clientId).stream() + .map(this::toDto) + .toList(); + } + + @Override + public List findGlobalByApiDefinitionId(String apiDefinitionId) { + return repository.findGlobalByApiDefinitionId(apiDefinitionId).stream() + .map(this::toDto) + .toList(); + } + + private PolicyRule toDto(AuthorizationPolicyEntity entity) { + Map config = parseConfig(entity.getPolicyConfig()); + return new PolicyRule( + entity.getId(), + entity.getApiDefinitionId(), + entity.getClientId(), + entity.getPolicyType(), + config + ); + } + + private Map parseConfig(String json) { + if (json == null || json.isBlank()) { + return Collections.emptyMap(); + } + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("Failed to parse policy config JSON: {}", e.getMessage()); + return Collections.emptyMap(); + } + } +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ApiDefinitionEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ApiDefinitionEntity.java new file mode 100644 index 0000000..1ef2fec --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ApiDefinitionEntity.java @@ -0,0 +1,51 @@ +package org.opendevstack.apiservice.persistence.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.UUID; + +@Entity +@Table(name = "api_definitions") +@Getter +@Setter +@NoArgsConstructor +public class ApiDefinitionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true) + private String apiId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String basePath; + + @Column(nullable = false) + private String version; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "auth_types", columnDefinition = "varchar(30)[]", nullable = false) + private String[] authTypes = new String[0]; + + @Column(nullable = false) + private boolean isPublic; + + private String proxyUrl; + + @Column(nullable = false) + private boolean enabled = true; +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuditableEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuditableEntity.java new file mode 100644 index 0000000..47c9064 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuditableEntity.java @@ -0,0 +1,39 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import java.time.OffsetDateTime; +import lombok.Getter; +import lombok.Setter; + +/** + * Shared audit timestamps for persistence entities. + */ +@MappedSuperclass +@Getter +@Setter +public abstract class AuditableEntity { + + /** Original creation timestamp (UTC). Set automatically on first persist. */ + @Column(name = "created_at", nullable = false, updatable = false, + columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime createdAt; + + /** Timestamp of last update (UTC). Updated automatically on every merge. */ + @Column(name = "updated_at", nullable = false, columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime updatedAt; + + @PrePersist + protected void onPrePersist() { + OffsetDateTime now = OffsetDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + protected void onPreUpdate() { + this.updatedAt = OffsetDateTime.now(); + } +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuthorizationPolicyEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuthorizationPolicyEntity.java new file mode 100644 index 0000000..1ff60e8 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuthorizationPolicyEntity.java @@ -0,0 +1,36 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Entity +@Table(name = "authorization_policies") +@Getter +@Setter +@NoArgsConstructor +public class AuthorizationPolicyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String apiDefinitionId; + + private String clientId; + + @Column(nullable = false) + private String policyType; + + @Column(columnDefinition = "jsonb") + private String policyConfig; +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java new file mode 100644 index 0000000..98a1421 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java @@ -0,0 +1,56 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * JPA entity mapping the {@code client_apps} table. + */ +@Entity +@Table(name = "client_apps") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ClientAppEntity extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + /** Azure AD application/client identifier. */ + @Column(name = "client_id", nullable = false, unique = true) + private UUID clientId; + + /** Optional display name for the client application. */ + @Column(name = "client_name", length = 255) + private String clientName; + + /** Whether the client is allowed to call the API. */ + @Column(name = "enabled", nullable = false) + @Builder.Default + private boolean enabled = true; + + /** Project flavor configurations associated with this client. */ + @OneToMany(mappedBy = "clientApp", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List projectFlavors = new ArrayList<>(); + +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java new file mode 100644 index 0000000..5d0015e --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java @@ -0,0 +1,75 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * JPA entity mapping the {@code client_app_project_flavors} table. + */ +@Entity +@Table(name = "client_app_project_flavors") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString(exclude = "clientApp") +public class ClientAppProjectFlavorEntity extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + /** Owning client application. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "client_app_id", nullable = false) + private ClientAppEntity clientApp; + + /** Human-readable flavor name. */ + @Column(name = "name", nullable = false, length = 50) + private String name; + + /** Project key generation pattern. */ + @Column(name = "project_key_pattern", nullable = false, length = 100) + private String projectKeyPattern; + + /** Default project owner. */ + @Column(name = "project_owner", length = 255) + private String projectOwner; + + /** Service account associated with the flavor. */ + @Column(name = "service_account", length = 255) + private String serviceAccount; + + /** Default CMDB configuration item. */ + @Column(name = "config_item", length = 255) + private String configItem; + + /** Allowed CMDB configuration item overrides. */ + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "allowed_config_items", nullable = false, columnDefinition = "TEXT[]") + @Builder.Default + private String[] allowedConfigItems = new String[0]; + + /** Deployment region. */ + @Column(name = "location", length = 50) + private String location; + +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java new file mode 100644 index 0000000..ae28dac --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java @@ -0,0 +1,103 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +/** + * JPA entity mapping the {@code projects} table. + * + *

+ * Schema is managed externally via Liquibase (see {@code database} module). Hibernate is + * configured with {@code ddl-auto=validate} so it only validates alignment at boot — it never + * modifies the schema. + *

+ * + *

+ * Soft-deletes are supported via the {@link #deleted} flag. Use + * {@code findByDeletedFalse()} to retrieve only active projects. + *

+ */ +@Entity +@Table(name = "projects") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString(exclude = { "description", "ldapGroupManager", "ldapGroupTeam", "ldapGroupStakeholder" }) +public class ProjectEntity extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + /** + * Human-readable unique identifier (e.g. {@code MY-PROJECT}). Maps to the + * {@code uq_projects_project_key} unique index. + */ + @Column(name = "project_key", nullable = false, unique = true, length = 100) + private String projectKey; + + /** Optional display name. */ + @Column(name = "project_name", length = 255) + private String projectName; + + /** Optional free-text description. */ + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + /** Configuration item identifier. */ + @Column(name = "configuration_item", nullable = false, length = 255) + private String configurationItem; + + /** + * Deployment region. + */ + @Column(name = "location", nullable = false, length = 50) + private String location; + + /** + * Project type classifier. + */ + @Column(name = "project_flavor", length = 50) + private String projectFlavor; + + /** + * Provisioning status. Known values: {@code Pending}, {@code Running}, {@code Failed} + * ({@code null} = completed successfully). + */ + @Column(name = "status", length = 50) + private String status; + + /** + * Soft-delete flag. Active projects have {@code deleted = false}. + */ + @Column(name = "deleted", nullable = false) + @Builder.Default + private boolean deleted = false; + + /** Full LDAP DN for the PROJECT_MANAGER group. */ + @Column(name = "ldap_group_manager", columnDefinition = "TEXT") + private String ldapGroupManager; + + /** Full LDAP DN for the PROJECT_TEAM group. */ + @Column(name = "ldap_group_team", columnDefinition = "TEXT") + private String ldapGroupTeam; + + /** Full LDAP DN for the PROJECT_STAKEHOLDER group. */ + @Column(name = "ldap_group_stakeholder", columnDefinition = "TEXT") + private String ldapGroupStakeholder; + +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ApiDefinitionJpaRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ApiDefinitionJpaRepository.java new file mode 100644 index 0000000..74e8bd5 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ApiDefinitionJpaRepository.java @@ -0,0 +1,17 @@ +package org.opendevstack.apiservice.persistence.repository; + +import org.opendevstack.apiservice.persistence.entity.ApiDefinitionEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ApiDefinitionJpaRepository extends JpaRepository { + + Optional findByApiId(String apiId); + + Optional findByBasePathAndVersion(String basePath, String version); + + List findByEnabledTrue(); +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java new file mode 100644 index 0000000..1df9182 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java @@ -0,0 +1,24 @@ +package org.opendevstack.apiservice.persistence.repository; + +import org.opendevstack.apiservice.persistence.entity.AuthorizationPolicyEntity; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface AuthorizationPolicyJpaRepository extends JpaRepository { + + List findByApiDefinitionId(String apiDefinitionId); + + List findByApiDefinitionIdAndClientId(String apiDefinitionId, String clientId); + + @Query(""" + SELECT p + FROM AuthorizationPolicyEntity p + WHERE p.apiDefinitionId = :apiDefinitionId + AND (p.clientId IS NULL OR TRIM(p.clientId) = '') + """) + List findGlobalByApiDefinitionId(@Param("apiDefinitionId") String apiDefinitionId); +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java new file mode 100644 index 0000000..b088a85 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java @@ -0,0 +1,45 @@ +package org.opendevstack.apiservice.persistence.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for {@link ClientAppEntity}. + */ +@Repository +public interface ClientAppRepository extends JpaRepository { + + /** + * Finds a client application by its Azure AD client identifier. + * @param clientId Azure AD application/client UUID + * @return matching client app, if present + */ + Optional findByClientId(UUID clientId); + + /** + * Returns all enabled client applications. + * @return enabled client apps + */ + List findByEnabledTrue(); + + /** + * Checks if a client application exists for the given Azure AD client identifier. + * @param clientId Azure AD application/client UUID + * @return {@code true} if the client app exists + */ + boolean existsByClientId(UUID clientId); + + /** + * Loads a client application together with its project flavor configuration. + * @param clientId Azure AD application/client UUID + * @return client app with project flavors initialized, if present + */ + @EntityGraph(attributePaths = "projectFlavors") + Optional findDetailedByClientId(UUID clientId); + +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java new file mode 100644 index 0000000..7393dc5 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java @@ -0,0 +1,32 @@ +package org.opendevstack.apiservice.persistence.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for {@link ProjectEntity}. + * + *

+ * Provides standard CRUD operations inherited from {@link JpaRepository} plus + * domain-specific derived query methods. All query methods that return project + * collections exclude soft-deleted records (i.e. {@code deleted = false}) by + * convention — use {@code findAllIncludingDeleted()} variants explicitly when + * deleted records are needed. + *

+ */ +@Repository +public interface ProjectRepository extends JpaRepository { + + Optional findByProjectKeyIgnoreCase(String projectKey); + + List findByProjectNameIgnoreCase(String projectName); + + List findByDeletedFalse(); + + boolean existsByProjectKeyIgnoreCase(String projectKey); + +} diff --git a/persistence/src/main/resources/db/changelog/db.changelog-master.xml b/persistence/src/main/resources/db/changelog/db.changelog-master.xml new file mode 100644 index 0000000..1c6499a --- /dev/null +++ b/persistence/src/main/resources/db/changelog/db.changelog-master.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/persistence/src/main/resources/db/changelog/migrations/001_create_projects_table.sql b/persistence/src/main/resources/db/changelog/migrations/001_create_projects_table.sql new file mode 100644 index 0000000..382c40a --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/001_create_projects_table.sql @@ -0,0 +1,46 @@ +--liquibase formatted sql + +--changeset ods:001-create-projects +-- Description: Creates the `projects` table, which is the central entity of the +-- ods-api-service. Each row represents a managed project identified by +-- a unique, Atlassian project key (e.g. "MY-PROJECT"). +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS projects ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + project_key VARCHAR(100) NOT NULL, + project_name VARCHAR(255), + description VARCHAR(255), + configuration_item VARCHAR(255) NOT NULL, + location VARCHAR(50) NOT NULL, + project_flavor VARCHAR(50), + status VARCHAR(50), + deleted BOOLEAN NOT NULL DEFAULT FALSE, + ldap_group_manager VARCHAR(255), + ldap_group_team VARCHAR(255), + ldap_group_stakeholder VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Unique index on project_key — used as a natural FK target from other tables +CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_project_key + ON projects (project_key); + +COMMENT ON TABLE projects IS 'Managed projects maintained by ods-api-service'; +COMMENT ON COLUMN projects.project_key IS 'Human-readable unique identifier (e.g. MY-PROJECT)'; +COMMENT ON COLUMN projects.project_name IS 'Optional display name'; +COMMENT ON COLUMN projects.description IS 'Optional free-text description'; +COMMENT ON COLUMN projects.configuration_item IS 'CMDB configuration item identifier'; +COMMENT ON COLUMN projects.location IS 'Deployment region; known values: cn, eu, nah, us, us-test'; +COMMENT ON COLUMN projects.project_flavor IS 'Project type classifier; known values: AMP, DLSS'; +COMMENT ON COLUMN projects.status IS 'Provisioning status; known values: Failed, Pending (null = completed)'; +COMMENT ON COLUMN projects.deleted IS 'Soft-delete flag'; +COMMENT ON COLUMN projects.ldap_group_manager IS 'Full LDAP CN for the PROJECT_MANAGER group'; +COMMENT ON COLUMN projects.ldap_group_team IS 'Full LDAP CN for the PROJECT_TEAM group'; +COMMENT ON COLUMN projects.ldap_group_stakeholder IS 'Full LDAP CN for the PROJECT_STAKEHOLDER group'; +COMMENT ON COLUMN projects.created_at IS 'Original creation timestamp (UTC)'; +COMMENT ON COLUMN projects.updated_at IS 'Timestamp of last update (UTC)'; + +--rollback DROP INDEX IF EXISTS uq_projects_project_key; +--rollback DROP TABLE IF EXISTS projects; diff --git a/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql b/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql new file mode 100644 index 0000000..8814481 --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql @@ -0,0 +1,63 @@ +--liquibase formatted sql + +--changeset ods:002-create-client-apps +-- Description: Creates the `client_apps` table, representing registered Azure AD +-- clients that are authorised to call the service APIs. +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS client_apps ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + client_id VARCHAR(36) NOT NULL, + client_name VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_client_apps_client_id + ON client_apps (client_id); + +COMMENT ON TABLE client_apps IS 'Azure AD clients authorised to call the service APIs'; +COMMENT ON COLUMN client_apps.client_id IS 'Azure AD Application (client) UUID'; +COMMENT ON COLUMN client_apps.client_name IS 'Azure AD application display name'; +COMMENT ON COLUMN client_apps.enabled IS 'When FALSE the client is denied access without removing the row'; + +--rollback DROP INDEX IF EXISTS uq_client_apps_client_id; +--rollback DROP TABLE IF EXISTS client_apps; + + +--changeset ods:002-create-client-app-project-flavors +-- Description: Each API client can be configured with one or more project flavors +-- that control how projects are created (key pattern, template, owner, etc.). +CREATE TABLE IF NOT EXISTS client_app_project_flavors ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + client_app_id UUID NOT NULL, + name VARCHAR(50) NOT NULL, + project_key_pattern VARCHAR(100) NOT NULL, + project_owner VARCHAR(255), + service_account VARCHAR(255), + config_item VARCHAR(255), + allowed_config_items TEXT[] NOT NULL DEFAULT '{}', + location VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_project_flavors_client_app + FOREIGN KEY (client_app_id) REFERENCES client_apps (id) + ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_client_app_project_flavors_client_app_id + ON client_app_project_flavors (client_app_id); + +COMMENT ON TABLE client_app_project_flavors IS 'Project flavor configurations per API client'; +COMMENT ON COLUMN client_app_project_flavors.client_app_id IS 'FK to client_apps.id (UUID)'; +COMMENT ON COLUMN client_app_project_flavors.name IS 'Flavor name (e.g. DLSS, AMP)'; +COMMENT ON COLUMN client_app_project_flavors.project_key_pattern IS 'printf-style pattern used to generate the project key (e.g. DLSS%06d)'; +COMMENT ON COLUMN client_app_project_flavors.project_owner IS 'Default project owner username'; +COMMENT ON COLUMN client_app_project_flavors.service_account IS 'Service account associated with the flavor'; +COMMENT ON COLUMN client_app_project_flavors.config_item IS 'Default CMDB configuration item for projects created under this flavor'; +COMMENT ON COLUMN client_app_project_flavors.allowed_config_items IS 'Allowed CMDB configuration item overrides (empty = no overrides permitted)'; +COMMENT ON COLUMN client_app_project_flavors.location IS 'Deployment region (e.g. eu, us)'; + +--rollback DROP INDEX IF EXISTS idx_client_app_project_flavors_client_app_id; +--rollback DROP TABLE IF EXISTS client_app_project_flavors; diff --git a/persistence/src/main/resources/db/changelog/migrations/003_create_policy_engine_tables.sql b/persistence/src/main/resources/db/changelog/migrations/003_create_policy_engine_tables.sql new file mode 100644 index 0000000..4c16077 --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/003_create_policy_engine_tables.sql @@ -0,0 +1,81 @@ +--liquibase formatted sql + +-- ============================================================================ +-- changeset ods:006-create-api-definitions +-- Description: API definitions table Each row represents a managed API module. +-- ============================================================================ +CREATE TABLE IF NOT EXISTS api_definitions ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + api_id VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + base_path VARCHAR(255) NOT NULL, + version VARCHAR(20) NOT NULL, + auth_types VARCHAR(30)[] NOT NULL DEFAULT '{}', + is_public BOOLEAN NOT NULL DEFAULT FALSE, + proxy_url VARCHAR(500), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_definitions_api_id + ON api_definitions (api_id); + +COMMENT ON TABLE api_definitions IS 'Managed API modules with auth and routing metadata'; +COMMENT ON COLUMN api_definitions.api_id IS 'Unique logical identifier (e.g. project-v0, project-users-v1)'; +COMMENT ON COLUMN api_definitions.base_path IS 'URL base path (e.g. projects, projects/users)'; +COMMENT ON COLUMN api_definitions.version IS 'API version (e.g. v0, v1)'; +COMMENT ON COLUMN api_definitions.auth_types IS 'Allowed OAuth2 flow types: NONE, OBO, CLIENT_CREDENTIALS'; +COMMENT ON COLUMN api_definitions.is_public IS 'When TRUE the API is accessible without authentication'; +COMMENT ON COLUMN api_definitions.proxy_url IS 'If set, requests are proxied to this URL (null = local controller)'; + +--rollback DROP INDEX IF EXISTS uq_api_definitions_api_id; +--rollback DROP TABLE IF EXISTS api_definitions; + + +-- ============================================================================ +-- changeset ods:006-create-authorization-policies +-- Description: Flexible, JSON-configured authorization policies. +-- Each policy ties an API definition to optional client-specific rules. +-- policy_config stores evaluator-specific parameters as JSONB. +-- ============================================================================ +CREATE TABLE IF NOT EXISTS authorization_policies ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + api_definition_id VARCHAR(100) NOT NULL, + client_id VARCHAR(36), + policy_type VARCHAR(100) NOT NULL, + policy_config JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_auth_policies_api_def_id + ON authorization_policies (api_definition_id); + +CREATE INDEX IF NOT EXISTS idx_auth_policies_client_id + ON authorization_policies (client_id); + +COMMENT ON TABLE authorization_policies IS 'JSON-configured authorization policy rules'; +COMMENT ON COLUMN authorization_policies.api_definition_id IS 'Logical API id (matches api_definitions.api_id)'; +COMMENT ON COLUMN authorization_policies.client_id IS 'Azure client id (NULL = global rule for all clients)'; +COMMENT ON COLUMN authorization_policies.policy_type IS 'Evaluator type (e.g. ALLOWED_CLIENTS, SCOPE_REQUIRED, FLAVOR_RESTRICTION)'; +COMMENT ON COLUMN authorization_policies.policy_config IS 'Evaluator-specific config as JSONB'; + +--rollback DROP INDEX IF EXISTS idx_auth_policies_client_id; +--rollback DROP INDEX IF EXISTS idx_auth_policies_api_def_id; +--rollback DROP TABLE IF EXISTS authorization_policies; + + +-- ============================================================================ +-- changeset ods:006-seed-api-definitions +-- Description: Seed API definitions. +-- ============================================================================ +INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled) +VALUES + ('project-v0', 'Project API v0', 'projects', 'v0', ARRAY['CLIENT_CREDENTIALS'], FALSE, TRUE), + ('project-users-v1', 'Project Users API v1', 'projects/*/users', 'v1', ARRAY['CLIENT_CREDENTIALS'], FALSE, TRUE), + ('project-platforms-v1', 'Project Platforms API v1', 'projects/*/platforms', 'v1', ARRAY['NONE'], FALSE, TRUE) +ON CONFLICT (api_id) DO NOTHING; + +--rollback DELETE FROM api_definitions WHERE api_id IN ('project-v0', 'project-users-v1'); + diff --git a/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql b/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql new file mode 100644 index 0000000..852026f --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +--changeset ods:004-alter-client-apps-client-id-to-uuid +-- Description: Converts client_apps.client_id from VARCHAR(36) to UUID to align DB and Java types. +ALTER TABLE client_apps + ALTER COLUMN client_id TYPE UUID + USING client_id::uuid; + +--rollback ALTER TABLE client_apzzps +--rollback ALTER COLUMN client_id TYPE VARCHAR(36) +--rollback USING client_id::text; + diff --git a/persistence/src/main/resources/db/changelog/migrations/005_add_component_apis.sql b/persistence/src/main/resources/db/changelog/migrations/005_add_component_apis.sql new file mode 100644 index 0000000..825794f --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/005_add_component_apis.sql @@ -0,0 +1,26 @@ +--liquibase formatted sql + +-- ============================================================================ +-- changeset ods:007-update-platform-api-visibility +-- Description: Set project-platforms-v1 API as public. +-- ============================================================================ +UPDATE api_definitions +SET is_public = TRUE +WHERE api_id = 'project-platforms-v1'; + +--rollback UPDATE api_definitions SET is_public = FALSE WHERE api_id = 'project-platforms-v1'; + + +-- ============================================================================ +-- changeset ods:008-seed-component-api-definitions +-- Description: Seed API definitions for Project Components APIs v0 and v1. +-- ============================================================================ +INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled) +VALUES + ('project-component-v1', 'Project Components API v1', 'projects/*/components', 'v1', ARRAY['CLIENT_CREDENTIALS', 'OBO'], FALSE, TRUE), + ('project-component-v0', 'Project Components API v0', 'projects/*/components', 'v0', ARRAY['CLIENT_CREDENTIALS', 'OBO'], FALSE, TRUE) +ON CONFLICT (api_id) DO NOTHING; + +--rollback DELETE FROM api_definitions WHERE api_id IN ('project-component-v1', 'project-component-v0'); + + diff --git a/persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql b/persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql new file mode 100644 index 0000000..d5daa7d --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +-- ============================================================================ +-- changeset ods:009-add-project-v1-api-definition +-- Description: Seed API definitions for Project API v1. +-- ============================================================================ +INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled) +VALUES + ('project-v1', 'Project API v1', 'projects', 'v1', ARRAY['CLIENT_CREDENTIALS'], FALSE, TRUE) + ON CONFLICT (api_id) DO NOTHING; + +--rollback DELETE FROM api_definitions WHERE api_id IN ('project-v1'); \ No newline at end of file diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java new file mode 100644 index 0000000..0360a18 --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.persistence; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Shared Spring Boot test bootstrap for the persistence module. + */ +@SpringBootConfiguration +@EnableAutoConfiguration +public class PersistenceTestApplication { + +} \ No newline at end of file diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/PersistenceIntegrationTestConfig.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/PersistenceIntegrationTestConfig.java new file mode 100644 index 0000000..a83c804 --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/PersistenceIntegrationTestConfig.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.persistence.integration; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Test configuration for persistence integration tests. + * Enables Spring Boot auto-configuration, entity scanning, and JPA repository + * scanning for the persistence module. + */ +@SpringBootConfiguration +@EnableAutoConfiguration +@EntityScan(basePackages = "org.opendevstack.apiservice.persistence.entity") +@EnableJpaRepositories(basePackages = "org.opendevstack.apiservice.persistence.repository") +public class PersistenceIntegrationTestConfig { + +} diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java new file mode 100644 index 0000000..b944e73 --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java @@ -0,0 +1,273 @@ +package org.opendevstack.apiservice.persistence.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.persistence.repository.ProjectRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; + +import lombok.extern.slf4j.Slf4j; + +/** + * Full-context integration tests for {@link ProjectRepository} against a real + * local PostgreSQL database. + + *

+ * Requires a running PostgreSQL instance reachable via the environment + * variables consumed by these tests. If no environment variables are provided, + * the defaults point to the local development database. + * + *

+ * Test data is inserted under well-known keys ({@code IT-ACTIVE-01}, + * {@code IT-DELETED-01}, + * {@code IT-PENDING-01}) and cleaned up in {@code @AfterEach}, so the tests do + * not interfere with existing data in the database. + *

+ */ +@SpringBootTest(classes = PersistenceIntegrationTestConfig.class) +@ActiveProfiles("local") +@TestPropertySource(properties = { "spring.config.activate.on-profile=local" }) +@EnabledIfEnvironmentVariable(named = "PROJECT_REPOSITORY_INTEGRATION_TEST_ENABLED", matches = "true") +@Slf4j +class ProjectRepositoryIntegrationTest { + + private static final String DEFAULT_TEST_DATASOURCE_URL = "jdbc:postgresql://localhost:5432/ods_api_service"; + private static final String DEFAULT_TEST_DATASOURCE_USERNAME = "ods_api_service"; + private static final String DEFAULT_TEST_DATASOURCE_PASSWORD = "ods_api_service"; + + @DynamicPropertySource + static void datasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", + () -> System.getenv().getOrDefault("IT_SPRING_DATASOURCE_URL", DEFAULT_TEST_DATASOURCE_URL)); + registry.add("spring.datasource.username", + () -> System.getenv().getOrDefault("IT_SPRING_DATASOURCE_USERNAME", DEFAULT_TEST_DATASOURCE_USERNAME)); + registry.add("spring.datasource.password", + () -> System.getenv().getOrDefault("IT_SPRING_DATASOURCE_PASSWORD", DEFAULT_TEST_DATASOURCE_PASSWORD)); + } + + /** Keys reserved exclusively for these integration tests. */ + private static final Set TEST_KEYS = Set.of("IT-ACTIVE-01", "IT-DELETED-01", "IT-PENDING-01", + "IT-DUPLICATE-01"); + + @Autowired + private ProjectRepository projectRepository; + + private ProjectEntity activeProject; + private ProjectEntity deletedProject; + + @BeforeEach + void setUp() { + cleanupTestData(); + + activeProject = projectRepository.save(ProjectEntity.builder() + .projectKey("IT-ACTIVE-01") + .projectName("IT Active Project") + .configurationItem("CI-IT-001") + .location("eu") + .projectFlavor("AMP") + .deleted(false) + .build()); + + deletedProject = projectRepository.save(ProjectEntity.builder() + .projectKey("IT-DELETED-01") + .projectName("IT Deleted Project") + .configurationItem("CI-IT-002") + .location("us") + .projectFlavor("DLSS") + .status("Failed") + .deleted(true) + .build()); + + // Pending project — active but not yet provisioned + projectRepository.save(ProjectEntity.builder() + .projectKey("IT-PENDING-01") + .projectName("IT Pending Project") + .configurationItem("CI-IT-003") + .location("eu") + .projectFlavor("AMP") + .status("Pending") + .deleted(false) + .build()); + } + + @AfterEach + void tearDown() { + cleanupTestData(); + } + + private void cleanupTestData() { + TEST_KEYS.forEach(key -> projectRepository.findByProjectKeyIgnoreCase(key).ifPresent(projectRepository::delete)); + } + + @Nested + @DisplayName("findByProjectKeyIgnoreCase") + class findByProjectKeyIgnoreCase { + + @Test + @DisplayName("returns active project by its key") + void returnsActiveProjectByKey() { + Optional result = projectRepository.findByProjectKeyIgnoreCase("IT-ACTIVE-01"); + + assertThat(result).isPresent(); + assertThat(result.get().getProjectKey()).isEqualTo("IT-ACTIVE-01"); + assertThat(result.get().isDeleted()).isFalse(); + } + + @Test + @DisplayName("returns soft-deleted project by its key") + void returnsDeletedProjectByKey() { + Optional result = projectRepository.findByProjectKeyIgnoreCase("IT-DELETED-01"); + + assertThat(result).isPresent(); + assertThat(result.get().getProjectKey()).isEqualTo("IT-DELETED-01"); + assertThat(result.get().isDeleted()).isTrue(); + } + + @Test + @DisplayName("returns empty Optional for a key that has never existed") + void returnsEmptyForUnknownKey() { + Optional result = projectRepository.findByProjectKeyIgnoreCase("NONEXISTENTKEY"); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("returned entity contains all persisted fields") + void returnedEntityContainsAllFields() { + Optional result = projectRepository.findByProjectKeyIgnoreCase("IT-DELETED-01"); + + assertThat(result).isPresent(); + ProjectEntity entity = result.get(); + assertThat(entity.getProjectName()).isEqualTo("IT Deleted Project"); + assertThat(entity.getConfigurationItem()).isEqualTo("CI-IT-002"); + assertThat(entity.getLocation()).isEqualTo("us"); + assertThat(entity.getStatus()).isEqualTo("Failed"); + } + } + + @Nested + @DisplayName("findByDeletedFalse") + class FindByDeletedFalse { + + @Test + @DisplayName("returns only non-deleted projects") + void returnsOnlyNonDeletedProjects() { + List active = projectRepository.findByDeletedFalse(); + + assertThat(active) + .isNotEmpty() + .allMatch(p -> !p.isDeleted()); + } + + @Test + @DisplayName("excludes soft-deleted projects from results") + void excludesSoftDeletedProjects() { + List active = projectRepository.findByDeletedFalse(); + + assertThat(active) + .isNotEmpty() + .extracting(ProjectEntity::getProjectKey) + .doesNotContain("IT-DELETED-01"); + } + + @Test + @DisplayName("includes test active projects regardless of their status field") + void includesAllActiveProjectsRegardlessOfStatus() { + List active = projectRepository.findByDeletedFalse(); + + // The real DB may contain other projects; assert our test keys are present + assertThat(active) + .extracting(ProjectEntity::getProjectKey) + .contains("IT-ACTIVE-01", "IT-PENDING-01"); + } + } + + // ── existsByProjectKey ──────────────────────────────────────────────────── + + @Nested + @DisplayName("existsByProjectKey") + class ExistsByProjectKey { + + @Test + @DisplayName("returns true for an existing active project key") + void returnsTrueForActiveKey() { + assertThat(projectRepository.existsByProjectKeyIgnoreCase("IT-ACTIVE-01")).isTrue(); + } + + @Test + @DisplayName("returns true for a soft-deleted project key") + void returnsTrueForDeletedKey() { + assertThat(projectRepository.existsByProjectKeyIgnoreCase("IT-DELETED-01")).isTrue(); + } + + @Test + @DisplayName("returns false for a key that has never existed") + void returnsFalseForNonExistentKey() { + assertThat(projectRepository.existsByProjectKeyIgnoreCase("NONEXISTENTKEY")).isFalse(); + } + } + + @Nested + @DisplayName("Persistence lifecycle") + class PersistenceLifecycle { + + @Test + @DisplayName("save() auto-populates createdAt and updatedAt timestamps") + void saveAutoPopulatesTimestamps() { + assertThat(activeProject.getCreatedAt()).isNotNull(); + assertThat(activeProject.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("save() with duplicate project_key throws DataIntegrityViolationException") + void saveDuplicateProjectKeyThrowsException() { + ProjectEntity duplicate = ProjectEntity.builder() + .projectKey("IT-ACTIVE-01") // already exists + .configurationItem("CI-IT-999") + .location("eu") + .build(); + + assertThatThrownBy(() -> projectRepository.saveAndFlush(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("soft-delete by setting deleted=true is reflected in findByDeletedFalse") + void softDeleteIsReflectedInActiveList() { + activeProject.setDeleted(true); + projectRepository.save(activeProject); + + assertThat(projectRepository.findByDeletedFalse()) + .extracting(ProjectEntity::getProjectKey) + .doesNotContain("IT-ACTIVE-01"); + } + + @Test + @DisplayName("restoring a soft-deleted project makes it reappear in findByDeletedFalse") + void restoredProjectReappearsInActiveList() { + deletedProject.setDeleted(false); + projectRepository.save(deletedProject); + + assertThat(projectRepository.findByDeletedFalse()) + .extracting(ProjectEntity::getProjectKey) + .contains("IT-DELETED-01"); + } + } +} \ No newline at end of file diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java new file mode 100644 index 0000000..4d433e7 --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java @@ -0,0 +1,126 @@ +package org.opendevstack.apiservice.persistence.repository; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link ClientAppRepository}. + */ +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = Replace.NONE) +class ClientAppRepositoryTest { + + @Container + @SuppressWarnings("resource") + static final PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18-alpine") + .withDatabaseName("devstack_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void postgresProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.datasource.hikari.maximum-pool-size", () -> "2"); + registry.add("spring.datasource.hikari.minimum-idle", () -> "0"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + } + + @Autowired + private ClientAppRepository repository; + + private ClientAppEntity enabledClientApp; + + @BeforeEach + void setUp() { + repository.deleteAll(); + + enabledClientApp = createClientApp(UUID.fromString("11111111-1111-1111-1111-111111111111"), true); + createClientApp(UUID.fromString("22222222-2222-2222-2222-222222222222"), false); + } + + private ClientAppEntity createClientApp(UUID clientId, boolean enabled) { + ClientAppEntity clientApp = ClientAppEntity.builder() + .clientId(clientId) + .clientName(enabled ? "Enabled app" : "Disabled app") + .enabled(enabled) + .build(); + + return repository.saveAndFlush(clientApp); + } + + @Nested + @DisplayName("findByClientId") + class FindByClientId { + + @Test + @DisplayName("returns the matching client app") + void returnsMatchingClientApp() { + Optional found = repository.findByClientId(UUID.fromString("11111111-1111-1111-1111-111111111111")); + + assertThat(found).isPresent(); + assertThat(found.get().getClientName()).isEqualTo("Enabled app"); + assertThat(found.get().isEnabled()).isTrue(); + } + + @Test + @DisplayName("returns empty for unknown client id") + void returnsEmptyForUnknownClientId() { + assertThat(repository.findByClientId(UUID.fromString("99999999-9999-9999-9999-999999999999"))).isEmpty(); + } + + } + + @Nested + @DisplayName("findByEnabledTrue") + class FindByEnabledTrue { + + @Test + @DisplayName("returns only enabled client apps") + void returnsOnlyEnabledClientApps() { + List enabledApps = repository.findByEnabledTrue(); + + assertThat(enabledApps).hasSize(1); + assertThat(enabledApps.get(0).getClientId().toString()).isEqualTo("11111111-1111-1111-1111-111111111111"); + } + + } + @Nested + @DisplayName("existsByClientId") + class ExistsByClientId { + + @Test + @DisplayName("returns true for existing client id") + void returnsTrueForExistingClientId() { + assertThat(repository.existsByClientId(UUID.fromString("11111111-1111-1111-1111-111111111111"))).isTrue(); + } + + @Test + @DisplayName("returns false for unknown client id") + void returnsFalseForUnknownClientId() { + assertThat(repository.existsByClientId(UUID.fromString("99999999-9999-9999-9999-999999999999"))).isFalse(); + } + + } + +} \ No newline at end of file diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java new file mode 100644 index 0000000..ca30d90 --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java @@ -0,0 +1,163 @@ +package org.opendevstack.apiservice.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration tests for {@link ProjectRepository}. + * + *

+ * Uses a real PostgreSQL instance via Testcontainers with Hibernate + * {@code create-drop} so the tests are fully schema-autonomous — no dependency + * on the Liquibase migration files at test time. This keeps the persistence + * module independently testable. + *

+ */ +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = Replace.NONE) +class ProjectRepositoryTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18-alpine") + .withDatabaseName("devstack_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void postgresProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.datasource.hikari.maximum-pool-size", () -> "2"); + registry.add("spring.datasource.hikari.minimum-idle", () -> "0"); + // Let Hibernate create the schema once for the container lifecycle and avoid + // delayed drop-on-close work during JVM shutdown. + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + } + + @Autowired + private ProjectRepository repository; + + private ProjectEntity activeProject; + + private ProjectEntity deletedProject; + + @BeforeEach + void setUp() { + repository.deleteAll(); + + activeProject = repository.save(ProjectEntity.builder() + .projectKey("ACTIVE-01") + .projectName("Active Project One") + .configurationItem("CI-001") + .location("eu") + .projectFlavor("AMP") + .status(null) // null = completed in this domain + .deleted(false) + .build()); + + deletedProject = repository.save(ProjectEntity.builder() + .projectKey("DELETED-01") + .projectName("Deleted Project One") + .configurationItem("CI-002") + .location("us") + .projectFlavor("DLSS") + .status("Failed") + .deleted(true) + .build()); + } + + // ── CRUD ────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Basic CRUD") + class BasicCrud { + + @Test + @DisplayName("save() persists entity and auto-sets timestamps") + void save_persistsEntityWithTimestamps() { + assertThat(activeProject.getId()).isNotNull(); + assertThat(activeProject.getCreatedAt()).isNotNull(); + assertThat(activeProject.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("findById() returns the saved entity") + void findById_returnsEntity() { + Optional found = repository.findById(activeProject.getId()); + assertThat(found).isPresent(); + assertThat(found.get().getProjectKey()).isEqualTo("ACTIVE-01"); + } + + @Test + @DisplayName("save() with duplicate project_key throws DataIntegrityViolationException") + void save_duplicateProjectKey_throwsException() { + ProjectEntity duplicate = ProjectEntity.builder() + .projectKey("ACTIVE-01") // same key + .configurationItem("CI-999") + .location("eu") + .build(); + + assertThatThrownBy(() -> { + repository.saveAndFlush(duplicate); + }).isInstanceOf(DataIntegrityViolationException.class); + } + + } + + @Nested + @DisplayName("findByProjectKeyIgnoreCase") + class findByProjectKeyIgnoreCase { + + @Test + @DisplayName("returns project regardless of deleted flag") + void returnsProjectRegardlessOfDeletedFlag() { + assertThat(repository.findByProjectKeyIgnoreCase("ACTIVE-01")).isPresent(); + assertThat(repository.findByProjectKeyIgnoreCase("DELETED-01")).isPresent(); + } + + @Test + @DisplayName("returns empty for unknown key") + void returnsEmptyForUnknownKey() { + assertThat(repository.findByProjectKeyIgnoreCase("UNKNOWN")).isEmpty(); + } + + } + + + // ── findByDeletedFalse ──────────────────────────────────────────────────── + + @Nested + @DisplayName("findByDeletedFalse") + class FindByDeletedFalse { + + @Test + @DisplayName("returns only active projects") + void returnsOnlyActiveProjects() { + List active = repository.findByDeletedFalse(); + assertThat(active).hasSize(1); + assertThat(active.get(0).getProjectKey()).isEqualTo("ACTIVE-01"); + } + + } + +} diff --git a/persistence/src/test/resources/application-local.properties b/persistence/src/test/resources/application-local.properties new file mode 100644 index 0000000..af3a390 --- /dev/null +++ b/persistence/src/test/resources/application-local.properties @@ -0,0 +1,10 @@ +logging.level.org.springframework.boot.autoconfigure.jdbc=DEBUG +spring.datasource.url=jdbc\:postgresql\://localhost\:5432/ods_api_service +spring.datasource.username=ods_api_service +spring.datasource.password=ods_api_service +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.hikari.connection-timeout=30000 +spring.datasource.hikari.maximum-pool-size=2 +spring.datasource.hikari.minimum-idle=0 +spring.datasource.hikari.idle-timeout=600000 +spring.datasource.hikari.max-lifetime=1800000 diff --git a/persistence/src/test/resources/application.properties b/persistence/src/test/resources/application.properties new file mode 100644 index 0000000..bee83c5 --- /dev/null +++ b/persistence/src/test/resources/application.properties @@ -0,0 +1,18 @@ +# ── HikariCP tuning for Testcontainers ───────────────────────────────────────── +# +# When the @Container PostgreSQL instance stops (after the test class finishes), +# HikariCP's background keepalive thread tries to validate open connections and +# logs "Failed to validate connection" warnings. These settings eliminate those +# warnings and make the pool give up quickly on unreachable connections during +# teardown — they have no effect on test correctness. + +# Disable background keepalive probes entirely (default: 0 = disabled already, +# but some Spring Boot auto-config versions set it higher). +spring.datasource.hikari.keepalive-time=0 + +# Reduce how long HikariCP waits to acquire a connection (default: 30 000 ms). +# During teardown this prevents the pool from blocking on a dead container. +spring.datasource.hikari.connection-timeout=3000 + +# Reduce how long HikariCP waits to validate a connection (default: 5 000 ms). +spring.datasource.hikari.validation-timeout=1000 diff --git a/pom.xml b/pom.xml index 605d1ef..f14bc6d 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ org.opendevstack.apiservice devstack-api-service Devstack API Service - 0.0.2 + 0.0.3 21 @@ -36,6 +36,7 @@ 0.0.47 0.2.7 3.6.1 + 1.6.3 @@ -44,16 +45,23 @@ external-service-api + persistence core + core-contracts + core-security external-service-aap external-service-projects-info-service external-service-uipath external-service-ocp external-service-bitbucket external-service-jira + external-service-marketplace external-service-webhookproxy + service-projects api-project-users api-project-platform + api-project + api-project-component-v0 @@ -173,8 +181,7 @@ **/*Test.java - - ../application.yaml + ${SPRING_CONFIG_DIR} diff --git a/security-tests.http b/security-tests.http new file mode 100644 index 0000000..e8f0a6e --- /dev/null +++ b/security-tests.http @@ -0,0 +1,547 @@ +### =========================================================================== +### core-security — Security Test Matrix (HTTP Client) +### Reference: doc/core-security/tests.md +### =========================================================================== +### +### SETUP — create a .env file at project root: +### +### AZURE_TENANT_ID= +### AZURE_CLIENT_ID= # app in ALLOWED_CLIENTS policy +### AZURE_CLIENT_SECRET= +### AZURE_CLIENT_ID_OTHER= # app NOT in ALLOWED_CLIENTS policy +### AZURE_CLIENT_SECRET_OTHER= +### AZURE_SCOPE= # e.g. 5dcea642-a10e-4bc8-9e7e-f758d28def0a +### +### OBO TOKENS — cannot be obtained automatically (require browser/user login). +### Obtain via MSAL device-code or auth-code flow: +### scope: api:///user_impersonation +### Paste the access_token into @obo_token and @obo_token_wrong_scope below. +### +### FEASIBILITY KEY +### ✅ Fully automated — token is retrieved via client_credentials grant +### ⚠ Partially manual — OBO token must be pasted in @obo_token +### 🔧 Config required — needs specific DB api_definition or policy setup +### =========================================================================== + +@host = http://localhost:8080 + +### ── Azure AD – client credentials (ALLOWED client) ────────────────────────── +@azure_tenant_id = {{$dotenv AZURE_TENANT_ID}} +@azure_client_id = {{$dotenv AZURE_CLIENT_ID}} +@azure_client_secret = {{$dotenv AZURE_CLIENT_SECRET}} +@azure_scope = api://{{$dotenv AZURE_SCOPE}}/.default + +### ── Azure AD – client credentials (DISALLOWED client) ─────────────────────── +@azure_client_id_other = {{$dotenv AZURE_CLIENT_ID_OTHER}} +@azure_client_secret_other = {{$dotenv AZURE_CLIENT_SECRET_OTHER}} + +### ── Test data ──────────────────────────────────────────────────────────────── +@project_key = {{$dotenv PROJECT_KEY}} +@user_id = {{$dotenv USER_ID}} + +### ── Bad tokens (static, for error-case tests) ────────────────────────────── +### S-00-04: malformed — not a valid JWT structure at all +@malformed_token = not.a.valid.jwt.token + +### S-00-05: expired — well-formed JWT but exp=2020-01-01, signature is invalid → +### BearerTokenAuthenticationFilter returns 401 before any policy runs +@expired_token = {{$dotenv EXPIRED_TOKEN}} + +### ── OBO tokens (MANUAL — paste after browser / MSAL device-code login) ────── +### Required for: §4 all OBO tests, P-03, flow-enforcement cross-flow tests +@obo_token = {{$dotenv OBO_TOKEN}} + +### OBO token from a client NOT in ALLOWED_CLIENTS (different azp claim) +### Required for: OBO-P-02, OBO-M-03 + @obo_token_other_client = {{$dotenv OBO_TOKEN_OTHER_CLIENT}} + +### OBO token whose scp does NOT include the required scope +### Required for: OBO-S-02, OBO-M-02 +@obo_token_wrong_scope = {{$dotenv OBO_TOKEN_WRONG_SCOPE}} + + +### =========================================================================== +### TOKEN ACQUISITION — automated via client_credentials grant +### =========================================================================== + +### ── Allowed client ────────────────────────────────────────────────────────── +# @name getToken +POST https://login.microsoftonline.com/{{azure_tenant_id}}/oauth2/v2.0/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id={{azure_client_id}} +&client_secret={{azure_client_secret}} +&scope={{azure_scope}} + +### + +@cc_token = {{getToken.response.body.access_token}} + +### ── Disallowed client ─────────────────────────────────────────────────────── +# @name getTokenOther +POST https://login.microsoftonline.com/{{azure_tenant_id}}/oauth2/v2.0/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id={{azure_client_id_other}} +&client_secret={{azure_client_secret_other}} +&scope={{azure_scope}} + +### + +@cc_token_other = {{getTokenOther.response.body.access_token}} + + +### =========================================================================== +### §1 PUBLIC API +### Endpoint: GET /actuator/health (configured via app.security.public-endpoints) +### All cases → 200 — Spring Security permitAll() bypasses JWT validation entirely +### =========================================================================== + +### P-01 ✅ · No token → 200 +GET {{host}}/actuator/health +Accept: application/json + +### + +### P-02 ✅ · Valid CC token → 200 (token accepted but not required) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### P-03 ⚠ · Valid OBO token → 200 (token accepted but not required) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{obo_token}} + +### + +### P-04a ✅ · Malformed token → 401 (permitAll short-circuits before JWT validation) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{malformed_token}} + +### + +### P-04b ✅ · Expired token → 401 (permitAll short-circuits before JWT validation) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{expired_token}} + +### + + +### =========================================================================== +### §2 SECURED API — NO TOKEN +### Endpoint: GET /api/pub/v0/projects/{key} (CLIENT_CREDENTIALS, not public) +### Expected: 401 — BearerTokenAuthenticationFilter rejects before policy runs +### =========================================================================== + +### S-00-01 ✅ · CLIENT_CREDENTIALS endpoint, no token → 401 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json + +### + +### S-00-02 ✅ · OBO-typed endpoint (project-users-v1), no token → 401 +GET {{host}}/api/v1/projects/{{project_key}}/users/{{user_id}}/status +Accept: application/json + +### + +### S-00-03 ✅ · Multiple-auth-types endpoint, no token → 401 +### (use any secured endpoint; both CC and OBO require a bearer token) +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json + +### + +### S-00-04 ✅ · Malformed JWT → 401 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{malformed_token}} + +### + +### S-00-05 ✅ · Expired JWT → 401 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{expired_token}} + +### + + +### =========================================================================== +### §3 CLIENT_CREDENTIALS FLOW +### =========================================================================== + +### ── §3.1 Flow enforcement (AuthTypeEnforcementFilter) ───────────────────── +### +### Note: flow enforcement at the filter level requires an upstream component +### to set the API_DEFINITION_ATTR request attribute. Without that, flow type +### is enforced implicitly via policy rules (ALLOWED_CLIENTS / SCOPE_REQUIRED). +### Tests CC-F-02 and CC-F-04 therefore depend on DB policy configuration. + +### CC-F-01 ✅ · CC endpoint + CC token → passes filter (200 or policy result) +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-F-02 🔧 · OBO-only endpoint + CC token → 403 (wrong flow) +### Requires: api_definition with auth_types = ['OBO'] in DB +### As currently seeded: project-users-v1 uses CLIENT_CREDENTIALS, so a CC +### token here may pass flow but fail ALLOWED_CLIENTS policy. +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{cc_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "CC-F-02: CC token on OBO-only endpoint — expected 403" +} + +### + +### CC-F-03 ✅ · Both-flows endpoint + CC token → passes filter +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-F-04 🔧 · No-auth endpoint (AuthType.NONE) + CC token → passes (token ignored) +### Seeded: project-platforms-v1 has auth_types=['NONE'] — requiresAuth()=false +### Note: PolicyAuthorizationManager will still run; result depends on policy rules. +GET {{host}}/api/v1/projects/{{project_key}}/platforms +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### ── §3.2 Policy — ALLOWED_CLIENTS ───────────────────────────────────────── + +### CC-P-01 ✅ · Allowed client (azp in ALLOWED_CLIENTS policy) → 200 / PERMIT +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-P-02 ✅ · Disallowed client (azp NOT in ALLOWED_CLIENTS) → 403 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token_other}} + +### + +### CC-P-03 🔧 · No policy rules configured for this API → ABSTAIN → 200 +### Requires: api_definition with zero rows in authorization_policies table +### Adjust path to an endpoint whose api_definition has no policy rows. +GET {{host}}/api/pub/v0/projects/{{project_key}}/components/anything +Authorization: Bearer {{cc_token}} + +### + +### CC-P-04 🔧 · Empty rules list → PolicyEngine.evaluate([]) → DENY → 403 +### Same condition as CC-P-03: covered by unit test PolicyEngineTest.evaluate_emptyRules_returnsDeny +### Integration path: any secured endpoint with zero policy rows in DB. + +### + +### ── §3.3 Policy — SCOPE_REQUIRED ────────────────────────────────────────── + +### CC-S-01 ✅ · CC token (no scp claim) against SCOPE_REQUIRED policy → 403 +### A standard CC token has roles[] but no scp claim. +### If the api_definition has SCOPE_REQUIRED policy, evaluator returns DENY. +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-S-02 🔧 · CC token WITH scp + SCOPE_REQUIRED matching scope → 200 +### Non-standard scenario: requires either a custom CC token with scp, or +### a resource server that issues CC tokens with delegated scopes. +### Best validated via SecurityIntegrationTest.ccToken_scopeRequired_withScp_returns200. + +### + +### ── §3.4 Multiple rules (ALLOWED_CLIENTS + SCOPE_REQUIRED) ───────────────── + +### CC-M-01 ✅ · Allowed client + required scope present → 200 +### Standard CC token from allowed client; if SCOPE_REQUIRED is active, +### add scp manually (see CC-S-02 note). Without SCOPE_REQUIRED policy, passes via ALLOWED_CLIENTS. +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token}} + +{ + "projectName": "Security Test CC-M-01", + "projectKey": "{{project_key}}-m01", + "flavor": "DLSS", + "projectDescription": "CC-M-01: allowed client + scope — expected 200" +} + +### + +### CC-M-02 ✅ · Allowed client + scope missing → 403 +### CC token has no scp; if SCOPE_REQUIRED policy is active → DENY +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token}} + +{ + "projectName": "Security Test CC-M-02", + "projectKey": "{{project_key}}-m02", + "flavor": "DLSS", + "projectDescription": "CC-M-02: scope missing — expected 403 if SCOPE_REQUIRED is active" +} + +### + +### CC-M-03 ✅ · Disallowed client + scope present → 403 +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token_other}} + +{ + "projectName": "Security Test CC-M-03", + "projectKey": "{{project_key}}-m03", + "flavor": "DLSS", + "projectDescription": "CC-M-03: disallowed client — expected 403" +} + +### + +### CC-M-04 ✅ · Disallowed client + scope missing → 403 +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token_other}} + +{ + "projectName": "Security Test CC-M-04", + "projectKey": "{{project_key}}-m04", + "flavor": "DLSS", + "projectDescription": "CC-M-04: disallowed client + no scope — expected 403" +} + +### + + +### =========================================================================== +### §4 OBO FLOW (On-Behalf-Of / delegated) +### ⚠ ALL TESTS BELOW require @obo_token (and variants) to be set manually. +### See SETUP INSTRUCTIONS at the top of this file. +### =========================================================================== + +### ── §4.1 Flow enforcement ─────────────────────────────────────────────────── + +### OBO-F-01 ⚠ · OBO-only endpoint + OBO token → passes (200 or policy result) +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-F-01: correct OBO flow" +} + +### + +### OBO-F-02 ✅ · OBO-configured endpoint + CC token → 403 (wrong flow) +### CC token sent to project-users endpoint; if ALLOWED_CLIENTS policy is active +### and cc_token_other's azp is not in the allowed list → 403. +### To test pure flow enforcement, configure auth_types=['OBO'] for this api_definition. +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{cc_token_other}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-F-02: CC token on OBO endpoint — expected 403" +} + +### + +### OBO-F-03 ⚠ · Both-flows endpoint + OBO token → passes +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{obo_token}} + +### + +### ── §4.2 Policy — ALLOWED_CLIENTS ────────────────────────────────────────── + +### OBO-P-01 ⚠ · Allowed client OBO token (azp in ALLOWED_CLIENTS) → 200 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-P-01: allowed SPA client" +} + +### + +### OBO-P-02 ⚠ · Disallowed client OBO token → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_other_client}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-P-02: disallowed client — expected 403" +} + +### + +### OBO-P-03 🔧 · No policy rules for OBO api_definition → ABSTAIN → 200 +### Requires: api_definition entry with zero authorization_policies rows in DB + +### + +### ── §4.3 Policy — SCOPE_REQUIRED ─────────────────────────────────────────── + +### OBO-S-01 ⚠ · scp contains required scope → 200 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-S-01: scp has required scope" +} + +### + +### OBO-S-02 ⚠ · scp does NOT contain required scope → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_wrong_scope}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-S-02: scp missing required scope — expected 403" +} + +### + +### OBO-S-03 · Blank scp claim → AuthFlowResolver treats as CLIENT_CREDENTIALS +### Cannot be crafted easily in .http client (requires custom JWT with scp=" "). +### Covered by: SecurityIntegrationTest › OboTests › blankScp_returns403 + +### + +### ── §4.4 Multiple rules ────────────────────────────────────────────────────── + +### OBO-M-01 ⚠ · Allowed client + required scope → 200 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-M-01: both PERMIT" +} + +### + +### OBO-M-02 ⚠ · Allowed client + scope missing → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_wrong_scope}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-M-02: PERMIT + DENY — expected 403" +} + +### + +### OBO-M-03 ⚠ · Disallowed client + scope present → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_other_client}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-M-03: DENY + PERMIT — expected 403" +} + +### + + +### =========================================================================== +### §5 UNKNOWN ROUTE (no matching api_definition) +### PolicyAuthorizationManager.check() → resolver returns empty → fail-closed +### Expected: 403 (or 401 when no token and ExceptionTranslationFilter fires first) +### =========================================================================== + +### U-01 ✅ · No token → 401 (no JWT) or 403 (anonymous → fail-closed) +GET {{host}}/api/v0/nonexistent-endpoint +Accept: application/json + +### + +### U-02 ✅ · Valid CC token, unknown route → 403 +GET {{host}}/api/v0/nonexistent-endpoint +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### U-03 ⚠ · Valid OBO token, unknown route → 403 +GET {{host}}/api/v0/nonexistent-endpoint +Accept: application/json +Authorization: Bearer {{obo_token}} + +### + + +### =========================================================================== +### SUMMARY MATRIX (reference) +### +### │ PUBLIC │ SECURED/CC │ SECURED/OBO │ UNKNOWN ROUTE +### ───────────────────┼────────┼────────────┼─────────────┼────────────── +### No token │ 200 │ 401 │ 401 │ 401/403 +### Valid, allowed │ 200 │ 200 │ 200 │ 403 +### Valid, wrong flow │ 200 │ 403 │ 403 │ 403 +### Valid, denied │ 200 │ 403 │ 403 │ 403 +### Expired/malformed │ 200 │ 401 │ 401 │ 401 +### =========================================================================== diff --git a/service-projects/pom.xml b/service-projects/pom.xml new file mode 100644 index 0000000..f391ffe --- /dev/null +++ b/service-projects/pom.xml @@ -0,0 +1,116 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + service-projects + Service Projects + Service module for project operations: key generation and project existence checks + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.opendevstack.apiservice + persistence + ${project.version} + + + + org.opendevstack.apiservice + external-service-api + ${project.version} + + + + org.opendevstack.apiservice + external-service-bitbucket + ${project.version} + + + + org.opendevstack.apiservice + external-service-jira + ${project.version} + + + + org.opendevstack.apiservice + external-service-ocp + ${project.version} + + + + org.opendevstack.apiservice + external-service-aap + ${project.version} + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + + + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java new file mode 100644 index 0000000..fa3a5d0 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.serviceproject.exception; + +public class ProjectExistenceServiceException extends Exception { + + public ProjectExistenceServiceException(String message) { + super(message); + } + + public ProjectExistenceServiceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java new file mode 100644 index 0000000..0d1fa5f --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java @@ -0,0 +1,13 @@ +package org.opendevstack.apiservice.serviceproject.exception; + +public class ProjectKeyGenerationException extends Exception { + + public ProjectKeyGenerationException(String message) { + super(message); + } + + public ProjectKeyGenerationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java new file mode 100644 index 0000000..199a43b --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java @@ -0,0 +1,37 @@ +package org.opendevstack.apiservice.serviceproject.mapper; + +import java.util.Arrays; +import java.util.List; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.IterableMapping; +import org.mapstruct.Named; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +@Mapper(componentModel = "spring") +public interface ProjectResponseMapper { + + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Mapping(source = "id", target = "projectId") + @Named("mapEntityToResponse") + ProjectResponse toCreateProjectResponse(ProjectEntity entity); + + @IterableMapping(qualifiedByName = "mapEntityToResponse") + List toCreateProjectResponse(List entities); + + @Named("mapStatus") + default Status mapStatus(String value) { + if (value == null) { + return null; + } + + return Arrays.stream(Status.values()) + .filter(s -> s.getDbValue().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown Status db value: '" + value + "'")); + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java new file mode 100644 index 0000000..9bc0f91 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java @@ -0,0 +1,35 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ProjectRequest { + + private UUID projectId; + + private String projectKey; + + private String projectName; + + private String projectDescription; + + private String projectFlavor; + + private String configurationItem; + + private String location; + + private String x2OdsAccount; + + private String owner; + + private UUID clientId; + + private Status status; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java new file mode 100644 index 0000000..6db5063 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ProjectResponse { + + private UUID projectId; + + private String projectKey; + + private Status status; + + private String projectFlavor; + + private String message; + + private String error; + + private String errorKey; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java new file mode 100644 index 0000000..960efc6 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Status { + + PENDING("Pending"), + RUNNING("Running"), + FAILED("Failed"); + + private final String dbValue; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java new file mode 100644 index 0000000..b00d7bf --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; + +public interface GenerateProjectKeyService { + + String DEFAULT_PROJECT_KEY_PATTERN = "SS%06d"; + + String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException, ProjectExistenceServiceException; +} + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java new file mode 100644 index 0000000..7ab8dd5 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; + +public interface ProjectExistenceService { + + boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException; + + boolean isProjectFoundByName(String projecName) throws ProjectExistenceServiceException; + + boolean isProjectFoundInCollection(String projectKey) throws ProjectExistenceServiceException; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java new file mode 100644 index 0000000..896e7c6 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java @@ -0,0 +1,18 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; + +import java.util.List; + +public interface ProjectService { + + ProjectResponse saveProject(ProjectRequest request); + + ProjectResponse getProject(String projectKey); + + void updateProjectStatus(String projectKey, String status); + + List findProjectsByName(String projectName); +} + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java new file mode 100644 index 0000000..7b80fd3 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java @@ -0,0 +1,60 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.util.Random; + +@Service +@Slf4j +public class GenerateProjectKeyServiceImpl implements GenerateProjectKeyService { + + private static final int MAX_RETRIES = 10; + + private final ProjectExistenceService projectExistenceService; + + private final Random random; + + @Autowired + public GenerateProjectKeyServiceImpl(ProjectExistenceService projectExistenceService) { + this(projectExistenceService, new SecureRandom()); + } + + GenerateProjectKeyServiceImpl(ProjectExistenceService projectExistenceService, Random random) { + this.projectExistenceService = projectExistenceService; + this.random = random; + } + + @Override + public String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException, ProjectExistenceServiceException { + String pattern = resolveProjectKeyPattern(projectKeyPattern); + + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + int randomNumber = random.nextInt(1_000_000); + String projectKey = String.format(pattern, randomNumber); + + if (!projectExistenceService.isProjectFound(projectKey)) { + log.debug("Generated unique project key '{}' on attempt {}", projectKey, attempt); + return projectKey; + } + + log.debug("Project key '{}' already exists (attempt {}/{})", projectKey, attempt, MAX_RETRIES); + } + + throw new ProjectKeyGenerationException( + String.format("Failed to generate unique project key after %d retries", MAX_RETRIES)); + } + + private String resolveProjectKeyPattern(String projectKeyPattern) { + if (projectKeyPattern == null || projectKeyPattern.isBlank()) { + return DEFAULT_PROJECT_KEY_PATTERN; + } + return projectKeyPattern; + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java new file mode 100644 index 0000000..76e02e0 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java @@ -0,0 +1,97 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import java.util.Comparator; +import java.util.Set; + +@Slf4j +@Service +public class ProjectExistenceServiceImpl implements ProjectExistenceService { + + private final BitbucketService bitbucketService; + private final JiraService jiraService; + private final OpenshiftService openshiftService; + private final ProjectService projectService; + + @Autowired + public ProjectExistenceServiceImpl(BitbucketService bitbucketService, JiraService jiraService, + OpenshiftService openshiftService, ProjectService projectService) { + this.bitbucketService = bitbucketService; + this.jiraService = jiraService; + this.openshiftService = openshiftService; + this.projectService = projectService; + } + @Override + public boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException { + try { + if (existsInCollection(projectKey)) return true; + if (existsInAnyBitbucketInstance(projectKey)) return true; + if (existsInAnyJiraInstance(projectKey)) return true; + return existsInAnyOpenshift(projectKey); + } catch (BitbucketException e) { + throw new ProjectExistenceServiceException("Failed to check project in Bitbucket", e); + } catch (JiraException e) { + throw new ProjectExistenceServiceException("Failed to check project in Jira", e); + } catch (OpenshiftException e) { + throw new ProjectExistenceServiceException("Failed to check project in Openshift", e); + } catch (RuntimeException e) { + log.error("Unexpected error while checking project existence for key '{}'", projectKey, e); + throw new ProjectExistenceServiceException("Unexpected error while checking project existence", e); + } + } + + @Override + public boolean isProjectFoundByName(String projecName) throws ProjectExistenceServiceException { + return !projectService.findProjectsByName(projecName).isEmpty(); + } + + @Override + public boolean isProjectFoundInCollection(String projectKey) throws ProjectExistenceServiceException { + return existsInCollection(projectKey); + } + + private boolean existsInCollection(String projectKey) { + return projectService.getProject(projectKey) != null; + } + + private boolean existsInAnyBitbucketInstance(String projectKey) throws BitbucketException { + Set instances = bitbucketService.getAvailableInstances(); + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (bitbucketService.projectExists(instance, projectKey)) return true; + } + return false; + } + + private boolean existsInAnyJiraInstance(String projectKey) throws JiraException { + Set instances = jiraService.getAvailableInstances(); + if (instances == null || instances.isEmpty()) { + return jiraService.projectExists(projectKey); + } + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (jiraService.projectExists(instance, projectKey)) return true; + } + return false; + } + + private boolean existsInAnyOpenshift(String projectKey) throws OpenshiftException { + Set instances = openshiftService.getAvailableInstances(); + if (instances == null || instances.isEmpty()) return false; + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (openshiftService.projectExists(instance, projectKey)) return true; + } + return false; + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java new file mode 100644 index 0000000..fe9b34e --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -0,0 +1,88 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.persistence.repository.ProjectRepository; +import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +@Slf4j +public class ProjectServiceImpl implements ProjectService { + + private static final String MANAGER_ROLE = "MANAGER"; + private static final String TEAM_ROLE = "TEAM"; + private static final String STAKEHOLDER_ROLE = "STAKEHOLDER"; + + @Value("${services.project.ldap.group.pattern}") + private String ldapGroupPattern; + + private final ProjectRepository projectRepository; + + private final ProjectResponseMapper projectResponseMapper; + + public ProjectServiceImpl(ProjectRepository projectRepository, + ProjectResponseMapper projectResponseMapper) { + this.projectRepository = projectRepository; + this.projectResponseMapper = projectResponseMapper; + } + + @Override + public ProjectResponse saveProject(ProjectRequest request) { + ProjectEntity entity = new ProjectEntity(); + entity.setProjectKey(request.getProjectKey()); + entity.setProjectName(request.getProjectName()); + entity.setProjectFlavor(request.getProjectFlavor()); + entity.setConfigurationItem(request.getConfigurationItem()); + entity.setDescription(request.getProjectDescription()); + entity.setLdapGroupManager(getLdapGroup(MANAGER_ROLE, request.getProjectKey())); + entity.setLdapGroupTeam(getLdapGroup(TEAM_ROLE, request.getProjectKey())); + entity.setLdapGroupStakeholder(getLdapGroup(STAKEHOLDER_ROLE, request.getProjectKey())); + entity.setStatus(request.getStatus().getDbValue()); + entity.setLocation(request.getLocation()); + ProjectEntity save = projectRepository.save(entity); + return projectResponseMapper.toCreateProjectResponse(save); + } + + @Override + public ProjectResponse getProject(String projectKey) { + Optional project = projectRepository.findByProjectKeyIgnoreCase(projectKey); + + if (project.isPresent()) { + return projectResponseMapper.toCreateProjectResponse(project.get()); + } + + return null; + } + + @Override + public void updateProjectStatus(String projectKey, String status) { + Optional project = projectRepository.findByProjectKeyIgnoreCase(projectKey); + + if (project.isPresent()) { + ProjectEntity entity = project.get(); + entity.setStatus(status); + projectRepository.save(entity); + } + } + + @Override + public List findProjectsByName(String projectName) { + List projects = projectRepository.findByProjectNameIgnoreCase(projectName); + return projectResponseMapper.toCreateProjectResponse(projects); + } + + private String getLdapGroup(String role, String projectKey) { + return ldapGroupPattern + .replace("{{projectKey}}", projectKey) + .replace("{{role}}", role); + } +} + diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java new file mode 100644 index 0000000..6e5b02f --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java @@ -0,0 +1,77 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; + +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class GenerateProjectKeyServiceImplTest { + + @Mock + private ProjectExistenceService projectExistenceService; + + @Mock + private Random random; + + private GenerateProjectKeyServiceImpl tested; + + @BeforeEach + void setup() { + tested = new GenerateProjectKeyServiceImpl(projectExistenceService, random); + } + + @Test + void generate_project_key_returns_key_when_first_candidate_is_free() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(7); + when(projectExistenceService.isProjectFound("SS000007")).thenReturn(false); + + String result = tested.generateProjectKey(null); + + assertThat(result).isEqualTo("SS000007"); + } + + @Test + void generate_project_key_retries_until_unique_key_found() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(1, 2); + when(projectExistenceService.isProjectFound("SS000001")).thenReturn(true); + when(projectExistenceService.isProjectFound("SS000002")).thenReturn(false); + + String result = tested.generateProjectKey("SS%06d"); + + assertThat(result).isEqualTo("SS000002"); + } + + @Test + void generate_project_key_throws_exception_when_no_unique_key_after_max_retries() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11); + when(projectExistenceService.isProjectFound(anyString())).thenReturn(true); + + assertThatThrownBy(() -> tested.generateProjectKey("SS%06d")) + .isInstanceOf(ProjectKeyGenerationException.class) + .hasMessageContaining("Failed to generate unique project key after 10 retries"); + } + + @Test + void generate_project_key_uses_custom_pattern_when_provided() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(42); + when(projectExistenceService.isProjectFound("AB0042")).thenReturn(false); + + String result = tested.generateProjectKey("AB%04d"); + + assertThat(result).isEqualTo("AB0042"); + } +} diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java new file mode 100644 index 0000000..b437291 --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java @@ -0,0 +1,158 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +import java.util.List; +import java.util.Collections; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ProjectExistenceServiceImplTest { + + @Mock + private BitbucketService bitbucketService; + @Mock + private JiraService jiraService; + @Mock + private OpenshiftService openshiftService; + @Mock + private ProjectService projectService; + + private ProjectExistenceServiceImpl sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + sut = new ProjectExistenceServiceImpl(bitbucketService, jiraService, openshiftService, projectService); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_db() throws Exception { + when(projectService.getProject("KEY1")).thenReturn(new ProjectResponse()); + + boolean result = sut.isProjectFound("KEY1"); + assertTrue(result); + verify(projectService).getProject("KEY1"); + verifyNoInteractions(bitbucketService, jiraService, openshiftService); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_bitbucket() throws Exception { + when(projectService.getProject("KEY2")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("inst1")); + when(bitbucketService.projectExists("inst1", "KEY2")).thenReturn(true); + + boolean result = sut.isProjectFound("KEY2"); + assertTrue(result); + verify(bitbucketService).projectExists("inst1", "KEY2"); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_jira() throws Exception { + when(projectService.getProject("KEY3")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("jira1")); + when(jiraService.projectExists("jira1", "KEY3")).thenReturn(true); + boolean result = sut.isProjectFound("KEY3"); + assertTrue(result); + verify(jiraService).projectExists("jira1", "KEY3"); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_openshift() throws Exception { + when(projectService.getProject("KEY4")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of("ocp1")); + when(openshiftService.projectExists("ocp1", "KEY4")).thenReturn(true); + + boolean result = sut.isProjectFound("KEY4"); + assertTrue(result); + verify(openshiftService).projectExists("ocp1", "KEY4"); + } + + @Test + void is_project_found_returns_false_when_project_not_found_anywhere() throws Exception { + when(projectService.getProject("KEY5")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("inst1")); + when(bitbucketService.projectExists("inst1", "KEY5")).thenReturn(false); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("jira1")); + when(jiraService.projectExists("jira1", "KEY5")).thenReturn(false); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of("ocp1")); + + when(openshiftService.projectExists("ocp1", "KEY5")).thenReturn(false); + boolean result = sut.isProjectFound("KEY5"); + assertFalse(result); + } + + @Test + void is_project_found_throws_exception_when_bitbucket_fails() { + when(projectService.getProject("KEY6")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY6")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY6"); + }); + } + + @Test + void is_project_found_throws_exception_when_jira_fails() { + when(projectService.getProject("KEY7")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + + assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY7")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY7"); + }); + } + + @Test + void is_project_found_throws_exception_when_openshift_fails() { + when(projectService.getProject("KEY8")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(openshiftService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY8"); + }); + } + + @Test + void is_project_found_by_name_returns_true_when_project_exists_in_db() throws Exception { + when(projectService.findProjectsByName("Project One")).thenReturn(List.of(new ProjectResponse())); + + boolean result = sut.isProjectFoundByName("Project One"); + + assertTrue(result); + verify(projectService).findProjectsByName("Project One"); + } + + @Test + void is_project_found_by_name_returns_false_when_project_does_not_exist_in_db() throws Exception { + when(projectService.findProjectsByName("Missing Project")).thenReturn(List.of()); + + boolean result = sut.isProjectFoundByName("Missing Project"); + + assertFalse(result); + verify(projectService).findProjectsByName("Missing Project"); + } +} \ No newline at end of file diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java new file mode 100644 index 0000000..ca30c71 --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java @@ -0,0 +1,235 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.ArgumentCaptor; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.persistence.repository.ProjectRepository; +import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectServiceImplTest { + + @Mock + private ProjectRepository projectRepository; + + @Mock + private ProjectResponseMapper projectResponseMapper; + + private ProjectServiceImpl projectService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + projectService = new ProjectServiceImpl( + projectRepository, + projectResponseMapper + ); + } + + @Test + void get_project_returns_response_when_project_exists() { + + String projectKey = "MY-PROJECT"; + UUID projectId = UUID.randomUUID(); + + ProjectEntity projectEntity = ProjectEntity.builder() + .id(projectId) + .projectKey(projectKey) + .projectName("My Project") + .description("Test project") + .configurationItem("CI-123") + .location("eu") + .projectFlavor("AMP") + .status("Completed") + .deleted(false) + .ldapGroupManager("cn=my-project-manager,ou=groups,dc=example,dc=com") + .ldapGroupTeam("cn=my-project-team,ou=groups,dc=example,dc=com") + .build(); + + ProjectResponse expectedResponse = ProjectResponse.builder() + .projectKey(projectKey) + .status(Status.RUNNING) + .build(); + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(projectEntity)); + when(projectResponseMapper.toCreateProjectResponse(projectEntity)).thenReturn(expectedResponse); + + ProjectResponse result = projectService.getProject(projectKey); + + assertNotNull(result); + assertEquals(projectKey, result.getProjectKey()); + assertEquals(Status.RUNNING, result.getStatus()); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + verify(projectResponseMapper).toCreateProjectResponse(projectEntity); + } + + @Test + void get_project_returns_null_when_project_does_not_exist() { + + String projectKey = "NON-EXISTING"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.empty()); + + ProjectResponse result = projectService.getProject(projectKey); + + assertNull(result); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + verify(projectResponseMapper, never()).toCreateProjectResponse(any(ProjectEntity.class)); + } + + @Test + void get_project_propagates_repository_exception() { + + String projectKey = "ERROR-PROJECT"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)) + .thenThrow(new RuntimeException("Database connection error")); + + assertThrows(RuntimeException.class, () -> projectService.getProject(projectKey)); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + } + + @Test + void get_project_returns_null_when_project_key_is_null() { + + when(projectRepository.findByProjectKeyIgnoreCase(null)).thenReturn(Optional.empty()); + + ProjectResponse result = projectService.getProject(null); + + assertNull(result); + verify(projectRepository).findByProjectKeyIgnoreCase(null); + } + + @Test + void get_project_returns_response_for_soft_deleted_project() { + + String projectKey = "DELETED-PROJECT"; + + ProjectEntity deletedEntity = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey(projectKey) + .projectName("Deleted Project") + .configurationItem("CI-456") + .location("eu") + .deleted(true) + .build(); + + ProjectResponse expectedResponse = ProjectResponse.builder() + .projectKey(projectKey) + .status(Status.RUNNING) + .build(); + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(deletedEntity)); + when(projectResponseMapper.toCreateProjectResponse(deletedEntity)).thenReturn(expectedResponse); + + + ProjectResponse result = projectService.getProject(projectKey); + + + assertNotNull(result); + assertEquals(projectKey, result.getProjectKey()); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + } + + @Test + void find_projects_by_name_returns_responses_when_projects_exist() { + String projectName = "My Project"; + + ProjectEntity projectEntity = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey("MY-PROJECT") + .projectName(projectName) + .build(); + + ProjectResponse expectedResponse = ProjectResponse.builder() + .projectKey("MY-PROJECT") + .status(Status.RUNNING) + .build(); + + when(projectRepository.findByProjectNameIgnoreCase(projectName)).thenReturn(List.of(projectEntity)); + when(projectResponseMapper.toCreateProjectResponse(List.of(projectEntity))).thenReturn(List.of(expectedResponse)); + + List result = projectService.findProjectsByName(projectName); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("MY-PROJECT", result.get(0).getProjectKey()); + verify(projectRepository).findByProjectNameIgnoreCase(projectName); + verify(projectResponseMapper).toCreateProjectResponse(List.of(projectEntity)); + } + + @Test + void find_projects_by_name_returns_empty_list_when_project_does_not_exist() { + String projectName = "Unknown Project"; + + when(projectRepository.findByProjectNameIgnoreCase(projectName)).thenReturn(List.of()); + when(projectResponseMapper.toCreateProjectResponse(anyList())).thenReturn(List.of()); + + List result = projectService.findProjectsByName(projectName); + + assertNotNull(result); + assertEquals(0, result.size()); + verify(projectRepository).findByProjectNameIgnoreCase(projectName); + verify(projectResponseMapper).toCreateProjectResponse(anyList()); + } + + @Test + void update_project_status_saves_entity_with_new_status_when_project_exists() { + String projectKey = "PROJ01"; + ProjectEntity entity = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey(projectKey) + .projectName("My Project") + .status("Pending") + .build(); + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(entity)); + + projectService.updateProjectStatus(projectKey, "Running"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ProjectEntity.class); + verify(projectRepository).save(captor.capture()); + assertEquals("Running", captor.getValue().getStatus()); + } + + @Test + void update_project_status_does_nothing_when_project_not_found() { + String projectKey = "UNKNOWN"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.empty()); + + projectService.updateProjectStatus(projectKey, "Running"); + + verify(projectRepository, never()).save(any(ProjectEntity.class)); + } + + @Test + void update_project_status_propagates_repository_exception() { + String projectKey = "ERROR"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)) + .thenThrow(new RuntimeException("DB error")); + + assertThrows(RuntimeException.class, () -> projectService.updateProjectStatus(projectKey, "Running")); + + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + } +} From 7ddf3f158e4785d17e74aa551bdb06fc63178bc5 Mon Sep 17 00:00:00 2001 From: "Vila,Jordi (IT EDP)" Date: Thu, 2 Jul 2026 09:07:39 +0200 Subject: [PATCH 16/19] Upgrade version from 0.0.3 to 0.0.4 --- Makefile | 2 +- README.md | 2 +- api-marketplace-metrics/pom.xml | 2 +- api-project-component-v0/pom.xml | 2 +- api-project-platform/pom.xml | 2 +- api-project-users/pom.xml | 2 +- api-project/pom.xml | 2 +- application.yaml | 4 ++-- core-contracts/pom.xml | 2 +- core-security/pom.xml | 2 +- core/pom.xml | 2 +- external-service-aap/pom.xml | 2 +- external-service-api/pom.xml | 2 +- external-service-bitbucket/pom.xml | 2 +- external-service-jira/pom.xml | 2 +- external-service-marketplace/pom.xml | 2 +- external-service-ocp/pom.xml | 2 +- external-service-projects-info-service/pom.xml | 2 +- external-service-uipath/pom.xml | 2 +- external-service-webhookproxy/pom.xml | 2 +- persistence/pom.xml | 2 +- pom.xml | 2 +- service-projects/pom.xml | 2 +- 23 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Makefile b/Makefile index ceea7c8..34c1cb4 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Project variables PROJECT_NAME := opendevstack-api-service -VERSION := 0.0.3 +VERSION := 0.0.4 JAVA_VERSION := 21 MAIN_CLASS := org.opendevstack.apiservice.core.DevstackApiServiceApplication diff --git a/README.md b/README.md index bfda216..7f851cb 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Build a traditional Spring Boot JAR file: ```bash make jar ``` -- Output: `core/target/core-0.0.3.jar` +- Output: `core/target/core-0.0.4.jar` - Includes all dependencies - Standard Spring Boot startup time diff --git a/api-marketplace-metrics/pom.xml b/api-marketplace-metrics/pom.xml index 9dc6991..066c3c3 100644 --- a/api-marketplace-metrics/pom.xml +++ b/api-marketplace-metrics/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 api-marketplace-metrics diff --git a/api-project-component-v0/pom.xml b/api-project-component-v0/pom.xml index 495e438..92ba26f 100644 --- a/api-project-component-v0/pom.xml +++ b/api-project-component-v0/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 api-project-component-v0 diff --git a/api-project-platform/pom.xml b/api-project-platform/pom.xml index abf2834..75241ed 100644 --- a/api-project-platform/pom.xml +++ b/api-project-platform/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 api-project-platform diff --git a/api-project-users/pom.xml b/api-project-users/pom.xml index c75bf70..4378fac 100644 --- a/api-project-users/pom.xml +++ b/api-project-users/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 api-project-users diff --git a/api-project/pom.xml b/api-project/pom.xml index 799043f..a8baf15 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 api-project diff --git a/application.yaml b/application.yaml index 23a4a84..234046f 100644 --- a/application.yaml +++ b/application.yaml @@ -133,7 +133,7 @@ otel: service: # Logical service name and version attached to telemetry data. name: devstack-api-service-dev - version: 0.0.3 + version: 0.0.4 exporter: otlp: # Endpoint of the OpenTelemetry collector. @@ -149,7 +149,7 @@ otel: exporter: none resource: # Resource attributes attached to every exported span. - attributes: service.name=devstack-api-service,service.version=0.0.3,deployment.environment=development + attributes: service.name=devstack-api-service,service.version=0.0.4,deployment.environment=development instrumentation: jdbc: # JDBC instrumentation is disabled, likely to reduce noise or overhead. diff --git a/core-contracts/pom.xml b/core-contracts/pom.xml index 1db3a6d..c494221 100644 --- a/core-contracts/pom.xml +++ b/core-contracts/pom.xml @@ -8,7 +8,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 core-contracts diff --git a/core-security/pom.xml b/core-security/pom.xml index 04bc8fb..8240d73 100644 --- a/core-security/pom.xml +++ b/core-security/pom.xml @@ -7,7 +7,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 core-security diff --git a/core/pom.xml b/core/pom.xml index 7a70f78..97cf450 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 core diff --git a/external-service-aap/pom.xml b/external-service-aap/pom.xml index 0c80ffe..d108ce7 100644 --- a/external-service-aap/pom.xml +++ b/external-service-aap/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 external-service-aap diff --git a/external-service-api/pom.xml b/external-service-api/pom.xml index b695737..7e163de 100644 --- a/external-service-api/pom.xml +++ b/external-service-api/pom.xml @@ -7,7 +7,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 external-service-api diff --git a/external-service-bitbucket/pom.xml b/external-service-bitbucket/pom.xml index 39467b7..bb654b9 100644 --- a/external-service-bitbucket/pom.xml +++ b/external-service-bitbucket/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 external-service-bitbucket diff --git a/external-service-jira/pom.xml b/external-service-jira/pom.xml index f89d456..703a15a 100644 --- a/external-service-jira/pom.xml +++ b/external-service-jira/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 external-service-jira diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml index 609e96a..443d7bf 100644 --- a/external-service-marketplace/pom.xml +++ b/external-service-marketplace/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 external-service-marketplace diff --git a/external-service-ocp/pom.xml b/external-service-ocp/pom.xml index befcf6f..a8e855b 100644 --- a/external-service-ocp/pom.xml +++ b/external-service-ocp/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 external-service-ocp diff --git a/external-service-projects-info-service/pom.xml b/external-service-projects-info-service/pom.xml index 76a7779..b30c6d0 100644 --- a/external-service-projects-info-service/pom.xml +++ b/external-service-projects-info-service/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 external-service-projects-info-service diff --git a/external-service-uipath/pom.xml b/external-service-uipath/pom.xml index b2a9925..f4bf51b 100644 --- a/external-service-uipath/pom.xml +++ b/external-service-uipath/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 external-service-uipath diff --git a/external-service-webhookproxy/pom.xml b/external-service-webhookproxy/pom.xml index 94c8ec1..7d43a4f 100644 --- a/external-service-webhookproxy/pom.xml +++ b/external-service-webhookproxy/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 external-service-webhookproxy diff --git a/persistence/pom.xml b/persistence/pom.xml index 92dc1e5..e6faef4 100644 --- a/persistence/pom.xml +++ b/persistence/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 persistence diff --git a/pom.xml b/pom.xml index 06f8435..25a21cb 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ org.opendevstack.apiservice devstack-api-service Devstack API Service - 0.0.3 + 0.0.4 21 diff --git a/service-projects/pom.xml b/service-projects/pom.xml index f391ffe..c5faf36 100644 --- a/service-projects/pom.xml +++ b/service-projects/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.3 + 0.0.4 service-projects From 770e18f20117954a6afc588642651b485a9d06b0 Mon Sep 17 00:00:00 2001 From: sergio-soria-bi Date: Thu, 2 Jul 2026 15:36:41 +0200 Subject: [PATCH 17/19] [all-catalog-items] - Set new/updated catalog items endpoint. (#40) --- .gitattributes | 1 + .../openapi/api-marketplace-metrics.yaml | 190 +++++++++++++--- .../api/MarketplaceMetricsApi.java | 62 +++++- .../MarketplaceMetricsController.java | 19 ++ .../facade/MarketplaceMetricsFacade.java | 3 + .../impl/MarketplaceMetricsFacadeImpl.java | 11 + .../mapper/MarketplaceMetricsMapper.java | 63 +++++- .../MarketplaceMetricsControllerTest.java | 112 ++++++++++ .../MarketplaceMetricsFacadeImplTest.java | 36 +++ .../mapper/MarketplaceMetricsMapperTest.java | 208 ++++++++++++++++-- .../openapi-component_catalog-v1.0.0.yaml | 8 +- external-service-marketplace/pom.xml | 2 +- .../service/MarketplaceService.java | 4 + .../service/impl/MarketplaceServiceImpl.java | 30 +++ .../service/MarketplaceServiceImplTest.java | 114 ++++++++++ 15 files changed, 805 insertions(+), 58 deletions(-) create mode 100644 .gitattributes create mode 100644 api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsControllerTest.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml b/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml index a444e60..7bf3d5e 100644 --- a/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml +++ b/api-marketplace-metrics/openapi/api-marketplace-metrics.yaml @@ -12,59 +12,100 @@ servers: default: localhost:8080 description: Development environment tags: -- name: Marketplace metrics - description: API for managing marketplace metrics + - name: Marketplace metrics + description: API for managing marketplace metrics paths: /metrics/marketplace/project-components: get: tags: - - MarketplaceMetrics + - MarketplaceMetrics summary: Get Marketplace metrics regarding project components. description: Returns all the components that were provisioned from the Marketplace in all the projects. operationId: getMarketplaceProjectComponentsMetrics parameters: - - name: page - in: query - description: Page number (from 0 until limit) - required: false - schema: - type: integer - default: 0 - - name: size - in: query - description: Page size - required: false - schema: - type: integer - default: 20 + - name: page + in: query + description: Page number (from 0 until limit) + required: false + schema: + type: integer + default: 0 + - name: size + in: query + description: Page size + required: false + schema: + type: integer + default: 20 responses: "200": + description: A paginated list of all the provisioned project components content: application/json: schema: $ref: "#/components/schemas/MarketplaceProjectComponentsMetrics" - description: A paginated list of all the provisioned project components "401": - content: - application/json: - schema: - $ref: "#/components/schemas/RestErrorMessage" - description: Invalid client token on the request. + $ref: "#/components/responses/UnauthorizedResponse" "403": - content: - application/json: - schema: - $ref: "#/components/schemas/RestErrorMessage" - description: Insufficient permissions for the client to access the resource. + $ref: "#/components/responses/ForbiddenResponse" + "404": + $ref: "#/components/responses/NotFoundResponse" "500": + $ref: "#/components/responses/InternalErrorResponse" + + /metrics/marketplace/catalog-items: + get: + tags: + - MarketplaceMetrics + summary: Get Marketplace metrics regarding catalog items. + description: Returns usage metrics of catalog items available in the marketplace. + operationId: getMarketplaceCatalogItemsMetrics + responses: + "200": + description: A paginated list of catalog items metrics content: application/json: schema: - $ref: "#/components/schemas/RestErrorMessage" - description: Server error. + $ref: "#/components/schemas/MarketplaceCatalogItemsMetrics" + "401": + $ref: "#/components/responses/UnauthorizedResponse" + "403": + $ref: "#/components/responses/ForbiddenResponse" + "404": + $ref: "#/components/responses/NotFoundResponse" + "500": + $ref: "#/components/responses/InternalErrorResponse" + components: + responses: + UnauthorizedResponse: + description: Invalid client token + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + ForbiddenResponse: + description: Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + NotFoundResponse: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + InternalErrorResponse: + description: Server error + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + schemas: RestErrorMessage: + type: object properties: message: type: string @@ -176,3 +217,92 @@ components: nullable: true example: "https://api.example.com/resources?page=0&size=20" description: URL of the previous page (or null if the current is the first one) + + MarketplaceCatalogItemsMetrics: + type: object + example: + data: + - id: aSdFam...yCg== + slug: myproject_my-component-repo + title: An item title + shortDescription: Example description + date: "2021-07-01T00:00:00Z" + pagination: + page: 0 + size: 20 + totalElements: 50 + totalPages: 3 + next: https://api.example.com/resources?page=1&size=20 + previous: null + properties: + data: + type: array + items: + $ref: '#/components/schemas/CatalogItem' + pagination: + $ref: '#/components/schemas/MarketplaceCatalogItemsMetricsPagination' + + MarketplaceCatalogItemsMetricsPagination: + type: object + properties: + page: + type: integer + size: + type: integer + totalElements: + type: integer + totalPages: + type: integer + next: + type: string + format: uri + nullable: true + previous: + type: string + format: uri + nullable: true + + CatalogItem: + type: object + properties: + id: + type: string + slug: + type: string + path: + type: string + title: + type: string + shortDescription: + type: string + descriptionFileId: + type: string + imageFileId: + type: string + description: + type: string + description: Resolved description content of the catalog item (e.g. markdown converted to plain text or HTML) + example: "This component provides a reusable service for handling..." + itemSrc: + type: string + tags: + type: array + items: + $ref: '#/components/schemas/CatalogItemTag' + authors: + type: array + items: + type: string + date: + type: string + format: date-time + + CatalogItemTag: + type: object + properties: + label: + type: string + options: + type: array + items: + type: string \ No newline at end of file diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java index b44ef2e..b6a7db3 100644 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java @@ -5,6 +5,7 @@ */ package org.opendevstack.apiservice.marketplacemetrics.api; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.RestErrorMessage; import io.swagger.v3.oas.annotations.ExternalDocumentation; @@ -40,6 +41,51 @@ @Tag(name = "MarketplaceMetrics", description = "the MarketplaceMetrics API") public interface MarketplaceMetricsApi { + public static final String PATH_GET_MARKETPLACE_CATALOG_ITEMS_METRICS = "/metrics/marketplace/catalog-items"; + /** + * GET /metrics/marketplace/catalog-items : Get Marketplace metrics regarding catalog items. + * Returns usage metrics of catalog items available in the marketplace. + * + * @return A paginated list of catalog items metrics (status code 200) + * or Invalid client token (status code 401) + * or Insufficient permissions (status code 403) + * or Resource not found (status code 404) + * or Server error (status code 500) + */ + @Operation( + operationId = "getMarketplaceCatalogItemsMetrics", + summary = "Get Marketplace metrics regarding catalog items.", + description = "Returns usage metrics of catalog items available in the marketplace.", + tags = { "MarketplaceMetrics" }, + responses = { + @ApiResponse(responseCode = "200", description = "A paginated list of catalog items metrics", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = MarketplaceCatalogItemsMetrics.class)) + }), + @ApiResponse(responseCode = "401", description = "Invalid client token", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) + }), + @ApiResponse(responseCode = "403", description = "Insufficient permissions", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) + }), + @ApiResponse(responseCode = "404", description = "Resource not found", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) + }), + @ApiResponse(responseCode = "500", description = "Server error", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) + }) + } + ) + @RequestMapping( + method = RequestMethod.GET, + value = MarketplaceMetricsApi.PATH_GET_MARKETPLACE_CATALOG_ITEMS_METRICS, + produces = { "application/json" } + ) + + ResponseEntity getMarketplaceCatalogItemsMetrics( + + ); + + public static final String PATH_GET_MARKETPLACE_PROJECT_COMPONENTS_METRICS = "/metrics/marketplace/project-components"; /** * GET /metrics/marketplace/project-components : Get Marketplace metrics regarding project components. @@ -48,9 +94,10 @@ public interface MarketplaceMetricsApi { * @param page Page number (from 0 until limit) (optional, default to 0) * @param size Page size (optional, default to 20) * @return A paginated list of all the provisioned project components (status code 200) - * or Invalid client token on the request. (status code 401) - * or Insufficient permissions for the client to access the resource. (status code 403) - * or Server error. (status code 500) + * or Invalid client token (status code 401) + * or Insufficient permissions (status code 403) + * or Resource not found (status code 404) + * or Server error (status code 500) */ @Operation( operationId = "getMarketplaceProjectComponentsMetrics", @@ -61,13 +108,16 @@ public interface MarketplaceMetricsApi { @ApiResponse(responseCode = "200", description = "A paginated list of all the provisioned project components", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = MarketplaceProjectComponentsMetrics.class)) }), - @ApiResponse(responseCode = "401", description = "Invalid client token on the request.", content = { + @ApiResponse(responseCode = "401", description = "Invalid client token", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) + }), + @ApiResponse(responseCode = "403", description = "Insufficient permissions", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) }), - @ApiResponse(responseCode = "403", description = "Insufficient permissions for the client to access the resource.", content = { + @ApiResponse(responseCode = "404", description = "Resource not found", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) }), - @ApiResponse(responseCode = "500", description = "Server error.", content = { + @ApiResponse(responseCode = "500", description = "Server error", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) }) } diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java index 231fcfb..822c685 100644 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsController.java @@ -4,6 +4,7 @@ import org.opendevstack.apiservice.marketplacemetrics.api.MarketplaceMetricsApi; import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException; import org.opendevstack.apiservice.marketplacemetrics.facade.MarketplaceMetricsFacade; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -15,6 +16,24 @@ public class MarketplaceMetricsController implements MarketplaceMetricsApi { MarketplaceMetricsFacade marketplaceMetricsFacade; + @CrossOrigin(origins = "*") + @GetMapping("/metrics/marketplace/catalog-items") + @Override + public ResponseEntity getMarketplaceCatalogItemsMetrics() { + MarketplaceCatalogItemsMetrics catalogItemsMetrics; + try { + catalogItemsMetrics = marketplaceMetricsFacade.getMarketplaceCatalogItemsMetrics(); + } catch (MarketplaceMetricsException e) { + return sneakyThrow(e); + } + + if (catalogItemsMetrics == null) { + return ResponseEntity.notFound().build(); + } else { + return ResponseEntity.ok(catalogItemsMetrics); + } + } + @Override @CrossOrigin(origins = "*") @GetMapping("/metrics/marketplace/project-components") diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java index 94bd912..60253f4 100644 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/MarketplaceMetricsFacade.java @@ -1,8 +1,11 @@ package org.opendevstack.apiservice.marketplacemetrics.facade; import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; public interface MarketplaceMetricsFacade { MarketplaceProjectComponentsMetrics getMarketplaceProjectComponentsMetrics(Integer page, Integer size) throws MarketplaceMetricsException; + + MarketplaceCatalogItemsMetrics getMarketplaceCatalogItemsMetrics() throws MarketplaceMetricsException; } diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java index 5114781..4cb3c91 100644 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImpl.java @@ -7,6 +7,7 @@ import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException; import org.opendevstack.apiservice.marketplacemetrics.facade.MarketplaceMetricsFacade; import org.opendevstack.apiservice.marketplacemetrics.mapper.MarketplaceMetricsMapper; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; import org.springframework.stereotype.Component; @@ -32,4 +33,14 @@ public MarketplaceProjectComponentsMetrics getMarketplaceProjectComponentsMetric throw new MarketplaceMetricsException("Failed to retrieve marketplace project components metrics.", e); } } + + @Override + public MarketplaceCatalogItemsMetrics getMarketplaceCatalogItemsMetrics() throws MarketplaceMetricsException { + try { + var allCatalogItems = marketplaceService.getAllCatalogItems(); + return mapper.toApiModel(allCatalogItems); + } catch (MarketplaceException e) { + throw new MarketplaceMetricsException("Failed to retrieve marketplace catalog items metrics.", e); + } + } } diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java index b7eeba9..d12f579 100644 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java +++ b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapper.java @@ -1,15 +1,20 @@ package org.opendevstack.apiservice.marketplacemetrics.mapper; import org.openapitools.jackson.nullable.JsonNullable; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListItem; import org.opendevstack.apiservice.externalservice.marketplace.client.model.Pagination; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; +import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItem; +import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItemTag; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetricsPagination; import org.springframework.stereotype.Component; -import java.util.stream.Collectors; +import java.util.Collections; +import java.util.List; +import java.util.Optional; /** * Mapper class for converting between external service and API models. @@ -35,7 +40,7 @@ public MarketplaceProjectComponentsMetrics toApiModel(ProjectComponentListRespon marketplaceProjectComponentsMetrics.setData( projectComponentListResponse.getData().stream() .map(this::toApiMarketplaceProjectComponentMetric) - .collect(Collectors.toList()) + .toList() ); } @@ -89,5 +94,55 @@ private MarketplaceProjectComponentsMetricsPagination toApiMarketplaceProjectCom return marketplaceProjectComponentsMetricsPagination; } + + public MarketplaceCatalogItemsMetrics toApiModel(List allCatalogItems) { + var marketplaceCatalogItemsMetrics = new MarketplaceCatalogItemsMetrics(); + + var catalogItems = allCatalogItems.stream() + .map(this::toApiModel) + .toList(); + + marketplaceCatalogItemsMetrics.setData(catalogItems); + + return marketplaceCatalogItemsMetrics; + } + + public CatalogItem toApiModel(org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem catalogItem) { + + var tags = Optional.ofNullable(catalogItem.getTags()).orElse(Collections.emptyList()) + .stream() + .map(this::toApiModel) + .toList(); + + var apiCatalogItem = new CatalogItem(); + + apiCatalogItem.setId(catalogItem.getId()); + apiCatalogItem.setSlug(catalogItem.getSlug()); + apiCatalogItem.setPath(catalogItem.getPath()); + apiCatalogItem.setTitle(catalogItem.getTitle()); + apiCatalogItem.setShortDescription(catalogItem.getShortDescription()); + apiCatalogItem.setDescriptionFileId(catalogItem.getDescriptionFileId()); + apiCatalogItem.setImageFileId(catalogItem.getImageFileId()); + apiCatalogItem.setDescription(catalogItem.getDescription()); + apiCatalogItem.setItemSrc(catalogItem.getItemSrc()); + apiCatalogItem.setTags(tags); + apiCatalogItem.setAuthors(catalogItem.getAuthors()); + apiCatalogItem.setDate(catalogItem.getDate()); + + return apiCatalogItem; + } + + public CatalogItemTag toApiModel(org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag catalogItemTag) { + var options = Optional.ofNullable(catalogItemTag.getOptions()).orElse(Collections.emptySet()) + .stream() + .toList(); + + var apiCatalogItemTag = new CatalogItemTag(); + + apiCatalogItemTag.setLabel(catalogItemTag.getLabel()); + apiCatalogItemTag.setOptions(options); + + return apiCatalogItemTag; + } } diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsControllerTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsControllerTest.java new file mode 100644 index 0000000..782d3bd --- /dev/null +++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/controller/MarketplaceMetricsControllerTest.java @@ -0,0 +1,112 @@ +package org.opendevstack.apiservice.marketplacemetrics.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException; +import org.opendevstack.apiservice.marketplacemetrics.facade.MarketplaceMetricsFacade; +import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItem; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MarketplaceMetricsControllerTest { + + @Mock + MarketplaceMetricsFacade marketplaceMetricsFacade; + + MarketplaceMetricsController controller; + + @BeforeEach + void setUp() { + controller = new MarketplaceMetricsController(marketplaceMetricsFacade); + } + + @Test + void GivenFacadeReturnsCatalogMetrics_whenGetMarketplaceCatalogItemsMetrics_ThenReturnOkWithBody() throws MarketplaceMetricsException { + //given + MarketplaceCatalogItemsMetrics metrics = new MarketplaceCatalogItemsMetrics() + .addDataItem(new CatalogItem().id("id1").title("title1")); + when(marketplaceMetricsFacade.getMarketplaceCatalogItemsMetrics()).thenReturn(metrics); + + //when + ResponseEntity response = controller.getMarketplaceCatalogItemsMetrics(); + + //then + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isEqualTo(metrics); + } + + @Test + void GivenFacadeReturnsNull_whenGetMarketplaceCatalogItemsMetrics_ThenReturnNotFound() throws MarketplaceMetricsException { + //given + when(marketplaceMetricsFacade.getMarketplaceCatalogItemsMetrics()).thenReturn(null); + + //when + ResponseEntity response = controller.getMarketplaceCatalogItemsMetrics(); + + //then + assertThat(response.getStatusCode().value()).isEqualTo(404); + assertThat(response.getBody()).isNull(); + } + + @Test + void GivenFacadeThrowsException_whenGetMarketplaceCatalogItemsMetrics_ThenThrowMarketplaceMetricsException() throws MarketplaceMetricsException { + //given + when(marketplaceMetricsFacade.getMarketplaceCatalogItemsMetrics()).thenThrow(new MarketplaceMetricsException("boom")); + + //when //then + assertThatThrownBy(() -> controller.getMarketplaceCatalogItemsMetrics()) + .isInstanceOf(MarketplaceMetricsException.class) + .hasMessageContaining("boom"); + } + + @Test + void GivenFacadeReturnsProjectComponentsMetrics_whenGetMarketplaceProjectComponentsMetrics_ThenReturnOkWithBody() throws MarketplaceMetricsException { + //given + MarketplaceProjectComponentsMetrics metrics = new MarketplaceProjectComponentsMetrics() + .addDataItem(new MarketplaceProjectComponentMetrics().projectKey("P1").componentId("C1")); + when(marketplaceMetricsFacade.getMarketplaceProjectComponentsMetrics(1, 10)).thenReturn(metrics); + + //when + ResponseEntity response = controller.getMarketplaceProjectComponentsMetrics(1, 10); + + //then + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isEqualTo(metrics); + } + + @Test + void GivenFacadeReturnsNull_whenGetMarketplaceProjectComponentsMetrics_ThenReturnNotFound() throws MarketplaceMetricsException { + //given + when(marketplaceMetricsFacade.getMarketplaceProjectComponentsMetrics(0, 0)).thenReturn(null); + + //when + ResponseEntity response = controller.getMarketplaceProjectComponentsMetrics(0, 0); + + //then + assertThat(response.getStatusCode().value()).isEqualTo(404); + assertThat(response.getBody()).isNull(); + } + + @Test + void GivenFacadeThrowsException_whenGetMarketplaceProjectComponentsMetrics_ThenThrowMarketplaceMetricsException() throws MarketplaceMetricsException { + //given + when(marketplaceMetricsFacade.getMarketplaceProjectComponentsMetrics(null, null)).thenThrow(new MarketplaceMetricsException("err")); + + //when //then + assertThatThrownBy(() -> controller.getMarketplaceProjectComponentsMetrics(null, null)) + .isInstanceOf(MarketplaceMetricsException.class) + .hasMessageContaining("err"); + } +} + + diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java index b75faa5..4d3fa45 100644 --- a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java +++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/facade/impl/MarketplaceMetricsFacadeImplTest.java @@ -4,11 +4,14 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.opendevstack.apiservice.marketplacemetrics.exception.MarketplaceMetricsException; import org.opendevstack.apiservice.marketplacemetrics.mapper.MarketplaceMetricsMapper; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics; +import java.util.List; import org.springframework.security.core.context.SecurityContextHolder; import static org.assertj.core.api.Assertions.assertThat; @@ -75,4 +78,37 @@ void get_marketplace_project_components_metrics_wraps_marketplace_exception() th verifyNoInteractions(mapper); } + @Test + void GivenServiceReturnsCatalogItems_whenGetMarketplaceCatalogItemsMetrics_ThenReturnMappedResult() throws Exception { + //given + List serviceResponse = List.of(mock(CatalogItem.class)); + MarketplaceCatalogItemsMetrics mappedResponse = mock(MarketplaceCatalogItemsMetrics.class); + when(marketplaceService.getAllCatalogItems()).thenReturn(serviceResponse); + when(mapper.toApiModel(serviceResponse)).thenReturn(mappedResponse); + + //when + MarketplaceCatalogItemsMetrics result = sut.getMarketplaceCatalogItemsMetrics(); + + //then + assertThat(result).isSameAs(mappedResponse); + verify(marketplaceService).getAllCatalogItems(); + verify(mapper).toApiModel(serviceResponse); + } + + @Test + void GivenServiceThrowsMarketplaceException_whenGetMarketplaceCatalogItemsMetrics_ThenWrapMarketplaceMetricsException() throws Exception { + //given + MarketplaceException cause = new MarketplaceException("unavailable"); + when(marketplaceService.getAllCatalogItems()).thenThrow(cause); + + //when //then + assertThatThrownBy(() -> sut.getMarketplaceCatalogItemsMetrics()) + .isInstanceOf(MarketplaceMetricsException.class) + .hasMessage("Failed to retrieve marketplace catalog items metrics.") + .hasCause(cause); + + verify(marketplaceService).getAllCatalogItems(); + verifyNoInteractions(mapper); + } + } \ No newline at end of file diff --git a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java index 49354d4..7d04e09 100644 --- a/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java +++ b/api-marketplace-metrics/src/test/java/org/opendevstack/apiservice/marketplacemetrics/mapper/MarketplaceMetricsMapperTest.java @@ -5,14 +5,21 @@ import org.opendevstack.apiservice.externalservice.marketplace.client.model.Pagination; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListItem; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentListResponse; +import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItem; +import org.opendevstack.apiservice.marketplacemetrics.model.CatalogItemTag; +import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics; import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; import java.math.BigDecimal; import java.net.URI; -import java.util.Arrays; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class MarketplaceMetricsMapperTest { @@ -25,7 +32,7 @@ void setUp() { @Test void to_api_model_returns_null_when_response_is_null() { - assertThat(mapper.toApiModel(null)).isNull(); + assertThat(mapper.toApiModel( (ProjectComponentListResponse) null)).isNull(); } @Test @@ -47,7 +54,7 @@ void to_api_model_maps_all_fields_when_response_contains_data_and_pagination() { pagination.setPrevious(URI.create("https://api.example.com/resources?page=0&size=20")); ProjectComponentListResponse response = new ProjectComponentListResponse(); - response.setData(Arrays.asList(item)); + response.setData(List.of(item)); response.setPagination(pagination); MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response); @@ -55,7 +62,7 @@ void to_api_model_maps_all_fields_when_response_contains_data_and_pagination() { assertThat(result).isNotNull(); assertThat(result.getData()).hasSize(1); - MarketplaceProjectComponentMetrics mappedItem = result.getData().get(0); + MarketplaceProjectComponentMetrics mappedItem = result.getData().getFirst(); assertThat(mappedItem.getComponentId()).isEqualTo("comp-1"); assertThat(mappedItem.getProjectKey()).isEqualTo("PRJ1"); assertThat(mappedItem.getCaller()).isEqualTo("user@example.com"); @@ -79,26 +86,195 @@ void to_api_model_maps_all_fields_when_response_contains_data_and_pagination() { @Test void to_api_model_maps_null_item_in_data_list_as_null_entry() { ProjectComponentListResponse response = new ProjectComponentListResponse(); - response.setData(Arrays.asList((ProjectComponentListItem) null)); + response.setData(Collections.singletonList(null)); MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response); assertThat(result).isNotNull(); assertThat(result.getData()).hasSize(1); - assertThat(result.getData().get(0)).isNull(); + assertThat(result.getData().getFirst()).isNull(); } - @Test - void to_api_model_keeps_default_data_and_sets_null_pagination_when_source_has_null_data_and_pagination() { - ProjectComponentListResponse response = new ProjectComponentListResponse(); - response.setData(null); - response.setPagination(null); + @Test + void to_api_model_keeps_default_data_and_sets_null_pagination_when_source_has_null_data_and_pagination() { + ProjectComponentListResponse response = new ProjectComponentListResponse(); + response.setData(null); + response.setPagination(null); - MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response); + MarketplaceProjectComponentsMetrics result = mapper.toApiModel(response); - assertThat(result).isNotNull(); - assertThat(result.getData()).isEmpty(); - assertThat(result.getPagination()).isNull(); - } + assertThat(result).isNotNull(); + assertThat(result.getData()).isEmpty(); + assertThat(result.getPagination()).isNull(); + } + + @Test + void to_api_model_for_catalog_items_list_throws_exception_when_list_is_null() { + assertThatThrownBy(() -> mapper.toApiModel((List) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void to_api_model_for_catalog_items_list_maps_empty_list() { + List emptyList = Collections.emptyList(); + + MarketplaceCatalogItemsMetrics result = mapper.toApiModel(emptyList); + + assertThat(result).isNotNull(); + assertThat(result.getData()).isEmpty(); + } + + @Test + void to_api_model_for_catalog_items_list_maps_items_correctly() { + org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem sourceItem = new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem(); + sourceItem.setId("item-1"); + sourceItem.setSlug("java-17"); + sourceItem.setPath("/path/to/item"); + sourceItem.setTitle("Java 17"); + sourceItem.setShortDescription("Short desc"); + sourceItem.setDescription("Full description"); + sourceItem.setItemSrc("http://example.com/item"); + sourceItem.setAuthors(List.of("author1", "author2")); + + MarketplaceCatalogItemsMetrics result = mapper.toApiModel(List.of(sourceItem)); + + assertThat(result).isNotNull(); + assertThat(result.getData()).hasSize(1); + + CatalogItem mappedItem = result.getData().getFirst(); + assertThat(mappedItem.getId()).isEqualTo("item-1"); + assertThat(mappedItem.getSlug()).isEqualTo("java-17"); + assertThat(mappedItem.getPath()).isEqualTo("/path/to/item"); + assertThat(mappedItem.getTitle()).isEqualTo("Java 17"); + assertThat(mappedItem.getShortDescription()).isEqualTo("Short desc"); + assertThat(mappedItem.getDescription()).isEqualTo("Full description"); + assertThat(mappedItem.getItemSrc()).isEqualTo("http://example.com/item"); + assertThat(mappedItem.getAuthors()).containsExactly("author1", "author2"); + } + + @Test + void to_api_model_for_catalog_item_returns_null_when_item_is_null() { + assertThatThrownBy(() -> mapper.toApiModel((org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void to_api_model_for_catalog_item_maps_all_fields_when_populated() { + org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem sourceItem = new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem(); + sourceItem.setId("item-1"); + sourceItem.setSlug("java-17"); + sourceItem.setPath("/path/to/item"); + sourceItem.setTitle("Java 17"); + sourceItem.setShortDescription("Short desc"); + sourceItem.setDescriptionFileId("file-id-1"); + sourceItem.setImageFileId("image-id-1"); + sourceItem.setDescription("Full description"); + sourceItem.setItemSrc("http://example.com/item"); + sourceItem.setAuthors(List.of("author1")); + sourceItem.setDate(OffsetDateTime.now()); + + org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag sourceTag = + new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag(); + sourceTag.setLabel("version"); + sourceTag.setOptions(Set.of("1.0", "2.0")); + sourceItem.setTags(List.of(sourceTag)); + + CatalogItem result = mapper.toApiModel(sourceItem); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo("item-1"); + assertThat(result.getSlug()).isEqualTo("java-17"); + assertThat(result.getPath()).isEqualTo("/path/to/item"); + assertThat(result.getTitle()).isEqualTo("Java 17"); + assertThat(result.getShortDescription()).isEqualTo("Short desc"); + assertThat(result.getDescriptionFileId()).isEqualTo("file-id-1"); + assertThat(result.getImageFileId()).isEqualTo("image-id-1"); + assertThat(result.getDescription()).isEqualTo("Full description"); + assertThat(result.getItemSrc()).isEqualTo("http://example.com/item"); + assertThat(result.getAuthors()).containsExactly("author1"); + assertThat(result.getDate()).isNotNull(); + assertThat(result.getTags()).hasSize(1); + assertThat(result.getTags().getFirst().getLabel()).isEqualTo("version"); + assertThat(result.getTags().getFirst().getOptions()).contains("1.0", "2.0"); + } + + @Test + void to_api_model_for_catalog_item_maps_with_null_fields() { + org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem sourceItem = new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem(); + sourceItem.setId("item-1"); + sourceItem.setTitle("Title"); + + CatalogItem result = mapper.toApiModel(sourceItem); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo("item-1"); + assertThat(result.getTitle()).isEqualTo("Title"); + assertThat(result.getSlug()).isNull(); + assertThat(result.getPath()).isNull(); + assertThat(result.getDescription()).isNull(); + assertThat(result.getTags()).isEmpty(); + assertThat(result.getAuthors()).isEmpty(); + } + + @Test + void to_api_model_for_catalog_item_tag_returns_null_when_tag_is_null() { + assertThatThrownBy(() -> mapper.toApiModel((org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag) null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void to_api_model_for_catalog_item_tag_maps_all_fields_when_populated() { + org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag sourceTag = + new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag(); + sourceTag.setLabel("version"); + sourceTag.setOptions(Set.of("1.0", "2.0", "3.0")); + + CatalogItemTag result = mapper.toApiModel(sourceTag); + + assertThat(result).isNotNull(); + assertThat(result.getLabel()).isEqualTo("version"); + assertThat(result.getOptions()).hasSize(3).contains("1.0", "2.0", "3.0"); + } + + @Test + void to_api_model_for_catalog_item_tag_maps_with_null_options() { + org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag sourceTag = + new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag(); + sourceTag.setLabel("environment"); + sourceTag.setOptions(null); + + CatalogItemTag result = mapper.toApiModel(sourceTag); + + assertThat(result).isNotNull(); + assertThat(result.getLabel()).isEqualTo("environment"); + assertThat(result.getOptions()).isEmpty(); + } + + @Test + void to_api_model_for_catalog_item_maps_multiple_tags() { + org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem sourceItem = new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem(); + sourceItem.setId("item-1"); + + org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag tag1 = + new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag(); + tag1.setLabel("version"); + tag1.setOptions(Set.of("1.0", "2.0")); + + org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag tag2 = + new org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemTag(); + tag2.setLabel("environment"); + tag2.setOptions(Set.of("prod", "dev")); + + sourceItem.setTags(List.of(tag1, tag2)); + + CatalogItem result = mapper.toApiModel(sourceItem); + + assertThat(result).isNotNull(); + assertThat(result.getTags()).hasSize(2); + assertThat(result.getTags().get(0).getLabel()).isEqualTo("version"); + assertThat(result.getTags().get(0).getOptions()).contains("1.0", "2.0"); + assertThat(result.getTags().get(1).getLabel()).isEqualTo("environment"); + assertThat(result.getTags().get(1).getOptions()).contains("prod", "dev"); + } } diff --git a/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml index 6bf9eb3..bafbb6d 100644 --- a/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml +++ b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml @@ -307,7 +307,7 @@ paths: - name: catalogId in: query description: id for the Catalog. - required: true + required: false schema: type: string - name: sortByTitle @@ -1000,6 +1000,10 @@ components: imageFileId: type: string example: cHJvam...YXN0Z + description: + type: string + description: Resolved description content of the catalog item (e.g. markdown converted to plain text or HTML) + example: "This component provides a reusable service for handling..." itemSrc: type: string example: 'https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master' @@ -1040,6 +1044,8 @@ components: shortDescription: This is a short description for the item descriptionFileId: cHJvam...0ZXIK imageFileId: cHJvam...YXN0Z + description: "This component provides a reusable service for handling..." + image: "https://example.com/images/component.png" itemSrc: https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master tags: - label: data diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml index 443d7bf..b43b4be 100644 --- a/external-service-marketplace/pom.xml +++ b/external-service-marketplace/pom.xml @@ -150,7 +150,7 @@ generate - FILTER=operationId:getCatalogItemById|getCatalogItemBySlug|getCatalogHealth + FILTER=operationId:getCatalogItemById|getCatalogItemBySlug|getCatalogItems|getCatalogHealth java ${project.basedir}/target/generated-sources/openapi resttemplate diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java index 5e6ae7f..9857250 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java @@ -31,6 +31,10 @@ public interface MarketplaceService extends ExternalService { CatalogItem getCatalogItem(String instanceName, String catalogItemId) throws MarketplaceException; + List getAllCatalogItems() throws MarketplaceException; + + List getAllCatalogItems(String instanceName) throws MarketplaceException; + CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException; CatalogItem getCatalogItemBySlug(String instanceName, String slug) throws MarketplaceException; diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index c6c3e39..15df246 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -21,6 +21,7 @@ import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; +import java.util.Collections; import java.util.List; import java.util.Set; @@ -72,6 +73,35 @@ public CatalogItem getCatalogItem(String instanceName, String catalogItemId) thr } } + @Override + public List getAllCatalogItems() throws MarketplaceException { + return getAllCatalogItems(getDefaultInstance()); + } + + @Override + public List getAllCatalogItems(String instanceName) throws MarketplaceException { + log.debug("Marketplace service GET all catalog items in instance {} ", instanceName); + + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + CatalogItemsApi catalogItemsApi = new CatalogItemsApi(apiClient); + + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + + return catalogItemsApi.getCatalogItems(SortOrder.ASC, null); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Catalog items not found in Marketplace instance '{}'", instanceName); + return Collections.emptyList(); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting all catalog items in instance '%s'", instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve catalog items in instance '%s'", instanceName), e); + } + } + @Override public CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException { return getCatalogItemBySlug(getDefaultInstance(), slug); diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java index 81dee1c..2b0faca 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -662,6 +662,120 @@ void testGetCatalogItem_DefaultInstance() throws MarketplaceException { verify(clientFactory).getClient("default"); } + // ------------------------------------------------------------------------- + // getAllCatalogItems + // ------------------------------------------------------------------------- + + @Test + void testGetAllCatalogItems_Success_ReturnsList() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + CatalogItem item = new CatalogItem(); + item.setId("cid"); + List mockList = List.of(item); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI("/catalog-items", HttpMethod.GET) + .thenReturn(new ResponseEntity<>(mockList, HttpStatus.OK)); + + // Act + List result = marketplaceService.getAllCatalogItems(instanceName); + + // Assert + assertEquals(mockList, result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testGetAllCatalogItems_NotFound_ReturnsEmptyList() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI("/catalog-items", HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + List result = marketplaceService.getAllCatalogItems(instanceName); + + // Assert + assertTrue(result.isEmpty()); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testGetAllCatalogItems_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI("/catalog-items", HttpMethod.GET).thenThrow(forbiddenEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getAllCatalogItems(instanceName)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testGetAllCatalogItems_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI("/catalog-items", HttpMethod.GET).thenThrow(new RestClientException("Connection failed")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getAllCatalogItems(instanceName)); + + assertTrue(exception.getMessage().contains("Failed to retrieve catalog items")); + } + + @Test + void testGetAllCatalogItems_DefaultInstance_UsesDefaultClient() throws MarketplaceException { + // Arrange + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + List mockList = List.of(new CatalogItem()); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI("/catalog-items", HttpMethod.GET) + .thenReturn(new ResponseEntity<>(mockList, HttpStatus.OK)); + + // Act + List result = marketplaceService.getAllCatalogItems(); + + // Assert + assertEquals(mockList, result); + verify(clientFactory).getClient("default"); + } + // ------------------------------------------------------------------------- // getProjectComponent — auth error // ------------------------------------------------------------------------- From ef995c9cdcfccce0b6c55005afca8a737fd40afc Mon Sep 17 00:00:00 2001 From: "Vila,Jordi (IT EDP)" Date: Thu, 2 Jul 2026 15:39:31 +0200 Subject: [PATCH 18/19] Apply PR feedback --- application.yaml | 3 +-- .../ClientCredentialsTokenService.java | 6 ++---- .../jwt/ClientCredentialsTokenServiceTest.java | 16 +++++++--------- .../config/MarketplaceInstanceConfig.java | 4 ---- .../service/impl/MarketplaceServiceImpl.java | 3 +-- ....sql => 007_add_marketplace_metrics_apis.sql} | 2 +- 6 files changed, 12 insertions(+), 22 deletions(-) rename persistence/src/main/resources/db/changelog/migrations/{006_add_marketplace_metrics_apis.sql => 007_add_marketplace_metrics_apis.sql} (90%) diff --git a/application.yaml b/application.yaml index 234046f..b312c81 100644 --- a/application.yaml +++ b/application.yaml @@ -70,7 +70,7 @@ app: - /api/v1/projects/*/platforms client-credentials: # Azure AD token endpoint used for request a token. - token-url: ${CLIENT_CREDENTIALS_TOKEN_URL:https://login.microsoftonline.com//oauth2/v2.0/token} + token-url: ${CLIENT_CREDENTIALS_TOKEN_URL:https://login.microsoftonline.com/${AZURE_TENANT_ID:}/oauth2/v2.0/token} # Client ID of this application's Azure AD app registration. client-id: ${CLIENT_CREDENTIALS_CLIENT_ID} # Client secret for token request. Must be injected via environment variable or secret store. @@ -322,5 +322,4 @@ externalservices: trust-all-certificates: ${MARKETPLACE_TRUST_ALL_CERTS:false} username: ${MARKETPLACE_USERNAME:} password: ${MARKETPLACE_PASSWORD:} - tenant-id: ${AZURE_TENANT_ID:} client-credentials-scope: ${MARKETPLACE_CLIENT_CREDENTIALS_SCOPE:} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenService.java index 8875243..b9c954d 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenService.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/client/crendentials/ClientCredentialsTokenService.java @@ -35,7 +35,7 @@ public ClientCredentialsTokenService(ClientCredentialsTokenProperties properties * @return the access token string * @throws ClientCredentialsTokenException if the token request fails */ - public String requestToken(String scope, String tenantId) { + public String requestToken(String scope) { log.debug("Requesting access token using JWT assertion"); MultiValueMap body = new LinkedMultiValueMap<>(); @@ -50,10 +50,8 @@ public String requestToken(String scope, String tenantId) { HttpEntity> request = new HttpEntity<>(body, headers); try { - String urlWithTenant = properties.getTokenUrl().replace("", tenantId); - ResponseEntity response = - restTemplate.postForEntity(urlWithTenant, request, ClientCredentialsTokenResponse.class); + restTemplate.postForEntity(properties.getTokenUrl(), request, ClientCredentialsTokenResponse.class); if (response.getBody() == null || response.getBody().getAccessToken() == null) { throw new ClientCredentialsTokenException("JWT token response body or access_token is null"); diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java index b76ccd1..4e143fe 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/ClientCredentialsTokenServiceTest.java @@ -40,7 +40,7 @@ class ClientCredentialsTokenServiceTest { void setUp() { MockitoAnnotations.openMocks(this); properties = new ClientCredentialsTokenProperties(); - properties.setTokenUrl("https://login.microsoftonline.com//oauth2/v2.0/token"); + properties.setTokenUrl("https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token"); properties.setClientId("test-client-id"); properties.setClientSecret("test-client-secret"); sut = new ClientCredentialsTokenService(properties, restTemplate); @@ -49,7 +49,6 @@ void setUp() { @Test void request_token_returns_access_token_on_success() { // GIVEN - String tenantId = "tenant-id"; String scope = "api://target-app/.default"; ClientCredentialsTokenResponse tokenResponse = new ClientCredentialsTokenResponse(); tokenResponse.setAccessToken("jwt-access-token"); @@ -57,13 +56,13 @@ void request_token_returns_access_token_on_success() { tokenResponse.setExpiresIn(3600); tokenResponse.setScope(scope); - String expectedUrl = properties.getTokenUrl().replace("", tenantId); + String expectedUrl = properties.getTokenUrl(); when(restTemplate.postForEntity(eq(expectedUrl), any(HttpEntity.class), eq(ClientCredentialsTokenResponse.class))) .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); // WHEN - String result = sut.requestToken(scope, tenantId); + String result = sut.requestToken(scope); // THEN assertEquals("jwt-access-token", result); @@ -74,7 +73,6 @@ void request_token_returns_access_token_on_success() { @SuppressWarnings("unchecked") void request_token_sends_correct_form_parameters_and_headers() { // GIVEN - String tenantId = "t-1"; String scope = "api://app/scope"; ClientCredentialsTokenResponse tokenResponse = new ClientCredentialsTokenResponse(); tokenResponse.setAccessToken("token"); @@ -84,7 +82,7 @@ void request_token_sends_correct_form_parameters_and_headers() { .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); // WHEN - sut.requestToken(scope, tenantId); + sut.requestToken(scope); // THEN MultiValueMap body = captor.getValue().getBody(); @@ -107,7 +105,7 @@ void request_token_throws_jwt_exception_when_response_body_is_null() { // WHEN / THEN ClientCredentialsTokenException ex = assertThrows(ClientCredentialsTokenException.class, - () -> sut.requestToken("scope", "tenant")); + () -> sut.requestToken("scope")); assertTrue(ex.getMessage().contains("null")); } @@ -121,7 +119,7 @@ void request_token_throws_jwt_exception_when_access_token_is_null() { .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); // WHEN / THEN - assertThrows(ClientCredentialsTokenException.class, () -> sut.requestToken("scope", "tenant")); + assertThrows(ClientCredentialsTokenException.class, () -> sut.requestToken("scope")); } @Test @@ -132,7 +130,7 @@ void request_token_throws_jwt_exception_on_rest_client_error() { // WHEN / THEN ClientCredentialsTokenException ex = assertThrows(ClientCredentialsTokenException.class, - () -> sut.requestToken("scope", "tenant")); + () -> sut.requestToken("scope")); assertTrue(ex.getMessage().contains("Connection refused")); } } diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java index 0915b4c..5083ae2 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -51,10 +51,6 @@ public class MarketplaceInstanceConfig { */ private String oboScope; - /** - * The tenant ID used for JWT token request when calling this Marketplace instance. - */ - private String tenantId; /** * OAuth2 scope used for Client Credentials token exchange when calling this Marketplace instance. diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index 15df246..a8e910b 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -430,9 +430,8 @@ private MarketplaceApiClient getClientCredentialsAuthenticatedClient(String inst try { String scope = client.getConfig().getClientCredentialsScope(); - String tenantId = client.getConfig().getTenantId(); - jwtToken = clientCredentialsTokenService.requestToken(scope, tenantId); + jwtToken = clientCredentialsTokenService.requestToken(scope); } catch (RuntimeException ex) { throw new MarketplaceException( String.format( diff --git a/persistence/src/main/resources/db/changelog/migrations/006_add_marketplace_metrics_apis.sql b/persistence/src/main/resources/db/changelog/migrations/007_add_marketplace_metrics_apis.sql similarity index 90% rename from persistence/src/main/resources/db/changelog/migrations/006_add_marketplace_metrics_apis.sql rename to persistence/src/main/resources/db/changelog/migrations/007_add_marketplace_metrics_apis.sql index 9dd7f57..db40a3b 100644 --- a/persistence/src/main/resources/db/changelog/migrations/006_add_marketplace_metrics_apis.sql +++ b/persistence/src/main/resources/db/changelog/migrations/007_add_marketplace_metrics_apis.sql @@ -1,7 +1,7 @@ --liquibase formatted sql -- ============================================================================ --- changeset ods:009-seed-marketplace-metrics-api-definitions +-- changeset ods:010-seed-marketplace-metrics-api-definitions -- Description: Seed API definitions for Marketplace Metrics API. -- ============================================================================ INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled) From 1bf2134580cdf8906164306e1ea7330c94ca814b Mon Sep 17 00:00:00 2001 From: sergio-soria-bi Date: Tue, 7 Jul 2026 10:03:53 +0200 Subject: [PATCH 19/19] Feature/remove auto generated code from git repo (#41) Co-authored-by: Vila,Jordi (IT EDP) --- api-marketplace-metrics/pom.xml | 57 ++++- .../api/MarketplaceMetricsApi.java | 136 ----------- .../MarketplaceProjectComponentMetrics.java | 205 ---------------- .../MarketplaceProjectComponentsMetrics.java | 122 ---------- ...aceProjectComponentsMetricsPagination.java | 219 ------------------ .../model/RestErrorMessage.java | 95 -------- 6 files changed, 51 insertions(+), 783 deletions(-) delete mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java delete mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentMetrics.java delete mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetrics.java delete mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetricsPagination.java delete mode 100644 api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/RestErrorMessage.java diff --git a/api-marketplace-metrics/pom.xml b/api-marketplace-metrics/pom.xml index 066c3c3..9e29b82 100644 --- a/api-marketplace-metrics/pom.xml +++ b/api-marketplace-metrics/pom.xml @@ -98,26 +98,47 @@ org.openapitools openapi-generator-maven-plugin + generate-api-marketplace-metrics + generate-sources + generate + spring - ${project.basedir} + + + ${project.build.directory}/generated-sources/openapi + + spring-boot - ${project.basedir}/openapi/api-marketplace-metrics.yaml - org.opendevstack.apiservice.marketplacemetrics.api - org.opendevstack.apiservice.marketplacemetrics.model - org.opendevstack.apiservice.marketplacemetrics - false + + + ${project.basedir}/openapi/api-marketplace-metrics.yaml + + + + org.opendevstack.apiservice.marketplacemetrics.api + + + + org.opendevstack.apiservice.marketplacemetrics.model + + + + org.opendevstack.apiservice.marketplacemetrics + + false false false false false + true true @@ -130,6 +151,30 @@ + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + + add-openapi-sources + generate-sources + + + add-source + + + + + + ${project.build.directory}/generated-sources/openapi/src/main/java + + + + + + org.springframework.boot spring-boot-maven-plugin diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java deleted file mode 100644 index b6a7db3..0000000 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/api/MarketplaceMetricsApi.java +++ /dev/null @@ -1,136 +0,0 @@ -/* - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.15.0). - * https://openapi-generator.tech - * Do not edit the class manually. - */ -package org.opendevstack.apiservice.marketplacemetrics.api; - -import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceCatalogItemsMetrics; -import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetrics; -import org.opendevstack.apiservice.marketplacemetrics.model.RestErrorMessage; -import io.swagger.v3.oas.annotations.ExternalDocumentation; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; -import io.swagger.v3.oas.annotations.media.ArraySchema; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.annotations.enums.ParameterIn; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.lang.Nullable; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.multipart.MultipartFile; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import jakarta.annotation.Generated; - -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") -@Validated -@Tag(name = "MarketplaceMetrics", description = "the MarketplaceMetrics API") -public interface MarketplaceMetricsApi { - - public static final String PATH_GET_MARKETPLACE_CATALOG_ITEMS_METRICS = "/metrics/marketplace/catalog-items"; - /** - * GET /metrics/marketplace/catalog-items : Get Marketplace metrics regarding catalog items. - * Returns usage metrics of catalog items available in the marketplace. - * - * @return A paginated list of catalog items metrics (status code 200) - * or Invalid client token (status code 401) - * or Insufficient permissions (status code 403) - * or Resource not found (status code 404) - * or Server error (status code 500) - */ - @Operation( - operationId = "getMarketplaceCatalogItemsMetrics", - summary = "Get Marketplace metrics regarding catalog items.", - description = "Returns usage metrics of catalog items available in the marketplace.", - tags = { "MarketplaceMetrics" }, - responses = { - @ApiResponse(responseCode = "200", description = "A paginated list of catalog items metrics", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = MarketplaceCatalogItemsMetrics.class)) - }), - @ApiResponse(responseCode = "401", description = "Invalid client token", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) - }), - @ApiResponse(responseCode = "403", description = "Insufficient permissions", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) - }), - @ApiResponse(responseCode = "404", description = "Resource not found", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) - }), - @ApiResponse(responseCode = "500", description = "Server error", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) - }) - } - ) - @RequestMapping( - method = RequestMethod.GET, - value = MarketplaceMetricsApi.PATH_GET_MARKETPLACE_CATALOG_ITEMS_METRICS, - produces = { "application/json" } - ) - - ResponseEntity getMarketplaceCatalogItemsMetrics( - - ); - - - public static final String PATH_GET_MARKETPLACE_PROJECT_COMPONENTS_METRICS = "/metrics/marketplace/project-components"; - /** - * GET /metrics/marketplace/project-components : Get Marketplace metrics regarding project components. - * Returns all the components that were provisioned from the Marketplace in all the projects. - * - * @param page Page number (from 0 until limit) (optional, default to 0) - * @param size Page size (optional, default to 20) - * @return A paginated list of all the provisioned project components (status code 200) - * or Invalid client token (status code 401) - * or Insufficient permissions (status code 403) - * or Resource not found (status code 404) - * or Server error (status code 500) - */ - @Operation( - operationId = "getMarketplaceProjectComponentsMetrics", - summary = "Get Marketplace metrics regarding project components.", - description = "Returns all the components that were provisioned from the Marketplace in all the projects.", - tags = { "MarketplaceMetrics" }, - responses = { - @ApiResponse(responseCode = "200", description = "A paginated list of all the provisioned project components", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = MarketplaceProjectComponentsMetrics.class)) - }), - @ApiResponse(responseCode = "401", description = "Invalid client token", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) - }), - @ApiResponse(responseCode = "403", description = "Insufficient permissions", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) - }), - @ApiResponse(responseCode = "404", description = "Resource not found", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) - }), - @ApiResponse(responseCode = "500", description = "Server error", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = RestErrorMessage.class)) - }) - } - ) - @RequestMapping( - method = RequestMethod.GET, - value = MarketplaceMetricsApi.PATH_GET_MARKETPLACE_PROJECT_COMPONENTS_METRICS, - produces = { "application/json" } - ) - - ResponseEntity getMarketplaceProjectComponentsMetrics( - @Parameter(name = "page", description = "Page number (from 0 until limit)", in = ParameterIn.QUERY) @Valid @RequestParam(value = "page", required = false, defaultValue = "0") Integer page, - @Parameter(name = "size", description = "Page size", in = ParameterIn.QUERY) @Valid @RequestParam(value = "size", required = false, defaultValue = "20") Integer size - ); - -} diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentMetrics.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentMetrics.java deleted file mode 100644 index dfdda38..0000000 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentMetrics.java +++ /dev/null @@ -1,205 +0,0 @@ -package org.opendevstack.apiservice.marketplacemetrics.model; - -import java.net.URI; -import java.util.Objects; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonCreator; -import java.math.BigDecimal; -import org.springframework.lang.Nullable; -import org.openapitools.jackson.nullable.JsonNullable; -import java.time.OffsetDateTime; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import io.swagger.v3.oas.annotations.media.Schema; - - -import java.util.*; -import jakarta.annotation.Generated; - -/** - * MarketplaceProjectComponentMetrics - */ - -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") -public class MarketplaceProjectComponentMetrics { - - private @Nullable String projectKey; - - private @Nullable String componentId; - - private @Nullable String caller; - - private @Nullable String catalogItemSlug; - - private @Nullable BigDecimal createdAt; - - private @Nullable BigDecimal updatedAt; - - public MarketplaceProjectComponentMetrics projectKey(@Nullable String projectKey) { - this.projectKey = projectKey; - return this; - } - - /** - * The projectKey which the component is provisioned for. - * @return projectKey - */ - - @Schema(name = "projectKey", example = "SOMEPROJECT", description = "The projectKey which the component is provisioned for.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("projectKey") - public @Nullable String getProjectKey() { - return projectKey; - } - - public void setProjectKey(@Nullable String projectKey) { - this.projectKey = projectKey; - } - - public MarketplaceProjectComponentMetrics componentId(@Nullable String componentId) { - this.componentId = componentId; - return this; - } - - /** - * The componentId set by the user. - * @return componentId - */ - @Pattern(regexp = "^(?!\\s*$).+") @Size(min = 1) - @Schema(name = "componentId", example = "any-component-id-from-backend", description = "The componentId set by the user.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("componentId") - public @Nullable String getComponentId() { - return componentId; - } - - public void setComponentId(@Nullable String componentId) { - this.componentId = componentId; - } - - public MarketplaceProjectComponentMetrics caller(@Nullable String caller) { - this.caller = caller; - return this; - } - - /** - * The email of who provisioned the component. - * @return caller - */ - - @Schema(name = "caller", example = "some-person@email.com", description = "The email of who provisioned the component.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("caller") - public @Nullable String getCaller() { - return caller; - } - - public void setCaller(@Nullable String caller) { - this.caller = caller; - } - - public MarketplaceProjectComponentMetrics catalogItemSlug(@Nullable String catalogItemSlug) { - this.catalogItemSlug = catalogItemSlug; - return this; - } - - /** - * The provisioned catalog item slug. - * @return catalogItemSlug - */ - - @Schema(name = "catalogItemSlug", example = "some_technology-name", description = "The provisioned catalog item slug.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("catalogItemSlug") - public @Nullable String getCatalogItemSlug() { - return catalogItemSlug; - } - - public void setCatalogItemSlug(@Nullable String catalogItemSlug) { - this.catalogItemSlug = catalogItemSlug; - } - - public MarketplaceProjectComponentMetrics createdAt(@Nullable BigDecimal createdAt) { - this.createdAt = createdAt; - return this; - } - - /** - * The timestamp of the provision action. - * @return createdAt - */ - @Valid - @Schema(name = "createdAt", example = "1707043200000", description = "The timestamp of the provision action.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("createdAt") - public @Nullable BigDecimal getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(@Nullable BigDecimal createdAt) { - this.createdAt = createdAt; - } - - public MarketplaceProjectComponentMetrics updatedAt(@Nullable BigDecimal updatedAt) { - this.updatedAt = updatedAt; - return this; - } - - /** - * The timestamp of the last change of the provisioned component. - * @return updatedAt - */ - @Valid - @Schema(name = "updatedAt", example = "1707043200000", description = "The timestamp of the last change of the provisioned component.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("updatedAt") - public @Nullable BigDecimal getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(@Nullable BigDecimal updatedAt) { - this.updatedAt = updatedAt; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - MarketplaceProjectComponentMetrics marketplaceProjectComponentMetrics = (MarketplaceProjectComponentMetrics) o; - return Objects.equals(this.projectKey, marketplaceProjectComponentMetrics.projectKey) && - Objects.equals(this.componentId, marketplaceProjectComponentMetrics.componentId) && - Objects.equals(this.caller, marketplaceProjectComponentMetrics.caller) && - Objects.equals(this.catalogItemSlug, marketplaceProjectComponentMetrics.catalogItemSlug) && - Objects.equals(this.createdAt, marketplaceProjectComponentMetrics.createdAt) && - Objects.equals(this.updatedAt, marketplaceProjectComponentMetrics.updatedAt); - } - - @Override - public int hashCode() { - return Objects.hash(projectKey, componentId, caller, catalogItemSlug, createdAt, updatedAt); - } - - @Override - public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("class MarketplaceProjectComponentMetrics {\n"); - sb.append(" projectKey: ").append(toIndentedString(projectKey)).append("\n"); - sb.append(" componentId: ").append(toIndentedString(componentId)).append("\n"); - sb.append(" caller: ").append(toIndentedString(caller)).append("\n"); - sb.append(" catalogItemSlug: ").append(toIndentedString(catalogItemSlug)).append("\n"); - sb.append(" createdAt: ").append(toIndentedString(createdAt)).append("\n"); - sb.append(" updatedAt: ").append(toIndentedString(updatedAt)).append("\n"); - sb.append("}"); - return sb.toString(); - } - - /** - * Convert the given object to string with each line indented by 4 spaces - * (except the first line). - */ - private String toIndentedString(Object o) { - if (o == null) { - return "null"; - } - return o.toString().replace("\n", "\n "); - } -} - diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetrics.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetrics.java deleted file mode 100644 index 5cfc12a..0000000 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetrics.java +++ /dev/null @@ -1,122 +0,0 @@ -package org.opendevstack.apiservice.marketplacemetrics.model; - -import java.net.URI; -import java.util.Objects; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonCreator; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentMetrics; -import org.opendevstack.apiservice.marketplacemetrics.model.MarketplaceProjectComponentsMetricsPagination; -import org.springframework.lang.Nullable; -import org.openapitools.jackson.nullable.JsonNullable; -import java.time.OffsetDateTime; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import io.swagger.v3.oas.annotations.media.Schema; - - -import java.util.*; -import jakarta.annotation.Generated; - -/** - * MarketplaceProjectComponentsMetrics - */ - -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") -public class MarketplaceProjectComponentsMetrics { - - @Valid - private List<@Valid MarketplaceProjectComponentMetrics> data = new ArrayList<>(); - - private @Nullable MarketplaceProjectComponentsMetricsPagination pagination; - - public MarketplaceProjectComponentsMetrics data(List<@Valid MarketplaceProjectComponentMetrics> data) { - this.data = data; - return this; - } - - public MarketplaceProjectComponentsMetrics addDataItem(MarketplaceProjectComponentMetrics dataItem) { - if (this.data == null) { - this.data = new ArrayList<>(); - } - this.data.add(dataItem); - return this; - } - - /** - * Get data - * @return data - */ - @Valid - @Schema(name = "data", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("data") - public List<@Valid MarketplaceProjectComponentMetrics> getData() { - return data; - } - - public void setData(List<@Valid MarketplaceProjectComponentMetrics> data) { - this.data = data; - } - - public MarketplaceProjectComponentsMetrics pagination(@Nullable MarketplaceProjectComponentsMetricsPagination pagination) { - this.pagination = pagination; - return this; - } - - /** - * Get pagination - * @return pagination - */ - @Valid - @Schema(name = "pagination", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("pagination") - public @Nullable MarketplaceProjectComponentsMetricsPagination getPagination() { - return pagination; - } - - public void setPagination(@Nullable MarketplaceProjectComponentsMetricsPagination pagination) { - this.pagination = pagination; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - MarketplaceProjectComponentsMetrics marketplaceProjectComponentsMetrics = (MarketplaceProjectComponentsMetrics) o; - return Objects.equals(this.data, marketplaceProjectComponentsMetrics.data) && - Objects.equals(this.pagination, marketplaceProjectComponentsMetrics.pagination); - } - - @Override - public int hashCode() { - return Objects.hash(data, pagination); - } - - @Override - public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("class MarketplaceProjectComponentsMetrics {\n"); - sb.append(" data: ").append(toIndentedString(data)).append("\n"); - sb.append(" pagination: ").append(toIndentedString(pagination)).append("\n"); - sb.append("}"); - return sb.toString(); - } - - /** - * Convert the given object to string with each line indented by 4 spaces - * (except the first line). - */ - private String toIndentedString(Object o) { - if (o == null) { - return "null"; - } - return o.toString().replace("\n", "\n "); - } -} - diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetricsPagination.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetricsPagination.java deleted file mode 100644 index f694bf6..0000000 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/MarketplaceProjectComponentsMetricsPagination.java +++ /dev/null @@ -1,219 +0,0 @@ -package org.opendevstack.apiservice.marketplacemetrics.model; - -import java.net.URI; -import java.util.Objects; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonCreator; -import java.net.URI; -import java.util.Arrays; -import org.openapitools.jackson.nullable.JsonNullable; -import org.springframework.lang.Nullable; -import java.util.NoSuchElementException; -import org.openapitools.jackson.nullable.JsonNullable; -import java.time.OffsetDateTime; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import io.swagger.v3.oas.annotations.media.Schema; - - -import java.util.*; -import jakarta.annotation.Generated; - -/** - * MarketplaceProjectComponentsMetricsPagination - */ - -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") -public class MarketplaceProjectComponentsMetricsPagination { - - private @Nullable Integer page; - - private @Nullable Integer size; - - private @Nullable Integer totalElements; - - private @Nullable Integer totalPages; - - private JsonNullable next = JsonNullable.undefined(); - - private JsonNullable previous = JsonNullable.undefined(); - - public MarketplaceProjectComponentsMetricsPagination page(@Nullable Integer page) { - this.page = page; - return this; - } - - /** - * Current page of the response. - * @return page - */ - - @Schema(name = "page", example = "0", description = "Current page of the response.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("page") - public @Nullable Integer getPage() { - return page; - } - - public void setPage(@Nullable Integer page) { - this.page = page; - } - - public MarketplaceProjectComponentsMetricsPagination size(@Nullable Integer size) { - this.size = size; - return this; - } - - /** - * Current page size of the response. - * @return size - */ - - @Schema(name = "size", example = "20", description = "Current page size of the response.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("size") - public @Nullable Integer getSize() { - return size; - } - - public void setSize(@Nullable Integer size) { - this.size = size; - } - - public MarketplaceProjectComponentsMetricsPagination totalElements(@Nullable Integer totalElements) { - this.totalElements = totalElements; - return this; - } - - /** - * Total number of elements. - * @return totalElements - */ - - @Schema(name = "totalElements", example = "117", description = "Total number of elements.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("totalElements") - public @Nullable Integer getTotalElements() { - return totalElements; - } - - public void setTotalElements(@Nullable Integer totalElements) { - this.totalElements = totalElements; - } - - public MarketplaceProjectComponentsMetricsPagination totalPages(@Nullable Integer totalPages) { - this.totalPages = totalPages; - return this; - } - - /** - * Total number of pages of this exact size. - * @return totalPages - */ - - @Schema(name = "totalPages", example = "6", description = "Total number of pages of this exact size.", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("totalPages") - public @Nullable Integer getTotalPages() { - return totalPages; - } - - public void setTotalPages(@Nullable Integer totalPages) { - this.totalPages = totalPages; - } - - public MarketplaceProjectComponentsMetricsPagination next(URI next) { - this.next = JsonNullable.of(next); - return this; - } - - /** - * URL of the next page (or null if the current is the last one) - * @return next - */ - @Valid - @Schema(name = "next", example = "https://api.example.com/resources?page=1&size=20", description = "URL of the next page (or null if the current is the last one)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("next") - public JsonNullable getNext() { - return next; - } - - public void setNext(JsonNullable next) { - this.next = next; - } - - public MarketplaceProjectComponentsMetricsPagination previous(URI previous) { - this.previous = JsonNullable.of(previous); - return this; - } - - /** - * URL of the previous page (or null if the current is the first one) - * @return previous - */ - @Valid - @Schema(name = "previous", example = "https://api.example.com/resources?page=0&size=20", description = "URL of the previous page (or null if the current is the first one)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @JsonProperty("previous") - public JsonNullable getPrevious() { - return previous; - } - - public void setPrevious(JsonNullable previous) { - this.previous = previous; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - MarketplaceProjectComponentsMetricsPagination marketplaceProjectComponentsMetricsPagination = (MarketplaceProjectComponentsMetricsPagination) o; - return Objects.equals(this.page, marketplaceProjectComponentsMetricsPagination.page) && - Objects.equals(this.size, marketplaceProjectComponentsMetricsPagination.size) && - Objects.equals(this.totalElements, marketplaceProjectComponentsMetricsPagination.totalElements) && - Objects.equals(this.totalPages, marketplaceProjectComponentsMetricsPagination.totalPages) && - equalsNullable(this.next, marketplaceProjectComponentsMetricsPagination.next) && - equalsNullable(this.previous, marketplaceProjectComponentsMetricsPagination.previous); - } - - private static boolean equalsNullable(JsonNullable a, JsonNullable b) { - return a == b || (a != null && b != null && a.isPresent() && b.isPresent() && Objects.deepEquals(a.get(), b.get())); - } - - @Override - public int hashCode() { - return Objects.hash(page, size, totalElements, totalPages, hashCodeNullable(next), hashCodeNullable(previous)); - } - - private static int hashCodeNullable(JsonNullable a) { - if (a == null) { - return 1; - } - return a.isPresent() ? Arrays.deepHashCode(new Object[]{a.get()}) : 31; - } - - @Override - public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("class MarketplaceProjectComponentsMetricsPagination {\n"); - sb.append(" page: ").append(toIndentedString(page)).append("\n"); - sb.append(" size: ").append(toIndentedString(size)).append("\n"); - sb.append(" totalElements: ").append(toIndentedString(totalElements)).append("\n"); - sb.append(" totalPages: ").append(toIndentedString(totalPages)).append("\n"); - sb.append(" next: ").append(toIndentedString(next)).append("\n"); - sb.append(" previous: ").append(toIndentedString(previous)).append("\n"); - sb.append("}"); - return sb.toString(); - } - - /** - * Convert the given object to string with each line indented by 4 spaces - * (except the first line). - */ - private String toIndentedString(Object o) { - if (o == null) { - return "null"; - } - return o.toString().replace("\n", "\n "); - } -} - diff --git a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/RestErrorMessage.java b/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/RestErrorMessage.java deleted file mode 100644 index 4376b03..0000000 --- a/api-marketplace-metrics/src/main/java/org/opendevstack/apiservice/marketplacemetrics/model/RestErrorMessage.java +++ /dev/null @@ -1,95 +0,0 @@ -package org.opendevstack.apiservice.marketplacemetrics.model; - -import java.net.URI; -import java.util.Objects; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonCreator; -import org.springframework.lang.Nullable; -import org.openapitools.jackson.nullable.JsonNullable; -import java.time.OffsetDateTime; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import io.swagger.v3.oas.annotations.media.Schema; - - -import java.util.*; -import jakarta.annotation.Generated; - -/** - * RestErrorMessage - */ - -@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.15.0") -public class RestErrorMessage { - - private String message; - - public RestErrorMessage() { - super(); - } - - /** - * Constructor with only required parameters - */ - public RestErrorMessage(String message) { - this.message = message; - } - - public RestErrorMessage message(String message) { - this.message = message; - return this; - } - - /** - * Get message - * @return message - */ - @NotNull - @Schema(name = "message", requiredMode = Schema.RequiredMode.REQUIRED) - @JsonProperty("message") - public String getMessage() { - return message; - } - - public void setMessage(String message) { - this.message = message; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - RestErrorMessage restErrorMessage = (RestErrorMessage) o; - return Objects.equals(this.message, restErrorMessage.message); - } - - @Override - public int hashCode() { - return Objects.hash(message); - } - - @Override - public String toString() { - StringBuilder sb = new StringBuilder(); - sb.append("class RestErrorMessage {\n"); - sb.append(" message: ").append(toIndentedString(message)).append("\n"); - sb.append("}"); - return sb.toString(); - } - - /** - * Convert the given object to string with each line indented by 4 spaces - * (except the first line). - */ - private String toIndentedString(Object o) { - if (o == null) { - return "null"; - } - return o.toString().replace("\n", "\n "); - } -} -