diff --git a/.mvn/maven.config b/.mvn/maven.config new file mode 100644 index 0000000..ebbe288 --- /dev/null +++ b/.mvn/maven.config @@ -0,0 +1 @@ +-T 1C diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/config/ExternalServiceConfig.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/config/ExternalServiceConfig.java index 097b91a..d0d79d6 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/config/ExternalServiceConfig.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/config/ExternalServiceConfig.java @@ -1,29 +1,20 @@ package org.opendevstack.apiservice.externalservice.aap.config; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.api.http.RestClientFactory; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.util.StringUtils; -import org.springframework.web.client.RestTemplate; - -import lombok.extern.slf4j.Slf4j; - -import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.security.GeneralSecurityException; -import java.security.cert.X509Certificate; +import org.springframework.web.client.RestClient; /** - * Configuration class for external service components. + * Configuration class for the Ansible Automation Platform external service. + * + *

SSL wiring is delegated to {@link RestClientFactory} in {@code external-service-api}; + * no SSL boilerplate lives here. */ @Configuration @EnableAsync @@ -33,85 +24,25 @@ public class ExternalServiceConfig { private final SslProperties sslProperties; + @Value("${automation.platform.ansible.timeout:30000}") + private int timeoutMs; + public ExternalServiceConfig(@Qualifier("aapSslProperties") SslProperties sslProperties) { this.sslProperties = sslProperties; } /** - * Creates a RestTemplate bean for HTTP client operations with configurable SSL settings. + * {@link RestClient} bean for the Ansible Automation Platform. + * + *

SSL and timeouts are configured via {@code automation.platform.ansible.ssl.*} + * and {@code automation.platform.ansible.timeout} properties respectively. * - * @return RestTemplate instance with SSL configuration + * @param builder Spring prototype builder (injected fresh per bean definition) + * @return configured {@link RestClient} */ @Bean - public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { - if (!sslProperties.isVerifyCertificates()) { - log.warn("SSL certificate verification is DISABLED - this should only be used in development environments"); - return createInsecureRestTemplate(); - } else { - log.info("SSL certificate verification is ENABLED"); - return createSecureRestTemplate(); - } - } - - private RestTemplate createInsecureRestTemplate() { - try { - // Create a trust manager that accepts all certificates - // WARNING: This is insecure and should only be used in development environments - TrustManager[] trustAllCerts = new TrustManager[] { - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; // Return empty array instead of null - } - public void checkClientTrusted(X509Certificate[] certs, String authType) { - // Intentionally empty - accepts all client certificates (insecure) - } - public void checkServerTrusted(X509Certificate[] certs, String authType) { - // Intentionally empty - accepts all server certificates (insecure) - } - } - }; - - // Install the all-trusting trust manager - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - - // Create hostname verifier that accepts all hostnames (insecure) - HostnameVerifier allHostsValid = (hostname, session) -> true; - - // Create a custom request factory that uses our SSL configuration - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() { - @Override - protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException { - if (connection instanceof HttpsURLConnection httpsConnection) { - httpsConnection.setSSLSocketFactory(sslContext.getSocketFactory()); - httpsConnection.setHostnameVerifier(allHostsValid); - } - super.prepareConnection(connection, httpMethod); - } - }; - - return new RestTemplate(requestFactory); - - } catch (GeneralSecurityException e) { - log.warn("Failed to create insecure RestTemplate, falling back to default: {}", e.getMessage()); - return new RestTemplate(); - } - } - - private RestTemplate createSecureRestTemplate() { - try { - // If custom trust store is provided, configure it - if (StringUtils.hasText(sslProperties.getTrustStorePath())) { - log.info("Custom trust store specified: {} (custom trust store support can be added in future versions)", - sslProperties.getTrustStorePath()); - } - - // Return default RestTemplate with system SSL settings - return new RestTemplate(); - - } catch (Exception e) { - log.warn("Failed to create secure RestTemplate with custom trust store, using default: {}", e.getMessage()); - return new RestTemplate(); - } + public RestClient aapRestClient(RestClient.Builder builder) { + log.info("Creating AAP RestClient (connect/read timeout={}ms)", timeoutMs); + return RestClientFactory.build(builder, sslProperties, timeoutMs, timeoutMs); } } diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/config/SslProperties.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/config/SslProperties.java index 4bd4e94..c2a7f1d 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/config/SslProperties.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/config/SslProperties.java @@ -1,67 +1,16 @@ package org.opendevstack.apiservice.externalservice.aap.config; +import org.opendevstack.apiservice.externalservice.api.http.ExternalServiceSslProperties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; /** - * Configuration properties for SSL settings in external service calls. + * SSL configuration properties for the Ansible Automation Platform external service. + * Binds to the {@code automation.platform.ansible.ssl} prefix. */ @Component("aapSslProperties") @ConfigurationProperties(prefix = "automation.platform.ansible.ssl") -public class SslProperties { - - /** - * Whether to verify SSL certificates when making external service calls. - * Default is true for security. - */ - private boolean verifyCertificates = true; - - /** - * Path to the trust store file for SSL certificate validation. - * Optional - if not provided, uses system default trust store. - */ - private String trustStorePath; - - /** - * Password for the trust store. - */ - private String trustStorePassword; - - /** - * Type of the trust store (JKS, PKCS12, etc.). - * Default is JKS. - */ - private String trustStoreType = "JKS"; - - public boolean isVerifyCertificates() { - return verifyCertificates; - } - - public void setVerifyCertificates(boolean verifyCertificates) { - this.verifyCertificates = verifyCertificates; - } - - public String getTrustStorePath() { - return trustStorePath; - } - - public void setTrustStorePath(String trustStorePath) { - this.trustStorePath = trustStorePath; - } - - public String getTrustStorePassword() { - return trustStorePassword; - } - - public void setTrustStorePassword(String trustStorePassword) { - this.trustStorePassword = trustStorePassword; - } - - public String getTrustStoreType() { - return trustStoreType; - } - - public void setTrustStoreType(String trustStoreType) { - this.trustStoreType = trustStoreType; - } -} \ No newline at end of file +public class SslProperties extends ExternalServiceSslProperties { + // All fields inherited from ExternalServiceSslProperties. + // Add AAP-specific SSL overrides here if ever needed. +} diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/impl/AnsibleAutomationPlatformService.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/impl/AnsibleAutomationPlatformService.java index 7a3395d..dc834f6 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/impl/AnsibleAutomationPlatformService.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/impl/AnsibleAutomationPlatformService.java @@ -1,22 +1,20 @@ package org.opendevstack.apiservice.externalservice.aap.service.impl; +import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; import org.opendevstack.apiservice.externalservice.aap.model.AutomationJobStatus; import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriUtils; -import lombok.extern.slf4j.Slf4j; - import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; @@ -25,13 +23,13 @@ /** * Implementation of AutomationPlatformService for Ansible Automation Platform. - * This service provides integration with Ansible AWX/Tower for executing workflows and modules. + * Provides integration with Ansible AWX/Tower for executing workflows and modules. */ @Service("automationPlatformService") @Slf4j public class AnsibleAutomationPlatformService implements AutomationPlatformService { - private final RestTemplate restTemplate; + private final RestClient restClient; @Value("${automation.platform.ansible.base-url:http://localhost:8080/api/v2}") private String baseUrl; @@ -42,46 +40,45 @@ public class AnsibleAutomationPlatformService implements AutomationPlatformServi @Value("${automation.platform.ansible.password:password}") private String password; - @Value("${automation.platform.ansible.timeout:30000}") - private int timeoutMs; - - public AnsibleAutomationPlatformService(RestTemplate restTemplate) { - this.restTemplate = restTemplate; + public AnsibleAutomationPlatformService(RestClient aapRestClient) { + this.restClient = aapRestClient; } @Override - public AutomationExecutionResult executeWorkflow(String workflowName, Map parameters) throws AutomationPlatformException { + public AutomationExecutionResult executeWorkflow(String workflowName, Map parameters) + throws AutomationPlatformException { log.info("Executing workflow '{}' with parameters: {}", workflowName, parameters); - + try { - // Create headers with authentication - HttpHeaders headers = createAuthHeaders(); - - // Prepare request body Map requestBody = new HashMap<>(); requestBody.put("extra_vars", parameters); - - HttpEntity> request = new HttpEntity<>(requestBody, headers); - - // Execute workflow job template with proper URL encoding + String encodedWorkflowName = UriUtils.encodePath(workflowName, StandardCharsets.UTF_8); String url = baseUrl + "/workflow_job_templates/" + encodedWorkflowName + "/launch/"; - ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - Map responseBody = response.getBody(); + + Map responseBody = restClient.post() + .uri(url) + .headers(this::applyAuthHeaders) + .contentType(MediaType.APPLICATION_JSON) + .body(requestBody) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + + if (responseBody != null) { String jobId = String.valueOf(responseBody.get("id")); String status = String.valueOf(responseBody.get("status")); - - AutomationExecutionResult result = new AutomationExecutionResult(jobId, status, true, "Workflow executed successfully"); + + AutomationExecutionResult result = + new AutomationExecutionResult(jobId, status, true, "Workflow executed successfully"); result.setMetadata(responseBody); - + log.info("Workflow '{}' executed successfully with job ID: {}", workflowName, jobId); return result; } else { - throw new AutomationPlatformException.WorkflowExecutionException(workflowName, "Unexpected response status: " + response.getStatusCode()); + throw new AutomationPlatformException.WorkflowExecutionException( + workflowName, "Empty response body"); } - + } catch (RestClientException e) { log.error("Failed to execute workflow '{}': {}", workflowName, e.getMessage(), e); throw new AutomationPlatformException.WorkflowExecutionException(workflowName, e); @@ -90,17 +87,17 @@ public AutomationExecutionResult executeWorkflow(String workflowName, Map executeWorkflowAsync(String workflowName, Map parameters) { + public CompletableFuture executeWorkflowAsync( + String workflowName, Map parameters) { try { AutomationExecutionResult result = executeWorkflow(workflowName, parameters); return CompletableFuture.completedFuture(result); } catch (AutomationPlatformException e) { log.error("Async workflow execution failed: {}", e.getMessage(), e); AutomationExecutionResult errorResult = AutomationExecutionResult.failure( - UUID.randomUUID().toString(), - "Async execution failed: " + e.getMessage(), - e.getErrorCode() - ); + UUID.randomUUID().toString(), + "Async execution failed: " + e.getMessage(), + e.getErrorCode()); return CompletableFuture.completedFuture(errorResult); } } @@ -108,50 +105,33 @@ public CompletableFuture executeWorkflowAsync(String @Override public AutomationJobStatus getJobStatus(String jobId) throws AutomationPlatformException { log.debug("Checking status for job ID: {}", jobId); - - try { - HttpHeaders headers = createAuthHeaders(); - HttpEntity request = new HttpEntity<>(headers); - - String encodedJobId = UriUtils.encodePath(jobId, StandardCharsets.UTF_8); - String url = baseUrl + "/jobs/" + encodedJobId + "/"; - return fetchJobStatus(jobId, url, request); - } catch (RestClientException e) { - log.error("Failed to get job status for ID '{}': {}", jobId, e.getMessage(), e); - throw new AutomationPlatformException("Failed to get job status", e); - } + String encodedJobId = UriUtils.encodePath(jobId, StandardCharsets.UTF_8); + String url = baseUrl + "/jobs/" + encodedJobId + "/"; + return fetchJobStatus(jobId, url); } @Override public AutomationJobStatus getWorkflowJobStatus(String workflowId) throws AutomationPlatformException { log.debug("Checking workflow status for job ID: {}", workflowId); - - try { - HttpHeaders headers = createAuthHeaders(); - HttpEntity request = new HttpEntity<>(headers); - - String encodedWorkflowId = UriUtils.encodePath(workflowId, StandardCharsets.UTF_8); - String url = baseUrl + "/workflow_jobs/" + encodedWorkflowId + "/"; - return fetchJobStatus(workflowId, url, request); - } catch (RestClientException e) { - log.error("Failed to get workflow job status for ID '{}': {}", workflowId, e.getMessage(), e); - throw new AutomationPlatformException("Failed to get workflow job status", e); - } + String encodedWorkflowId = UriUtils.encodePath(workflowId, StandardCharsets.UTF_8); + String url = baseUrl + "/workflow_jobs/" + encodedWorkflowId + "/"; + return fetchJobStatus(workflowId, url); } - private AutomationJobStatus fetchJobStatus(String jobId, String url, HttpEntity request) throws AutomationPlatformException { + private AutomationJobStatus fetchJobStatus(String jobId, String url) throws AutomationPlatformException { try { - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - Map responseBody = response.getBody(); + Map responseBody = restClient.get() + .uri(url) + .headers(this::applyAuthHeaders) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + if (responseBody != null) { AutomationJobStatus status = new AutomationJobStatus(); status.setJobId(jobId); status.setStatus(parseJobStatus(String.valueOf(responseBody.get("status")))); status.setStatusMessage(String.valueOf(responseBody.get("result_traceback"))); status.setResult(responseBody); - return status; } else { throw new AutomationPlatformException.JobNotFoundException(jobId); @@ -165,16 +145,13 @@ private AutomationJobStatus fetchJobStatus(String jobId, String url, HttpEntity< @Override public boolean validateConnection() { try { - HttpHeaders headers = createAuthHeaders(); - HttpEntity request = new HttpEntity<>(headers); - - String url = baseUrl + "/ping/"; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); - - boolean isValid = response.getStatusCode().is2xxSuccessful(); - log.debug("Connection validation: {}", isValid ? "successful" : "failed"); - return isValid; - + restClient.get() + .uri(baseUrl + "/ping/") + .headers(this::applyAuthHeaders) + .retrieve() + .toBodilessEntity(); + log.debug("Connection validation: successful"); + return true; } catch (Exception e) { log.warn("Connection validation failed: {}", e.getMessage()); return false; @@ -184,8 +161,6 @@ public boolean validateConnection() { @Override public boolean isHealthy() { try { - // Use validateConnection for health checks, but don't log warnings on failure - // as health checks are frequent and failures are expected to be handled by the health indicator return validateConnection(); } catch (Exception e) { log.debug("Health check failed: {}", e.getMessage()); @@ -193,25 +168,22 @@ public boolean isHealthy() { } } - private HttpHeaders createAuthHeaders() { - HttpHeaders headers = new HttpHeaders(); + private void applyAuthHeaders(HttpHeaders headers) { headers.setBasicAuth(username, password); - headers.set("Content-Type", "application/json"); - return headers; + headers.setContentType(MediaType.APPLICATION_JSON); } private AutomationJobStatus.Status parseJobStatus(String status) { if (status == null) { return AutomationJobStatus.Status.PENDING; } - return switch (status.toLowerCase()) { - case "pending" -> AutomationJobStatus.Status.PENDING; - case "running" -> AutomationJobStatus.Status.RUNNING; - case "successful" -> AutomationJobStatus.Status.SUCCESSFUL; - case "failed" -> AutomationJobStatus.Status.FAILED; - case "canceled", "cancelled" -> AutomationJobStatus.Status.CANCELLED; - default -> AutomationJobStatus.Status.ERROR; + case "pending" -> AutomationJobStatus.Status.PENDING; + case "running" -> AutomationJobStatus.Status.RUNNING; + case "successful" -> AutomationJobStatus.Status.SUCCESSFUL; + case "failed" -> AutomationJobStatus.Status.FAILED; + case "canceled", "cancelled"-> AutomationJobStatus.Status.CANCELLED; + default -> AutomationJobStatus.Status.ERROR; }; } } diff --git a/external-service-aap/src/test/java/org/opendevstack/apiservice/externalservice/aap/service/impl/AnsibleAutomationPlatformServiceTest.java b/external-service-aap/src/test/java/org/opendevstack/apiservice/externalservice/aap/service/impl/AnsibleAutomationPlatformServiceTest.java index 705db4d..dd18895 100644 --- a/external-service-aap/src/test/java/org/opendevstack/apiservice/externalservice/aap/service/impl/AnsibleAutomationPlatformServiceTest.java +++ b/external-service-aap/src/test/java/org/opendevstack/apiservice/externalservice/aap/service/impl/AnsibleAutomationPlatformServiceTest.java @@ -1,23 +1,17 @@ package org.opendevstack.apiservice.externalservice.aap.service.impl; -import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; -import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; -import org.opendevstack.apiservice.externalservice.aap.model.AutomationJobStatus; 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.Captor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationJobStatus; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; import java.util.HashMap; import java.util.Map; @@ -30,101 +24,86 @@ /** * Unit tests for AnsibleAutomationPlatformService. - * Tests all methods with various scenarios including success cases, error cases, and edge cases. + * Mocks the RestClient fluent chain to test all method paths. */ @ExtendWith(MockitoExtension.class) @SuppressWarnings({"rawtypes", "unchecked"}) class AnsibleAutomationPlatformServiceTest { - @Mock - private RestTemplate restTemplate; + // --- RestClient fluent-chain mocks --- + @Mock private RestClient restClient; - private AnsibleAutomationPlatformService service; + // POST chain + @Mock private RestClient.RequestBodyUriSpec postUriSpec; + @Mock private RestClient.RequestBodySpec postBodySpec; + @Mock private RestClient.ResponseSpec postResponseSpec; - @Captor - private ArgumentCaptor>> httpEntityCaptor; + // GET chain + @Mock private RestClient.RequestHeadersUriSpec getUriSpec; + @Mock private RestClient.RequestHeadersSpec getHeadersSpec; + @Mock private RestClient.ResponseSpec getResponseSpec; - @Captor - private ArgumentCaptor> httpEntityVoidCaptor; + private AnsibleAutomationPlatformService service; private static final String BASE_URL = "http://localhost:8080/api/v2"; - private static final String USERNAME = "testuser"; - private static final String PASSWORD = "testpass"; - private static final int TIMEOUT = 30000; + private static final String USERNAME = "testuser"; + private static final String PASSWORD = "testpass"; @BeforeEach void setUp() { - service = new AnsibleAutomationPlatformService(restTemplate); - ReflectionTestUtils.setField(service, "baseUrl", BASE_URL); + service = new AnsibleAutomationPlatformService(restClient); + ReflectionTestUtils.setField(service, "baseUrl", BASE_URL); ReflectionTestUtils.setField(service, "username", USERNAME); ReflectionTestUtils.setField(service, "password", PASSWORD); - ReflectionTestUtils.setField(service, "timeoutMs", TIMEOUT); + + // Wire POST chain + lenient().when(restClient.post()).thenReturn(postUriSpec); + lenient().when(postUriSpec.uri(anyString())).thenReturn(postBodySpec); + lenient().doReturn(postBodySpec).when(postBodySpec).headers(any()); + lenient().when(postBodySpec.contentType(any())).thenReturn(postBodySpec); + lenient().doReturn(postBodySpec).when(postBodySpec).body(any(Object.class)); + lenient().when(postBodySpec.retrieve()).thenReturn(postResponseSpec); + + // Wire GET chain + lenient().when(restClient.get()).thenReturn(getUriSpec); + lenient().doReturn(getHeadersSpec).when(getUriSpec).uri(anyString()); + lenient().doReturn(getHeadersSpec).when(getHeadersSpec).headers(any()); + lenient().when(getHeadersSpec.retrieve()).thenReturn(getResponseSpec); } + // ========================================================================= + // executeWorkflow + // ========================================================================= + @Test void executeWorkflow_Success() throws AutomationPlatformException { - // Arrange - String workflowName = "test-workflow"; - Map parameters = new HashMap<>(); - parameters.put("env", "dev"); - parameters.put("region", "us-east-1"); - Map responseBody = new HashMap<>(); responseBody.put("id", "12345"); responseBody.put("status", "pending"); responseBody.put("url", BASE_URL + "/workflow_jobs/12345/"); - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); + doReturn(responseBody).when(postResponseSpec).body(any(ParameterizedTypeReference.class)); - // Act - AutomationExecutionResult result = service.executeWorkflow(workflowName, parameters); + AutomationExecutionResult result = service.executeWorkflow("test-workflow", Map.of("env", "dev")); - // Assert assertNotNull(result); assertEquals("12345", result.getJobId()); assertEquals("pending", result.getStatus()); assertTrue(result.isSuccessful()); assertEquals("Workflow executed successfully", result.getMessage()); - assertNotNull(result.getMetadata()); assertEquals(responseBody, result.getMetadata()); - - // Verify REST call - verify(restTemplate).postForEntity( - eq(BASE_URL + "/workflow_job_templates/" + workflowName + "/launch/"), - httpEntityCaptor.capture(), - eq(Map.class) - ); - - HttpEntity> capturedEntity = httpEntityCaptor.getValue(); - HttpHeaders headers = capturedEntity.getHeaders(); - assertEquals("application/json", headers.getFirst("Content-Type")); - assertNotNull(headers.getFirst(HttpHeaders.AUTHORIZATION)); - - Map requestBody = capturedEntity.getBody(); - assertNotNull(requestBody); - assertEquals(parameters, requestBody.get("extra_vars")); } @Test void executeWorkflow_WithNullParameters() throws AutomationPlatformException { - // Arrange - String workflowName = "test-workflow"; - Map parameters = null; - Map responseBody = new HashMap<>(); responseBody.put("id", "12345"); responseBody.put("status", "pending"); - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); + doReturn(responseBody).when(postResponseSpec).body(any(ParameterizedTypeReference.class)); - // Act - AutomationExecutionResult result = service.executeWorkflow(workflowName, parameters); + AutomationExecutionResult result = service.executeWorkflow("test-workflow", null); - // Assert assertNotNull(result); assertEquals("12345", result.getJobId()); assertTrue(result.isSuccessful()); @@ -132,563 +111,233 @@ void executeWorkflow_WithNullParameters() throws AutomationPlatformException { @Test void executeWorkflow_RestClientException() { - // Arrange - String workflowName = "test-workflow"; - Map parameters = new HashMap<>(); - parameters.put("env", "dev"); + when(postBodySpec.retrieve()).thenThrow(new RestClientException("Connection timeout")); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenThrow(new RestClientException("Connection timeout")); - - // Act & Assert - AutomationPlatformException.WorkflowExecutionException exception = assertThrows( + AutomationPlatformException.WorkflowExecutionException ex = assertThrows( AutomationPlatformException.WorkflowExecutionException.class, - () -> service.executeWorkflow(workflowName, parameters) - ); + () -> service.executeWorkflow("test-workflow", Map.of("env", "dev"))); - assertTrue(exception.getMessage().contains(workflowName)); - assertEquals("WORKFLOW_EXECUTION_FAILED", exception.getErrorCode()); + assertTrue(ex.getMessage().contains("test-workflow")); + assertEquals("WORKFLOW_EXECUTION_FAILED", ex.getErrorCode()); } @Test - void executeWorkflow_UnexpectedResponseStatus() { - // Arrange - String workflowName = "test-workflow"; - Map parameters = new HashMap<>(); - - ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); + void executeWorkflow_NullResponseBody() { + doReturn(null).when(postResponseSpec).body(any(ParameterizedTypeReference.class)); - // Act & Assert - AutomationPlatformException.WorkflowExecutionException exception = assertThrows( + AutomationPlatformException.WorkflowExecutionException ex = assertThrows( AutomationPlatformException.WorkflowExecutionException.class, - () -> service.executeWorkflow(workflowName, parameters) - ); + () -> service.executeWorkflow("test-workflow", Map.of())); - assertTrue(exception.getMessage().contains("Unexpected response status")); + assertTrue(ex.getMessage().contains("Empty response body")); } @Test - void executeWorkflow_NullResponseBody() { - // Arrange - String workflowName = "test-workflow"; - Map parameters = new HashMap<>(); + void executeWorkflow_EmptyParameters() throws AutomationPlatformException { + Map responseBody = Map.of("id", "12345", "status", "pending"); + doReturn(responseBody).when(postResponseSpec).body(any(ParameterizedTypeReference.class)); + + AutomationExecutionResult result = service.executeWorkflow("test-workflow", new HashMap<>()); + assertNotNull(result); + assertTrue(result.isSuccessful()); + } - ResponseEntity responseEntity = new ResponseEntity<>(null, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); + @Test + void executeWorkflow_VerifyExtraVarsPassedCorrectly() throws AutomationPlatformException { + Map responseBody = Map.of("id", "99999", "status", "pending"); + doReturn(responseBody).when(postResponseSpec).body(any(ParameterizedTypeReference.class)); - // Act & Assert - AutomationPlatformException.WorkflowExecutionException exception = assertThrows( - AutomationPlatformException.WorkflowExecutionException.class, - () -> service.executeWorkflow(workflowName, parameters) - ); + Map params = new HashMap<>(); + params.put("app_name", "my-app"); + params.put("version", "1.2.3"); + params.put("replicas", 3); - assertTrue(exception.getMessage().contains("Unexpected response status")); + AutomationExecutionResult result = service.executeWorkflow("deploy-app", params); + assertNotNull(result); + assertTrue(result.isSuccessful()); } + // ========================================================================= + // executeWorkflowAsync + // ========================================================================= + @Test void executeWorkflowAsync_Success() throws ExecutionException, InterruptedException { - // Arrange - String workflowName = "test-workflow"; - Map parameters = new HashMap<>(); - parameters.put("env", "prod"); - - Map responseBody = new HashMap<>(); - responseBody.put("id", "67890"); - responseBody.put("status", "running"); + Map responseBody = Map.of("id", "67890", "status", "running"); + doReturn(responseBody).when(postResponseSpec).body(any(ParameterizedTypeReference.class)); - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); + CompletableFuture future = + service.executeWorkflowAsync("test-workflow", Map.of("env", "prod")); - // Act - CompletableFuture futureResult = - service.executeWorkflowAsync(workflowName, parameters); - - // Assert - assertNotNull(futureResult); - AutomationExecutionResult result = futureResult.get(); + AutomationExecutionResult result = future.get(); assertNotNull(result); assertEquals("67890", result.getJobId()); - assertEquals("running", result.getStatus()); assertTrue(result.isSuccessful()); } @Test void executeWorkflowAsync_Failure() throws ExecutionException, InterruptedException { - // Arrange - String workflowName = "test-workflow"; - Map parameters = new HashMap<>(); - - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenThrow(new RestClientException("Network error")); + when(postBodySpec.retrieve()).thenThrow(new RestClientException("Network error")); - // Act - CompletableFuture futureResult = - service.executeWorkflowAsync(workflowName, parameters); + CompletableFuture future = + service.executeWorkflowAsync("test-workflow", Map.of()); - // Assert - assertNotNull(futureResult); - AutomationExecutionResult result = futureResult.get(); + AutomationExecutionResult result = future.get(); assertNotNull(result); assertFalse(result.isSuccessful()); assertTrue(result.getMessage().contains("Async execution failed")); assertEquals("WORKFLOW_EXECUTION_FAILED", result.getErrorDetails()); } + // ========================================================================= + // getJobStatus + // ========================================================================= + @Test void getJobStatus_Success() throws AutomationPlatformException { - // Arrange - String jobId = "12345"; Map responseBody = new HashMap<>(); - responseBody.put("id", jobId); + responseBody.put("id", "12345"); responseBody.put("status", "successful"); responseBody.put("result_traceback", "Job completed successfully"); - responseBody.put("elapsed", 125.5); - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); + doReturn(responseBody).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); - // Act - AutomationJobStatus status = service.getJobStatus(jobId); + AutomationJobStatus status = service.getJobStatus("12345"); - // Assert assertNotNull(status); - assertEquals(jobId, status.getJobId()); + assertEquals("12345", status.getJobId()); assertEquals(AutomationJobStatus.Status.SUCCESSFUL, status.getStatus()); assertEquals("Job completed successfully", status.getStatusMessage()); - assertNotNull(status.getResult()); assertEquals(responseBody, status.getResult()); - - verify(restTemplate).exchange( - eq(BASE_URL + "/jobs/" + jobId + "/"), - eq(HttpMethod.GET), - httpEntityVoidCaptor.capture(), - eq(Map.class) - ); } @Test void getJobStatus_PendingStatus() throws AutomationPlatformException { - // Arrange - String jobId = "12345"; - Map responseBody = new HashMap<>(); - responseBody.put("id", jobId); - responseBody.put("status", "pending"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - AutomationJobStatus status = service.getJobStatus(jobId); - - // Assert - assertEquals(AutomationJobStatus.Status.PENDING, status.getStatus()); + doReturn(Map.of("status", "pending")).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); + assertEquals(AutomationJobStatus.Status.PENDING, service.getJobStatus("1").getStatus()); } @Test void getJobStatus_RunningStatus() throws AutomationPlatformException { - // Arrange - String jobId = "12345"; - Map responseBody = new HashMap<>(); - responseBody.put("status", "running"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - AutomationJobStatus status = service.getJobStatus(jobId); - - // Assert - assertEquals(AutomationJobStatus.Status.RUNNING, status.getStatus()); + doReturn(Map.of("status", "running")).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); + assertEquals(AutomationJobStatus.Status.RUNNING, service.getJobStatus("1").getStatus()); } @Test void getJobStatus_FailedStatus() throws AutomationPlatformException { - // Arrange - String jobId = "12345"; - Map responseBody = new HashMap<>(); - responseBody.put("status", "failed"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - AutomationJobStatus status = service.getJobStatus(jobId); - - // Assert - assertEquals(AutomationJobStatus.Status.FAILED, status.getStatus()); + doReturn(Map.of("status", "failed")).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); + assertEquals(AutomationJobStatus.Status.FAILED, service.getJobStatus("1").getStatus()); } @Test void getJobStatus_CancelledStatus() throws AutomationPlatformException { - // Arrange - String jobId = "12345"; - Map responseBody = new HashMap<>(); - responseBody.put("status", "canceled"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - AutomationJobStatus status = service.getJobStatus(jobId); - - // Assert - assertEquals(AutomationJobStatus.Status.CANCELLED, status.getStatus()); + doReturn(Map.of("status", "canceled")).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); + assertEquals(AutomationJobStatus.Status.CANCELLED, service.getJobStatus("1").getStatus()); } @Test void getJobStatus_CancelledAlternativeSpelling() throws AutomationPlatformException { - // Arrange - String jobId = "12345"; - Map responseBody = new HashMap<>(); - responseBody.put("status", "cancelled"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - AutomationJobStatus status = service.getJobStatus(jobId); - - // Assert - assertEquals(AutomationJobStatus.Status.CANCELLED, status.getStatus()); + doReturn(Map.of("status", "cancelled")).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); + assertEquals(AutomationJobStatus.Status.CANCELLED, service.getJobStatus("1").getStatus()); } @Test void getJobStatus_UnknownStatus() throws AutomationPlatformException { - // Arrange - String jobId = "12345"; - Map responseBody = new HashMap<>(); - responseBody.put("status", "unknown_status"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - AutomationJobStatus status = service.getJobStatus(jobId); - - // Assert - assertEquals(AutomationJobStatus.Status.ERROR, status.getStatus()); + doReturn(Map.of("status", "unknown_status")).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); + assertEquals(AutomationJobStatus.Status.ERROR, service.getJobStatus("1").getStatus()); } @Test void getJobStatus_NullStatus() throws AutomationPlatformException { - // Arrange - String jobId = "12345"; - Map responseBody = new HashMap<>(); - responseBody.put("status", null); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - AutomationJobStatus status = service.getJobStatus(jobId); - - // Assert - assertEquals(AutomationJobStatus.Status.ERROR, status.getStatus()); + Map body = new HashMap<>(); + body.put("status", null); + doReturn(body).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); + assertEquals(AutomationJobStatus.Status.ERROR, service.getJobStatus("1").getStatus()); } @Test void getJobStatus_JobNotFound() { - // Arrange - String jobId = "99999"; - - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenThrow(new RestClientException("404 Not Found")); + when(getHeadersSpec.retrieve()).thenThrow(new RestClientException("404 Not Found")); - // Act & Assert - AutomationPlatformException.JobNotFoundException exception = assertThrows( + AutomationPlatformException.JobNotFoundException ex = assertThrows( AutomationPlatformException.JobNotFoundException.class, - () -> service.getJobStatus(jobId) - ); + () -> service.getJobStatus("99999")); - assertTrue(exception.getMessage().contains(jobId)); - assertEquals("JOB_NOT_FOUND", exception.getErrorCode()); + assertTrue(ex.getMessage().contains("99999")); + assertEquals("JOB_NOT_FOUND", ex.getErrorCode()); } @Test void getJobStatus_NullResponseBody() { - // Arrange - String jobId = "12345"; + doReturn(null).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); - ResponseEntity responseEntity = new ResponseEntity<>(null, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act & Assert assertThrows( AutomationPlatformException.JobNotFoundException.class, - () -> service.getJobStatus(jobId) - ); + () -> service.getJobStatus("12345")); } + // ========================================================================= + // getWorkflowJobStatus + // ========================================================================= + @Test void getWorkflowJobStatus_Success() throws AutomationPlatformException { - // Arrange - String workflowId = "67890"; Map responseBody = new HashMap<>(); - responseBody.put("id", workflowId); + responseBody.put("id", "67890"); responseBody.put("status", "successful"); responseBody.put("result_traceback", "Workflow completed"); - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); + doReturn(responseBody).when(getResponseSpec).body(any(ParameterizedTypeReference.class)); - // Act - AutomationJobStatus status = service.getWorkflowJobStatus(workflowId); + AutomationJobStatus status = service.getWorkflowJobStatus("67890"); - // Assert assertNotNull(status); - assertEquals(workflowId, status.getJobId()); + assertEquals("67890", status.getJobId()); assertEquals(AutomationJobStatus.Status.SUCCESSFUL, status.getStatus()); assertEquals("Workflow completed", status.getStatusMessage()); - verify(restTemplate).exchange( - eq(BASE_URL + "/workflow_jobs/" + workflowId + "/"), - eq(HttpMethod.GET), - any(HttpEntity.class), - eq(Map.class) - ); + verify(getUriSpec, times(1)).uri(eq(BASE_URL + "/workflow_jobs/67890/")); } @Test void getWorkflowJobStatus_NotFound() { - // Arrange - String workflowId = "99999"; + when(getHeadersSpec.retrieve()).thenThrow(new RestClientException("404 Not Found")); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenThrow(new RestClientException("404 Not Found")); - - // Act & Assert - AutomationPlatformException.JobNotFoundException exception = assertThrows( + AutomationPlatformException.JobNotFoundException ex = assertThrows( AutomationPlatformException.JobNotFoundException.class, - () -> service.getWorkflowJobStatus(workflowId) - ); + () -> service.getWorkflowJobStatus("99999")); - assertTrue(exception.getMessage().contains(workflowId)); - assertEquals("JOB_NOT_FOUND", exception.getErrorCode()); + assertTrue(ex.getMessage().contains("99999")); + assertEquals("JOB_NOT_FOUND", ex.getErrorCode()); } - @Test - void validateConnection_Success() { - // Arrange - Map responseBody = new HashMap<>(); - responseBody.put("ping", "pong"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - boolean isValid = service.validateConnection(); - - // Assert - assertTrue(isValid); - verify(restTemplate).exchange( - eq(BASE_URL + "/ping/"), - eq(HttpMethod.GET), - any(HttpEntity.class), - eq(Map.class) - ); - } + // ========================================================================= + // validateConnection / isHealthy + // ========================================================================= @Test - void validateConnection_Failure() { - // Arrange - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenThrow(new RestClientException("Connection refused")); - - // Act - boolean isValid = service.validateConnection(); + void validateConnection_Success() { + when(getResponseSpec.toBodilessEntity()).thenReturn(null); // any non-throw = success - // Assert - assertFalse(isValid); + assertTrue(service.validateConnection()); + verify(getUriSpec, times(1)).uri(eq(BASE_URL + "/ping/")); } @Test - void validateConnection_UnsuccessfulStatusCode() { - // Arrange - ResponseEntity responseEntity = new ResponseEntity<>(HttpStatus.UNAUTHORIZED); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - boolean isValid = service.validateConnection(); - - // Assert - assertFalse(isValid); + void validateConnection_Failure() { + when(getHeadersSpec.retrieve()).thenThrow(new RestClientException("Connection refused")); + assertFalse(service.validateConnection()); } @Test void isHealthy_Success() { - // Arrange - Map responseBody = new HashMap<>(); - responseBody.put("ping", "pong"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - boolean isHealthy = service.isHealthy(); - - // Assert - assertTrue(isHealthy); + when(getResponseSpec.toBodilessEntity()).thenReturn(null); + assertTrue(service.isHealthy()); } @Test void isHealthy_Failure() { - // Arrange - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenThrow(new RestClientException("Service unavailable")); - - // Act - boolean isHealthy = service.isHealthy(); - - // Assert - assertFalse(isHealthy); - } - - @Test - void executeWorkflow_VerifyAuthHeaders() throws AutomationPlatformException { - // Arrange - String workflowName = "test-workflow"; - Map parameters = new HashMap<>(); - - Map responseBody = new HashMap<>(); - responseBody.put("id", "12345"); - responseBody.put("status", "pending"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - service.executeWorkflow(workflowName, parameters); - - // Assert - verify(restTemplate).postForEntity(anyString(), httpEntityCaptor.capture(), eq(Map.class)); - HttpHeaders headers = httpEntityCaptor.getValue().getHeaders(); - - // Verify Basic Auth is set - assertNotNull(headers.getFirst(HttpHeaders.AUTHORIZATION)); - assertTrue(headers.getFirst(HttpHeaders.AUTHORIZATION).startsWith("Basic ")); - - // Verify Content-Type - assertEquals("application/json", headers.getFirst("Content-Type")); - } - - @Test - void getJobStatus_VerifyUrl() throws AutomationPlatformException { - // Arrange - String jobId = "test-job-123"; - Map responseBody = new HashMap<>(); - responseBody.put("id", jobId); - responseBody.put("status", "running"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - service.getJobStatus(jobId); - - // Assert - verify(restTemplate).exchange( - eq(BASE_URL + "/jobs/" + jobId + "/"), - eq(HttpMethod.GET), - any(HttpEntity.class), - eq(Map.class) - ); - } - - @Test - void getWorkflowJobStatus_VerifyUrl() throws AutomationPlatformException { - // Arrange - String workflowId = "test-workflow-456"; - Map responseBody = new HashMap<>(); - responseBody.put("id", workflowId); - responseBody.put("status", "pending"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.exchange(anyString(), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - service.getWorkflowJobStatus(workflowId); - - // Assert - verify(restTemplate).exchange( - eq(BASE_URL + "/workflow_jobs/" + workflowId + "/"), - eq(HttpMethod.GET), - any(HttpEntity.class), - eq(Map.class) - ); - } - - @Test - void executeWorkflow_VerifyExtraVarsPassedCorrectly() throws AutomationPlatformException { - // Arrange - String workflowName = "deploy-app"; - Map parameters = new HashMap<>(); - parameters.put("app_name", "my-app"); - parameters.put("version", "1.2.3"); - parameters.put("replicas", 3); - - Map responseBody = new HashMap<>(); - responseBody.put("id", "99999"); - responseBody.put("status", "pending"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - service.executeWorkflow(workflowName, parameters); - - // Assert - verify(restTemplate).postForEntity(anyString(), httpEntityCaptor.capture(), eq(Map.class)); - Map requestBody = httpEntityCaptor.getValue().getBody(); - assertNotNull(requestBody); - - Map extraVars = (Map) requestBody.get("extra_vars"); - assertNotNull(extraVars); - assertEquals("my-app", extraVars.get("app_name")); - assertEquals("1.2.3", extraVars.get("version")); - assertEquals(3, extraVars.get("replicas")); - } - - @Test - void executeWorkflow_EmptyParameters() throws AutomationPlatformException { - // Arrange - String workflowName = "test-workflow"; - Map parameters = new HashMap<>(); - - Map responseBody = new HashMap<>(); - responseBody.put("id", "12345"); - responseBody.put("status", "pending"); - - ResponseEntity responseEntity = new ResponseEntity<>(responseBody, HttpStatus.OK); - when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(Map.class))) - .thenReturn(responseEntity); - - // Act - AutomationExecutionResult result = service.executeWorkflow(workflowName, parameters); - - // Assert - assertNotNull(result); - assertTrue(result.isSuccessful()); - verify(restTemplate).postForEntity(anyString(), httpEntityCaptor.capture(), eq(Map.class)); + when(getHeadersSpec.retrieve()).thenThrow(new RestClientException("Service unavailable")); + assertFalse(service.isHealthy()); } } diff --git a/external-service-api/pom.xml b/external-service-api/pom.xml index b695737..cb277ae 100644 --- a/external-service-api/pom.xml +++ b/external-service-api/pom.xml @@ -21,6 +21,19 @@ spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + com.fasterxml.jackson.core diff --git a/external-service-api/src/main/java/org/opendevstack/apiservice/externalservice/api/http/ExternalServiceSslProperties.java b/external-service-api/src/main/java/org/opendevstack/apiservice/externalservice/api/http/ExternalServiceSslProperties.java new file mode 100644 index 0000000..3b47f63 --- /dev/null +++ b/external-service-api/src/main/java/org/opendevstack/apiservice/externalservice/api/http/ExternalServiceSslProperties.java @@ -0,0 +1,53 @@ +package org.opendevstack.apiservice.externalservice.api.http; + +import lombok.Data; + +/** + * Common SSL configuration properties for external service HTTP clients. + * + *

Each external service module declares its own + * {@link org.springframework.boot.context.properties.ConfigurationProperties} binding with + * its own prefix. This class is intentionally not annotated with + * {@code @ConfigurationProperties} or {@code @Component} so that every module can bind it under + * whichever YAML prefix it needs, e.g.: + * + *

{@code
+ * @ConfigurationProperties(prefix = "automation.platform.ansible.ssl")
+ * public class AapSslProperties extends ExternalServiceSslProperties { ... }
+ * }
+ * + * or embedded directly inside a parent properties class: + * + *
{@code
+ * public class UiPathProperties {
+ *     private ExternalServiceSslProperties ssl = new ExternalServiceSslProperties();
+ * }
+ * }
+ */ +@Data +public class ExternalServiceSslProperties { + + /** + * Whether to verify SSL certificates when making external service calls. + * Default is {@code true} for security. Set to {@code false} only in + * development/test environments. + */ + private boolean verifyCertificates = true; + + /** + * Path to the trust store file for SSL certificate validation. + * Optional — when not set the JVM default trust store is used. + */ + private String trustStorePath; + + /** + * Password for the trust store file. May be empty for passwordless stores. + */ + private String trustStorePassword; + + /** + * Type of the trust store ({@code JKS}, {@code PKCS12}, etc.). + * Default is {@code JKS}. + */ + private String trustStoreType = "JKS"; +} diff --git a/external-service-api/src/main/java/org/opendevstack/apiservice/externalservice/api/http/RestClientFactory.java b/external-service-api/src/main/java/org/opendevstack/apiservice/externalservice/api/http/RestClientFactory.java new file mode 100644 index 0000000..55493c2 --- /dev/null +++ b/external-service-api/src/main/java/org/opendevstack/apiservice/externalservice/api/http/RestClientFactory.java @@ -0,0 +1,153 @@ +package org.opendevstack.apiservice.externalservice.api.http; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestTemplate; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.SecureRandom; + +/** + * Shared factory for building configured {@link RestClient} instances. + * + *

All external service modules share identical SSL-wiring logic: optionally load a custom + * trust store, build an {@link SSLContext}, and inject it into a + * {@link SimpleClientHttpRequestFactory}. This class centralises that logic so each module + * only needs to supply its own {@link ExternalServiceSslProperties} and timeout values. + * + *

This is a pure utility — not a Spring bean. Call + * {@link #build(RestClient.Builder, ExternalServiceSslProperties, int, int)} from each + * module's {@code @Configuration} class or factory component. + * + *

Usage

+ *
{@code
+ * @Bean
+ * public RestClient aapRestClient(RestClient.Builder builder) {
+ *     return RestClientFactory.build(builder, sslProperties,
+ *             aapProperties.getConnectTimeout(),
+ *             aapProperties.getReadTimeout());
+ * }
+ * }
+ */ +@Slf4j +public final class RestClientFactory { + + private RestClientFactory() { + // utility class — no instances + } + + /** + * Build a {@link RestClient} with the given SSL configuration and timeouts. + * + * @param builder Spring's prototype {@link RestClient.Builder} (inject via constructor + * or {@code @Bean} parameter — Spring provides a fresh instance each time) + * @param ssl SSL properties for this external service + * @param connectTimeoutMs TCP connection timeout in milliseconds + * @param readTimeoutMs Socket read timeout in milliseconds + * @return Fully configured {@link RestClient} + */ + public static RestClient build( + RestClient.Builder builder, + ExternalServiceSslProperties ssl, + int connectTimeoutMs, + int readTimeoutMs) { + + SimpleClientHttpRequestFactory requestFactory = buildRequestFactory(ssl, connectTimeoutMs, readTimeoutMs); + return builder.requestFactory(requestFactory).build(); + } + + /** + * Build a {@link RestTemplate} with the given SSL configuration and timeouts. + * + *

Use this overload for modules whose OpenAPI-generated {@code ApiClient} only accepts a + * {@link RestTemplate} (projects-info-service, jira, bitbucket). All other modules should + * prefer {@link #build(RestClient.Builder, ExternalServiceSslProperties, int, int)}. + * + * @param ssl SSL properties for this external service + * @param connectTimeoutMs TCP connection timeout in milliseconds + * @param readTimeoutMs Socket read timeout in milliseconds + * @return Fully configured {@link RestTemplate} + */ + public static RestTemplate buildRestTemplate( + ExternalServiceSslProperties ssl, + int connectTimeoutMs, + int readTimeoutMs) { + + SimpleClientHttpRequestFactory requestFactory = buildRequestFactory(ssl, connectTimeoutMs, readTimeoutMs); + return new RestTemplate(requestFactory); + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private static SimpleClientHttpRequestFactory buildRequestFactory( + ExternalServiceSslProperties ssl, + int connectTimeoutMs, + int readTimeoutMs) { + + SimpleClientHttpRequestFactory factory = buildSslRequestFactory(ssl); + factory.setConnectTimeout(connectTimeoutMs); + factory.setReadTimeout(readTimeoutMs); + return factory; + } + + /** + * Build a {@link SimpleClientHttpRequestFactory} wired with the appropriate + * {@link SSLSocketFactory}. Falls back to the JVM default trust store if no custom + * trust store path is configured or if loading the trust store fails. + */ + private static SimpleClientHttpRequestFactory buildSslRequestFactory(ExternalServiceSslProperties ssl) { + + if (!StringUtils.hasText(ssl.getTrustStorePath())) { + log.debug("No custom trust store configured — using JVM default trust store"); + return new SimpleClientHttpRequestFactory(); + } + + try { + log.info("Loading custom trust store from: {}", ssl.getTrustStorePath()); + KeyStore trustStore = KeyStore.getInstance(ssl.getTrustStoreType()); + try (FileInputStream fis = new FileInputStream(ssl.getTrustStorePath())) { + char[] password = StringUtils.hasText(ssl.getTrustStorePassword()) + ? ssl.getTrustStorePassword().toCharArray() + : new char[0]; + trustStore.load(fis, password); + } + + TrustManagerFactory tmf = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tmf.getTrustManagers(), new SecureRandom()); + + final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); + + return new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(HttpURLConnection connection, String httpMethod) + throws IOException { + if (connection instanceof HttpsURLConnection https) { + https.setSSLSocketFactory(sslSocketFactory); + } + super.prepareConnection(connection, httpMethod); + } + }; + + } catch (GeneralSecurityException | IOException e) { + log.error("Failed to load custom trust store '{}' — falling back to JVM default: {}", + ssl.getTrustStorePath(), e.getMessage()); + return new SimpleClientHttpRequestFactory(); + } + } +} diff --git a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactory.java b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactory.java index b8eeb58..4c8575f 100644 --- a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactory.java +++ b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactory.java @@ -1,66 +1,47 @@ package org.opendevstack.apiservice.externalservice.bitbucket.client; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.api.http.ExternalServiceSslProperties; +import org.opendevstack.apiservice.externalservice.api.http.RestClientFactory; import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration; import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration.BitbucketInstanceConfig; import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; -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.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.X509Certificate; import java.util.Map; import java.util.Set; /** * Factory for creating {@link BitbucketApiClient} instances. - * Uses the factory pattern to provide configured clients for different Bitbucket instances. - * Clients are cached and reused for efficiency. + * + *

SSL wiring is delegated to {@link RestClientFactory} in {@code external-service-api}. + * A {@link RestTemplate} is produced (not {@code RestClient}) because the OpenAPI-generated + * {@link ApiClient} only accepts {@code RestTemplate}. + * + *

Clients are cached by instance name via Spring's {@code @Cacheable}. */ @Component @Slf4j public class BitbucketApiClientFactory { private final BitbucketServiceConfiguration configuration; - private final RestTemplateBuilder restTemplateBuilder; - /** - * Constructor with dependency injection. - * - * @param configuration Bitbucket service configuration - * @param restTemplateBuilder RestTemplate builder for creating HTTP clients - */ - public BitbucketApiClientFactory(BitbucketServiceConfiguration configuration, - RestTemplateBuilder restTemplateBuilder) { + public BitbucketApiClientFactory(BitbucketServiceConfiguration configuration) { this.configuration = configuration; - this.restTemplateBuilder = restTemplateBuilder; - log.info("BitbucketApiClientFactory initialized with {} instance(s)", - configuration.getInstances().size()); + configuration.getInstances().size()); } /** - * Resolve the effective instance name. - *

+ * Resolve the effective default instance name. * - * @return The resolved default instance name (never {@code null}/blank) + * @return The resolved instance name (never {@code null}/blank) * @throws BitbucketException if no Bitbucket instances are configured */ public String getDefaultInstanceName() throws BitbucketException { - String defaultInstance = configuration.getDefaultInstance(); if (defaultInstance != null && !defaultInstance.isBlank()) { return defaultInstance; @@ -75,155 +56,83 @@ public String getDefaultInstanceName() throws BitbucketException { } /** - * Get a {@link BitbucketApiClient} for a specific instance. - * If {@code instanceName} is {@code null} or blank, a {@link BitbucketException} is thrown. + * Get a {@link BitbucketApiClient} for a named instance. * - * @param instanceName Name of the Bitbucket instance - * @return Configured BitbucketApiClient - * @throws BitbucketException if the instance name is null/blank or not configured + * @param instanceName Name of the Bitbucket instance (must not be null/blank) + * @return Configured {@link BitbucketApiClient} + * @throws BitbucketException if the instance is not configured */ - @Cacheable(value = "bitbucketApiClients", key = "#instanceName", + @Cacheable(value = "bitbucketApiClients", key = "#instanceName", condition = "#instanceName != null && !#instanceName.isBlank()") public BitbucketApiClient getClient(String instanceName) throws BitbucketException { if (instanceName == null || instanceName.isBlank()) { throw new BitbucketException( - String.format("Provide instance name. Available instances: %s", - configuration.getInstances().keySet())); + String.format("Provide instance name. Available instances: %s", + configuration.getInstances().keySet())); } BitbucketInstanceConfig instanceConfig = configuration.getInstances().get(instanceName); - if (instanceConfig == null) { throw new BitbucketException( String.format("Bitbucket instance '%s' is not configured. Available instances: %s", - instanceName, configuration.getInstances().keySet())); + instanceName, configuration.getInstances().keySet())); } log.info("Creating new BitbucketApiClient for instance '{}'", instanceName); - - RestTemplate restTemplate = createRestTemplate(instanceConfig); - return new BitbucketApiClient(instanceName, instanceConfig, restTemplate); + return new BitbucketApiClient(instanceName, instanceConfig, buildRestTemplate(instanceConfig)); } /** - * Get the default client, as determined by {@code externalservices.bitbucket.default-instance}. - * Falls back to the first configured instance when {@code default-instance} is not set. + * Get the default client. * - * @return BitbucketApiClient for the default instance + * @return {@link BitbucketApiClient} for the default instance * @throws BitbucketException if no instances are configured */ @Cacheable(value = "bitbucketApiClients", key = "'default'") public BitbucketApiClient getClient() throws BitbucketException { String defaultInstanceName = getDefaultInstanceName(); BitbucketInstanceConfig instanceConfig = configuration.getInstances().get(defaultInstanceName); - RestTemplate restTemplate = createRestTemplate(instanceConfig); - - return new BitbucketApiClient(defaultInstanceName, instanceConfig, restTemplate); + return new BitbucketApiClient(defaultInstanceName, instanceConfig, buildRestTemplate(instanceConfig)); } - /** - * Get all available instance names. - * - * @return Set of configured instance names - */ + /** @return all 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 - */ + /** @return {@code true} if the named instance is configured */ public boolean hasInstance(String instanceName) { return configuration.getInstances().containsKey(instanceName); } - /** - * Clear the client cache (useful for testing or when configuration changes). - */ + /** Clear the client cache (useful for testing or when configuration changes). */ @CacheEvict(value = "bitbucketApiClients", allEntries = true) public void clearCache() { log.info("Clearing BitbucketApiClient cache"); } - - /** - * Create a configured RestTemplate for a Bitbucket instance. - * - * @param config Configuration for the instance - * @return Configured RestTemplate - */ - private RestTemplate createRestTemplate(BitbucketInstanceConfig config) { - RestTemplate restTemplate = restTemplateBuilder.build(); - - if (config.isTrustAllCertificates()) { - log.warn("Trust all certificates is enabled for Bitbucket connection. " + - "This should only be used in development environments!"); - restTemplate.setRequestFactory(createTrustAllRequestFactory(config)); - } else { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(config.getConnectionTimeout()); - requestFactory.setReadTimeout(config.getReadTimeout()); - restTemplate.setRequestFactory(requestFactory); - } - return restTemplate; + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private RestTemplate buildRestTemplate(BitbucketInstanceConfig config) { + log.info("Creating RestTemplate for Bitbucket instance (connect={}ms, read={}ms)", + config.getConnectionTimeout(), config.getReadTimeout()); + return RestClientFactory.buildRestTemplate( + toSslProperties(config), + config.getConnectionTimeout(), + config.getReadTimeout()); } /** - * Create a {@link SimpleClientHttpRequestFactory} that trusts all SSL certificates - * only for this specific RestTemplate, without modifying the JVM-wide defaults. - *

- * WARNING: This should only be used in development environments. - * - * @param config Instance configuration (for timeouts) - * @return A request factory whose connections skip SSL verification + * Adapt {@link BitbucketInstanceConfig} SSL fields to {@link ExternalServiceSslProperties} + * so we can pass them to {@link RestClientFactory} without changing the generated config class. */ - @SuppressWarnings({"java:S4830", "java:S1186"}) // Intentionally disabling SSL validation for development - private SimpleClientHttpRequestFactory createTrustAllRequestFactory(BitbucketInstanceConfig config) { - try { - TrustManager[] trustAllCerts = new TrustManager[]{ - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - public void checkClientTrusted(X509Certificate[] certs, String authType) { - // No validation performed - development only - } - public void checkServerTrusted(X509Certificate[] certs, String authType) { - // No validation performed - development only - } - } - }; - - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - - final javax.net.ssl.SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory(); - final javax.net.ssl.HostnameVerifier trustAllHostnames = (hostname, session) -> true; - - // Override prepareConnection so SSL settings apply only to this RestTemplate - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() { - @Override - protected void prepareConnection(java.net.HttpURLConnection connection, String httpMethod) throws java.io.IOException { - if (connection instanceof HttpsURLConnection httpsConnection) { - httpsConnection.setSSLSocketFactory(sslSocketFactory); - httpsConnection.setHostnameVerifier(trustAllHostnames); - } - super.prepareConnection(connection, httpMethod); - } - }; - requestFactory.setConnectTimeout(config.getConnectionTimeout()); - requestFactory.setReadTimeout(config.getReadTimeout()); - return requestFactory; - - } catch (NoSuchAlgorithmException | KeyManagementException e) { - log.error("Failed to configure SSL trust all certificates, falling back to default factory", e); - SimpleClientHttpRequestFactory fallback = new SimpleClientHttpRequestFactory(); - fallback.setConnectTimeout(config.getConnectionTimeout()); - fallback.setReadTimeout(config.getReadTimeout()); - return fallback; - } + private static ExternalServiceSslProperties toSslProperties(BitbucketInstanceConfig config) { + ExternalServiceSslProperties ssl = new ExternalServiceSslProperties(); + ssl.setTrustStorePath(config.getTrustStorePath()); + ssl.setTrustStorePassword(config.getTrustStorePassword()); + ssl.setTrustStoreType(config.getTrustStoreType()); + return ssl; } } diff --git a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/config/BitbucketServiceConfiguration.java b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/config/BitbucketServiceConfiguration.java index 3b81f62..211e196 100644 --- a/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/config/BitbucketServiceConfiguration.java +++ b/external-service-bitbucket/src/main/java/org/opendevstack/apiservice/externalservice/bitbucket/config/BitbucketServiceConfiguration.java @@ -79,9 +79,19 @@ public static class BitbucketInstanceConfig { private int readTimeout = 30000; /** - * Whether to trust all SSL certificates (default: false) - * WARNING: Should only be used in development environments + * Path to the trust store file for SSL certificate validation. + * Optional - if not provided, uses the JVM default trust store. */ - private boolean trustAllCertificates = false; + private String trustStorePath; + + /** + * Password for the trust store. + */ + private String trustStorePassword; + + /** + * Type of the trust store (JKS, PKCS12, etc.). Default is JKS. + */ + private String trustStoreType = "JKS"; } } diff --git a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactoryTest.java b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactoryTest.java index 8362c16..d51e116 100644 --- a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactoryTest.java +++ b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/client/BitbucketApiClientFactoryTest.java @@ -2,14 +2,9 @@ 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.bitbucket.config.BitbucketServiceConfiguration; import org.opendevstack.apiservice.externalservice.bitbucket.config.BitbucketServiceConfiguration.BitbucketInstanceConfig; import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.web.client.RestTemplate; import java.util.LinkedHashMap; import java.util.Map; @@ -19,32 +14,22 @@ 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 BitbucketApiClientFactory}. * Focuses on default-instance resolution logic and client creation. */ -@ExtendWith(MockitoExtension.class) class BitbucketApiClientFactoryTest { - @Mock - private RestTemplateBuilder restTemplateBuilder; - - @Mock - private RestTemplate restTemplate; - private BitbucketServiceConfiguration configuration; @BeforeEach void setUp() { configuration = new BitbucketServiceConfiguration(); - lenient().when(restTemplateBuilder.build()).thenReturn(restTemplate); } private BitbucketApiClientFactory factory() { - return new BitbucketApiClientFactory(configuration, restTemplateBuilder); + return new BitbucketApiClientFactory(configuration); } // ------------------------------------------------------------------------- @@ -120,7 +105,6 @@ void getClient_unknownInstance_throwsBitbucketException() { @Test void getClient_validInstance_returnsClient() throws BitbucketException { - when(restTemplateBuilder.build()).thenReturn(restTemplate); configuration.setInstances(Map.of("dev", config("https://bitbucket.dev.example.com"))); BitbucketApiClient client = factory().getClient("dev"); @@ -135,7 +119,6 @@ void getClient_validInstance_returnsClient() throws BitbucketException { @Test void getClient_returnsClientForConfiguredDefaultInstance() throws BitbucketException { - when(restTemplateBuilder.build()).thenReturn(restTemplate); configuration.setDefaultInstance("prod"); configuration.setInstances(orderedMap("dev", "prod")); @@ -147,7 +130,6 @@ void getClient_returnsClientForConfiguredDefaultInstance() throws BitbucketExcep @Test void getClient_noDefaultConfigured_returnsFirstInstance() throws BitbucketException { - when(restTemplateBuilder.build()).thenReturn(restTemplate); Map instances = new LinkedHashMap<>(); instances.put("alpha", config("https://bitbucket-alpha.example.com")); instances.put("beta", config("https://bitbucket-beta.example.com")); diff --git a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketServiceIntegrationTest.java b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketServiceIntegrationTest.java index f848719..23b32b7 100644 --- a/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketServiceIntegrationTest.java +++ b/external-service-bitbucket/src/test/java/org/opendevstack/apiservice/externalservice/bitbucket/integration/BitbucketServiceIntegrationTest.java @@ -12,7 +12,6 @@ import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.test.context.ActiveProfiles; import org.springframework.web.client.HttpClientErrorException; @@ -52,9 +51,6 @@ class BitbucketServiceIntegrationTest { @Autowired private BitbucketServiceConfiguration bitbucketConfiguration; - @Autowired - private RestTemplateBuilder restTemplateBuilder; - private String testInstance; private String testProjectKey; private String testRepositorySlug; @@ -363,12 +359,11 @@ private BitbucketService createUnauthorizedBitbucketService() { badConfig.setBearerToken(null); // force basic auth with bad credentials badConfig.setConnectionTimeout(realConfig.getConnectionTimeout()); badConfig.setReadTimeout(realConfig.getReadTimeout()); - badConfig.setTrustAllCertificates(realConfig.isTrustAllCertificates()); BitbucketServiceConfiguration badConfiguration = new BitbucketServiceConfiguration(); badConfiguration.setInstances(java.util.Map.of("unauthorized", badConfig)); - BitbucketApiClientFactory badFactory = new BitbucketApiClientFactory(badConfiguration, restTemplateBuilder); + BitbucketApiClientFactory badFactory = new BitbucketApiClientFactory(badConfiguration); return new BitbucketServiceImpl(badFactory); } diff --git a/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactory.java b/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactory.java index 1e62618..cefc8c6 100644 --- a/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactory.java +++ b/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactory.java @@ -1,64 +1,47 @@ package org.opendevstack.apiservice.externalservice.jira.client; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.api.http.ExternalServiceSslProperties; +import org.opendevstack.apiservice.externalservice.api.http.RestClientFactory; import org.opendevstack.apiservice.externalservice.jira.config.JiraServiceConfiguration; import org.opendevstack.apiservice.externalservice.jira.config.JiraServiceConfiguration.JiraInstanceConfig; import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import javax.net.ssl.*; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.X509Certificate; import java.util.Map; import java.util.Set; /** * Factory for creating {@link JiraApiClient} instances. - * Uses the factory pattern to provide configured clients for different Jira instances. - * Clients are cached and reused for efficiency. + * + *

SSL wiring is delegated to {@link RestClientFactory} in {@code external-service-api}. + * A {@link RestTemplate} is produced (not {@code RestClient}) because the OpenAPI-generated + * {@link ApiClient} only accepts {@code RestTemplate}. + * + *

Clients are cached by instance name via Spring's {@code @Cacheable}. */ @Component @Slf4j public class JiraApiClientFactory { private final JiraServiceConfiguration configuration; - private final RestTemplateBuilder restTemplateBuilder; - /** - * Constructor with dependency injection. - * - * @param configuration Jira service configuration - * @param restTemplateBuilder RestTemplate builder for creating HTTP clients - */ - public JiraApiClientFactory(JiraServiceConfiguration configuration, - RestTemplateBuilder restTemplateBuilder) { + public JiraApiClientFactory(JiraServiceConfiguration configuration) { this.configuration = configuration; - this.restTemplateBuilder = restTemplateBuilder; - log.info("JiraApiClientFactory initialized with {} instance(s)", configuration.getInstances().size()); } /** - * Resolve the effective instance name. - *

+ * Resolve the effective default instance name. * - * @param instanceName Explicit instance name, or {@code null}/{@code ""} to use the default * @return The resolved instance name (never {@code null}/blank) * @throws JiraException if no Jira instances are configured */ public String getDefaultInstanceName() throws JiraException { - String defaultInstance = configuration.getDefaultInstance(); if (defaultInstance != null && !defaultInstance.isBlank()) { return defaultInstance; @@ -73,23 +56,21 @@ public String getDefaultInstanceName() throws JiraException { } /** - * Get a {@link JiraApiClient} for a specific instance. - * If {@code instanceName} is {@code null} or blank, the default instance is used. + * Get a {@link JiraApiClient} for a named instance. * - * @param instanceName Name of the Jira instance, or {@code null}/{@code ""} for the default - * @return Configured JiraApiClient + * @param instanceName Name of the Jira instance (must not be null/blank) + * @return Configured {@link JiraApiClient} * @throws JiraException if the instance is not configured */ @Cacheable(value = "jiraApiClients", key = "#instanceName", condition = "#instanceName != null && !#instanceName.isBlank()") public JiraApiClient getClient(String instanceName) throws JiraException { if (instanceName == null || instanceName.isBlank()) { throw new JiraException( - String.format("Provide instance name. Available instances: %s", - configuration.getInstances().keySet())); + String.format("Provide instance name. Available instances: %s", + configuration.getInstances().keySet())); } JiraInstanceConfig instanceConfig = configuration.getInstances().get(instanceName); - if (instanceConfig == null) { throw new JiraException( String.format("Jira instance '%s' is not configured. Available instances: %s", @@ -97,111 +78,61 @@ public JiraApiClient getClient(String instanceName) throws JiraException { } log.info("Creating new JiraApiClient for instance '{}'", instanceName); - - RestTemplate restTemplate = createRestTemplate(instanceConfig); - return new JiraApiClient(instanceName, instanceConfig, restTemplate); + return new JiraApiClient(instanceName, instanceConfig, buildRestTemplate(instanceConfig)); } /** - * Get the default client, as determined by {@code externalservices.jira.default-instance}. - * Falls back to the first configured instance when {@code default-instance} is not set. + * Get the default client. * - * @return JiraApiClient for the default instance + * @return {@link JiraApiClient} for the default instance * @throws JiraException if no instances are configured */ @Cacheable(value = "jiraApiClients", key = "'default'") public JiraApiClient getClient() throws JiraException { String defaultInstanceName = getDefaultInstanceName(); JiraInstanceConfig instanceConfig = configuration.getInstances().get(defaultInstanceName); - RestTemplate restTemplate = createRestTemplate(instanceConfig); - - return new JiraApiClient(defaultInstanceName, instanceConfig, restTemplate); + return new JiraApiClient(defaultInstanceName, instanceConfig, buildRestTemplate(instanceConfig)); } - /** - * Get all available instance names. - * - * @return Set of configured instance names - */ + /** @return all 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 - */ + /** @return {@code true} if the named instance is configured */ public boolean hasInstance(String instanceName) { return configuration.getInstances().containsKey(instanceName); } - /** - * Clear the client cache (useful for testing or when configuration changes). - */ + /** Clear the client cache (useful for testing or when configuration changes). */ @CacheEvict(value = "jiraApiClients", allEntries = true) public void clearCache() { log.info("Clearing JiraApiClient cache"); } - /** - * Create a configured RestTemplate for a Jira instance. - * - * @param config Configuration for the instance - * @return Configured RestTemplate - */ - private RestTemplate createRestTemplate(JiraInstanceConfig config) { - RestTemplate restTemplate = restTemplateBuilder.build(); - - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(config.getConnectionTimeout()); - requestFactory.setReadTimeout(config.getReadTimeout()); - restTemplate.setRequestFactory(requestFactory); - - if (config.isTrustAllCertificates()) { - log.warn("Trust all certificates is enabled for Jira connection. " - + "This should only be used in development environments!"); - configureTrustAllCertificates(restTemplate); - } - - return restTemplate; + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private RestTemplate buildRestTemplate(JiraInstanceConfig config) { + log.info("Creating RestTemplate for Jira instance (connect={}ms, read={}ms)", + config.getConnectionTimeout(), config.getReadTimeout()); + return RestClientFactory.buildRestTemplate( + toSslProperties(config), + config.getConnectionTimeout(), + config.getReadTimeout()); } /** - * Configure RestTemplate to trust all SSL certificates. - * WARNING: This should only be used in development environments. - * - * @param restTemplate RestTemplate to configure + * Adapt {@link JiraInstanceConfig} SSL fields to {@link ExternalServiceSslProperties} + * so we can pass them to {@link RestClientFactory} without changing the generated + * config class. */ - @SuppressWarnings({"java:S4830", "java:S1186"}) - private void configureTrustAllCertificates(RestTemplate restTemplate) { - try { - TrustManager[] trustAllCerts = new TrustManager[]{ - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - // Intentionally empty - trusting all certificates for development environments - public void checkClientTrusted(X509Certificate[] certs, String authType) { - // No validation performed - development only - } - // Intentionally empty - trusting all certificates for development environments - public void checkServerTrusted(X509Certificate[] certs, String authType) { - // No validation performed - development only - } - } - }; - - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - - HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); - // Intentionally disabling hostname verification for development environments - HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); - - } catch (NoSuchAlgorithmException | KeyManagementException e) { - log.error("Failed to configure SSL trust all certificates", e); - } + private static ExternalServiceSslProperties toSslProperties(JiraInstanceConfig config) { + ExternalServiceSslProperties ssl = new ExternalServiceSslProperties(); + ssl.setTrustStorePath(config.getTrustStorePath()); + ssl.setTrustStorePassword(config.getTrustStorePassword()); + ssl.setTrustStoreType(config.getTrustStoreType()); + return ssl; } } diff --git a/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/config/JiraServiceConfiguration.java b/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/config/JiraServiceConfiguration.java index 6b0547e..9b86e76 100644 --- a/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/config/JiraServiceConfiguration.java +++ b/external-service-jira/src/main/java/org/opendevstack/apiservice/externalservice/jira/config/JiraServiceConfiguration.java @@ -82,9 +82,19 @@ public static class JiraInstanceConfig { private int readTimeout = 30000; /** - * Whether to trust all SSL certificates (default: false). - * WARNING: Should only be used in development environments. + * Path to the trust store file for SSL certificate validation. + * Optional - if not provided, uses the JVM default trust store. */ - private boolean trustAllCertificates = false; + private String trustStorePath; + + /** + * Password for the trust store. + */ + private String trustStorePassword; + + /** + * Type of the trust store (JKS, PKCS12, etc.). Default is JKS. + */ + private String trustStoreType = "JKS"; } } diff --git a/external-service-jira/src/test/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactoryTest.java b/external-service-jira/src/test/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactoryTest.java index 3a291d7..ff6d038 100644 --- a/external-service-jira/src/test/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactoryTest.java +++ b/external-service-jira/src/test/java/org/opendevstack/apiservice/externalservice/jira/client/JiraApiClientFactoryTest.java @@ -5,42 +5,26 @@ import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; 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.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.*; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.when; /** * Unit tests for {@link JiraApiClientFactory}. * Focuses on the default-instance resolution logic introduced in {@code resolveInstanceName}. */ -@ExtendWith(MockitoExtension.class) class JiraApiClientFactoryTest { - @Mock - private RestTemplateBuilder restTemplateBuilder; - - @Mock - private RestTemplate restTemplate; - private JiraServiceConfiguration configuration; @BeforeEach void setUp() { configuration = new JiraServiceConfiguration(); - lenient().when(restTemplateBuilder.build()).thenReturn(restTemplate); } private JiraApiClientFactory factory() { - return new JiraApiClientFactory(configuration, restTemplateBuilder); + return new JiraApiClientFactory(configuration); } // ------------------------------------------------------------------------- @@ -118,7 +102,6 @@ void getClient_unknownInstance_throwsJiraException() { @Test void getClient_returnsClientForConfiguredDefaultInstance() throws JiraException { - when(restTemplateBuilder.build()).thenReturn(restTemplate); configuration.setDefaultInstance("prod"); configuration.setInstances(orderedMap("dev", "prod")); @@ -130,7 +113,6 @@ void getClient_returnsClientForConfiguredDefaultInstance() throws JiraException @Test void getClient_noDefaultConfigured_returnsFirstInstance() throws JiraException { - when(restTemplateBuilder.build()).thenReturn(restTemplate); Map instances = new LinkedHashMap<>(); instances.put("alpha", config("https://jira-alpha.example.com")); instances.put("beta", config("https://jira-beta.example.com")); diff --git a/external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/config/ProjectsInfoServiceConfig.java b/external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/config/ProjectsInfoServiceConfig.java index 6881360..6449ac9 100644 --- a/external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/config/ProjectsInfoServiceConfig.java +++ b/external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/config/ProjectsInfoServiceConfig.java @@ -1,31 +1,23 @@ package org.opendevstack.apiservice.externalservice.projectsinfoservice.config; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.api.http.RestClientFactory; import org.opendevstack.apiservice.externalservice.projects_info_service.v1_0_0.client.ApiClient; import org.opendevstack.apiservice.externalservice.projects_info_service.v1_0_0.client.api.ProjectsApi; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.util.StringUtils; import org.springframework.web.client.RestTemplate; -import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.security.GeneralSecurityException; -import java.security.cert.X509Certificate; - /** - * Configuration class for external service components. + * Configuration class for the Projects Info Service external service. + * + *

SSL wiring is delegated to {@link RestClientFactory} in {@code external-service-api}. + * A {@link RestTemplate} is produced (not {@code RestClient}) because the OpenAPI-generated + * {@link ApiClient} only accepts {@code RestTemplate}. */ @Configuration @EnableAsync @@ -33,8 +25,11 @@ @Slf4j public class ProjectsInfoServiceConfig { - @Value("${externalservices.projects-info-service.base-url:http://localhost:8080}") - private String baseUrl; + @Value("${externalservices.projects-info-service.connect-timeout:30000}") + private int connectTimeoutMs; + + @Value("${externalservices.projects-info-service.read-timeout:30000}") + private int readTimeoutMs; private final ProjectsInfoServiceSslProperties sslProperties; @@ -43,24 +38,18 @@ public ProjectsInfoServiceConfig(ProjectsInfoServiceSslProperties sslProperties) } /** - * Creates a RestTemplate bean for HTTP client operations with configurable SSL settings. - * - * @return RestTemplate instance with SSL configuration + * {@link RestTemplate} used by the OpenAPI-generated {@link ApiClient}. */ @Bean - public RestTemplate projectsInfoServiceRestTemplate(RestTemplateBuilder restTemplateBuilder) { - if (!sslProperties.isVerifyCertificates()) { - log.warn("SSL certificate verification is DISABLED - this should only be used in development environments"); - return createInsecureRestTemplate(); - } else { - log.info("SSL certificate verification is ENABLED"); - return createSecureRestTemplate(); - } + public RestTemplate projectsInfoServiceRestTemplate() { + log.info("Creating ProjectsInfoService RestTemplate (connect={}ms, read={}ms)", + connectTimeoutMs, readTimeoutMs); + return RestClientFactory.buildRestTemplate(sslProperties, connectTimeoutMs, readTimeoutMs); } @Bean - public ApiClient apiClient(RestTemplate restTemplate) { - return new ApiClient(restTemplate); + public ApiClient apiClient(RestTemplate projectsInfoServiceRestTemplate) { + return new ApiClient(projectsInfoServiceRestTemplate); } @Qualifier("ProjectsInfoServiceApiClient") @@ -68,66 +57,4 @@ public ApiClient apiClient(RestTemplate restTemplate) { public ProjectsApi projectsApi(ApiClient apiClient) { return new ProjectsApi(apiClient); } - - private RestTemplate createInsecureRestTemplate() { - try { - // Create a trust manager that accepts all certificates - // WARNING: This is insecure and should only be used in development environments - TrustManager[] trustAllCerts = new TrustManager[] { - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; // Return empty array instead of null - } - public void checkClientTrusted(X509Certificate[] certs, String authType) { - // Intentionally empty - accepts all client certificates (insecure) - } - public void checkServerTrusted(X509Certificate[] certs, String authType) { - // Intentionally empty - accepts all server certificates (insecure) - } - } - }; - - // Install the all-trusting trust manager - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - - // Create hostname verifier that accepts all hostnames (insecure) - HostnameVerifier allHostsValid = (hostname, session) -> true; - - // Create a custom request factory that uses our SSL configuration - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() { - @Override - protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException { - if (connection instanceof HttpsURLConnection httpsConnection) { - httpsConnection.setSSLSocketFactory(sslContext.getSocketFactory()); - httpsConnection.setHostnameVerifier(allHostsValid); - } - super.prepareConnection(connection, httpMethod); - } - }; - - return new RestTemplate(requestFactory); - - } catch (GeneralSecurityException e) { - log.warn("Failed to create insecure RestTemplate, falling back to default: {}", e.getMessage()); - return new RestTemplate(); - } - } - - private RestTemplate createSecureRestTemplate() { - try { - // If custom trust store is provided, configure it - if (StringUtils.hasText(sslProperties.getTrustStorePath())) { - log.info("Custom trust store specified: {} (custom trust store support can be added in future versions)", - sslProperties.getTrustStorePath()); - } - - // Return default RestTemplate with system SSL settings - return new RestTemplate(); - - } catch (Exception e) { - log.warn("Failed to create secure RestTemplate with custom trust store, using default: {}", e.getMessage()); - return new RestTemplate(); - } - } } diff --git a/external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/config/ProjectsInfoServiceSslProperties.java b/external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/config/ProjectsInfoServiceSslProperties.java index 4cef9c8..bdea138 100644 --- a/external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/config/ProjectsInfoServiceSslProperties.java +++ b/external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/config/ProjectsInfoServiceSslProperties.java @@ -1,65 +1,15 @@ package org.opendevstack.apiservice.externalservice.projectsinfoservice.config; +import org.opendevstack.apiservice.externalservice.api.http.ExternalServiceSslProperties; import org.springframework.boot.context.properties.ConfigurationProperties; /** - * Configuration properties for SSL settings in external service calls. + * SSL configuration properties for the Projects Info Service external service. + * + *

Binds to the {@code externalservices.projects-info-service.ssl} prefix. */ @ConfigurationProperties(prefix = "externalservices.projects-info-service.ssl") -public class ProjectsInfoServiceSslProperties { - - /** - * Whether to verify SSL certificates when making external service calls. - * Default is true for security. - */ - private boolean verifyCertificates = true; - - /** - * Path to the trust store file for SSL certificate validation. - * Optional - if not provided, uses system default trust store. - */ - private String trustStorePath; - - /** - * Password for the trust store. - */ - private String trustStorePassword; - - /** - * Type of the trust store (JKS, PKCS12, etc.). - * Default is JKS. - */ - private String trustStoreType = "JKS"; - - public boolean isVerifyCertificates() { - return verifyCertificates; - } - - public void setVerifyCertificates(boolean verifyCertificates) { - this.verifyCertificates = verifyCertificates; - } - - public String getTrustStorePath() { - return trustStorePath; - } - - public void setTrustStorePath(String trustStorePath) { - this.trustStorePath = trustStorePath; - } - - public String getTrustStorePassword() { - return trustStorePassword; - } - - public void setTrustStorePassword(String trustStorePassword) { - this.trustStorePassword = trustStorePassword; - } - - public String getTrustStoreType() { - return trustStoreType; - } - - public void setTrustStoreType(String trustStoreType) { - this.trustStoreType = trustStoreType; - } -} \ No newline at end of file +public class ProjectsInfoServiceSslProperties extends ExternalServiceSslProperties { + // All fields inherited from ExternalServiceSslProperties. + // Add Projects-Info-Service-specific SSL overrides here if ever needed. +} diff --git a/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/config/SslProperties.java b/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/config/SslProperties.java index 365ec95..b7e11d4 100644 --- a/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/config/SslProperties.java +++ b/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/config/SslProperties.java @@ -1,35 +1,15 @@ package org.opendevstack.apiservice.externalservice.uipath.config; -import lombok.Data; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; +import org.opendevstack.apiservice.externalservice.api.http.ExternalServiceSslProperties; /** - * Configuration properties for SSL settings in external service calls. + * SSL configuration properties for the UiPath Orchestrator external service. + * + *

Bound as a nested field inside {@link UiPathProperties} under the + * {@code automation.platform.uipath.ssl.*} prefix — no standalone + * {@code @ConfigurationProperties} annotation needed. */ -@Component("uipathSslProperties") -@ConfigurationProperties(prefix = "automation.platform.uipath.ssl") -@Data -public class SslProperties { - - /** - * Whether to verify SSL certificates when making external service calls. - * Default is true for security. - */ - private boolean verifyCertificates = true; - /** - * Path to the trust store file for SSL certificate validation. - * Optional - if not provided, uses system default trust store. - */ - private String trustStorePath; - /** - * Password for the trust store. - */ - private String trustStorePassword; - /** - * Type of the trust store (JKS, PKCS12, etc.). - * Default is JKS. - */ - private String trustStoreType = "JKS"; +public class SslProperties extends ExternalServiceSslProperties { + // All fields inherited from ExternalServiceSslProperties. + // Add UiPath-specific SSL overrides here if ever needed. } diff --git a/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/config/UiPathServiceConfig.java b/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/config/UiPathServiceConfig.java index cec3df2..4052f93 100644 --- a/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/config/UiPathServiceConfig.java +++ b/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/config/UiPathServiceConfig.java @@ -1,107 +1,42 @@ package org.opendevstack.apiservice.externalservice.uipath.config; -import org.springframework.boot.context.properties.EnableConfigurationProperties; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.api.http.RestClientFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.web.client.RestTemplate; - -import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.security.GeneralSecurityException; -import java.security.cert.X509Certificate; -import lombok.extern.slf4j.Slf4j; +import org.springframework.web.client.RestClient; /** - * Configuration class for UIPath service components. + * Configuration class for the UiPath Orchestrator external service. + * + *

SSL wiring is delegated to {@link RestClientFactory} in {@code external-service-api}; + * no SSL boilerplate lives here. */ @Configuration @EnableAsync -@EnableConfigurationProperties(UiPathProperties.class) @Slf4j public class UiPathServiceConfig { private final UiPathProperties uiPathProperties; - public UiPathServiceConfig(@org.springframework.beans.factory.annotation.Qualifier("uiPathOrchestratorProperties") UiPathProperties uiPathProperties) { + public UiPathServiceConfig(UiPathProperties uiPathProperties) { this.uiPathProperties = uiPathProperties; } /** - * Creates a RestTemplate bean for HTTP client operations with configurable SSL settings. - * Uses a different bean name to avoid conflicts with other RestTemplate beans. + * {@link RestClient} bean for UiPath Orchestrator. + * + *

SSL and timeouts are configured via {@code automation.platform.uipath.ssl.*} + * and {@code automation.platform.uipath.timeout} properties respectively. * - * @return RestTemplate instance with SSL configuration + * @param builder Spring prototype builder (injected fresh per bean definition) + * @return configured {@link RestClient} */ - @Bean(name = "uiPathRestTemplate") - public RestTemplate uiPathRestTemplate() { - if (!uiPathProperties.getSsl().isVerifyCertificates()) { - log.warn("UIPath SSL certificate verification is DISABLED - this should only be used in development environments"); - return createInsecureRestTemplate(); - } else { - log.info("UIPath SSL certificate verification is ENABLED"); - return createSecureRestTemplate(); - } - } - - private RestTemplate createInsecureRestTemplate() { - try { - // Create a trust manager that accepts all certificates - // WARNING: This is insecure and should only be used in development environments - TrustManager[] trustAllCerts = new TrustManager[] { - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - public void checkClientTrusted(X509Certificate[] certs, String authType) { - // Intentionally empty - accepts all client certificates (insecure) - } - public void checkServerTrusted(X509Certificate[] certs, String authType) { - // Intentionally empty - accepts all server certificates (insecure) - } - } - }; - - // Install the all-trusting trust manager - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - - // Create hostname verifier that accepts all hostnames (insecure) - HostnameVerifier allHostsValid = (hostname, session) -> true; - - // Create a custom request factory that uses our SSL configuration - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory() { - @Override - protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws IOException { - if (connection instanceof HttpsURLConnection httpsConnection) { - httpsConnection.setSSLSocketFactory(sslContext.getSocketFactory()); - httpsConnection.setHostnameVerifier(allHostsValid); - } - super.prepareConnection(connection, httpMethod); - } - }; - - requestFactory.setConnectTimeout(uiPathProperties.getTimeout()); - requestFactory.setReadTimeout(uiPathProperties.getTimeout()); - - return new RestTemplate(requestFactory); - - } catch (GeneralSecurityException e) { - log.warn("Failed to create insecure RestTemplate, falling back to default: {}", e.getMessage()); - return createSecureRestTemplate(); - } - } - - private RestTemplate createSecureRestTemplate() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(uiPathProperties.getTimeout()); - requestFactory.setReadTimeout(uiPathProperties.getTimeout()); - return new RestTemplate(requestFactory); + @Bean(name = "uiPathRestClient") + public RestClient uiPathRestClient(RestClient.Builder builder) { + int timeout = uiPathProperties.getTimeout(); + log.info("Creating UiPath RestClient (connect/read timeout={}ms)", timeout); + return RestClientFactory.build(builder, uiPathProperties.getSsl(), timeout, timeout); } } diff --git a/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/service/impl/UiPathOrchestratorServiceImpl.java b/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/service/impl/UiPathOrchestratorServiceImpl.java index e6d6a04..a4d6038 100644 --- a/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/service/impl/UiPathOrchestratorServiceImpl.java +++ b/external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/service/impl/UiPathOrchestratorServiceImpl.java @@ -1,5 +1,6 @@ package org.opendevstack.apiservice.externalservice.uipath.service.impl; +import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.externalservice.uipath.config.UiPathProperties; import org.opendevstack.apiservice.externalservice.uipath.exception.UiPathException; import org.opendevstack.apiservice.externalservice.uipath.model.QueueItemStatus; @@ -12,40 +13,36 @@ import org.opendevstack.apiservice.externalservice.uipath.service.UiPathOrchestratorService; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; -import lombok.extern.slf4j.Slf4j; - import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; /** - * Implementation of UiPathService for UIPath Orchestrator. - * This service provides integration with UIPath Orchestrator for managing queue items + * Implementation of UiPathOrchestratorService for UIPath Orchestrator. + * Provides integration with UIPath Orchestrator for managing queue items * and checking robot execution status. */ @Service("uiPathOrchestratorService") @Slf4j public class UiPathOrchestratorServiceImpl implements UiPathOrchestratorService { - private final RestTemplate restTemplate; + private final RestClient restClient; private final UiPathProperties properties; public UiPathOrchestratorServiceImpl( - @Qualifier("uiPathRestTemplate") RestTemplate restTemplate, + @Qualifier("uiPathRestClient") RestClient restClient, @Qualifier("uiPathOrchestratorProperties") UiPathProperties properties) { - this.restTemplate = restTemplate; + this.restClient = restClient; this.properties = properties; } @@ -57,36 +54,26 @@ public String authenticate() throws UiPathException.AuthenticationException { UiPathAuthRequest authRequest = new UiPathAuthRequest( properties.getTenancyName(), properties.getClientId(), - properties.getClientSecret() - ); - - HttpHeaders headers = new HttpHeaders(); - headers.set("Content-Type", "application/json"); - - HttpEntity request = new HttpEntity<>(authRequest, headers); - - ResponseEntity response = restTemplate.postForEntity( - properties.getLoginUrl(), - request, - UiPathAuthResponse.class - ); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - UiPathAuthResponse authResponse = response.getBody(); - - if (authResponse.isSuccess() && StringUtils.hasText(authResponse.getToken())) { - log.debug("Successfully authenticated to UIPath Orchestrator"); - return authResponse.getToken(); - } else { - String errorMsg = authResponse.getError() != null ? authResponse.getError() : "Unknown authentication error"; - throw new UiPathException.AuthenticationException("Authentication failed: " + errorMsg); - } - } else { - throw new UiPathException.AuthenticationException( - "Unexpected response status: " + response.getStatusCode() - ); + properties.getClientSecret()); + + UiPathAuthResponse authResponse = restClient.post() + .uri(properties.getLoginUrl()) + .contentType(MediaType.APPLICATION_JSON) + .body(authRequest) + .retrieve() + .body(UiPathAuthResponse.class); + + if (authResponse != null && authResponse.isSuccess() + && StringUtils.hasText(authResponse.getToken())) { + log.debug("Successfully authenticated to UIPath Orchestrator"); + return authResponse.getToken(); } + String errorMsg = (authResponse != null && authResponse.getError() != null) + ? authResponse.getError() + : "Unknown authentication error"; + throw new UiPathException.AuthenticationException("Authentication failed: " + errorMsg); + } catch (RestClientException e) { log.error("Failed to authenticate to UIPath Orchestrator: {}", e.getMessage(), e); throw new UiPathException.AuthenticationException("Authentication failed", e); @@ -94,38 +81,31 @@ public String authenticate() throws UiPathException.AuthenticationException { } @Override - public UiPathQueueItem addQueueItem(UiPathQueueItemRequest request) + public UiPathQueueItem addQueueItem(UiPathQueueItemRequest request) throws UiPathException.QueueItemCreationException { - - String reference = request.getItemData() != null ? request.getItemData().getReference() : "unknown"; + + String reference = request.getItemData() != null + ? request.getItemData().getReference() + : "unknown"; log.info("Adding queue item with reference '{}'", reference); try { - // Authenticate first String token = authenticate(); - // Create headers with authentication and organization unit - HttpHeaders headers = createAuthHeaders(token); - - HttpEntity httpRequest = new HttpEntity<>(request, headers); - - ResponseEntity response = restTemplate.postForEntity( - properties.getQueueItemsUrl(), - httpRequest, - UiPathQueueItem.class - ); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - UiPathQueueItem queueItem = response.getBody(); - log.info("Successfully created queue item with ID {} and reference '{}'", - queueItem.getId(), reference); + UiPathQueueItem queueItem = restClient.post() + .uri(properties.getQueueItemsUrl()) + .headers(h -> applyAuthHeaders(h, token)) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(UiPathQueueItem.class); + + if (queueItem != null) { + log.info("Successfully created queue item with ID {} and reference '{}'", + queueItem.getId(), reference); return queueItem; - } else { - throw new UiPathException.QueueItemCreationException( - reference, - "Unexpected response status: " + response.getStatusCode() - ); } + throw new UiPathException.QueueItemCreationException(reference, "Empty response body"); } catch (UiPathException.AuthenticationException e) { log.error("Failed to authenticate before adding queue item: {}", e.getMessage(), e); @@ -140,8 +120,7 @@ public UiPathQueueItem addQueueItem(UiPathQueueItemRequest request) @Async public CompletableFuture addQueueItemAsync(UiPathQueueItemRequest request) { try { - UiPathQueueItem result = addQueueItem(request); - return CompletableFuture.completedFuture(result); + return CompletableFuture.completedFuture(addQueueItem(request)); } catch (UiPathException.QueueItemCreationException e) { log.error("Async queue item creation failed: {}", e.getMessage(), e); return CompletableFuture.failedFuture(e); @@ -149,32 +128,26 @@ public CompletableFuture addQueueItemAsync(UiPathQueueItemReque } @Override - public UiPathQueueItem getQueueItemById(Long queueItemId) + public UiPathQueueItem getQueueItemById(Long queueItemId) throws UiPathException.QueueItemNotFoundException, UiPathException.StatusCheckException { - + log.debug("Getting queue item by ID: {}", queueItemId); try { String token = authenticate(); - HttpHeaders headers = createAuthHeaders(token); - HttpEntity request = new HttpEntity<>(headers); - String url = properties.getQueueItemsUrl() + "(" + queueItemId + ")"; - ResponseEntity response = restTemplate.exchange( - url, - HttpMethod.GET, - request, - UiPathQueueItem.class - ); + UiPathQueueItem queueItem = restClient.get() + .uri(url) + .headers(h -> applyAuthHeaders(h, token)) + .retrieve() + .body(UiPathQueueItem.class); - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - UiPathQueueItem queueItem = response.getBody(); + if (queueItem != null) { log.debug("Found queue item {} with status: {}", queueItemId, queueItem.getStatus()); return queueItem; - } else { - throw new UiPathException.QueueItemNotFoundException(queueItemId.toString()); } + throw new UiPathException.QueueItemNotFoundException(queueItemId.toString()); } catch (UiPathException.AuthenticationException e) { log.error("Authentication failed while getting queue item: {}", e.getMessage()); @@ -186,41 +159,28 @@ public UiPathQueueItem getQueueItemById(Long queueItemId) } @Override - public List getQueueItemsByReference(String reference) + public List getQueueItemsByReference(String reference) throws UiPathException.StatusCheckException { - + log.debug("Getting queue items by reference: '{}'", reference); try { String token = authenticate(); - HttpHeaders headers = createAuthHeaders(token); - HttpEntity request = new HttpEntity<>(headers); - - // Build OData query: $filter=Reference eq 'reference'&$select=Id (or all fields) String url = UriComponentsBuilder.fromUriString(properties.getQueueItemsUrl()) .queryParam("$filter", "Reference eq '" + reference + "'") .build() .toUriString(); - ResponseEntity> response = restTemplate.exchange( - url, - HttpMethod.GET, - request, - new ParameterizedTypeReference>() {} - ); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - UiPathODataResponse odataResponse = response.getBody(); - List items = odataResponse.getValue(); - - log.debug("Found {} queue item(s) with reference '{}'", items != null ? items.size() : 0, reference); - - return items != null ? items : List.of(); - } else { - log.warn("Unexpected response when querying by reference '{}': {}", - reference, response.getStatusCode()); - return List.of(); - } + UiPathODataResponse odataResponse = restClient.get() + .uri(url) + .headers(h -> applyAuthHeaders(h, token)) + .retrieve() + .body(new ParameterizedTypeReference<>() {}); + + List items = (odataResponse != null) ? odataResponse.getValue() : null; + log.debug("Found {} queue item(s) with reference '{}'", + items != null ? items.size() : 0, reference); + return items != null ? items : List.of(); } catch (UiPathException.AuthenticationException e) { log.error("Authentication failed while querying by reference: {}", e.getMessage()); @@ -232,11 +192,10 @@ public List getQueueItemsByReference(String reference) } @Override - public Optional getLatestQueueItemByReference(String reference) + public Optional getLatestQueueItemByReference(String reference) throws UiPathException.StatusCheckException { - - log.debug("Getting latest queue item by reference: '{}'", reference); + log.debug("Getting latest queue item by reference: '{}'", reference); List items = getQueueItemsByReference(reference); if (items.isEmpty()) { @@ -244,24 +203,20 @@ public Optional getLatestQueueItemByReference(String reference) return Optional.empty(); } - // Get the item with the highest ID (most recent) Optional latestItem = items.stream() .max(Comparator.comparing(UiPathQueueItem::getId)); - latestItem.ifPresent(item -> - log.debug("Latest queue item for reference '{}' is ID {} with status: {}", - reference, item.getId(), item.getStatus()) - ); - + latestItem.ifPresent(item -> + log.debug("Latest queue item for reference '{}' is ID {} with status: {}", + reference, item.getId(), item.getStatus())); return latestItem; } @Override - public boolean hasQueueItemFinalized(String reference) + public boolean hasQueueItemFinalized(String reference) throws UiPathException.QueueItemNotFoundException, UiPathException.StatusCheckException { - - log.debug("Checking if queue item with reference '{}' has finalized", reference); + log.debug("Checking if queue item with reference '{}' has finalized", reference); Optional latestItem = getLatestQueueItemByReference(reference); if (latestItem.isEmpty()) { @@ -270,25 +225,20 @@ public boolean hasQueueItemFinalized(String reference) UiPathQueueItem item = latestItem.get(); boolean finalized = item.isFinalized(); - - log.debug("Queue item {} (reference '{}') finalized status: {} (status: {})", - item.getId(), reference, finalized, item.getStatus()); - + log.debug("Queue item {} (reference '{}') finalized status: {} (status: {})", + item.getId(), reference, finalized, item.getStatus()); return finalized; } @Override - public boolean hasQueueItemFinalizedById(Long queueItemId) + public boolean hasQueueItemFinalizedById(Long queueItemId) throws UiPathException.QueueItemNotFoundException, UiPathException.StatusCheckException { - - log.debug("Checking if queue item {} has finalized", queueItemId); + log.debug("Checking if queue item {} has finalized", queueItemId); UiPathQueueItem item = getQueueItemById(queueItemId); boolean finalized = item.isFinalized(); - - log.debug("Queue item {} finalized status: {} (status: {})", - queueItemId, finalized, item.getStatus()); - + log.debug("Queue item {} finalized status: {} (status: {})", + queueItemId, finalized, item.getStatus()); return finalized; } @@ -317,7 +267,6 @@ public boolean isHealthy() { @Override public UiPathQueueItemResult checkQueueItemByReference(String reference) { - // If no UIPath reference, consider the process complete and successful if (reference == null || reference.isEmpty()) { log.debug("No UIPath reference provided, returning NO_REFERENCE result"); return UiPathQueueItemResult.noReference(); @@ -336,19 +285,16 @@ public UiPathQueueItemResult checkQueueItemByReference(String reference) { QueueItemStatus status = item.getStatusEnum(); log.debug("UIPath queue item '{}' status: {}", reference, status); - // If UIPath is not in final state, return in-progress if (!status.isFinalState()) { log.debug("UIPath queue item '{}' is still in progress with status: {}", reference, status); return UiPathQueueItemResult.inProgress(item); } - // If UIPath failed, return failure if (!status.isSuccessful()) { log.warn("UIPath queue item '{}' failed with status: {}", reference, status); return UiPathQueueItemResult.failure(item); } - // UIPath succeeded log.debug("UIPath queue item '{}' completed successfully", reference); return UiPathQueueItemResult.success(item); @@ -362,17 +308,13 @@ public UiPathQueueItemResult checkQueueItemByReference(String reference) { } /** - * Creates HTTP headers with authentication token and organization unit ID. + * Applies Bearer token auth and optional organization unit header. */ - private HttpHeaders createAuthHeaders(String token) { - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bearer " + token); - headers.set("Content-Type", "application/json"); - + private void applyAuthHeaders(HttpHeaders headers, String token) { + headers.setBearerAuth(token); + headers.setContentType(MediaType.APPLICATION_JSON); if (StringUtils.hasText(properties.getOrganizationUnitId())) { headers.set("X-UIPATH-OrganizationUnitId", properties.getOrganizationUnitId()); } - - return headers; } } diff --git a/external-service-uipath/src/test/java/org/opendevstack/apiservice/externalservice/uipath/service/impl/UiPathOrchestratorServiceTest.java b/external-service-uipath/src/test/java/org/opendevstack/apiservice/externalservice/uipath/service/impl/UiPathOrchestratorServiceTest.java index 2a2c400..b552c40 100644 --- a/external-service-uipath/src/test/java/org/opendevstack/apiservice/externalservice/uipath/service/impl/UiPathOrchestratorServiceTest.java +++ b/external-service-uipath/src/test/java/org/opendevstack/apiservice/externalservice/uipath/service/impl/UiPathOrchestratorServiceTest.java @@ -1,23 +1,19 @@ package org.opendevstack.apiservice.externalservice.uipath.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.opendevstack.apiservice.externalservice.uipath.config.UiPathProperties; import org.opendevstack.apiservice.externalservice.uipath.exception.UiPathException; import org.opendevstack.apiservice.externalservice.uipath.model.UiPathAuthResponse; import org.opendevstack.apiservice.externalservice.uipath.model.UiPathODataResponse; import org.opendevstack.apiservice.externalservice.uipath.model.UiPathQueueItem; import org.opendevstack.apiservice.externalservice.uipath.model.UiPathQueueItemRequest; -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.springframework.core.ParameterizedTypeReference; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import org.springframework.web.client.RestTemplate; import java.util.HashMap; import java.util.List; @@ -29,95 +25,108 @@ import static org.mockito.Mockito.*; /** - * Unit tests for UiPathOrchestratorService. + * Unit tests for UiPathOrchestratorServiceImpl. + * Mocks the RestClient fluent chain to test all method paths. */ @ExtendWith(MockitoExtension.class) class UiPathOrchestratorServiceTest { - @Mock - private RestTemplate restTemplate; + // --- RestClient fluent-chain mocks --- + @Mock private RestClient restClient; + + // POST chain + @Mock private RestClient.RequestBodyUriSpec postUriSpec; + @Mock private RestClient.RequestBodySpec postBodySpec; + @Mock private RestClient.ResponseSpec postResponseSpec; + + // GET chain + @Mock private RestClient.RequestHeadersUriSpec getUriSpec; + @Mock private RestClient.RequestHeadersSpec getHeadersSpec; + @Mock private RestClient.ResponseSpec getResponseSpec; private UiPathProperties properties; private UiPathOrchestratorServiceImpl service; + private static final String HOST = "https://orchestrator.example.com"; + @BeforeEach void setUp() { properties = new UiPathProperties(); - properties.setHost("https://orchestrator.example.com"); + properties.setHost(HOST); properties.setClientId("testuser"); properties.setClientSecret("testpass"); properties.setTenancyName("default"); properties.setOrganizationUnitId("123"); properties.setTimeout(30000); - service = new UiPathOrchestratorServiceImpl(restTemplate, properties); + service = new UiPathOrchestratorServiceImpl(restClient, properties); + + // Wire POST chain + lenient().when(restClient.post()).thenReturn(postUriSpec); + lenient().when(postUriSpec.uri(anyString())).thenReturn(postBodySpec); + lenient().when(postBodySpec.contentType(any())).thenReturn(postBodySpec); + lenient().doReturn(postBodySpec).when(postBodySpec).body(any(Object.class)); + lenient().doReturn(postBodySpec).when(postBodySpec).headers(any()); + lenient().when(postBodySpec.retrieve()).thenReturn(postResponseSpec); + + // Wire GET chain + lenient().when(restClient.get()).thenReturn(getUriSpec); + lenient().doReturn(getHeadersSpec).when(getUriSpec).uri(anyString()); + lenient().doReturn(getHeadersSpec).when(getHeadersSpec).headers(any()); + lenient().when(getHeadersSpec.retrieve()).thenReturn(getResponseSpec); + } + + // helper: configure a successful auth response + private void givenAuthSucceeds(String token) { + UiPathAuthResponse auth = new UiPathAuthResponse(); + auth.setSuccess(true); + auth.setResult(token); + when(postResponseSpec.body(UiPathAuthResponse.class)).thenReturn(auth); } + // ========================================================================= + // authenticate + // ========================================================================= + @Test void authenticate_Success() throws Exception { - // Given - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token-12345"); - - when(restTemplate.postForEntity( - anyString(), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // When + givenAuthSucceeds("test-token-12345"); + String token = service.authenticate(); - // Then assertNotNull(token); assertEquals("test-token-12345", token); - verify(restTemplate).postForEntity(anyString(), any(HttpEntity.class), eq(UiPathAuthResponse.class)); } @Test void authenticate_Failure() { - // Given - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(false); - authResponse.setError("Invalid credentials"); - - when(restTemplate.postForEntity( - anyString(), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // When & Then + UiPathAuthResponse auth = new UiPathAuthResponse(); + auth.setSuccess(false); + auth.setError("Invalid credentials"); + when(postResponseSpec.body(UiPathAuthResponse.class)).thenReturn(auth); + assertThrows(UiPathException.AuthenticationException.class, () -> service.authenticate()); } + // ========================================================================= + // addQueueItem + // ========================================================================= + @Test void addQueueItem_Success() throws Exception { - // Given - Auth - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token"); - - when(restTemplate.postForEntity( - eq(properties.getLoginUrl()), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // Given - Queue Item Creation + // Auth returns token on first call; queue item returned on second call + UiPathAuthResponse auth = new UiPathAuthResponse(); + auth.setSuccess(true); + auth.setResult("test-token"); + UiPathQueueItem createdItem = new UiPathQueueItem(); createdItem.setId(12345L); createdItem.setReference("TEST-001"); createdItem.setStatus("NEW"); - when(restTemplate.postForEntity( - eq(properties.getQueueItemsUrl()), - any(HttpEntity.class), - eq(UiPathQueueItem.class) - )).thenReturn(new ResponseEntity<>(createdItem, HttpStatus.CREATED)); + when(postResponseSpec.body(UiPathAuthResponse.class)).thenReturn(auth); + when(postResponseSpec.body(UiPathQueueItem.class)).thenReturn(createdItem); - // When Map content = new HashMap<>(); content.put("Project Key", "TEST-001"); @@ -129,43 +138,29 @@ void addQueueItem_Success() throws Exception { UiPathQueueItem result = service.addQueueItem(request); - // Then assertNotNull(result); assertEquals(12345L, result.getId()); assertEquals("TEST-001", result.getReference()); assertEquals("NEW", result.getStatus()); } + // ========================================================================= + // getQueueItemById + // ========================================================================= + @Test void getQueueItemById_Success() throws Exception { - // Given - Auth - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token"); - - when(restTemplate.postForEntity( - eq(properties.getLoginUrl()), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // Given - Queue Item Retrieval + givenAuthSucceeds("test-token"); + UiPathQueueItem queueItem = new UiPathQueueItem(); queueItem.setId(12345L); queueItem.setReference("TEST-001"); queueItem.setStatus("SUCCESSFUL"); - when(restTemplate.exchange( - anyString(), - eq(HttpMethod.GET), - any(HttpEntity.class), - eq(UiPathQueueItem.class) - )).thenReturn(new ResponseEntity<>(queueItem, HttpStatus.OK)); + when(getResponseSpec.body(UiPathQueueItem.class)).thenReturn(queueItem); - // When UiPathQueueItem result = service.getQueueItemById(12345L); - // Then assertNotNull(result); assertEquals(12345L, result.getId()); assertEquals("SUCCESSFUL", result.getStatus()); @@ -175,44 +170,21 @@ void getQueueItemById_Success() throws Exception { @Test void getQueueItemById_NotFound() throws Exception { - // Given - Auth - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token"); - - when(restTemplate.postForEntity( - eq(properties.getLoginUrl()), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // Given - Queue Item Not Found - when(restTemplate.exchange( - anyString(), - eq(HttpMethod.GET), - any(HttpEntity.class), - eq(UiPathQueueItem.class) - )).thenThrow(new RestClientException("404 Not Found")); - - // When & Then - assertThrows(UiPathException.QueueItemNotFoundException.class, - () -> service.getQueueItemById(99999L)); + givenAuthSucceeds("test-token"); + when(getHeadersSpec.retrieve()).thenThrow(new RestClientException("404 Not Found")); + + assertThrows(UiPathException.QueueItemNotFoundException.class, + () -> service.getQueueItemById(99999L)); } + // ========================================================================= + // getQueueItemsByReference + // ========================================================================= + @Test void getQueueItemsByReference_Success() throws Exception { - // Given - Auth - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token"); - - when(restTemplate.postForEntity( - eq(properties.getLoginUrl()), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // Given - OData Query Response + givenAuthSucceeds("test-token"); + UiPathQueueItem item1 = new UiPathQueueItem(); item1.setId(100L); item1.setReference("TEST-001"); @@ -226,59 +198,37 @@ void getQueueItemsByReference_Success() throws Exception { UiPathODataResponse odataResponse = new UiPathODataResponse<>(); odataResponse.setValue(List.of(item1, item2)); - when(restTemplate.exchange( - anyString(), - eq(HttpMethod.GET), - any(HttpEntity.class), - any(ParameterizedTypeReference.class) - )).thenReturn(new ResponseEntity<>(odataResponse, HttpStatus.OK)); + when(getResponseSpec.body(any(ParameterizedTypeReference.class))).thenReturn(odataResponse); - // When List results = service.getQueueItemsByReference("TEST-001"); - // Then assertNotNull(results); assertEquals(2, results.size()); } + // ========================================================================= + // getLatestQueueItemByReference + // ========================================================================= + @Test void getLatestQueueItemByReference_Success() throws Exception { - // Given - Auth - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token"); - - when(restTemplate.postForEntity( - eq(properties.getLoginUrl()), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // Given - Multiple items, should return the one with highest ID + givenAuthSucceeds("test-token"); + UiPathQueueItem item1 = new UiPathQueueItem(); item1.setId(100L); - item1.setReference("TEST-001"); item1.setStatus("FAILED"); UiPathQueueItem item2 = new UiPathQueueItem(); - item2.setId(200L); // This should be returned (highest ID) - item2.setReference("TEST-001"); + item2.setId(200L); item2.setStatus("SUCCESSFUL"); UiPathODataResponse odataResponse = new UiPathODataResponse<>(); odataResponse.setValue(List.of(item1, item2)); - when(restTemplate.exchange( - anyString(), - eq(HttpMethod.GET), - any(HttpEntity.class), - any(ParameterizedTypeReference.class) - )).thenReturn(new ResponseEntity<>(odataResponse, HttpStatus.OK)); + when(getResponseSpec.body(any(ParameterizedTypeReference.class))).thenReturn(odataResponse); - // When Optional result = service.getLatestQueueItemByReference("TEST-001"); - // Then assertTrue(result.isPresent()); assertEquals(200L, result.get().getId()); assertEquals("SUCCESSFUL", result.get().getStatus()); @@ -286,140 +236,67 @@ void getLatestQueueItemByReference_Success() throws Exception { @Test void getLatestQueueItemByReference_NotFound() throws Exception { - // Given - Auth - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token"); - - when(restTemplate.postForEntity( - eq(properties.getLoginUrl()), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // Given - Empty response + givenAuthSucceeds("test-token"); + UiPathODataResponse odataResponse = new UiPathODataResponse<>(); odataResponse.setValue(List.of()); - when(restTemplate.exchange( - anyString(), - eq(HttpMethod.GET), - any(HttpEntity.class), - any(ParameterizedTypeReference.class) - )).thenReturn(new ResponseEntity<>(odataResponse, HttpStatus.OK)); + when(getResponseSpec.body(any(ParameterizedTypeReference.class))).thenReturn(odataResponse); - // When Optional result = service.getLatestQueueItemByReference("NONEXISTENT"); - // Then assertFalse(result.isPresent()); } + // ========================================================================= + // hasQueueItemFinalized + // ========================================================================= + @Test void hasQueueItemFinalized_Success() throws Exception { - // Given - Auth - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token"); - - when(restTemplate.postForEntity( - eq(properties.getLoginUrl()), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // Given - Finalized item + givenAuthSucceeds("test-token"); + UiPathQueueItem item = new UiPathQueueItem(); item.setId(200L); - item.setReference("TEST-001"); item.setStatus("SUCCESSFUL"); UiPathODataResponse odataResponse = new UiPathODataResponse<>(); odataResponse.setValue(List.of(item)); - when(restTemplate.exchange( - anyString(), - eq(HttpMethod.GET), - any(HttpEntity.class), - any(ParameterizedTypeReference.class) - )).thenReturn(new ResponseEntity<>(odataResponse, HttpStatus.OK)); + when(getResponseSpec.body(any(ParameterizedTypeReference.class))).thenReturn(odataResponse); - // When - boolean finalized = service.hasQueueItemFinalized("TEST-001"); - - // Then - assertTrue(finalized); + assertTrue(service.hasQueueItemFinalized("TEST-001")); } @Test void hasQueueItemFinalized_StillProcessing() throws Exception { - // Given - Auth - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token"); - - when(restTemplate.postForEntity( - eq(properties.getLoginUrl()), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // Given - Item still in progress + givenAuthSucceeds("test-token"); + UiPathQueueItem item = new UiPathQueueItem(); item.setId(200L); - item.setReference("TEST-001"); item.setStatus("IN_PROGRESS"); UiPathODataResponse odataResponse = new UiPathODataResponse<>(); odataResponse.setValue(List.of(item)); - when(restTemplate.exchange( - anyString(), - eq(HttpMethod.GET), - any(HttpEntity.class), - any(ParameterizedTypeReference.class) - )).thenReturn(new ResponseEntity<>(odataResponse, HttpStatus.OK)); + when(getResponseSpec.body(any(ParameterizedTypeReference.class))).thenReturn(odataResponse); - // When - boolean finalized = service.hasQueueItemFinalized("TEST-001"); - - // Then - assertFalse(finalized); + assertFalse(service.hasQueueItemFinalized("TEST-001")); } + // ========================================================================= + // validateConnection / isHealthy + // ========================================================================= + @Test void validateConnection_Success() throws Exception { - // Given - UiPathAuthResponse authResponse = new UiPathAuthResponse(); - authResponse.setSuccess(true); - authResponse.setResult("test-token"); - - when(restTemplate.postForEntity( - anyString(), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenReturn(new ResponseEntity<>(authResponse, HttpStatus.OK)); - - // When - boolean isValid = service.validateConnection(); - - // Then - assertTrue(isValid); + givenAuthSucceeds("test-token"); + assertTrue(service.validateConnection()); } @Test void validateConnection_Failure() { - // Given - when(restTemplate.postForEntity( - anyString(), - any(HttpEntity.class), - eq(UiPathAuthResponse.class) - )).thenThrow(new RestClientException("Connection refused")); - - // When - boolean isValid = service.validateConnection(); - - // Then - assertFalse(isValid); + when(postBodySpec.retrieve()).thenThrow(new RestClientException("Connection refused")); + assertFalse(service.validateConnection()); } } diff --git a/external-service-webhookproxy/src/main/java/org/opendevstack/apiservice/externalservice/webhookproxy/client/WebhookProxyClientFactory.java b/external-service-webhookproxy/src/main/java/org/opendevstack/apiservice/externalservice/webhookproxy/client/WebhookProxyClientFactory.java index 7e25d02..6256e68 100644 --- a/external-service-webhookproxy/src/main/java/org/opendevstack/apiservice/externalservice/webhookproxy/client/WebhookProxyClientFactory.java +++ b/external-service-webhookproxy/src/main/java/org/opendevstack/apiservice/externalservice/webhookproxy/client/WebhookProxyClientFactory.java @@ -1,78 +1,54 @@ package org.opendevstack.apiservice.externalservice.webhookproxy.client; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.api.http.ExternalServiceSslProperties; +import org.opendevstack.apiservice.externalservice.api.http.RestClientFactory; import org.opendevstack.apiservice.externalservice.webhookproxy.config.WebhookProxyConfiguration; import org.opendevstack.apiservice.externalservice.webhookproxy.config.WebhookProxyConfiguration.ClusterConfig; import org.opendevstack.apiservice.externalservice.webhookproxy.exception.WebhookProxyException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import javax.net.ssl.*; -import java.io.IOException; -import java.net.HttpURLConnection; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.X509Certificate; -import java.time.Duration; -import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; /** - * Factory for creating WebhookProxyClient instances. - * Uses the factory pattern to provide configured clients for different - * clusters. - * Clients are cached per cluster+project combination for efficiency. + * Factory for creating {@link WebhookProxyClient} instances. + * + *

SSL wiring is delegated to {@link RestClientFactory} in {@code external-service-api}. + * A {@link RestTemplate} is produced because {@link WebhookProxyClient} is not backed by a + * generated OpenAPI client — it uses {@code RestTemplate} directly. + * + *

Clients are cached per {@code clusterName:projectKey} via Spring's {@code @Cacheable} + * (replaces the earlier manual {@code ConcurrentHashMap} cache). */ @Component @Slf4j public class WebhookProxyClientFactory { private final WebhookProxyConfiguration configuration; - private final Map clientCache; - private final RestTemplateBuilder restTemplateBuilder; - /** - * Constructor with dependency injection - * - * @param configuration Webhook proxy configuration - * @param restTemplateBuilder RestTemplate builder for creating HTTP clients - */ - public WebhookProxyClientFactory(WebhookProxyConfiguration configuration, - RestTemplateBuilder restTemplateBuilder) { + public WebhookProxyClientFactory(WebhookProxyConfiguration configuration) { this.configuration = configuration; - this.restTemplateBuilder = restTemplateBuilder; - this.clientCache = new ConcurrentHashMap<>(); - log.info("WebhookProxyClientFactory initialized with {} cluster(s)", configuration.getClusters().size()); } /** - * Get a WebhookProxyClient for a specific cluster and project - * - * @param clusterName Name of the cluster (e.g., "cluster-a", "cluster-b") - * @param projectKey Project key (e.g., "example-project") - * @return Configured WebhookProxyClient - * @throws WebhookProxyException.ConfigurationException if the cluster is not - * configured + * Get a {@link WebhookProxyClient} for a specific cluster and project. + * + * @param clusterName Name of the cluster (e.g. {@code "cluster-a"}) + * @param projectKey Project key (e.g. {@code "example-project"}) + * @return Configured {@link WebhookProxyClient} + * @throws WebhookProxyException.ConfigurationException if the cluster is not configured */ + @Cacheable(value = "webhookProxyClients", key = "#clusterName + ':' + #projectKey", + condition = "#clusterName != null && #projectKey != null") public WebhookProxyClient getClient(String clusterName, String projectKey) throws WebhookProxyException.ConfigurationException { - String cacheKey = clusterName + ":" + projectKey; - - // Check cache first - if (clientCache.containsKey(cacheKey)) { - log.debug("Returning cached client for cluster '{}' and project '{}'", clusterName, projectKey); - return clientCache.get(cacheKey); - } - - // Get cluster configuration ClusterConfig clusterConfig = configuration.getClusters().get(clusterName); - if (clusterConfig == null) { throw new WebhookProxyException.ConfigurationException( String.format("Cluster '%s' is not configured. Available clusters: %s", @@ -81,98 +57,51 @@ public WebhookProxyClient getClient(String clusterName, String projectKey) log.info("Creating new WebhookProxyClient for cluster '{}' and project '{}'", clusterName, projectKey); - // Build the webhook proxy URL dynamically String webhookProxyUrl = clusterConfig.buildWebhookProxyUrl(projectKey); log.debug("Webhook proxy URL: {}", webhookProxyUrl); - RestTemplate restTemplate = createRestTemplate(clusterConfig); - WebhookProxyClient client = new WebhookProxyClient(clusterName, projectKey, webhookProxyUrl, - clusterConfig, restTemplate); - - // Cache the client - clientCache.put(cacheKey, client); - - return client; + RestTemplate restTemplate = buildRestTemplate(clusterConfig); + return new WebhookProxyClient(clusterName, projectKey, webhookProxyUrl, clusterConfig, restTemplate); } - /** - * Get all available cluster names - * - * @return Set of configured cluster names - */ + /** @return all configured cluster names */ public Set getAvailableClusters() { return configuration.getClusters().keySet(); } - /** - * Check if a cluster is configured - * - * @param clusterName Name of the cluster to check - * @return true if configured, false otherwise - */ + /** @return {@code true} if the named cluster is configured */ public boolean hasCluster(String clusterName) { return configuration.getClusters().containsKey(clusterName); } - /** - * Create a RestTemplate configured for a specific cluster - * - * @param config Cluster configuration - * @return Configured RestTemplate - */ - private RestTemplate createRestTemplate(ClusterConfig config) { - RestTemplate restTemplate = restTemplateBuilder.build(); - - // Set timeouts using SimpleClientHttpRequestFactory - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(config.getConnectionTimeout()); - requestFactory.setReadTimeout(config.getReadTimeout()); - restTemplate.setRequestFactory(requestFactory); - - if (config.isTrustAllCertificates()) { - log.warn("Creating RestTemplate with SSL certificate verification DISABLED for webhook proxy - " + - "this should only be used in development environments"); - configureTrustAllCertificates(restTemplate); - - } + /** Clear the client cache (useful for testing or when configuration changes). */ + @CacheEvict(value = "webhookProxyClients", allEntries = true) + public void clearCache() { + log.info("Clearing WebhookProxyClient cache"); + } - return restTemplate; + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private RestTemplate buildRestTemplate(ClusterConfig config) { + log.info("Creating RestTemplate for webhook proxy cluster (connect={}ms, read={}ms)", + config.getConnectionTimeout(), config.getReadTimeout()); + return RestClientFactory.buildRestTemplate( + toSslProperties(config), + config.getConnectionTimeout(), + config.getReadTimeout()); } /** - * Configure RestTemplate to trust all SSL certificates - * WARNING: This should only be used in development environments - * - * @param restTemplate RestTemplate to configure + * Adapt {@link ClusterConfig} SSL fields to {@link ExternalServiceSslProperties} + * so we can pass them to {@link RestClientFactory} without changing the config class. */ - @SuppressWarnings({"java:S4830", "java:S1186"}) // Intentionally disabling SSL validation for development - private void configureTrustAllCertificates(RestTemplate restTemplate) { - try { - TrustManager[] trustAllCerts = new TrustManager[]{ - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - // Intentionally empty - trusting all certificates for development environments - public void checkClientTrusted(X509Certificate[] certs, String authType) { - // No validation performed - development only - } - // Intentionally empty - trusting all certificates for development environments - public void checkServerTrusted(X509Certificate[] certs, String authType) { - // No validation performed - development only - } - } - }; - - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - - HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); - // Intentionally disabling hostname verification for development environments - HttpsURLConnection.setDefaultHostnameVerifier((hostname, session) -> true); - - } catch (NoSuchAlgorithmException | KeyManagementException e) { - log.error("Failed to configure SSL trust all certificates", e); - } + private static ExternalServiceSslProperties toSslProperties(ClusterConfig config) { + ExternalServiceSslProperties ssl = new ExternalServiceSslProperties(); + ssl.setTrustStorePath(config.getTrustStorePath()); + ssl.setTrustStorePassword(config.getTrustStorePassword()); + ssl.setTrustStoreType(config.getTrustStoreType()); + return ssl; } } diff --git a/external-service-webhookproxy/src/main/java/org/opendevstack/apiservice/externalservice/webhookproxy/config/WebhookProxyConfiguration.java b/external-service-webhookproxy/src/main/java/org/opendevstack/apiservice/externalservice/webhookproxy/config/WebhookProxyConfiguration.java index 258a196..ebe5b98 100644 --- a/external-service-webhookproxy/src/main/java/org/opendevstack/apiservice/externalservice/webhookproxy/config/WebhookProxyConfiguration.java +++ b/external-service-webhookproxy/src/main/java/org/opendevstack/apiservice/externalservice/webhookproxy/config/WebhookProxyConfiguration.java @@ -62,10 +62,20 @@ public static class ClusterConfig { private int readTimeout = 30000; /** - * Whether to trust all SSL certificates (default: false) - * WARNING: Should only be used in development environments + * Path to the trust store file for SSL certificate validation. + * Optional - if not provided, uses the JVM default trust store. */ - private boolean trustAllCertificates = false; + private String trustStorePath; + + /** + * Password for the trust store. + */ + private String trustStorePassword; + + /** + * Type of the trust store (JKS, PKCS12, etc.). Default is JKS. + */ + private String trustStoreType = "JKS"; /** * Default Jenkinsfile path if not specified in request (default: Jenkinsfile) diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..ffedbee --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,25 @@ +# SonarQube configuration for the ODS API Service multi-module repository +# Tune sonar.projectKey to match your SonarQube project identifier. + +sonar.projectKey=org.opendevstack.apiservice:ods-api-service +sonar.projectName=ODS API Service +sonar.sourceEncoding=UTF-8 + +# Analyze Java sources from all Maven modules. +sonar.sources=. +sonar.tests=. + +# Restrict indexing to standard Maven source/test folders. +sonar.inclusions=**/src/main/java/**/*.java +sonar.test.inclusions=**/src/test/java/**/*.java + +# Ignore build output, generated code, and non-source artifacts. +sonar.exclusions=**/target/**,**/openapi/**,**/doc/**,**/.mvn/** + +# Java bytecode locations needed for semantic analysis. +sonar.java.binaries=**/target/classes +sonar.java.test.binaries=**/target/test-classes + +# Unit test and coverage reports produced by Maven plugins. +sonar.junit.reportPaths=**/target/surefire-reports +sonar.coverage.jacoco.xmlReportPaths=**/target/site/jacoco/jacoco.xml