diff --git a/CHANGELOG.md b/CHANGELOG.md index 667a31037..a72b52967 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. ### Numbers - Extend `NumberSinchEvents` class. - - **[fix]** `EventTypeEnum`: `DEPROVISIONING_FROM_VOICE_PLATFORM` is deprecated and has to be replaced by `VOICE_PLATFORM_DEPROVISIONING`. + - **[fix]** `EventTypeEnum`: `DEPROVISIONING_TO_VOICE_PLATFORM` is deprecated and has to be replaced by `DEPROVISIONING_FROM_VOICE_PLATFORM`. - **[feature]** Support new `internalFailureCode` field. - **[feature]** Support new `StatusEnum` values: `IN_REVIEW`, `BLOCKED`, `COMPLETED`, `REJECTED`, `EXPIRED`. - **[feature]** Support new `EventTypeEnum` value: `NUMBER_ORDER_PROCESSING`. @@ -29,6 +29,7 @@ All notable changes to the **Sinch Java SDK** are documented in this file. - **[feature]** Support `Consents` API: `listIdentities` and `listAuditRecords` endpoints ### SDK +- **[feature]** HTTP proxy support: configure an unauthenticated or authenticated (Basic) proxy via `HttpProxyConfiguration` - **[feature]** `SinchClient` exposes a `close()` method to shut down the underlying HTTP connection pool and release all associated resources deterministically - **[fix]** `HttpClientApache`: declare now `headersToBeAdded` as `volatile` to guarantee visibility across threads in concurrent usage - **[fix]** `HttpClientApache`: wrap response-body `Scanner` in a try-with-resources block to prevent resource leaks; gracefully handle empty (`null`) response entities diff --git a/README.md b/README.md index 7f0ad701a..04f7344ab 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ For more information on the SDK, refer to the dedicated [Java SDK documentation - [Getting started](#getting-started) - [Logging](#logging) - [Handling Exceptions](#handling-exceptions) +- [Proxy configuration](#proxy-configuration) - [Third-party dependencies](#third-party-dependencies) - [Examples](#examples) - [Changelog and Migration](#changelog--migration) @@ -397,6 +398,54 @@ try { - `ApiAuthException`: authentication/authorization failures. - `ApiMappingException`: the response payload could not be deserialized into the expected type. +## Proxy configuration + +If your network environment routes outbound traffic through an HTTP proxy, provide proxy +configuration via [HttpProxyConfiguration](client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java) on the [Configuration](client/src/main/com/sinch/sdk/models/Configuration.java) builder. + +When used, all connections will go through the proxy (including OAuth). + +**Unauthenticated proxy:** + +```java +import com.sinch.sdk.SinchClient; +import com.sinch.sdk.models.Configuration; +import com.sinch.sdk.models.HttpProxyConfiguration; + +... +Configuration configuration = Configuration.builder() + .setKeyId(PARAM_KEY_ID) + .setKeySecret(PARAM_KEY_SECRET) + .setProjectId(PARAM_PROJECT_ID) + .setHttpProxyConfiguration( + HttpProxyConfiguration.builder() + .setHostname(PARAM_PROXY_HOSTNAME) + .setPort(PARAM_PROXY_PORT) + .build()) + .build(); +SinchClient client = new SinchClient(configuration); +``` + +**Authenticated proxy:** + + +```java +Configuration configuration = Configuration.builder() + .setKeyId(PARAM_KEY_ID) + .setKeySecret(PARAM_KEY_SECRET) + .setProjectId(PARAM_PROJECT_ID) + .setHttpProxyConfiguration( + HttpProxyConfiguration.builder() + .setHostname(PARAM_PROXY_HOSTNAME) + .setPort(PARAM_PROXY_PORT) + .setUsername(PARAM_PROXY_USERNAME) + // prefer char[] over String to reduce password exposure in heap memory + .setPassword(PARAM_PROXY_PASSWORD_AS_CHAR_ARRAY) + .build()) + .build(); +SinchClient client = new SinchClient(configuration); +``` + ## Third-party dependencies The SDK relies on the following third-party dependencies: diff --git a/client/src/main/com/sinch/sdk/SinchClient.java b/client/src/main/com/sinch/sdk/SinchClient.java index 2fe0adc7f..36a6d6b1b 100644 --- a/client/src/main/com/sinch/sdk/SinchClient.java +++ b/client/src/main/com/sinch/sdk/SinchClient.java @@ -449,7 +449,7 @@ private HttpClientApache getHttpClient() { synchronized (this) { local = httpClient; if (null == local || local.isClosed()) { - local = new HttpClientApache(); + local = new HttpClientApache(configuration.getHttpProxyConfiguration().orElse(null)); // set SDK User-Agent String userAgent = formatSdkUserAgentHeader(); diff --git a/client/src/main/com/sinch/sdk/http/HttpClientApache.java b/client/src/main/com/sinch/sdk/http/HttpClientApache.java index e5882a487..bf3f1dad2 100644 --- a/client/src/main/com/sinch/sdk/http/HttpClientApache.java +++ b/client/src/main/com/sinch/sdk/http/HttpClientApache.java @@ -16,6 +16,7 @@ import com.sinch.sdk.core.http.URLParameter; import com.sinch.sdk.core.models.ServerConfiguration; import com.sinch.sdk.core.utils.Pair; +import com.sinch.sdk.models.HttpProxyConfiguration; import java.io.File; import java.io.IOException; import java.nio.charset.Charset; @@ -31,14 +32,22 @@ import java.util.Scanner; import java.util.logging.Logger; import java.util.stream.Collectors; +import org.apache.hc.client5.http.ClientProtocolException; +import org.apache.hc.client5.http.HttpResponseException; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.CredentialsProvider; import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; +import org.apache.hc.client5.http.impl.auth.CredentialsProviderBuilder; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner; import org.apache.hc.core5.http.ClassicHttpRequest; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.io.support.ClassicRequestBuilder; import org.apache.hc.core5.http.support.AbstractMessageBuilder; @@ -47,12 +56,48 @@ public class HttpClientApache implements com.sinch.sdk.core.http.HttpClient { private static final Logger LOGGER = Logger.getLogger(HttpClientApache.class.getName()); + /** + * HTTP 407 Proxy Authentication Required. Kept as a local constant to make the guard in {@link + * #invokeAPI} self-documenting and independent of any external enum. + */ + private static final int HTTP_PROXY_AUTHENTICATION_REQUIRED = 407; + private volatile Map headersToBeAdded; private volatile CloseableHttpClient client; public HttpClientApache() { - this.client = HttpClients.createDefault(); + this(null); + } + + public HttpClientApache(HttpProxyConfiguration proxyConfiguration) { + this.client = buildHttpClient(proxyConfiguration); + } + + private static CloseableHttpClient buildHttpClient(HttpProxyConfiguration proxyConfiguration) { + if (proxyConfiguration == null) { + return HttpClients.createDefault(); + } + + HttpHost proxyHost = + new HttpHost(proxyConfiguration.getHostname(), proxyConfiguration.getPort()); + HttpClientBuilder builder = + HttpClients.custom().setRoutePlanner(new DefaultProxyRoutePlanner(proxyHost)); + + if (proxyConfiguration.getUsername().isPresent()) { + // getPassword() returns a defensive copy of the internal array; HC5 receives that copy and + // owns it for the lifetime of the client. The caller does not need to keep the original + // alive. + CredentialsProvider credentialsProvider = + CredentialsProviderBuilder.create() + .add( + new AuthScope(proxyHost), + proxyConfiguration.getUsername().orElse(""), + proxyConfiguration.getPassword().orElse(new char[0])) + .build(); + builder.setDefaultCredentialsProvider(credentialsProvider); + } + return builder.build(); } public void setRequestHeaders(Map headers) { @@ -156,6 +201,20 @@ public HttpResponse invokeAPI( HttpResponse response = processRequest(activeClient, request); LOGGER.finest("connection response: " + response); + // HTTP 407 (Proxy Authentication Required) is normally handled transparently by Apache + // HttpClient via DefaultProxyRoutePlanner + CredentialsProvider (the 407→retry cycle + // happens inside processRequest and is invisible to this method). + // If 407 surfaces here it means proxy credentials are absent, wrong, or the proxy uses an + // unsupported auth scheme. Guard explicitly so that the OAuth-refresh block below does NOT + // misfire: some enterprise proxies include a `www-authenticate: Bearer error="expired"` + // header on 407 responses, which would incorrectly trigger an OAuth token reset. + if (response.getCode() == HTTP_PROXY_AUTHENTICATION_REQUIRED) { + LOGGER.warning( + "Proxy authentication required (HTTP 407). " + + "Verify HttpProxyConfiguration hostname, port and credentials."); + return response; + } + // UNAUTHORIZED (HTTP 401) error code could imply refreshing the OAuth token if (response.getCode() == HttpStatus.UNAUTHORIZED) { boolean couldRetryRequest = @@ -169,6 +228,15 @@ public HttpResponse invokeAPI( } } return response; + } catch (ClientProtocolException cpe) { + int code = extractHttpStatusCode(cpe); + if (code > 0) { + LOGGER.severe("HTTP protocol error with status code " + code + ": " + cpe.getMessage()); + throw new ApiException( + "HTTP protocol error (status code " + code + "): " + cpe.getMessage(), cpe, code); + } + LOGGER.severe("HTTP protocol error: " + cpe.getMessage()); + throw new ApiException("HTTP protocol error: " + cpe.getMessage(), cpe); } catch (Exception e) { LOGGER.severe("Error:" + e); throw new ApiException(e); @@ -180,6 +248,9 @@ private boolean processUnauthorizedResponse( HttpResponse response, Map authManagersByOasSecuritySchemes) { + if (null == authManagersByOasSecuritySchemes) { + return false; + } Map authManagersByAuthSchemes = authManagersByOasSecuritySchemes.values().stream() .map(authManager -> new AbstractMap.SimpleEntry<>(authManager.getSchema(), authManager)) @@ -348,6 +419,42 @@ HttpResponse processRequest(CloseableHttpClient client, ClassicHttpRequest reque return client.execute(request, HttpClientApache::processResponse); } + /** + * Extracts the HTTP status code from a {@link ClientProtocolException}. + * + *

Handles two cases: + * + *

    + *
  • {@link HttpResponseException} — carries the code directly via {@code getStatusCode()}. + *
  • Plain {@link ClientProtocolException} — Apache embeds the status line in the message + * (e.g. {@code "CONNECT refused by proxy: HTTP/1.1 407 Proxy Authentication Required"}). + *
+ * + * @return the HTTP status code, or {@code -1} if it cannot be determined + */ + private static int extractHttpStatusCode(ClientProtocolException e) { + if (e instanceof HttpResponseException) { + return ((HttpResponseException) e).getStatusCode(); + } + String message = e.getMessage(); + if (message == null) { + return -1; + } + int idx = message.indexOf("HTTP/"); + if (idx < 0) { + return -1; + } + String[] parts = message.substring(idx).split(" ", 3); + if (parts.length < 2) { + return -1; + } + try { + return Integer.parseInt(parts[1]); + } catch (NumberFormatException ignored) { + return -1; + } + } + private Optional extractCharset(AbstractMessageBuilder messageBuilder) { Header[] headers = messageBuilder.getHeaders(CONTENT_TYPE_HEADER); diff --git a/client/src/main/com/sinch/sdk/models/Configuration.java b/client/src/main/com/sinch/sdk/models/Configuration.java index 962f03190..05d26aef1 100644 --- a/client/src/main/com/sinch/sdk/models/Configuration.java +++ b/client/src/main/com/sinch/sdk/models/Configuration.java @@ -15,6 +15,7 @@ public class Configuration { private final VerificationContext verificationContext; private final VoiceContext voiceContext; private final ConversationContext conversationContext; + private final HttpProxyConfiguration httpProxyConfiguration; private final NumberLookupContext numberLookupContext; private Configuration( @@ -22,6 +23,7 @@ private Configuration( ApplicationCredentials applicationCredentials, SmsServicePlanCredentials smsServicePlanCredentials, String oauthUrl, + HttpProxyConfiguration httpProxyConfiguration, NumbersContext numbersContext, SmsContext smsContext, VerificationContext verificationContext, @@ -38,6 +40,7 @@ private Configuration( this.verificationContext = verificationContext; this.conversationContext = conversationContext; this.numberLookupContext = numberLookupContext; + this.httpProxyConfiguration = httpProxyConfiguration; } @Override @@ -58,6 +61,8 @@ public String toString() { + conversationContext + ", numberLookupContext=" + numberLookupContext + + ", httpProxyConfiguration=" + + httpProxyConfiguration + "}"; } @@ -175,6 +180,16 @@ public Optional getNumberLookupContext() { return Optional.ofNullable(numberLookupContext); } + /** + * Get HTTP proxy configuration + * + * @return HTTP proxy configuration + * @since 2.1 + */ + public Optional getHttpProxyConfiguration() { + return Optional.ofNullable(httpProxyConfiguration); + } + /** * Getting Builder * @@ -213,6 +228,7 @@ public static class Builder { VoiceContext.Builder voiceContext; ConversationContext.Builder conversationContext; NumberLookupContext.Builder numberLookupContext; + HttpProxyConfiguration httpProxyConfiguration; protected Builder() {} @@ -246,6 +262,7 @@ protected Builder(Configuration configuration) { configuration.getConversationContext().map(ConversationContext::builder).orElse(null); this.numberLookupContext = configuration.getNumberLookupContext().map(NumberLookupContext::builder).orElse(null); + this.httpProxyConfiguration = configuration.getHttpProxyConfiguration().orElse(null); } /** @@ -531,6 +548,19 @@ public Builder setNumberLookupContext(NumberLookupContext context) { return this; } + /** + * Set HTTP proxy configuration + * + * @param httpProxyConfiguration proxy configuration, or {@code null} to disable proxy + * @return Current builder + * @see Configuration#getHttpProxyConfiguration() getter + * @since 2.1 + */ + public Builder setHttpProxyConfiguration(HttpProxyConfiguration httpProxyConfiguration) { + this.httpProxyConfiguration = httpProxyConfiguration; + return this; + } + /** * Build a Configuration instance from builder current state * @@ -544,6 +574,7 @@ public Configuration build() { null != applicationCredentials ? applicationCredentials.build() : null, null != smsServicePlanCredentials ? smsServicePlanCredentials.build() : null, oauthUrl, + httpProxyConfiguration, null != numbersContext ? numbersContext.build() : null, null != smsContext ? smsContext.build() : null, null != verificationContext ? verificationContext.build() : null, diff --git a/client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java b/client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java new file mode 100644 index 000000000..519767e9c --- /dev/null +++ b/client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java @@ -0,0 +1,255 @@ +package com.sinch.sdk.models; + +import com.sinch.sdk.core.utils.StringUtil; +import java.util.Arrays; +import java.util.Optional; + +/** + * HTTP proxy configuration for the Sinch SDK HTTP client. + * + *

Provides a transport-agnostic proxy abstraction. Use {@link Builder} to construct an instance, + * optionally providing credentials for authenticated proxies. + * + *

Scheme: Only plain HTTP proxies are supported. HTTPS-terminating (SSL + * intercepting) proxies are not. All outbound connections — including OAuth token exchange — are + * routed through the configured proxy. + * + *

{@code
+ * // Unauthenticated proxy
+ * HttpProxyConfiguration proxy = HttpProxyConfiguration.builder()
+ *     .setHostname("proxy.corp.example.com")
+ *     .setPort(3128)
+ *     .build();
+ *
+ * // Authenticated proxy
+ * HttpProxyConfiguration proxy = HttpProxyConfiguration.builder()
+ *     .setHostname("proxy.corp.example.com")
+ *     .setPort(3128)
+ *     .setUsername("user")
+ *     .setPassword("secret")
+ *     .build();
+ * }
+ * + * @since 2.1 + */ +public class HttpProxyConfiguration { + private final String hostname; + private final int port; + private final String username; + private final char[] password; + + private HttpProxyConfiguration(String hostname, int port, String username, char[] password) { + this.hostname = hostname; + this.port = port; + this.username = username; + this.password = password; + } + + /** + * Proxy host name or IP address. + * + * @return hostname + * @since 2.1 + */ + public String getHostname() { + return hostname; + } + + /** + * Proxy port number. + * + * @return port + * @since 2.1 + */ + public int getPort() { + return port; + } + + /** + * Proxy username, present only when the proxy requires authentication. + * + * @return username, or empty if not configured + * @since 2.1 + */ + public Optional getUsername() { + return Optional.ofNullable(username); + } + + /** + * Proxy password, present only when the proxy requires authentication. + * + *

Returns a defensive copy of the internal array. Callers are encouraged to zero the array + * with {@code Arrays.fill(pwd, '\0')} once they have finished using it. + * + * @return password as a char array, or empty if not configured + * @since 2.1 + */ + public Optional getPassword() { + return password == null + ? Optional.empty() + : Optional.of(Arrays.copyOf(password, password.length)); + } + + @Override + public String toString() { + return "HttpProxyConfiguration{" + + "hostname='" + + hostname + + '\'' + + ", port=" + + port + + ", username=" + + (username != null ? "'***'" : "null") + + ", password=" + + (password != null ? "'***'" : "null") + + '}'; + } + + /** + * Getting Builder + * + * @return New Builder instance + * @since 2.1 + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Getting Builder pre-populated from an existing instance + * + * @param configuration source configuration + * @return New Builder instance + * @since 2.1 + */ + public static Builder builder(HttpProxyConfiguration configuration) { + return new Builder(configuration); + } + + /** + * Dedicated Builder + * + * @since 2.1 + */ + public static class Builder { + + private String hostname; + private int port; + private String username; + private char[] password; + + protected Builder() {} + + protected Builder(HttpProxyConfiguration configuration) { + if (null == configuration) { + return; + } + this.hostname = configuration.hostname; + this.port = configuration.port; + this.username = configuration.username; + this.password = + configuration.password == null + ? null + : Arrays.copyOf(configuration.password, configuration.password.length); + } + + /** + * Set proxy hostname or IP address + * + * @param hostname proxy host + * @return Current builder + * @since 2.1 + */ + public Builder setHostname(String hostname) { + this.hostname = hostname; + return this; + } + + /** + * Set proxy port + * + * @param port proxy port + * @return Current builder + * @since 2.1 + */ + public Builder setPort(int port) { + this.port = port; + return this; + } + + /** + * Set proxy username (for authenticated proxies) + * + * @param username proxy username + * @return Current builder + * @since 2.1 + */ + public Builder setUsername(String username) { + this.username = username; + return this; + } + + /** + * Set proxy password (for authenticated proxies). + * + *

The {@code String} argument is converted to a {@code char[]} immediately and the reference + * is not retained. Prefer {@link #setPassword(char[])} when the password is already available + * as a {@code char[]} (e.g. from {@code Console.readPassword()}) to avoid creating an + * intermediate {@code String} on the heap. + * + * @param password proxy password + * @return Current builder + * @since 2.1 + */ + public Builder setPassword(String password) { + this.password = password == null ? null : password.toCharArray(); + return this; + } + + /** + * Set proxy password as a char array (for authenticated proxies). + * + *

A defensive copy of the provided array is stored. The caller may zero the original array + * with {@code Arrays.fill(password, '\0')} immediately after this call. + * + * @param password proxy password as a char array + * @return Current builder + * @since 2.1 + */ + public Builder setPassword(char[] password) { + this.password = password == null ? null : Arrays.copyOf(password, password.length); + return this; + } + + /** + * Build an {@link HttpProxyConfiguration} instance + * + * @return HttpProxyConfiguration instance + * @since 2.1 + */ + public HttpProxyConfiguration build() { + String trimmedHostname = hostname == null ? null : hostname.trim(); + if (StringUtil.isEmpty(trimmedHostname)) { + throw new IllegalArgumentException( + "HttpProxyConfiguration: hostname must not be null or empty"); + } + if (port <= 0 || port > 65535) { + throw new IllegalArgumentException( + "HttpProxyConfiguration: port must be in range 1–65535, got: " + port); + } + if (password != null && username == null) { + throw new IllegalArgumentException( + "HttpProxyConfiguration: a password was provided without a username"); + } + if (username != null && password == null) { + throw new IllegalArgumentException( + "HttpProxyConfiguration: a username was provided without a password"); + } + return new HttpProxyConfiguration( + trimmedHostname, + port, + username, + password == null ? null : Arrays.copyOf(password, password.length)); + } + } +} diff --git a/client/src/test/java/com/sinch/sdk/SinchClientTest.java b/client/src/test/java/com/sinch/sdk/SinchClientTest.java index 5ec89d15a..8baee2d6a 100644 --- a/client/src/test/java/com/sinch/sdk/SinchClientTest.java +++ b/client/src/test/java/com/sinch/sdk/SinchClientTest.java @@ -3,11 +3,15 @@ import static org.junit.jupiter.api.Assertions.*; import com.sinch.sdk.core.utils.StringUtil; +import com.sinch.sdk.http.HttpClientApache; import com.sinch.sdk.models.Configuration; import com.sinch.sdk.models.ConversationRegion; +import com.sinch.sdk.models.HttpProxyConfiguration; import com.sinch.sdk.models.SMSRegion; import com.sinch.sdk.models.VoiceContext; import com.sinch.sdk.models.VoiceRegion; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import org.junit.jupiter.api.Test; class SinchClientTest { @@ -278,4 +282,59 @@ void doubleCloseBeforeAnyCall() { client.close(); assertDoesNotThrow(client::close); } + + /** + * Verifies that a proxy configuration set on {@link Configuration} is preserved in the {@link + * SinchClient}'s internal configuration after construction. + */ + @Test + void proxyConfigurationPreservedInConfiguration() { + HttpProxyConfiguration proxy = + HttpProxyConfiguration.builder() + .setHostname("proxy.corp.example.com") + .setPort(3128) + .build(); + Configuration configuration = Configuration.builder().setHttpProxyConfiguration(proxy).build(); + SinchClient client = new SinchClient(configuration); + assertTrue( + client.getConfiguration().getHttpProxyConfiguration().isPresent(), + "Proxy configuration must be present in the SinchClient's stored configuration"); + assertEquals( + "proxy.corp.example.com", + client.getConfiguration().getHttpProxyConfiguration().get().getHostname(), + "Proxy hostname must survive SinchClient construction"); + assertEquals( + 3128, + client.getConfiguration().getHttpProxyConfiguration().get().getPort(), + "Proxy port must survive SinchClient construction"); + } + + @Test + void proxyConfigurationWiredIntoHttpClient() throws Exception { + HttpProxyConfiguration proxy = + HttpProxyConfiguration.builder() + .setHostname("proxy.corp.example.com") + .setPort(3128) + .build(); + Configuration configuration = Configuration.builder().setHttpProxyConfiguration(proxy).build(); + SinchClient sinchClient = new SinchClient(configuration); + + Method getHttpClient = SinchClient.class.getDeclaredMethod("getHttpClient"); + getHttpClient.setAccessible(true); + Object httpClientApache = getHttpClient.invoke(sinchClient); + assertInstanceOf(HttpClientApache.class, httpClientApache); + + Field clientField = HttpClientApache.class.getDeclaredField("client"); + clientField.setAccessible(true); + Object apacheClient = clientField.get(httpClientApache); + + Field routePlannerField = apacheClient.getClass().getDeclaredField("routePlanner"); + routePlannerField.setAccessible(true); + Object routePlanner = routePlannerField.get(apacheClient); + + assertInstanceOf( + org.apache.hc.client5.http.impl.routing.DefaultProxyRoutePlanner.class, + routePlanner, + "HttpClient must use a DefaultProxyRoutePlanner when proxy is configured"); + } } diff --git a/client/src/test/java/com/sinch/sdk/e2e/Config.java b/client/src/test/java/com/sinch/sdk/e2e/Config.java index badb5846a..ec636f6d4 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/Config.java +++ b/client/src/test/java/com/sinch/sdk/e2e/Config.java @@ -4,6 +4,7 @@ import com.sinch.sdk.models.Configuration; import com.sinch.sdk.models.ConversationContext; import com.sinch.sdk.models.ConversationRegion; +import com.sinch.sdk.models.HttpProxyConfiguration; import com.sinch.sdk.models.NumberLookupContext; import com.sinch.sdk.models.NumbersContext; import com.sinch.sdk.models.SMSRegion; @@ -37,8 +38,20 @@ public class Config { public static final String NUMBER_LOOKUP_HOST_NAME = "http://localhost:3022"; + public static final int PROXY_UNAUTHENTICATED_PORT = 3128; + public static final int PROXY_AUTHENTICATED_PORT = 3129; + public static final String PROXY_HOST = "localhost"; + public static final String PROXY_USERNAME = "user"; + public static final String PROXY_PASSWORD = "password"; + + public static final String PROXY_VISIBLE_AUTH_URL = + "http://authentication-server:1080/oauth2/token"; + public static final String PROXY_VISIBLE_NUMBER_LOOKUP_URL = "http://proxy-reachable-server:1080"; + private final SinchClient client; private final SinchClient clientServicePlanId; + private final SinchClient clientProxyUnauthenticated; + private final SinchClient clientProxyAuthenticated; private Config() { @@ -81,6 +94,24 @@ private Config() { .build(); clientServicePlanId = new SinchClient(configurationServicePlanId); + + clientProxyUnauthenticated = + new SinchClient( + createConfigurationWithProxyUsage( + HttpProxyConfiguration.builder() + .setHostname(PROXY_HOST) + .setPort(Config.PROXY_UNAUTHENTICATED_PORT) + .build())); + + clientProxyAuthenticated = + new SinchClient( + createConfigurationWithProxyUsage( + HttpProxyConfiguration.builder() + .setHostname(PROXY_HOST) + .setPort(Config.PROXY_AUTHENTICATED_PORT) + .setUsername(PROXY_USERNAME) + .setPassword(PROXY_PASSWORD) + .build())); } private static class LazyHolder { @@ -94,4 +125,26 @@ public static SinchClient getSinchClient() { public static SinchClient getSinchClientServicePlanId() { return LazyHolder.INSTANCE.clientServicePlanId; } + + public static SinchClient getSinchClientProxyUnauthenticated() { + return LazyHolder.INSTANCE.clientProxyUnauthenticated; + } + + public static SinchClient getSinchClientProxyAuthenticated() { + return LazyHolder.INSTANCE.clientProxyAuthenticated; + } + + private static Configuration createConfigurationWithProxyUsage(HttpProxyConfiguration proxy) { + return Configuration.builder() + .setOAuthUrl(PROXY_VISIBLE_AUTH_URL) + .setProjectId(Config.PROJECT_ID) + .setKeyId(Config.KEY_ID) + .setKeySecret(Config.KEY_SECRET) + .setNumberLookupContext( + NumberLookupContext.builder() + .setNumberLookupUrl(PROXY_VISIBLE_NUMBER_LOOKUP_URL) + .build()) + .setHttpProxyConfiguration(proxy) + .build(); + } } diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/HttpClientApacheProxyIT.java b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/HttpClientApacheProxyIT.java new file mode 100644 index 000000000..3064112ed --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/HttpClientApacheProxyIT.java @@ -0,0 +1,77 @@ +package com.sinch.sdk.e2e.domains.proxy; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sinch.sdk.core.http.HttpMethod; +import com.sinch.sdk.core.http.HttpRequest; +import com.sinch.sdk.core.http.HttpResponse; +import com.sinch.sdk.core.models.ServerConfiguration; +import com.sinch.sdk.e2e.Config; +import com.sinch.sdk.http.HttpClientApache; +import com.sinch.sdk.models.HttpProxyConfiguration; +import org.junit.jupiter.api.Test; + +class HttpClientApacheProxyIT { + + private static final String PROXY_HOST = "localhost"; + private static final String TARGET_URL = "http://proxy-reachable-server:1080/"; + + private static HttpRequest simpleGetRequest() { + return new HttpRequest("health", HttpMethod.GET, null, (String) null, null, null, null, null); + } + + @Test + void unauthenticatedProxyRoutesTraffic() throws Exception { + HttpProxyConfiguration proxyConfig = + HttpProxyConfiguration.builder() + .setHostname(PROXY_HOST) + .setPort(Config.PROXY_UNAUTHENTICATED_PORT) + .build(); + + try (HttpClientApache client = new HttpClientApache(proxyConfig)) { + HttpResponse response = + client.invokeAPI(new ServerConfiguration(TARGET_URL), null, simpleGetRequest()); + + assertEquals(200, response.getCode(), "Request through unauthenticated proxy must succeed"); + } + } + + @Test + void authenticatedProxyRoutesTraffic() throws Exception { + HttpProxyConfiguration proxyConfig = + HttpProxyConfiguration.builder() + .setHostname(PROXY_HOST) + .setPort(Config.PROXY_AUTHENTICATED_PORT) + .setUsername("user") + .setPassword("password") + .build(); + + try (HttpClientApache client = new HttpClientApache(proxyConfig)) { + HttpResponse response = + client.invokeAPI(new ServerConfiguration(TARGET_URL), null, simpleGetRequest()); + + assertEquals(200, response.getCode(), "Request through authenticated proxy must succeed"); + } + } + + @Test + void authenticatedProxyRejectsWrongCredentials() throws Exception { + HttpProxyConfiguration proxyConfig = + HttpProxyConfiguration.builder() + .setHostname(PROXY_HOST) + .setPort(Config.PROXY_AUTHENTICATED_PORT) + .setUsername("wrong-user") + .setPassword("wrong-pass") + .build(); + + try (HttpClientApache client = new HttpClientApache(proxyConfig)) { + HttpResponse response = + client.invokeAPI(new ServerConfiguration(TARGET_URL), null, simpleGetRequest()); + + assertEquals( + 407, + response.getCode(), + "Wrong credentials must result in 407 Proxy Authentication Required"); + } + } +} diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxyIT.java b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxyIT.java new file mode 100644 index 000000000..80db3d35c --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxyIT.java @@ -0,0 +1,18 @@ +package com.sinch.sdk.e2e.domains.proxy; + +import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME; +import static io.cucumber.junit.platform.engine.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; + +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; +import org.junit.platform.suite.api.SuiteDisplayName; + +@Suite +@SuiteDisplayName("Proxy") +@IncludeEngines("cucumber") +@SelectClasspathResource("features/proxy") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.sinch.sdk.e2e.domains.proxy") +@ConfigurationParameter(key = PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, value = "true") +public class ProxyIT {} diff --git a/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxySteps.java b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxySteps.java new file mode 100644 index 000000000..d498e65ea --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxySteps.java @@ -0,0 +1,69 @@ +package com.sinch.sdk.e2e.domains.proxy; + +import com.sinch.sdk.SinchClient; +import com.sinch.sdk.core.TestHelpers; +import com.sinch.sdk.domains.numberlookup.models.v2.request.NumberLookupRequest; +import com.sinch.sdk.domains.numberlookup.models.v2.response.Line; +import com.sinch.sdk.domains.numberlookup.models.v2.response.LineType; +import com.sinch.sdk.domains.numberlookup.models.v2.response.NumberLookupResponse; +import com.sinch.sdk.e2e.Config; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.junit.jupiter.api.Assertions; + +public class ProxySteps { + + NumberLookupResponse response; + + @Given("the proxy server is available") + public void proxyServerAvailable() { + Assertions.assertNotNull( + Config.getSinchClientProxyUnauthenticated(), "Unauthenticated proxy client"); + Assertions.assertNotNull( + Config.getSinchClientProxyAuthenticated(), "Authenticated proxy client"); + } + + @When("I send a Number Lookup request through the unauthenticated proxy") + public void lookupThroughUnauthenticatedProxy() { + response = lookup(Config.getSinchClientProxyUnauthenticated()); + } + + @When("I send a Number Lookup request through the authenticated proxy") + public void lookupThroughAuthenticatedProxy() { + response = lookup(Config.getSinchClientProxyAuthenticated()); + } + + @Then("the response is successfully returned through the unauthenticated proxy") + public void responseReturnedThroughUnauthenticatedProxy() { + assertResponse("a11a11a11a11a11a11a11a11a11unauth"); + } + + @Then("the response is successfully returned through the authenticated proxy") + public void responseReturnedThroughAuthenticatedProxy() { + assertResponse("b22b22b22b22b22b22b22b22b22b2auth"); + } + + private void assertResponse(String expectedTraceId) { + NumberLookupResponse expected = + NumberLookupResponse.builder() + .setNumber("+12016666666") + .setCountryCode("US") + .setTraceId(expectedTraceId) + .setLine( + Line.builder() + .setCarrier("T-Mobile USA") + .setType(LineType.MOBILE) + .setMobileCountryCode("310") + .setMobileNetworkCode("260") + .build()) + .build(); + + TestHelpers.recursiveEquals(response, expected); + } + + private static NumberLookupResponse lookup(SinchClient client) { + NumberLookupRequest request = NumberLookupRequest.builder().setNumber("+12016666666").build(); + return client.lookup().v2().lookup(request); + } +} diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheLifecycleTest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheLifecycleTest.java index 2c637f5dd..68f21426b 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheLifecycleTest.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheLifecycleTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.*; +import com.sinch.sdk.models.HttpProxyConfiguration; import org.junit.jupiter.api.Test; class HttpClientApacheLifecycleTest { @@ -36,4 +37,36 @@ void closeViaAutoCloseable() { } }); } + + @Test + void proxyConstructorCreatesOpenInstance() throws Exception { + HttpProxyConfiguration proxy = + HttpProxyConfiguration.builder().setHostname("proxy.example.com").setPort(3128).build(); + try (HttpClientApache client = new HttpClientApache(proxy)) { + assertFalse(client.isClosed(), "HttpClientApache created with proxy config must be open"); + } + } + + @Test + void proxyConstructorWithNullBehavesAsNoArgConstructor() throws Exception { + try (HttpClientApache client = new HttpClientApache(null)) { + assertFalse(client.isClosed(), "HttpClientApache(null) must behave like no-arg constructor"); + } + } + + @Test + void proxyConstructorWithAuthCreatesOpenInstance() throws Exception { + HttpProxyConfiguration proxy = + HttpProxyConfiguration.builder() + .setHostname("proxy.example.com") + .setPort(3128) + .setUsername("user") + .setPassword("pass") + .build(); + try (HttpClientApache client = new HttpClientApache(proxy)) { + assertFalse( + client.isClosed(), + "HttpClientApache created with authenticated proxy config must be open"); + } + } } diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java new file mode 100644 index 000000000..01e40bcc5 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java @@ -0,0 +1,184 @@ +package com.sinch.sdk.http; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sinch.sdk.core.http.HttpMethod; +import com.sinch.sdk.core.http.HttpRequest; +import com.sinch.sdk.core.models.ServerConfiguration; +import com.sinch.sdk.models.HttpProxyConfiguration; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HttpClientApacheProxyTest { + + MockWebServer mockProxy; + + @BeforeEach + void setUp() throws IOException { + mockProxy = new MockWebServer(); + mockProxy.start(); + } + + @AfterEach + void tearDown() throws IOException { + mockProxy.shutdown(); + } + + @Test + void unauthenticatedProxyRequestRoutedThroughProxy() throws Exception { + HttpProxyConfiguration proxySettings = + HttpProxyConfiguration.builder() + .setHostname("localhost") + .setPort(mockProxy.getPort()) + .build(); + + mockProxy.enqueue( + new MockResponse().setBody("{}").addHeader("Content-Type", "application/json")); + + try (HttpClientApache proxyClient = new HttpClientApache(proxySettings)) { + proxyClient.invokeAPI( + new ServerConfiguration("http://foo.com"), + null, + new HttpRequest( + "/api/path", HttpMethod.GET, null, (String) null, null, null, null, null)); + } + + RecordedRequest request = mockProxy.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "Proxy should have received the request"); + assertTrue( + request.getRequestLine().startsWith("GET http://foo.com/api/path"), + "Initial request should be a full URL as per RFC 7230 when sent to a proxy; actual: " + + request.getRequestLine()); + assertNotNull(request.getRequestUrl(), "Recorded request must have an URL"); + assertEquals( + "localhost", + request.getRequestUrl().host(), + "Proxy request should be sent to the proxy host"); + assertEquals( + mockProxy.getPort(), + request.getRequestUrl().port(), + "Proxy request should be sent to the proxy port"); + assertEquals( + "/", request.getRequestUrl().encodedPath(), "Initial request should have the proxy path"); + + RecordedRequest noMoreRequest = mockProxy.takeRequest(5, TimeUnit.SECONDS); + assertNull(noMoreRequest, "Proxy should not receive more than 1 request for the same call"); + } + + @Test + void authenticatedProxyCredentialsSentAfterChallenge() throws Exception { + HttpProxyConfiguration proxySettings = + HttpProxyConfiguration.builder() + .setHostname("localhost") + .setPort(mockProxy.getPort()) + .setUsername("proxy-user") + .setPassword("proxy-pass") + .build(); + + mockProxy.enqueue( + new MockResponse() + .setResponseCode(407) + .addHeader("Proxy-Authenticate", "Basic realm=\"proxy\"")); + mockProxy.enqueue( + new MockResponse().setBody("{}").addHeader("Content-Type", "application/json")); + + try (HttpClientApache proxyClient = new HttpClientApache(proxySettings)) { + proxyClient.invokeAPI( + new ServerConfiguration("http://foo.com"), + null, + new HttpRequest( + "/api/path", HttpMethod.GET, null, (String) null, null, null, null, null)); + } + + RecordedRequest initial = mockProxy.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(initial, "Proxy should have received the request"); + assertTrue( + initial.getRequestLine().startsWith("GET http://foo.com/api/path"), + "Initial request should be a full URL as per RFC 7230 when sent to a proxy; actual: " + + initial.getRequestLine()); + assertNotNull(initial.getRequestUrl(), "Recorded request must have an URL"); + assertEquals( + "localhost", + initial.getRequestUrl().host(), + "Proxy request should be sent to the proxy host"); + assertEquals( + mockProxy.getPort(), + initial.getRequestUrl().port(), + "Proxy request should be sent to the proxy port"); + assertEquals( + "/", initial.getRequestUrl().encodedPath(), "Initial request should have the proxy path"); + + RecordedRequest retry = mockProxy.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(retry, "Proxy should receive the retry request after 407 challenge"); + + String proxyAuthHeader = retry.getHeader("Proxy-Authorization"); + assertNotNull(proxyAuthHeader, "Proxy-Authorization must be present on the retry"); + assertTrue( + proxyAuthHeader.startsWith("Basic "), + "Proxy-Authorization must use the Basic scheme; actual: " + proxyAuthHeader); + String decoded = + new String( + Base64.getDecoder().decode(proxyAuthHeader.substring("Basic ".length())), + StandardCharsets.UTF_8); + assertEquals( + "proxy-user:proxy-pass", + decoded, + "Proxy-Authorization must encode the exact configured credentials"); + + RecordedRequest noMoreRequest = mockProxy.takeRequest(5, TimeUnit.SECONDS); + assertNull( + noMoreRequest, + "Proxy should not receive more than 2 requests for the same call (initial + retry)"); + } + + @Test + void authenticatedProxyNoChallengeSucceedsDirectly() throws Exception { + HttpProxyConfiguration proxySettings = + HttpProxyConfiguration.builder() + .setHostname("localhost") + .setPort(mockProxy.getPort()) + .setUsername("user") + .setPassword("pass") + .build(); + + mockProxy.enqueue( + new MockResponse().setBody("{}").addHeader("Content-Type", "application/json")); + + try (HttpClientApache proxyClient = new HttpClientApache(proxySettings)) { + proxyClient.invokeAPI( + new ServerConfiguration("http://foo.com"), + null, + new HttpRequest( + "/api/path", HttpMethod.GET, null, (String) null, null, null, null, null)); + } + + RecordedRequest request = mockProxy.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "Proxy should have received the request"); + assertTrue( + request.getRequestLine().startsWith("GET http://foo.com/api/path"), + "Initial request should be a full URL as per RFC 7230 when sent to a proxy; actual: " + + request.getRequestLine()); + assertNotNull(request.getRequestUrl(), "Recorded request must have an URL"); + assertEquals( + "localhost", + request.getRequestUrl().host(), + "Proxy request should be sent to the proxy host"); + assertEquals( + mockProxy.getPort(), + request.getRequestUrl().port(), + "Proxy request should be sent to the proxy port"); + assertEquals( + "/", request.getRequestUrl().encodedPath(), "Initial request should have the proxy path"); + + RecordedRequest noMoreRequest = mockProxy.takeRequest(5, TimeUnit.SECONDS); + assertNull(noMoreRequest, "Proxy should not receive more than 1 request for the same call"); + } +} diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java index 329580abd..f2835cbac 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java @@ -53,19 +53,18 @@ void testInvokeApiHandles401WithEmptyHeaders() throws Exception { doReturn(unauthorizedResponse).when(client).processRequest(any(), any()); // Mock ServerConfiguration and HttpRequest - ServerConfiguration serverConfig = mock(ServerConfiguration.class); - when(serverConfig.getUrl()).thenReturn("https://api.example.com"); - - HttpRequest request = mock(HttpRequest.class); - when(request.getFullUrl()).thenReturn(Optional.of("https://api.example.com/v1/test")); - when(request.getMethod()).thenReturn(HttpMethod.GET); - when(request.getQueryParameters()).thenReturn(Collections.emptyList()); - when(request.getBody()).thenReturn(null); - when(request.getFormParams()).thenReturn(Collections.emptyMap()); - when(request.getHeaderParams()).thenReturn(Collections.emptyMap()); - when(request.getAccept()).thenReturn(Collections.emptyList()); - when(request.getContentType()).thenReturn(Collections.emptyList()); - when(request.getAuthNames()).thenReturn(Collections.singletonList("Bearer")); + ServerConfiguration serverConfig = new ServerConfiguration("https://api.example.com"); + + HttpRequest request = + new HttpRequest( + "https://api.example.com/v1/test", + HttpMethod.GET, + Collections.emptyList(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyList(), + Collections.emptyList(), + Collections.singletonList("Bearer")); // Mock AuthManagers map (bearer) Map authManagers = new HashMap<>(); @@ -110,4 +109,62 @@ void processResponseDecodesBodyAsUtf8() throws Exception { assertEquals(nonAscii, decoded, "Response body must round-trip through UTF-8 correctly"); } } + + /** + * Verifies that a 407 Proxy Authentication Required response returned by {@code processRequest} + * (i.e. Apache's internal 407 handling failed) is returned immediately to the caller without + * triggering the OAuth token-refresh logic. + * + *

Some enterprise proxies include a {@code www-authenticate: Bearer error="expired"} header on + * 407 responses. Without the explicit 407 guard in {@link + * HttpClientApache#invokeAPI(com.sinch.sdk.core.models.ServerConfiguration, Map, + * com.sinch.sdk.core.http.HttpRequest)}, that header would cause the OAuthManager to reset its + * token and retry the request — incorrectly treating a proxy auth failure as an expired API + * token. + */ + @Test + void testInvokeApi407DoesNotTriggerOAuthRefresh() throws Exception { + // GIVEN: a 407 response that also carries www-authenticate: Bearer error="expired" + // (non-standard but seen on some corporate proxies) + Map> proxyHeaders = new HashMap<>(); + proxyHeaders.put( + OAuthManager.BEARER_AUTHENTICATE_RESPONSE_HEADER_KEYWORD, + Collections.singletonList("Bearer realm=\"proxy\", error=\"expired\"")); + + HttpResponse proxyAuthResponse = + new HttpResponse(407, "Proxy Authentication Required", proxyHeaders, null); + + doReturn(proxyAuthResponse).when(client).processRequest(any(), any()); + + ServerConfiguration serverConfig = new ServerConfiguration("https://api.example.com"); + + HttpRequest request = + new HttpRequest( + "https://api.example.com/v1/test", + HttpMethod.GET, + Collections.emptyList(), + Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyList(), + Collections.emptyList(), + Collections.singletonList("Bearer")); + + Map authManagers = new HashMap<>(); + authManagers.put(OAuthManager.SCHEMA_KEYWORD_BEARER, mockAuthManager); + when(mockAuthManager.getSchema()).thenReturn(OAuthManager.SCHEMA_KEYWORD_BEARER); + when(mockAuthManager.getAuthorizationHeaders(any(), any(), any(), any())) + .thenReturn(Collections.emptyList()); + + // WHEN: invokeAPI is called + HttpResponse response = client.invokeAPI(serverConfig, authManagers, request); + + // THEN: the 407 is returned as-is + assertEquals(407, response.getCode()); + + // AND: OAuth token reset must NOT be triggered by the proxy 407 + verify(mockAuthManager, never()).resetToken(); + + // AND: processRequest is called exactly once — no OAuth retry + verify(client, times(1)).processRequest(any(), any()); + } } diff --git a/client/src/test/java/com/sinch/sdk/models/ConfigurationBuilderTest.java b/client/src/test/java/com/sinch/sdk/models/ConfigurationBuilderTest.java index 772f49812..69c116765 100644 --- a/client/src/test/java/com/sinch/sdk/models/ConfigurationBuilderTest.java +++ b/client/src/test/java/com/sinch/sdk/models/ConfigurationBuilderTest.java @@ -14,6 +14,10 @@ class ConfigurationBuilderTest { static final ConversationRegion CONVERSATION_REGION = ConversationRegion.BR; static final String CONVERSATION_SERVER = "%sfooCONVERSATION_SERVER"; static final String CONVERSATION_TEMPLATE_SERVER = "%sfooCONVERSATION_TEMPLATE_SERVER"; + static final String PROXY_HOST = "proxy.corp.example.com"; + static final int PROXY_PORT = 3128; + static final String PROXY_USER = "proxyUser"; + static final String PROXY_PASS = "proxyPass"; @Test void build() { @@ -32,6 +36,13 @@ void build() { .setUrl(CONVERSATION_SERVER) .setTemplateManagementUrl(CONVERSATION_TEMPLATE_SERVER) .build()) + .setHttpProxyConfiguration( + HttpProxyConfiguration.builder() + .setHostname(PROXY_HOST) + .setPort(PROXY_PORT) + .setUsername(PROXY_USER) + .setPassword(PROXY_PASS) + .build()) .build(); Assertions.assertEquals(OAUTH_URL, builder.getOAuthServer().getUrl()); Assertions.assertEquals( @@ -67,5 +78,57 @@ void build() { .getUrl() .contains("fooCONVERSATION_TEMPLATE_SERVER"), "Conversation template server present within conversation template server URL"); + + Assertions.assertTrue( + builder.getHttpProxyConfiguration().isPresent(), "Proxy configuration should be present"); + Assertions.assertEquals( + PROXY_HOST, builder.getHttpProxyConfiguration().get().getHostname(), "Proxy hostname"); + Assertions.assertEquals( + PROXY_PORT, builder.getHttpProxyConfiguration().get().getPort(), "Proxy port"); + Assertions.assertEquals( + PROXY_USER, + builder.getHttpProxyConfiguration().get().getUsername().orElse(null), + "Proxy username"); + Assertions.assertArrayEquals( + PROXY_PASS.toCharArray(), + builder.getHttpProxyConfiguration().get().getPassword().orElse(null), + "Proxy password"); + } + + @Test + void buildWithoutProxy() { + Configuration config = + new Configuration.Builder() + .setKeyId(KEY) + .setKeySecret(SECRET) + .setProjectId(PROJECT) + .setOAuthUrl(OAUTH_URL) + .build(); + Assertions.assertFalse( + config.getHttpProxyConfiguration().isPresent(), + "Proxy configuration should be absent when not configured"); + } + + @Test + void builderCopyPreservesProxyConfiguration() { + HttpProxyConfiguration proxy = + HttpProxyConfiguration.builder().setHostname(PROXY_HOST).setPort(PROXY_PORT).build(); + Configuration original = + new Configuration.Builder() + .setKeyId(KEY) + .setKeySecret(SECRET) + .setProjectId(PROJECT) + .setOAuthUrl(OAUTH_URL) + .setHttpProxyConfiguration(proxy) + .build(); + + Configuration copy = Configuration.builder(original).build(); + Assertions.assertTrue(copy.getHttpProxyConfiguration().isPresent()); + Assertions.assertEquals( + PROXY_HOST, + copy.getHttpProxyConfiguration().get().getHostname(), + "Hostname preserved in copy"); + Assertions.assertEquals( + PROXY_PORT, copy.getHttpProxyConfiguration().get().getPort(), "Port preserved in copy"); } } diff --git a/client/src/test/java/com/sinch/sdk/models/HttpProxyConfigurationTest.java b/client/src/test/java/com/sinch/sdk/models/HttpProxyConfigurationTest.java new file mode 100644 index 000000000..1d17f5409 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/models/HttpProxyConfigurationTest.java @@ -0,0 +1,204 @@ +package com.sinch.sdk.models; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class HttpProxyConfigurationTest { + + static final String HOST = "proxy.corp.example.com"; + static final int PORT = 3128; + static final String USERNAME = "proxyUser"; + static final String PASSWORD = "proxyPass"; + + static final HttpProxyConfiguration fullSettings = + HttpProxyConfiguration.builder() + .setHostname(HOST) + .setPort(PORT) + .setUsername(USERNAME) + .setPassword(PASSWORD) + .build(); + + static final HttpProxyConfiguration minimalSettings = + HttpProxyConfiguration.builder().setHostname(HOST).setPort(PORT).build(); + + @Test + void getHostname() { + assertEquals(HOST, fullSettings.getHostname()); + } + + @Test + void getPort() { + assertEquals(PORT, fullSettings.getPort()); + } + + @Test + void getUsernameWhenConfigured() { + assertTrue(fullSettings.getUsername().isPresent()); + assertEquals(USERNAME, fullSettings.getUsername().get()); + } + + @Test + void getUsernameAbsentWhenNotConfigured() { + assertFalse(minimalSettings.getUsername().isPresent()); + } + + @Test + void getPasswordWhenConfigured() { + assertTrue(fullSettings.getPassword().isPresent()); + assertArrayEquals(PASSWORD.toCharArray(), fullSettings.getPassword().get()); + } + + @Test + void getPasswordAbsentWhenNotConfigured() { + assertFalse(minimalSettings.getPassword().isPresent()); + } + + @Test + void setPasswordCharArrayOverload() { + char[] pwd = PASSWORD.toCharArray(); + HttpProxyConfiguration config = + HttpProxyConfiguration.builder() + .setHostname(HOST) + .setPort(PORT) + .setUsername(USERNAME) + .setPassword(pwd) + .build(); + assertTrue(config.getPassword().isPresent()); + assertArrayEquals(PASSWORD.toCharArray(), config.getPassword().get()); + } + + @Test + void getPasswordReturnsDefensiveCopy() { + char[] first = fullSettings.getPassword().get(); + first[0] = 'X'; + char[] second = fullSettings.getPassword().get(); + assertEquals( + PASSWORD.charAt(0), + second[0], + "Mutating the returned array must not affect internal state"); + } + + @Test + void setPasswordCharArrayInputMutationDoesNotAffectConfig() { + char[] pwd = PASSWORD.toCharArray(); + HttpProxyConfiguration config = + HttpProxyConfiguration.builder() + .setHostname(HOST) + .setPort(PORT) + .setUsername(USERNAME) + .setPassword(pwd) + .build(); + pwd[0] = 'X'; + assertArrayEquals( + PASSWORD.toCharArray(), + config.getPassword().get(), + "Mutating the original char[] after build must not affect stored password"); + } + + @Test + void toStringSensitiveDataMasked() { + String value = fullSettings.toString(); + assertFalse(value.contains(USERNAME), "username must not appear in toString output"); + assertFalse(value.contains(PASSWORD), "password must not appear in toString output"); + assertTrue(value.contains(HOST), "hostname should be visible in toString output"); + } + + @Test + void builderCopyPreservesAllFields() { + HttpProxyConfiguration copy = HttpProxyConfiguration.builder(fullSettings).build(); + assertEquals(fullSettings.getHostname(), copy.getHostname()); + assertEquals(fullSettings.getPort(), copy.getPort()); + assertEquals(fullSettings.getUsername(), copy.getUsername()); + assertArrayEquals(fullSettings.getPassword().orElse(null), copy.getPassword().orElse(null)); + } + + @Test + void buildNullHostnameThrows() { + assertThrows( + IllegalArgumentException.class, + () -> HttpProxyConfiguration.builder().setPort(PORT).build()); + } + + @Test + void buildEmptyHostnameThrows() { + assertThrows( + IllegalArgumentException.class, + () -> HttpProxyConfiguration.builder().setHostname("").setPort(PORT).build()); + } + + @Test + void buildPortZeroThrows() { + assertThrows( + IllegalArgumentException.class, + () -> HttpProxyConfiguration.builder().setHostname(HOST).setPort(0).build()); + } + + @Test + void buildPortNegativeThrows() { + assertThrows( + IllegalArgumentException.class, + () -> HttpProxyConfiguration.builder().setHostname(HOST).setPort(-1).build()); + } + + @Test + void buildPortAboveMaxThrows() { + assertThrows( + IllegalArgumentException.class, + () -> HttpProxyConfiguration.builder().setHostname(HOST).setPort(65536).build()); + } + + @Test + void buildPortAtBoundaryAccepted() { + assertDoesNotThrow(() -> HttpProxyConfiguration.builder().setHostname(HOST).setPort(1).build()); + assertDoesNotThrow( + () -> HttpProxyConfiguration.builder().setHostname(HOST).setPort(65535).build()); + } + + @Test + void buildPasswordWithoutUsernameThrows() { + assertThrows( + IllegalArgumentException.class, + () -> + HttpProxyConfiguration.builder() + .setHostname(HOST) + .setPort(PORT) + .setPassword(PASSWORD) + .build()); + } + + @Test + void buildUsernameWithoutPasswordThrows() { + assertThrows( + IllegalArgumentException.class, + () -> + HttpProxyConfiguration.builder() + .setHostname(HOST) + .setPort(PORT) + .setUsername(USERNAME) + .build()); + } + + @Test + void buildHostnameWithWhitespaceOnlyThrows() { + assertThrows( + IllegalArgumentException.class, + () -> HttpProxyConfiguration.builder().setHostname(" ").setPort(PORT).build()); + } + + @Test + void buildHostnameWithLeadingTrailingSpacesIsTrimmed() { + HttpProxyConfiguration config = + HttpProxyConfiguration.builder().setHostname(" " + HOST + " ").setPort(PORT).build(); + assertEquals(HOST, config.getHostname(), "Hostname should be trimmed"); + } + + @Test + void builderCopyAllowsOverride() { + HttpProxyConfiguration modified = + HttpProxyConfiguration.builder(fullSettings).setHostname("other-proxy.example.com").build(); + assertEquals("other-proxy.example.com", modified.getHostname()); + assertEquals(PORT, modified.getPort()); + assertEquals(fullSettings.getUsername(), modified.getUsername()); + } +} diff --git a/pom.xml b/pom.xml index 0d3e83e8a..84273c1ca 100644 --- a/pom.xml +++ b/pom.xml @@ -297,6 +297,8 @@ com.sinch.sdk.e2e.domains.voice.v1.VoiceIT com.sinch.sdk.e2e.domains.verification.v1.VerificationIT com.sinch.sdk.e2e.domains.numberlookup.v2.NumberLookupIT + com.sinch.sdk.e2e.domains.proxy.ProxyIT + com.sinch.sdk.e2e.domains.proxy.HttpClientApacheProxyIT