diff --git a/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/NetworkVerifier.java b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/NetworkVerifier.java new file mode 100644 index 000000000..a22716c36 --- /dev/null +++ b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/NetworkVerifier.java @@ -0,0 +1,90 @@ +package com.sap.cloud.sdk.cloudplatform.connectivity; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.UnknownHostException; + +import javax.annotation.Nonnull; + +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException; + +import lombok.extern.slf4j.Slf4j; + +/** + * NetworkVerifier that performs TCP socket-based connectivity verification. + * + *

+ * This implementation uses standard Java socket connections to verify that a remote host and port combination is + * reachable and accepting connections. It establishes a temporary TCP connection to the target endpoint and immediately + * closes it upon successful establishment. + * + *

+ * Verification Process: + *

    + *
  1. Creates a new TCP socket
  2. + *
  3. Attempts to connect to the specified host and port with a timeout
  4. + *
  5. Immediately closes the connection if successful
  6. + *
  7. Throws appropriate exceptions for failures
  8. + *
+ * + *

+ * Timeout Configuration: The verification uses a fixed timeout of {@value #HOST_REACH_TIMEOUT} + * milliseconds to prevent indefinite blocking on unreachable endpoints. + * + *

+ * Error Handling: + *

+ * + * @see TransparentProxy + * @since 5.24.0 + */ +@Slf4j +class NetworkVerifier +{ + private static final int HOST_REACH_TIMEOUT = 5000; + + /** + * {@inheritDoc} + * + *

+ * This implementation creates a TCP socket connection to the specified host and port to verify connectivity. The + * connection is immediately closed after successful establishment. + * + * @param host + * {@inheritDoc} + * @param port + * {@inheritDoc} + * @throws DestinationAccessException + * {@inheritDoc} + *

+ * Specific error conditions: + *

+ */ + void verifyHostConnectivity( @Nonnull final String host, final int port ) + throws DestinationAccessException + { + log.info("Verifying that transparent proxy host is reachable on {}:{}", host, port); + try( Socket socket = new Socket() ) { + socket.connect(new InetSocketAddress(host, port), HOST_REACH_TIMEOUT); + log.info("Successfully verified that transparent proxy host is reachable on {}:{}", host, port); + } + catch( final UnknownHostException e ) { + throw new DestinationAccessException( + String.format("Host [%s] could not be resolved. Caused by: %s", host, e.getMessage()), + e); + } + catch( final IOException e ) { + throw new DestinationAccessException( + String.format("Host [%s] on port [%d] is not reachable. Caused by: %s", host, port, e.getMessage()), + e); + } + } +} diff --git a/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxy.java b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxy.java new file mode 100644 index 000000000..89433808b --- /dev/null +++ b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxy.java @@ -0,0 +1,461 @@ +package com.sap.cloud.sdk.cloudplatform.connectivity; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.function.Supplier; + +import javax.annotation.Nonnull; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.apache.http.HttpMessage; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpHead; + +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException; +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException; +import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceConfiguration; +import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceDecorator; +import com.sap.cloud.sdk.cloudplatform.resilience.ResilienceRuntimeException; +import com.sap.cloud.sdk.cloudplatform.tenant.DefaultTenant; +import com.sap.cloud.sdk.cloudplatform.tenant.Tenant; +import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor; +import com.sap.cloud.sdk.cloudplatform.tenant.exception.TenantAccessException; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import lombok.extern.slf4j.Slf4j; + +/** + * A transparent proxy loader that enables routing traffic through a single registered gateway host. + * + *

+ * This class provides a mechanism to register a proxy gateway that will handle all destination requests transparently. + * Once registered, all destination lookups will be routed through the configured gateway host and port. + * + *

+ * Key Features: + *

+ * + *

+ * Usage Example: + * + *

{@code
+ * // Register with default port 80
+ * TransparentProxy.register("gateway.svc.cluster.local");
+ *
+ * // Register with custom port
+ * TransparentProxy.register("http://gateway.svc.cluster.local", 8080);
+ * }
+ * + *

+ * Thread Safety: This class uses static state and is not thread-safe. Registration should be performed + * during application initialization. + * + * @since 5.24.0 + */ +@Slf4j +public class TransparentProxy implements DestinationLoader +{ + private static final ResilienceConfiguration.TimeLimiterConfiguration DEFAULT_TIME_LIMITER = + ResilienceConfiguration.TimeLimiterConfiguration.of().timeoutDuration(Duration.ofSeconds(10)); + + private static final String X_ERROR_INTERNAL_CODE_HEADER = "x-error-internal-code"; + private static final String X_ERROR_ORIGIN_HEADER = "x-error-origin"; + private static final String X_ERROR_MESSAGE_HEADER = "x-error-message"; + private static final String SET_COOKIE_HEADER = "Set-Cookie"; + private static final Integer DEFAULT_PORT = 80; + private static final String SCHEME_SEPARATOR = "://"; + private static final String HTTP_SCHEME = org.apache.http.HttpHost.DEFAULT_SCHEME_NAME + SCHEME_SEPARATOR; + private static final String PORT_SEPARATOR = ":"; + private static final String HOST_CONTAINS_PATH_ERROR_MESSAGE_TEMPLATE = + "Host '%s' contains a path '%s'. Paths are not allowed in host registration."; + private static final ResilienceConfiguration resilienceConfiguration = + createResilienceConfiguration("destinationverifier", DEFAULT_TIME_LIMITER); + private static final String FAILED_TO_VERIFY_DESTINATION = "Failed to verify destination. "; + private static final String NO_TENANT_PROVIDED_ERROR_MESSAGE = + "No current tenant defined and no provider tenant id configured. Transparent proxy always requires an explicit tenant ID. Please use register(host, providerID) in case provider tenant access is intended"; + static String uri; + static String providerTenantId; + static NetworkVerifier networkVerifier = new NetworkVerifier(); + + /** + * Registers a transparent proxy gateway using the default port 80. + * + *

+ * This method registers the specified host as a transparent proxy gateway that will handle all subsequent + * destination requests. The host will be validated for reachability and must not contain any path components. + * + *

+ * If no scheme is provided, HTTP will be used by default. The final URI will be constructed as: + * {@code :80} + * + * @param host + * the gateway host to register (e.g., "gateway.svc.cluster.local") Must not contain paths or be null + * @throws DestinationAccessException + * if the proxy is already registered, the host contains a path, or the host is not reachable on port 80 + * @throws IllegalArgumentException + * if host is null + * @see #register(String, Integer) + */ + public static void register( @Nonnull final String host ) + { + registerLoader(host, DEFAULT_PORT, null); + } + + /** + * Registers a transparent proxy gateway with a specified port. + * + *

+ * This method registers the specified host and port as a transparent proxy gateway that will handle all subsequent + * destination requests. The host will be validated for reachability on the specified port and must not contain any + * path components. + * + *

+ * If no scheme is provided, HTTP will be used by default. The final URI will be constructed as: + * {@code :} + * + * @param host + * the gateway host to register (e.g., "gateway" or "...") Must not contain + * paths or be null + * @param port + * the port number to use for the gateway connection. Must not be null and should be a valid port number + * (1-65535) + * @throws DestinationAccessException + * if the proxy is already registered, the host contains a path, or the host is not reachable on the + * specified port + * @throws IllegalArgumentException + * if host or port is null + * @see #register(String) + */ + public static void register( @Nonnull final String host, @Nonnull final Integer port ) + { + registerLoader(host, port, null); + } + + /** + * Registers a transparent proxy gateway with a specified port and provider tenant ID. + * + *

+ * This method registers the specified host and port as a transparent proxy gateway that will handle all subsequent + * destination requests. The host will be validated for reachability on the specified port and must not contain any + * path components. The provider tenant ID serves as a fallback when the current tenant cannot be accessed during + * destination preparation. + * + *

+ * If no scheme is provided, HTTP will be used by default. The final URI will be constructed as: + * {@code :} + * + *

+ * The provider tenant ID is particularly useful in scenarios where the transparent proxy needs to operate in + * contexts where tenant information is not readily available, providing a default tenant for authentication and + * authorization purposes. + * + * @param host + * the gateway host to register (e.g., "gateway" or "...") Must not contain + * paths or be null + * @param port + * the port number to use for the gateway connection. Must not be null and should be a valid port number + * (1-65535) + * @param providerTenantId + * the provider tenant ID to use as a fallback when the current tenant cannot be accessed. Must not be + * null + * @throws DestinationAccessException + * if the proxy is already registered, the host contains a path, or the host is not reachable on the + * specified port + * @throws IllegalArgumentException + * if host, port, or providerTenantId is null + * @see #register(String) + * @see #register(String, Integer) + */ + + public static + void + register( @Nonnull final String host, @Nonnull final Integer port, @Nonnull final String providerTenantId ) + { + registerLoader(host, port, providerTenantId); + } + + /** + * Registers a transparent proxy gateway using the default port 80 with a provider tenant ID. + * + *

+ * This method registers the specified host as a transparent proxy gateway that will handle all subsequent + * destination requests, using the default port 80. The host will be validated for reachability and must not contain + * any path components. The provider tenant ID serves as a fallback when the current tenant cannot be accessed + * during destination preparation. + * + *

+ * If no scheme is provided, HTTP will be used by default. The final URI will be constructed as: + * {@code :80} + * + *

+ * The provider tenant ID is particularly useful in scenarios where the transparent proxy needs to operate in + * contexts where tenant information is not readily available, providing a default tenant for authentication and + * authorization purposes. + * + * @param host + * the gateway host to register (e.g., "gateway.svc.cluster.local") Must not contain paths or be null + * @param providerTenantId + * the provider tenant ID to use as a fallback when the current tenant cannot be accessed. Must not be + * null + * @throws DestinationAccessException + * if the proxy is already registered, the host contains a path, or the host is not reachable on port 80 + * @throws IllegalArgumentException + * if host or providerTenantId is null + * @see #register(String) + * @see #register(String, Integer) + * @see #register(String, Integer, String) + */ + public static void register( @Nonnull final String host, @Nonnull final String providerTenantId ) + { + registerLoader(host, DEFAULT_PORT, providerTenantId); + } + + private static void registerLoader( @Nonnull final String host, final Integer port, final String providerTenantId ) + { + if( uri != null ) { + throw new DestinationAccessException( + "TransparentProxy is already registered. Only one registration is allowed."); + } + + final String normalizedHost = normalizeHostWithScheme(host); + try { + final String hostForVerification = getHostForVerification(host, normalizedHost); + networkVerifier.verifyHostConnectivity(hostForVerification, port); + } + catch( final URISyntaxException e ) { + throw new DestinationAccessException( + String.format("Invalid host format: [%s]. Caused by: %s", host, e.getMessage()), + e); + } + uri = String.format("%s%s%d", normalizedHost, PORT_SEPARATOR, port); + TransparentProxy.providerTenantId = providerTenantId; + DestinationAccessor.prependDestinationLoader(new TransparentProxy()); + } + + @Nonnull + private static String getHostForVerification( @Nonnull final String host, final String normalizedHost ) + throws URISyntaxException + { + final URI parsedUri = new URI(normalizedHost); + + final String path = parsedUri.getPath(); + if( path != null && !path.isEmpty() ) { + throw new DestinationAccessException(String.format(HOST_CONTAINS_PATH_ERROR_MESSAGE_TEMPLATE, host, path)); + } + + final String hostForVerification = parsedUri.getHost(); + if( hostForVerification == null ) { + throw new DestinationAccessException(String.format("Invalid host format: [%s]", host)); + } + return hostForVerification; + } + + @Nonnull + private static String normalizeHostWithScheme( @Nonnull final String host ) + { + if( host.contains(SCHEME_SEPARATOR) ) { + return host; + } + return HTTP_SCHEME + host; + } + + /** + * + * @param destination + * the destination to use + */ + private static TransparentProxyDestination verifyDestination( + @Nonnull final TransparentProxyDestination destination ) + { + final HttpClient httpClient = HttpClientAccessor.getHttpClient(destination); + final URI destinationUri = URI.create(uri); + final HttpHead headRequest = new HttpHead(destinationUri); + final String destinationName = getDestinationName(destination, destinationUri); + + log + .debug( + "Performing HEAD request to destination with name {} to verify the destination exists", + destinationName); + final Supplier tpDestinationVerifierSupplier = prepareSupplier(httpClient, headRequest); + HttpResponse response = null; + try { + response = ResilienceDecorator.executeSupplier(tpDestinationVerifierSupplier, resilienceConfiguration); + verifyTransparentProxyResponse(response, destinationName); + } + catch( final ResilienceRuntimeException e ) { + if( hasCauseAssignableFrom(e, DestinationNotFoundException.class) ) { + throw new DestinationNotFoundException(e); + } + throw new DestinationAccessException(e); + } + finally { + if( response != null ) { + try { + org.apache.http.util.EntityUtils.consume(response.getEntity()); + } + catch( IOException e ) { + log.warn("Failed to close HTTP response", e); + } + } + } + + return destination; + } + + private static boolean hasCauseAssignableFrom( @Nonnull final Throwable t, @Nonnull final Class cls ) + { + return ExceptionUtils.getThrowableList(t).stream().map(Throwable::getClass).anyMatch(cls::isAssignableFrom); + } + + private static + String + getDestinationName( @Nonnull final TransparentProxyDestination destination, final URI destinationUri ) + { + return destination + .getHeaders(destinationUri) + .stream() + .filter( + header -> TransparentProxyDestination.DESTINATION_NAME_HEADER_KEY.equalsIgnoreCase(header.getName())) + .findFirst() + .map(Header::getValue) + .orElseThrow( + () -> new IllegalStateException("Destination name header in Transparent Proxy loader is missing.")); + } + + private static void verifyTransparentProxyResponse( final HttpResponse response, final String destinationName ) + { + if( response == null ) { + throw new DestinationAccessException(FAILED_TO_VERIFY_DESTINATION + "Response is null."); + } + if( response.containsHeader(SET_COOKIE_HEADER) ) { + final org.apache.http.Header[] header = response.getHeaders(SET_COOKIE_HEADER); + final List cookieNames = Arrays.stream(header).map(h -> h.getValue().split("=", 2)[0]).toList(); + log + .warn( + "received set-cookie headers as part of destination health check. This is unexpected and may have side effects for your application. The following cookies were set: {}", + cookieNames); + } + + final int statusCode = response.getStatusLine().getStatusCode(); + final String errorInternalCode = getHeaderValue(response, X_ERROR_INTERNAL_CODE_HEADER); + final String errorMessage = getHeaderValue(response, X_ERROR_MESSAGE_HEADER); + final String errorOrigin = getHeaderValue(response, X_ERROR_ORIGIN_HEADER); + + log + .debug( + "HEAD request to destination with name {} returned status code: {}, x-error-internal-code: {}", + destinationName, + statusCode, + errorInternalCode); + if( statusCode == HttpStatus.SC_BAD_GATEWAY + && Integer.toString(HttpStatus.SC_NOT_FOUND).equals(errorInternalCode) ) { + throw new DestinationNotFoundException(errorMessage); + } + if( !"".equals(errorOrigin) ) { + final String detailedErrorMessage = + String + .format( + "%s Destination name: [%s], Origin: [%s], Code: [%s], Message: [%s]", + FAILED_TO_VERIFY_DESTINATION, + destinationName, + errorOrigin, + errorInternalCode, + errorMessage); + throw new DestinationAccessException(detailedErrorMessage); + } + } + + @Nonnull + private static Supplier prepareSupplier( final HttpClient httpClient, final HttpHead headRequest ) + { + return () -> { + try { + return httpClient.execute(headRequest); + } + catch( final IOException e ) { + throw new DestinationAccessException(FAILED_TO_VERIFY_DESTINATION, e); + } + }; + } + + @Nonnull + static ResilienceConfiguration createResilienceConfiguration( + @Nonnull final String identifier, + @Nonnull final ResilienceConfiguration.TimeLimiterConfiguration timeLimiterConfiguration ) + { + return ResilienceConfiguration + .of(TransparentProxy.class + identifier) + .timeLimiterConfiguration(timeLimiterConfiguration); + } + + private static String getHeaderValue( @Nonnull final HttpMessage message, @Nonnull final String headerName ) + { + if( message.containsHeader(headerName) ) { + return message.getFirstHeader(headerName).getValue(); + } + return ""; + } + + @Nonnull + private static + TransparentProxyDestination + prepareDestination( @Nonnull final String destinationName, @Nonnull final DestinationOptions options ) + { + final Tenant tenant = retrieveTenant(); + + final TransparentProxyDestination.GatewayBuilder gatewayBuilder = + TransparentProxyDestination.gateway(destinationName, uri); + gatewayBuilder.tenantId(tenant.getTenantId()); + + final Option fragmentNameOption = + DestinationServiceOptionsAugmenter.getFragmentName(options).peek(gatewayBuilder::fragmentName); + final Option crossLevelScope = + DestinationServiceOptionsAugmenter.getCrossLevelScope(options); + if( fragmentNameOption.isDefined() ) { + crossLevelScope.peek(gatewayBuilder::fragmentLevel); + } + crossLevelScope.peek(gatewayBuilder::destinationLevel); + DestinationServiceOptionsAugmenter.getAdditionalHeaders(options).forEach(gatewayBuilder::header); + + return gatewayBuilder.build(); + } + + private static Tenant retrieveTenant() + { + return TenantAccessor.tryGetCurrentTenant().orElse(() -> { + if( providerTenantId == null ) { + return Try.failure(new TenantAccessException(NO_TENANT_PROVIDED_ERROR_MESSAGE)); + } + return Try.success(new DefaultTenant(providerTenantId)); + }).getOrElseThrow(e -> new TenantAccessException(NO_TENANT_PROVIDED_ERROR_MESSAGE, e)); + } + + @Nonnull + @Override + public Try tryGetDestination( @Nonnull final String destinationName ) + { + return tryGetDestination(destinationName, DestinationOptions.builder().build()); + } + + @Nonnull + @Override + public + Try + tryGetDestination( @Nonnull final String destinationName, @Nonnull final DestinationOptions options ) + { + final TransparentProxyDestination destination = prepareDestination(destinationName, options); + return Try.of(() -> verifyDestination(destination)); + } +} diff --git a/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestination.java b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestination.java index 0892eaf15..085c6829c 100644 --- a/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestination.java +++ b/cloudplatform/connectivity-destination-service/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestination.java @@ -56,13 +56,10 @@ public class TransparentProxyDestination implements HttpDestination static final String CHAIN_VAR_SAML_PROVIDER_DESTINATION_NAME_HEADER_KEY = "x-chain-var-samlProviderDestinationName"; static final String TENANT_ID_AND_TENANT_SUBDOMAIN_BOTH_PASSED_ERROR_MESSAGE = "Tenant id and tenant subdomain cannot be passed at the same time."; - - @Delegate - private final DestinationProperties baseProperties; - @Nonnull final ImmutableList

customHeaders; - + @Delegate + private final DestinationProperties baseProperties; @Nonnull @Getter( AccessLevel.PACKAGE ) private final ImmutableList customHeaderProviders; @@ -91,6 +88,41 @@ private TransparentProxyDestination( } + static boolean containsHeader( final Collection
headers, final String headerName ) + { + return headers.stream().anyMatch(h -> h.getName().equalsIgnoreCase(headerName)); + } + + /** + * Creates a new builder for a destination. + *

+ * A destination connects directly to a specified URL and does not use the destination-gateway. It allows setting + * generic headers but does not support gateway-specific properties like destination name or fragments. + * + * @return A new {@link Builder} instance. + */ + @Nonnull + public static Builder destination( @Nonnull final String uri ) + { + return new Builder(uri); + } + + /** + * Creates a new builder for a destination-gateway. + *

+ * A destination-gateway requires a destination name and will be routed through the central destination-gateway. It + * supports all gateway-specific properties like fragments, tenant context, and authentication flows. + * + * @param destinationName + * The name of the destination to be resolved by the gateway. + * @return A new {@link GatewayBuilder} instance. + */ + @Nonnull + public static GatewayBuilder gateway( @Nonnull final String destinationName, @Nonnull final String uri ) + { + return new GatewayBuilder(destinationName, uri); + } + @Nonnull @Override public URI getUri() @@ -131,11 +163,6 @@ public Collection

getHeaders( @Nonnull final URI requestUri ) return allHeaders; } - static boolean containsHeader( final Collection
headers, final String headerName ) - { - return headers.stream().anyMatch(h -> h.getName().equalsIgnoreCase(headerName)); - } - @Nonnull @Override public Option getTlsVersion() @@ -229,36 +256,6 @@ public int hashCode() return new HashCodeBuilder(17, 37).append(baseProperties).append(customHeaders).toHashCode(); } - /** - * Creates a new builder for a destination. - *

- * A destination connects directly to a specified URL and does not use the destination-gateway. It allows setting - * generic headers but does not support gateway-specific properties like destination name or fragments. - * - * @return A new {@link Builder} instance. - */ - @Nonnull - public static Builder destination( @Nonnull final String uri ) - { - return new Builder(uri); - } - - /** - * Creates a new builder for a destination-gateway. - *

- * A destination-gateway requires a destination name and will be routed through the central destination-gateway. It - * supports all gateway-specific properties like fragments, tenant context, and authentication flows. - * - * @param destinationName - * The name of the destination to be resolved by the gateway. - * @return A new {@link GatewayBuilder} instance. - */ - @Nonnull - public static GatewayBuilder gateway( @Nonnull final String destinationName, @Nonnull final String uri ) - { - return new GatewayBuilder(destinationName, uri); - } - /** * Abstract base class for builders to share common functionality like adding headers and properties. * @@ -689,7 +686,7 @@ public GatewayBuilder fragmentName( @Nonnull final String fragmentName ) public GatewayBuilder destinationLevel( @Nonnull final DestinationServiceOptionsAugmenter.CrossLevelScope destinationLevel ) { - return header(new Header(DESTINATION_LEVEL_HEADER_KEY, destinationLevel.toString())); + return header(new Header(DESTINATION_LEVEL_HEADER_KEY, destinationLevel.toString().toLowerCase())); } /** @@ -704,7 +701,7 @@ public GatewayBuilder destinationLevel( public GatewayBuilder fragmentLevel( @Nonnull final DestinationServiceOptionsAugmenter.CrossLevelScope fragmentLevel ) { - return header(new Header(FRAGMENT_LEVEL_HEADER_KEY, fragmentLevel.toString())); + return header(new Header(FRAGMENT_LEVEL_HEADER_KEY, fragmentLevel.toString().toLowerCase())); } /** diff --git a/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestinationTest.java b/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestinationTest.java index 77408a4d5..1c95d2eaa 100644 --- a/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestinationTest.java +++ b/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyDestinationTest.java @@ -86,11 +86,11 @@ void testGatewayHeaders() new Header(TransparentProxyDestination.DESTINATION_NAME_HEADER_KEY, TEST_DEST_NAME), new Header( TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY, - DestinationServiceOptionsAugmenter.CrossLevelScope.SUBACCOUNT.toString()), + DestinationServiceOptionsAugmenter.CrossLevelScope.SUBACCOUNT.toString().toLowerCase()), new Header(TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY, "fragName"), new Header( TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY, - DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_SUBACCOUNT.toString()), + DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_SUBACCOUNT.toString().toLowerCase()), new Header(TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY, "true")); } diff --git a/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyTest.java b/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyTest.java new file mode 100644 index 000000000..b22b2c93e --- /dev/null +++ b/cloudplatform/connectivity-destination-service/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/TransparentProxyTest.java @@ -0,0 +1,1259 @@ +package com.sap.cloud.sdk.cloudplatform.connectivity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; + +import org.apache.http.HttpStatus; +import org.apache.http.HttpVersion; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpHead; +import org.apache.http.message.BasicHttpResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException; +import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationNotFoundException; +import com.sap.cloud.sdk.cloudplatform.tenant.DefaultTenant; +import com.sap.cloud.sdk.cloudplatform.tenant.Tenant; +import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor; + +import io.vavr.control.Try; + +class TransparentProxyTest +{ + private final String expectedErrorMessage = "Destination not found: non-existent-destination"; + + private TransparentProxy loader; + private NetworkVerifier mockNetworkVerifier; + + @BeforeEach + void setUp() + { + loader = new TransparentProxy(); + mockNetworkVerifier = mock(NetworkVerifier.class); + TransparentProxy.networkVerifier = mockNetworkVerifier; + } + + @AfterEach + void resetLoader() + { + TransparentProxy.uri = null; + TransparentProxy.providerTenantId = null; + HttpClientAccessor.setHttpClientFactory(null); + } + + private T executeWithTenant( java.util.concurrent.Callable callable ) + throws Exception + { + Tenant tenant = new DefaultTenant("tenant-id", ""); + return TenantAccessor.executeWithTenant(tenant, callable); + } + + // ========== Tests for register(String host) method ========== + + @Test + void testRegisterWithLocalhostHost() + throws Exception + { + // Test with localhost which should always be reachable + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("127.0.0.1", "tenant-id"); + + // Verify the stored URI has the http scheme + Try result = loader.tryGetDestination("test-destination"); + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination.asHttp().getUri().toString()).startsWith("http://127.0.0.1"); + } + + @Test + void testRegisterWithHostWithoutScheme() + throws Exception + { + // Test that http:// is automatically added to host without scheme + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("gateway", "tenant-id"); + + Try result = loader.tryGetDestination("test-destination"); + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway"); + } + + @Test + void testRegisterWithHostWithHttpScheme() + throws Exception + { + // Test that existing http:// scheme is preserved + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("http://gateway", "tenant-id"); + + Try result = loader.tryGetDestination("test-destination"); + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway"); + } + + @Test + void testRegisterWithHostWithHttpsScheme() + throws Exception + { + // Test that existing https:// scheme is preserved + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("https://gateway", "tenant-id"); + + Try result = loader.tryGetDestination("test-destination"); + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination.asHttp().getUri().toString()).startsWith("https://gateway"); + } + + // ========== Tests for register(String host, Integer port) method ========== + + @Test + void testRegisterWithUnknownHostAndPort() + { + doThrow(new DestinationAccessException("Host 'unknown-host' could not be resolved")) + .when(mockNetworkVerifier) + .verifyHostConnectivity("unknown-host", 8080); + + final DestinationAccessException exception = + assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("unknown-host", 8080)); + + assertThat(exception.getMessage()).contains("could not be resolved"); + } + + @Test + void testRegisterWithUnreachablePort() + { + doThrow(new DestinationAccessException("Host 'gateway' on port 65432 is not reachable")) + .when(mockNetworkVerifier) + .verifyHostConnectivity("gateway", 65432); + + final DestinationAccessException exception = + assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("gateway", 65432)); + + assertThat(exception.getMessage()).contains("is not reachable"); + } + + // ========== Tests for register methods with providerTenantId ========== + + @Test + void testRegisterWithHostPortAndProviderTenantId() + throws IOException + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("gateway", 8080, "provider-tenant-123"); + + Try result = loader.tryGetDestination("test-destination"); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.get()).isNotNull(); + } + + @Test + void testTenantIdFromOptionsOverridesProviderTenantId() + throws IOException + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + // Register with provider tenant ID + TransparentProxy.register("gateway", 8080, "provider-tenant-fallback"); + + // Pass a different tenant ID in options using custom headers - this should take precedence + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .customHeaders( + new Header(TransparentProxyDestination.TENANT_ID_HEADER_KEY, "options-tenant-123"))) + .build(); + + Try result = loader.tryGetDestination("test-destination", options); + assertThat(result.isSuccess()).isTrue(); + + // Verify that the tenant ID from options is used + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.TENANT_ID_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "options-tenant-123".equals(h.getValue())); + } + + @Test + void testContextTenantPreventsFallbackToProviderTenantId() + throws IOException + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + // Register with provider tenant ID + TransparentProxy.register("gateway", 8080, "provider-tenant-fallback"); + + // Execute with a tenant in context - providerTenantId should NOT be used + Tenant contextTenant = new DefaultTenant("context-tenant-id", "context-tenant-name"); + + Try result = + TenantAccessor.executeWithTenant(contextTenant, () -> loader.tryGetDestination("test-destination")); + + assertThat(result.isSuccess()).isTrue(); + + HttpDestination destination = result.get().asHttp(); + + // When tenant is available in context, providerTenantId should NOT be used + // The tenant from context takes precedence, so providerTenantId is ignored + assertThat(destination.getHeaders(destination.getUri())) + .noneMatch( + h -> TransparentProxyDestination.TENANT_ID_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "provider-tenant-fallback".equals(h.getValue())); + } + + @Test + void testRegisterWithHostAndProviderTenantId() + throws IOException + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("gateway", "provider-tenant-456"); + + Try result = loader.tryGetDestination("test-destination"); + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway:80"); + assertThat(TransparentProxy.providerTenantId).isEqualTo("provider-tenant-456"); + } + + @Test + void testRegisterWithHostPortProviderTenantIdAndScheme() + throws IOException + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("https://gateway", 443, "provider-tenant-789"); + + Try result = loader.tryGetDestination("test-destination"); + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination.asHttp().getUri().toString()).startsWith("https://gateway:443"); + assertThat(TransparentProxy.providerTenantId).isEqualTo("provider-tenant-789"); + } + + @Test + void testRegisterWithProviderTenantIdStoresCorrectly() + throws IOException + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + // Test with 3-parameter version + TransparentProxy.register("localhost", 8080, "tenant-abc"); + assertThat(TransparentProxy.providerTenantId).isEqualTo("tenant-abc"); + + // Reset and test with 2-parameter version + TransparentProxy.uri = null; + TransparentProxy.providerTenantId = null; + + TransparentProxy.register("localhost", "tenant-xyz"); + assertThat(TransparentProxy.providerTenantId).isEqualTo("tenant-xyz"); + } + + @Test + void testRegisterWithProviderTenantIdFailsOnHostWithPath() + { + final DestinationAccessException exception = + assertThrows( + DestinationAccessException.class, + () -> TransparentProxy.register("gateway/api", 8080, "provider-tenant")); + + assertThat(exception.getMessage()).contains("contains a path"); + assertThat(exception.getMessage()).contains("Paths are not allowed"); + } + + @Test + void testRegisterWithProviderTenantIdFailsOnSecondRegistration() + throws IOException + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("gateway", "provider-tenant-1"); + + final DestinationAccessException exception = + assertThrows( + DestinationAccessException.class, + () -> TransparentProxy.register("other-gateway", "provider-tenant-2")); + + assertThat(exception.getMessage()) + .contains("TransparentProxy is already registered. Only one registration is allowed."); + } + + @Test + void testTryGetDestinationWithoutOptions() + throws Exception + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("gateway", "tenant-id"); + + Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination")); + + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination).isNotNull(); + assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway"); + } + + @Test + void testTryGetDestinationWithOptions() + throws Exception + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("gateway", "tenant-id"); + + Try result = + executeWithTenant( + () -> loader.tryGetDestination("test-destination-with-options", DestinationOptions.builder().build())); + + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination).isNotNull(); + assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway"); + } + + @Test + void testTryGetDestinationReturnsSuccessfulTry() + throws Exception + { + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("https://gateway", "tenant-id"); + + Try result1 = executeWithTenant(() -> loader.tryGetDestination("destination1")); + Try result2 = + executeWithTenant(() -> loader.tryGetDestination("destination2", DestinationOptions.builder().build())); + + assertThat(result1.isSuccess()).isTrue(); + assertThat(result2.isSuccess()).isTrue(); + assertThat(result1.isFailure()).isFalse(); + assertThat(result2.isFailure()).isFalse(); + } + + // ========== Tests for edge cases and error handling ========== + + @Test + void testRegisterWithHostContainingPathFails() + { + final DestinationAccessException exception = + assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("gateway/some/path")); + + assertThat(exception.getMessage()).contains("contains a path"); + assertThat(exception.getMessage()).contains("Paths are not allowed"); + } + + @Test + void testRegisterWithComplexUriContainingPathFails() + { + final DestinationAccessException exception = + assertThrows( + DestinationAccessException.class, + () -> TransparentProxy.register("https://gateway:443/api/v1")); + + assertThat(exception.getMessage()).contains("contains a path"); + assertThat(exception.getMessage()).contains("/api/v1"); + assertThat(exception.getMessage()).contains("Paths are not allowed"); + } + + @Test + void testMultipleRegistrationsThrowsException() + throws Exception + { + // Test that first registration succeeds + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + TransparentProxy.register("gateway", "tenant-id"); + + Try result1 = executeWithTenant(() -> loader.tryGetDestination("test1")); + assertThat(result1.get().asHttp().getUri().toString()).startsWith("http://gateway"); + + // Test that second registration throws exception + DestinationAccessException exception = + assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("https://gateway")); + + assertThat(exception.getMessage()) + .contains("TransparentProxy is already registered. Only one registration is allowed."); + + // Verify that the original registration is still active + Try result2 = executeWithTenant(() -> loader.tryGetDestination("test2")); + assertThat(result2.get().asHttp().getUri().toString()).startsWith("http://gateway"); + } + + @Test + void testDestinationLoaderInterface() + throws Exception + { + // Test that TransparentProxyLoader properly implements DestinationLoader + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + assertThat(loader).isInstanceOf(DestinationLoader.class); + + // Setup: register a host first + TransparentProxy.register("gateway", "tenant-id"); + + // Test interface methods + DestinationLoader destinationLoader = loader; + + Try result1 = executeWithTenant(() -> destinationLoader.tryGetDestination("test")); + Try result2 = + executeWithTenant(() -> destinationLoader.tryGetDestination("test", DestinationOptions.builder().build())); + + assertThat(result1.isSuccess()).isTrue(); + assertThat(result2.isSuccess()).isTrue(); + } + + @Test + void testRegisterWithDifferentSchemes() + throws Exception + { + // Test various schemes to ensure they are preserved + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + String[] schemes = { "http://", "https://", "ftp://", "custom://" }; + String hostname = "gateway"; + + for( String scheme : schemes ) { + // Reset registration state between tests + TransparentProxy.uri = null; + TransparentProxy.providerTenantId = null; + + // Register with the current scheme + TransparentProxy.register(scheme + hostname, "tenant-id"); + + // Verify the scheme is preserved in the destination + Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination")); + assertThat(result.isSuccess()).isTrue(); + assertThat(result.get().asHttp().getUri().toString()).startsWith(scheme + hostname); + } + } + + @Test + void testRegisterWithPortInUriButNoPath() + throws Exception + { + TransparentProxy.register("https://gateway:9443", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination")); + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination.asHttp().getUri().toString()).startsWith("https://gateway:9443"); + } + + @Test + void testRegisterWithPortInUriAndPathFails() + { + final DestinationAccessException exception = + assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("https://gateway:9443/api")); + + assertThat(exception.getMessage()).contains("contains a path"); + assertThat(exception.getMessage()).contains("/api"); + assertThat(exception.getMessage()).contains("Paths are not allowed"); + } + + @Test + void testRegisterWithHostAndPort() + throws Exception + { + TransparentProxy.register("gateway", 8080, "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination")); + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination.asHttp().getUri().toString()).startsWith("http://gateway:8080"); + } + + @Test + void testRegisterWithHostAndPortWithScheme() + throws Exception + { + TransparentProxy.register("https://gateway", 443, "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination")); + assertThat(result.isSuccess()).isTrue(); + + Destination destination = result.get(); + assertThat(destination.asHttp().getUri().toString()).startsWith("https://gateway:443"); + } + + // ========== Comprehensive Path Validation Tests ========== + + @Test + void testRegisterWithRootPathIsNotAllowed() + { + // Test that root path "/" is rejected + final DestinationAccessException exception = + assertThrows(DestinationAccessException.class, () -> TransparentProxy.register("https://gateway/")); + + assertThat(exception.getMessage()).contains("contains a path"); + assertThat(exception.getMessage()).contains("/"); + assertThat(exception.getMessage()).contains("Paths are not allowed"); + } + + @Test + void testRegisterWithVariousPathsFails() + { + // Test various path patterns that should all fail + String[] hostsWithPaths = + { + "gateway/api", + "gateway/api/v1", + "gateway/path/to/resource", + "http://gateway/api", + "https://gateway/api/v1", + "gateway:8080/api", + "https://gateway:443/api/v1", + "gateway/api?query=param", + "gateway/api#fragment" }; + + for( String hostWithPath : hostsWithPaths ) { + // Reset registration state between tests + resetLoader(); + + DestinationAccessException exception = + assertThrows( + DestinationAccessException.class, + () -> TransparentProxy.register(hostWithPath), + "Expected path validation to fail for: " + hostWithPath); + + assertThat(exception.getMessage()) + .as("Error message should mention path for: " + hostWithPath) + .contains("contains a path"); + assertThat(exception.getMessage()) + .as("Error message should mention paths not allowed for: " + hostWithPath) + .contains("Paths are not allowed"); + } + } + + // ========== Tests for isDestinationNotFound - Destination Not Found Cases ========== + + @Test + void testDestinationNotFoundWhenStatus502WithErrorCode404() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = + new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_BAD_GATEWAY, "Bad Gateway"); + response.setHeader("x-error-internal-code", "404"); + response.setHeader("x-error-message", expectedErrorMessage); + + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + Try result = executeWithTenant(() -> loader.tryGetDestination("non-existent-destination")); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.getCause()).isInstanceOf(DestinationNotFoundException.class); + assertThat(result.getCause().getMessage()).contains(expectedErrorMessage); + } + + @Test + void testDestinationNotFoundWhenStatus502WithErrorCode404WithOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = + new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_BAD_GATEWAY, "Bad Gateway"); + response.setHeader("x-error-internal-code", "404"); + response.setHeader("x-error-message", expectedErrorMessage); + + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + Try result = + executeWithTenant( + () -> loader.tryGetDestination("non-existent-destination", DestinationOptions.builder().build())); + + assertThat(result.isFailure()).isTrue(); + assertThat(result.getCause()).isInstanceOf(DestinationNotFoundException.class); + assertThat(result.getCause().getMessage()).contains(expectedErrorMessage); + } + + @Test + void testDestinationFoundWhenStatus200() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + Try result = executeWithTenant(() -> loader.tryGetDestination("existing-destination")); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.get()).isNotNull(); + } + + @Test + void testDestinationFoundWhenStatus502WithoutErrorCode() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = + new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_BAD_GATEWAY, "Bad Gateway"); + + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + Try result = executeWithTenant(() -> loader.tryGetDestination("destination-with-502")); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.get()).isNotNull(); + } + + @Test + void testDestinationFoundWhenStatus502WithDifferentErrorCode() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = + new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_BAD_GATEWAY, "Bad Gateway"); + response.setHeader("x-error-internal-code", "500"); + + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + Try result = executeWithTenant(() -> loader.tryGetDestination("destination-with-different-error")); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.get()).isNotNull(); + } + + @Test + void testRegisterWithValidHostsWithoutPathsSucceeds() + throws Exception + { + String[] validHosts = + { + "gateway", + "gateway.example.com", + "gateway.svc.cluster.local", + "http://gateway", + "https://gateway", + "gateway:8080", + "gateway:443", + "http://gateway:8080", + "https://gateway:443", + "127.0.0.1", + "192.168.1.1:8080", + "localhost" }; + + for( String validHost : validHosts ) { + // Reset registration state between tests + resetLoader(); + + // This should not throw an exception + TransparentProxy.register(validHost, "tenant-id"); + + // Set up mock after registration + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + // Verify the registration worked + Try result = executeWithTenant(() -> loader.tryGetDestination("test-destination")); + assertThat(result.isSuccess()).as("Registration should succeed for valid host: " + validHost).isTrue(); + } + } + + @Test + void testTryGetDestinationWithLevelOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.SUBACCOUNT)) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-levels", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "subaccount".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .noneMatch( + h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "subaccount".equals(h.getValue())); + } + + @Test + void testTryGetDestinationWithAllSupportedOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .fragmentName("complete-fragment") + .customHeaders( + new Header(TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY, "false"), + new Header(TransparentProxyDestination.TENANT_ID_HEADER_KEY, "complete-tenant"), + new Header(TransparentProxyDestination.CLIENT_ASSERTION_HEADER_KEY, "complete-assertion"), + new Header(TransparentProxyDestination.AUTHORIZATION_HEADER_KEY, "Bearer complete-token"))) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-all", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + + // Verify all key headers are present + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch(h -> TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY.equalsIgnoreCase(h.getName())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch(h -> TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY.equalsIgnoreCase(h.getName())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch(h -> TransparentProxyDestination.TENANT_ID_HEADER_KEY.equalsIgnoreCase(h.getName())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch(h -> TransparentProxyDestination.CLIENT_ASSERTION_HEADER_KEY.equalsIgnoreCase(h.getName())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch(h -> TransparentProxyDestination.AUTHORIZATION_HEADER_KEY.equalsIgnoreCase(h.getName())); + } + + // ========== Unit Tests for Augmented Options Scenarios ========== + + @Test + void testFragmentNameWithAugmentedOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder(DestinationServiceOptionsAugmenter.augmenter().fragmentName("test-fragment")) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-fragment", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "test-fragment".equals(h.getValue())); + } + + @Test + void testCrossLevelConsumptionSubaccount() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.SUBACCOUNT)) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-subaccount", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "subaccount".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .noneMatch( + h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "subaccount".equals(h.getValue())); + } + + @Test + void testCrossLevelConsumptionProviderSubaccount() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_SUBACCOUNT)) + .build(); + + Try result = + executeWithTenant(() -> loader.tryGetDestination("dest-provider-subaccount", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "provider_subaccount".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .noneMatch( + h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "provider_subaccount".equals(h.getValue())); + } + + @Test + void testCrossLevelConsumptionInstance() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.INSTANCE)) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-instance", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "instance".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .noneMatch( + h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "instance".equals(h.getValue())); + } + + @Test + void testCrossLevelConsumptionProviderInstance() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_INSTANCE)) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-provider-instance", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "provider_instance".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .noneMatch( + h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "provider_instance".equals(h.getValue())); + } + + @Test + void testCustomHeadersWithAugmentedOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .customHeaders( + new Header("X-Custom-Header-1", "value1"), + new Header("X-Custom-Header-2", "value2"))) + .build(); + + Try result = + executeWithTenant(() -> loader.tryGetDestination("dest-with-custom-headers", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch(h -> "X-Custom-Header-1".equalsIgnoreCase(h.getName()) && "value1".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch(h -> "X-Custom-Header-2".equalsIgnoreCase(h.getName()) && "value2".equals(h.getValue())); + } + + @Test + void testCombinedFragmentAndCrossLevelOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .fragmentName("combined-fragment") + .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.INSTANCE)) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-combined", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "combined-fragment".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "instance".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "instance".equals(h.getValue())); + } + + @Test + void testFragmentWithCustomHeadersAndCrossLevel() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .fragmentName("full-fragment") + .crossLevelConsumption(DestinationServiceOptionsAugmenter.CrossLevelScope.PROVIDER_INSTANCE) + .customHeaders( + new Header("X-Test-Header", "test-value"), + new Header(TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY, "true"))) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-full-options", options)); + + assertThat(result.isSuccess()).isTrue(); + HttpDestination destination = result.get().asHttp(); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.FRAGMENT_NAME_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "full-fragment".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.DESTINATION_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "provider_instance".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.FRAGMENT_LEVEL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "provider_instance".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch(h -> "X-Test-Header".equalsIgnoreCase(h.getName()) && "test-value".equals(h.getValue())); + assertThat(destination.getHeaders(destination.getUri())) + .anyMatch( + h -> TransparentProxyDestination.FRAGMENT_OPTIONAL_HEADER_KEY.equalsIgnoreCase(h.getName()) + && "true".equals(h.getValue())); + } + + @Test + void testRetrievalStrategyWithAugmentedOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .retrievalStrategy(DestinationServiceRetrievalStrategy.ALWAYS_PROVIDER)) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-strategy", options)); + + assertThat(result.isSuccess()).isTrue(); + // Verify the strategy is stored in options + assertThat(DestinationServiceOptionsAugmenter.getRetrievalStrategy(options)) + .isNotEmpty() + .contains(DestinationServiceRetrievalStrategy.ALWAYS_PROVIDER); + } + + @Test + void testTokenExchangeStrategyWithAugmentedOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder( + DestinationServiceOptionsAugmenter + .augmenter() + .tokenExchangeStrategy(DestinationServiceTokenExchangeStrategy.FORWARD_USER_TOKEN)) + .build(); + + Try result = + executeWithTenant(() -> loader.tryGetDestination("dest-with-token-strategy", options)); + + assertThat(result.isSuccess()).isTrue(); + // Verify the token exchange strategy is stored in options + assertThat(DestinationServiceOptionsAugmenter.getTokenExchangeStrategy(options)) + .isNotEmpty() + .contains(DestinationServiceTokenExchangeStrategy.FORWARD_USER_TOKEN); + } + + @Test + void testRefreshTokenWithAugmentedOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions + .builder() + .augmentBuilder(DestinationServiceOptionsAugmenter.augmenter().refreshToken("test-refresh-token")) + .build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-with-refresh-token", options)); + + assertThat(result.isSuccess()).isTrue(); + // Verify the refresh token is stored in options + assertThat(DestinationServiceOptionsAugmenter.getRefreshToken(options)) + .isNotEmpty() + .contains("test-refresh-token"); + } + + @Test + void testEmptyAugmentedOptions() + throws Exception + { + TransparentProxy.register("gateway", "tenant-id"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + DestinationOptions options = + DestinationOptions.builder().augmentBuilder(DestinationServiceOptionsAugmenter.augmenter()).build(); + + Try result = executeWithTenant(() -> loader.tryGetDestination("dest-empty-augmented", options)); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.get()).isNotNull(); + } + + @Test + void testTenantAccessExceptionWhenTenantMissingInContextAndProviderTenantId() + throws IOException + { + TransparentProxy.register("gateway"); + + final HttpClient mockHttpClient = mock(HttpClient.class); + final BasicHttpResponse response = new BasicHttpResponse(HttpVersion.HTTP_1_1, HttpStatus.SC_OK, "OK"); + when(mockHttpClient.execute(org.mockito.ArgumentMatchers.any(HttpHead.class))).thenReturn(response); + HttpClientAccessor.setHttpClientFactory(dest -> mockHttpClient); + + com.sap.cloud.sdk.cloudplatform.tenant.exception.TenantAccessException exception = + assertThrows( + com.sap.cloud.sdk.cloudplatform.tenant.exception.TenantAccessException.class, + () -> loader.tryGetDestination("test-destination")); + + assertThat(exception.getMessage()).contains("No current tenant defined"); + assertThat(exception.getMessage()).contains("no provider tenant id configured"); + } + +} diff --git a/release_notes.md b/release_notes.md index b0c19875b..699ac878a 100644 --- a/release_notes.md +++ b/release_notes.md @@ -4,22 +4,26 @@ ### 🚧 Known Issues -- +- ### 🔧 Compatibility Notes -- +- ### ✨ New Functionality -- `DestinationService.tryGetDestination` now checks if the given destination exists before trying to call it directly. This behaviour is enabled by default and can be disabled via `DestinationService.Cache.disablePreLookupCheck`. +- `DestinationService.tryGetDestination` now checks if the given destination exists before trying to call it directly. + This behaviour is enabled by default and can be disabled via `DestinationService.Cache.disablePreLookupCheck`. +- Added native DestinationLoader implementation through the `TransparentProxy` class. This enables seamless destination + retrieval via `tryGetDestination` and provides full Cloud SDK integration for applications running in Kubernetes + environments where Transparent Proxy is available. ### 📈 Improvements -- +- ### 🐛 Fixed Issues -- Fix unintended modification of `serviceNameMappings.properties` during OData service regeneration altering stored mappings. +- Fix unintended modification of `serviceNameMappings.properties` during OData service regeneration altering stored + mappings. Additionally, service name cleanup is now case-insensitive for consistency. -