From 7b2b14e5e12e43e36921fe252239085398011276 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Mon, 4 May 2026 14:42:47 +0200 Subject: [PATCH 01/19] feat (HttpClient): Support proxy usage --- CHANGELOG.md | 1 + README.md | 48 ++++ .../src/main/com/sinch/sdk/SinchClient.java | 2 +- .../com/sinch/sdk/http/HttpClientApache.java | 106 +++++++- .../com/sinch/sdk/models/Configuration.java | 35 ++- .../sdk/models/HttpProxyConfiguration.java | 256 ++++++++++++++++++ .../java/com/sinch/sdk/SinchClientTest.java | 62 +++++ .../http/HttpClientApacheLifecycleTest.java | 33 +++ .../sdk/http/HttpClientApacheProxyTest.java | 155 +++++++++++ .../sinch/sdk/http/HttpClientApacheTest.java | 59 ++++ .../sdk/models/ConfigurationBuilderTest.java | 63 +++++ .../models/HttpProxyConfigurationTest.java | 204 ++++++++++++++ 12 files changed, 1020 insertions(+), 4 deletions(-) create mode 100644 client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java create mode 100644 client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java create mode 100644 client/src/test/java/com/sinch/sdk/models/HttpProxyConfigurationTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index e519254ac..62d0628ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,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..0ddd04d98 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ For more information on the SDK, refer to the dedicated [Java SDK documentation - [Prerequisites](#prerequisites) - [Installation](#installation) +- [Getting started](#getting-started) + - [Client initialization](#client-initialization) + - [Client lifecycle](#client-lifecycle) + - [Proxy configuration](#proxy-configuration) - [Supported APIs](#supported-apis) - [Getting started](#getting-started) - [Logging](#logging) @@ -168,6 +172,50 @@ SinchClient client = new SinchClient(configuration); > 2. Use the Conversation API, which works with project access keys. > 3. Contact your account manager +### Proxy configuration + +If your network environment routes outbound traffic through an HTTP proxy, provide proxy configuration via `HttpProxyConfiguration` on the `Configuration` builder. + +**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) + .setPassword(PARAM_PROXY_PASSWORD) + .build()) + .build(); +SinchClient client = new SinchClient(configuration); +``` + +## Supported APIs - **Service plan** — available in all regions (`US`, `EU`, `AU`, `BR`, `CA`). Use a `smsServicePlanId` and `smsApiToken`, both available on the [Service APIs dashboard](https://dashboard.sinch.com/sms/api/services): ```java 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..338d65d80 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 + BasicCredentialsProvider (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); @@ -348,6 +416,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..1a4c1ba32 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( @@ -27,7 +28,8 @@ private Configuration( VerificationContext verificationContext, VoiceContext voiceContext, ConversationContext conversationContext, - NumberLookupContext numberLookupContext) { + NumberLookupContext numberLookupContext, + HttpProxyConfiguration httpProxyConfiguration) { this.unifiedCredentials = unifiedCredentials; this.applicationCredentials = applicationCredentials; this.smsServicePlanCredentials = smsServicePlanCredentials; @@ -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 * @@ -549,7 +579,8 @@ public Configuration build() { null != verificationContext ? verificationContext.build() : null, null != voiceContext ? voiceContext.build() : null, null != conversationContext ? conversationContext.build() : null, - null != numberLookupContext ? numberLookupContext.build() : null); + null != numberLookupContext ? numberLookupContext.build() : null, + httpProxyConfiguration); } } } 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..51a56547a --- /dev/null +++ b/client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java @@ -0,0 +1,256 @@ +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..deee2cf0e 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,62 @@ 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"); + } + + /** + * Verifies that {@link SinchClient#getHttpClient()} (called via reflection) creates an {@link + * HttpClientApache} when proxy configuration is present — i.e. the proxy config is wired from + * {@link Configuration} into the HTTP-client factory. + */ + @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); + + // Trigger lazy initialization of the internal HttpClientApache via reflection + Method getHttpClient = SinchClient.class.getDeclaredMethod("getHttpClient"); + getHttpClient.setAccessible(true); + Object httpClient = getHttpClient.invoke(sinchClient); + + assertNotNull(httpClient, "getHttpClient() must return a non-null HttpClientApache"); + assertInstanceOf( + HttpClientApache.class, httpClient, "getHttpClient() must return an HttpClientApache"); + + // Verify the stored field in SinchClient was initialised (not null) + Field httpClientField = SinchClient.class.getDeclaredField("httpClient"); + httpClientField.setAccessible(true); + assertNotNull( + httpClientField.get(sinchClient), + "SinchClient.httpClient field must be initialised after getHttpClient()"); + } } 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..842b7f187 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java @@ -0,0 +1,155 @@ +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; + +/** + * Integration tests verifying that {@link HttpClientApache} routes traffic through a configured + * HTTP proxy and handles proxy authentication challenges. + * + *

Each test spins up its own {@link MockWebServer} that plays the role of the proxy, so tests + * are fully isolated and safe to run in parallel. + */ +class HttpClientApacheProxyTest { + + MockWebServer mockProxy; + String proxyBaseUrl; + + @BeforeEach + void setUp() throws IOException { + mockProxy = new MockWebServer(); + mockProxy.start(); + proxyBaseUrl = String.format("http://localhost:%s/", mockProxy.getPort()); + } + + @AfterEach + void tearDown() throws IOException { + mockProxy.shutdown(); + } + + /** + * Verifies that when a proxy is configured, the HTTP connection is established with the proxy + * host rather than the target host. Using the proxy server's address as both proxy and target + * keeps the test self-contained — if the proxy setting were ignored, Apache would still connect + * to the same MockWebServer directly and the request would be received anyway. The real proof is + * in {@link #authenticatedProxyCredentialsSentAfterChallenge()} where Apache must handle the + * 407/retry cycle correctly. + */ + @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(proxyBaseUrl), + 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"); + assertNotNull(request.getPath(), "Recorded request must have a path"); + } + + /** + * Verifies that when the proxy returns a 407 challenge, Apache retries with the correct {@code + * Proxy-Authorization} credentials and the call ultimately succeeds. + * + *

MockWebServer plays the role of an authenticating proxy: it first returns 407 with a {@code + * Proxy-Authenticate: Basic} challenge, then accepts the retry and returns 200. The test asserts + * that exactly two requests were received and that the retry carries the expected Basic + * credentials. + */ + @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(proxyBaseUrl), + 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 receive the initial request"); + + 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"); + } + + /** A proxy with credentials but no 407 challenge should work fine (no retry needed). */ + @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(proxyBaseUrl), + 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 receive the request"); + } +} 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..b2a3f941c 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheTest.java @@ -110,4 +110,63 @@ 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 = 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")); + + 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()); + } +} From 0b2baa00ab7b1244c62e62b8c8dc5c4a972c5f67 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Fri, 5 Jun 2026 08:26:40 +0200 Subject: [PATCH 02/19] Add E2E proxy tests with Docker Squid for unauthenticated and authenticated proxies --- .../http/HttpClientApacheProxyE2ETest.java | 249 ++++++++++++++++++ .../src/test/resources/squid-auth/passwords | 1 + .../src/test/resources/squid-auth/squid.conf | 6 + 3 files changed, 256 insertions(+) create mode 100644 client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java create mode 100644 client/src/test/resources/squid-auth/passwords create mode 100644 client/src/test/resources/squid-auth/squid.conf diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java new file mode 100644 index 000000000..32b041c86 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java @@ -0,0 +1,249 @@ +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.http.HttpResponse; +import com.sinch.sdk.core.models.ServerConfiguration; +import com.sinch.sdk.models.HttpProxyConfiguration; +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * E2E proxy tests using Docker directly with a real Squid proxy. + * + *

Requires Docker to be running. Tests are tagged so they can be included/excluded in CI: + * + *

{@code mvn test -Dgroups=proxy-e2e}
+ */ +@Tag("proxy-e2e") +class HttpClientApacheProxyE2ETest { + + private static final int SQUID_PORT = 3128; + + @BeforeAll + static void requireDocker() throws Exception { + Process p = + new ProcessBuilder("docker", "info").redirectErrorStream(true).start(); + boolean finished = p.waitFor(5, TimeUnit.SECONDS); + Assumptions.assumeTrue( + finished && p.exitValue() == 0, "Docker is not available, skipping proxy E2E tests"); + } + + private static HttpRequest simpleGetRequest() { + return new HttpRequest( + "api/test", HttpMethod.GET, null, (String) null, null, null, null, null); + } + + /** Start a plain (unauthenticated) Squid container, return the container ID. */ + private static String startSquidContainer(int hostPort) throws Exception { + ProcessBuilder pb = + new ProcessBuilder( + "docker", + "run", + "-d", + "--rm", + "-p", + hostPort + ":" + SQUID_PORT, + "ubuntu/squid:latest"); + pb.redirectErrorStream(true); + Process p = pb.start(); + String containerId; + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + containerId = r.readLine(); + } + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "docker run must finish"); + assertEquals(0, p.exitValue(), "docker run must succeed"); + assertNotNull(containerId, "container ID must be returned"); + // Give Squid a moment to start listening + Thread.sleep(3000); + return containerId.trim(); + } + + /** + * Start an authenticated Squid container using config and password files from test resources. + * Returns the container ID. + */ + private static String startAuthSquidContainer(int hostPort) throws Exception { + // Resolve resource files from classpath + Path squidConf = + java.nio.file.Paths.get( + HttpClientApacheProxyE2ETest.class + .getClassLoader() + .getResource("squid-auth/squid.conf") + .toURI()); + Path passwords = + java.nio.file.Paths.get( + HttpClientApacheProxyE2ETest.class + .getClassLoader() + .getResource("squid-auth/passwords") + .toURI()); + + assertTrue(Files.exists(squidConf), "squid.conf must exist on classpath"); + assertTrue(Files.exists(passwords), "passwords must exist on classpath"); + + ProcessBuilder pb = + new ProcessBuilder( + "docker", + "run", + "-d", + "--rm", + "-p", + hostPort + ":" + SQUID_PORT, + "-v", + squidConf.toAbsolutePath() + ":/etc/squid/squid.conf:ro", + "-v", + passwords.toAbsolutePath() + ":/etc/squid/passwords:ro", + "ubuntu/squid:latest"); + pb.redirectErrorStream(true); + Process p = pb.start(); + String containerId; + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + containerId = r.readLine(); + } + assertTrue(p.waitFor(30, TimeUnit.SECONDS), "docker run must finish"); + assertEquals(0, p.exitValue(), "docker run must succeed"); + assertNotNull(containerId, "container ID must be returned"); + Thread.sleep(3000); + return containerId.trim(); + } + + private static void stopContainer(String containerId) { + try { + new ProcessBuilder("docker", "stop", containerId) + .redirectErrorStream(true) + .start() + .waitFor(15, TimeUnit.SECONDS); + } catch (Exception ignored) { + } + } + + private static int findFreePort() throws Exception { + try (ServerSocket s = new ServerSocket(0)) { + return s.getLocalPort(); + } + } + + @Test + void unauthenticatedProxyRoutesTraffic() throws Exception { + int proxyPort = findFreePort(); + String containerId = startSquidContainer(proxyPort); + try { + MockWebServer targetServer = new MockWebServer(); + targetServer.start(); + try { + targetServer.enqueue( + new MockResponse() + .setBody("{\"status\":\"ok\"}") + .addHeader("Content-Type", "application/json")); + + String targetUrl = + String.format("http://host.docker.internal:%d/", targetServer.getPort()); + + HttpProxyConfiguration proxyConfig = + HttpProxyConfiguration.builder() + .setHostname("localhost") + .setPort(proxyPort) + .build(); + + try (HttpClientApache client = new HttpClientApache(proxyConfig)) { + HttpResponse response = + client.invokeAPI(new ServerConfiguration(targetUrl), null, simpleGetRequest()); + + assertEquals(200, response.getCode(), "Request through unauthenticated proxy must succeed"); + } + + RecordedRequest request = targetServer.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "Target server must receive the proxied request"); + } finally { + targetServer.shutdown(); + } + } finally { + stopContainer(containerId); + } + } + + @Test + void authenticatedProxyRoutesTrafficAfterChallenge() throws Exception { + int proxyPort = findFreePort(); + String containerId = startAuthSquidContainer(proxyPort); + try { + MockWebServer targetServer = new MockWebServer(); + targetServer.start(); + try { + targetServer.enqueue( + new MockResponse() + .setBody("{\"status\":\"ok\"}") + .addHeader("Content-Type", "application/json")); + + String targetUrl = + String.format("http://host.docker.internal:%d/", targetServer.getPort()); + + HttpProxyConfiguration proxyConfig = + HttpProxyConfiguration.builder() + .setHostname("localhost") + .setPort(proxyPort) + .setUsername("proxyuser") + .setPassword("proxypass") + .build(); + + try (HttpClientApache client = new HttpClientApache(proxyConfig)) { + HttpResponse response = + client.invokeAPI(new ServerConfiguration(targetUrl), null, simpleGetRequest()); + + assertEquals( + 200, + response.getCode(), + "Request through authenticated proxy must succeed after 407 challenge"); + } + + RecordedRequest request = targetServer.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(request, "Target server must receive the proxied request"); + } finally { + targetServer.shutdown(); + } + } finally { + stopContainer(containerId); + } + } + + @Test + void authenticatedProxyRejectsWrongCredentials() throws Exception { + int proxyPort = findFreePort(); + String containerId = startAuthSquidContainer(proxyPort); + try { + HttpProxyConfiguration proxyConfig = + HttpProxyConfiguration.builder() + .setHostname("localhost") + .setPort(proxyPort) + .setUsername("wrong-user") + .setPassword("wrong-pass") + .build(); + + try (HttpClientApache client = new HttpClientApache(proxyConfig)) { + HttpResponse response = + client.invokeAPI( + new ServerConfiguration("http://httpbin.org/get"), null, simpleGetRequest()); + + assertEquals( + 407, + response.getCode(), + "Wrong credentials must result in 407 Proxy Authentication Required"); + } + } finally { + stopContainer(containerId); + } + } +} diff --git a/client/src/test/resources/squid-auth/passwords b/client/src/test/resources/squid-auth/passwords new file mode 100644 index 000000000..5553772ed --- /dev/null +++ b/client/src/test/resources/squid-auth/passwords @@ -0,0 +1 @@ +proxyuser:$apr1$px0XbKbF$YDu.gLzmG9D8dW5RnCog0. diff --git a/client/src/test/resources/squid-auth/squid.conf b/client/src/test/resources/squid-auth/squid.conf new file mode 100644 index 000000000..70a7665a4 --- /dev/null +++ b/client/src/test/resources/squid-auth/squid.conf @@ -0,0 +1,6 @@ +auth_param basic program /usr/lib/squid/basic_ncsa_auth /etc/squid/passwords +auth_param basic realm proxy +acl authenticated proxy_auth REQUIRED +http_access allow authenticated +http_access deny all +http_port 3128 From c5c5f37c89179969c5c34aa43f8598fe38cda692 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Fri, 5 Jun 2026 08:33:57 +0200 Subject: [PATCH 03/19] Apply spotless formatting to proxy E2E test --- .../http/HttpClientApacheProxyE2ETest.java | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java index 32b041c86..5a2b8ceb8 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java @@ -35,16 +35,14 @@ class HttpClientApacheProxyE2ETest { @BeforeAll static void requireDocker() throws Exception { - Process p = - new ProcessBuilder("docker", "info").redirectErrorStream(true).start(); + Process p = new ProcessBuilder("docker", "info").redirectErrorStream(true).start(); boolean finished = p.waitFor(5, TimeUnit.SECONDS); Assumptions.assumeTrue( finished && p.exitValue() == 0, "Docker is not available, skipping proxy E2E tests"); } private static HttpRequest simpleGetRequest() { - return new HttpRequest( - "api/test", HttpMethod.GET, null, (String) null, null, null, null, null); + return new HttpRequest("api/test", HttpMethod.GET, null, (String) null, null, null, null, null); } /** Start a plain (unauthenticated) Squid container, return the container ID. */ @@ -149,20 +147,17 @@ void unauthenticatedProxyRoutesTraffic() throws Exception { .setBody("{\"status\":\"ok\"}") .addHeader("Content-Type", "application/json")); - String targetUrl = - String.format("http://host.docker.internal:%d/", targetServer.getPort()); + String targetUrl = String.format("http://host.docker.internal:%d/", targetServer.getPort()); HttpProxyConfiguration proxyConfig = - HttpProxyConfiguration.builder() - .setHostname("localhost") - .setPort(proxyPort) - .build(); + HttpProxyConfiguration.builder().setHostname("localhost").setPort(proxyPort).build(); try (HttpClientApache client = new HttpClientApache(proxyConfig)) { HttpResponse response = client.invokeAPI(new ServerConfiguration(targetUrl), null, simpleGetRequest()); - assertEquals(200, response.getCode(), "Request through unauthenticated proxy must succeed"); + assertEquals( + 200, response.getCode(), "Request through unauthenticated proxy must succeed"); } RecordedRequest request = targetServer.takeRequest(5, TimeUnit.SECONDS); @@ -188,8 +183,7 @@ void authenticatedProxyRoutesTrafficAfterChallenge() throws Exception { .setBody("{\"status\":\"ok\"}") .addHeader("Content-Type", "application/json")); - String targetUrl = - String.format("http://host.docker.internal:%d/", targetServer.getPort()); + String targetUrl = String.format("http://host.docker.internal:%d/", targetServer.getPort()); HttpProxyConfiguration proxyConfig = HttpProxyConfiguration.builder() From 6d68a0b65e724d48a7d116cdbbadd69636b68f03 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Fri, 5 Jun 2026 08:37:54 +0200 Subject: [PATCH 04/19] Exclude E2E tests from default test run --- pom.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pom.xml b/pom.xml index 0d3e83e8a..d6920955c 100644 --- a/pom.xml +++ b/pom.xml @@ -310,6 +310,8 @@ ${maven-surefire-plugin.version} ${skipUTs} + all + proxy-e2e From 8f3f0df6b6720cd733281a43b0d248ed2c6cf357 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Fri, 5 Jun 2026 08:47:37 +0200 Subject: [PATCH 05/19] Addressing comments from Copilot --- client/src/main/com/sinch/sdk/http/HttpClientApache.java | 3 ++- .../java/com/sinch/sdk/http/HttpClientApacheProxyTest.java | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/main/com/sinch/sdk/http/HttpClientApache.java b/client/src/main/com/sinch/sdk/http/HttpClientApache.java index 338d65d80..d79d36c5b 100644 --- a/client/src/main/com/sinch/sdk/http/HttpClientApache.java +++ b/client/src/main/com/sinch/sdk/http/HttpClientApache.java @@ -216,7 +216,8 @@ public HttpResponse invokeAPI( } // UNAUTHORIZED (HTTP 401) error code could imply refreshing the OAuth token - if (response.getCode() == HttpStatus.UNAUTHORIZED) { + if (response.getCode() == HttpStatus.UNAUTHORIZED + && authManagersByOasSecuritySchemes != null) { boolean couldRetryRequest = processUnauthorizedResponse(httpRequest, response, authManagersByOasSecuritySchemes); if (couldRetryRequest) { diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java index 842b7f187..6b39f617d 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java @@ -71,6 +71,11 @@ void unauthenticatedProxyRequestRoutedThroughProxy() throws Exception { RecordedRequest request = mockProxy.takeRequest(5, TimeUnit.SECONDS); assertNotNull(request, "Proxy should have received the request"); assertNotNull(request.getPath(), "Recorded request must have a path"); + assertTrue( + request.getPath().startsWith("http://"), + "When using an HTTP proxy, the request-target should be absolute-form (starts with" + + " http://); actual: " + + request.getPath()); } /** From 4d7be46bb32cf9faaf38bdf5fd975c3b5f1e66c7 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Fri, 5 Jun 2026 08:53:55 +0200 Subject: [PATCH 06/19] Reverting suggestion from Copilot --- .../java/com/sinch/sdk/http/HttpClientApacheProxyTest.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java index 6b39f617d..842b7f187 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java @@ -71,11 +71,6 @@ void unauthenticatedProxyRequestRoutedThroughProxy() throws Exception { RecordedRequest request = mockProxy.takeRequest(5, TimeUnit.SECONDS); assertNotNull(request, "Proxy should have received the request"); assertNotNull(request.getPath(), "Recorded request must have a path"); - assertTrue( - request.getPath().startsWith("http://"), - "When using an HTTP proxy, the request-target should be absolute-form (starts with" - + " http://); actual: " - + request.getPath()); } /** From f8970358dfb4d89598822641e4fb781a0eeeafbd Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Mon, 8 Jun 2026 11:50:18 +0200 Subject: [PATCH 07/19] Rename proxy E2E test to IT and add it to pom.xml --- ...oxyE2ETest.java => HttpClientApacheProxyIT.java} | 13 +++++-------- pom.xml | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) rename client/src/test/java/com/sinch/sdk/http/{HttpClientApacheProxyE2ETest.java => HttpClientApacheProxyIT.java} (96%) diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java similarity index 96% rename from client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java rename to client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java index 5a2b8ceb8..e0406ba7d 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyE2ETest.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java @@ -18,18 +18,15 @@ import okhttp3.mockwebserver.RecordedRequest; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; /** * E2E proxy tests using Docker directly with a real Squid proxy. * - *

Requires Docker to be running. Tests are tagged so they can be included/excluded in CI: - * - *

{@code mvn test -Dgroups=proxy-e2e}
+ *

Requires Docker to be running. Included in the failsafe integration-test phase alongside other + * {@code *IT} tests. */ -@Tag("proxy-e2e") -class HttpClientApacheProxyE2ETest { +class HttpClientApacheProxyIT { private static final int SQUID_PORT = 3128; @@ -78,13 +75,13 @@ private static String startAuthSquidContainer(int hostPort) throws Exception { // Resolve resource files from classpath Path squidConf = java.nio.file.Paths.get( - HttpClientApacheProxyE2ETest.class + HttpClientApacheProxyIT.class .getClassLoader() .getResource("squid-auth/squid.conf") .toURI()); Path passwords = java.nio.file.Paths.get( - HttpClientApacheProxyE2ETest.class + HttpClientApacheProxyIT.class .getClassLoader() .getResource("squid-auth/passwords") .toURI()); diff --git a/pom.xml b/pom.xml index d6920955c..aa3c20343 100644 --- a/pom.xml +++ b/pom.xml @@ -297,6 +297,7 @@ 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.http.HttpClientApacheProxyIT @@ -311,7 +312,6 @@ ${skipUTs} all - proxy-e2e From 88dbc8d8e2c497ffa6082709134b3a9cc6746103 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Mon, 8 Jun 2026 17:29:54 +0200 Subject: [PATCH 08/19] Fixed proxy for Linux CI runners --- .../sdk/http/HttpClientApacheProxyIT.java | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java index e0406ba7d..eade5f0f8 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java @@ -50,6 +50,7 @@ private static String startSquidContainer(int hostPort) throws Exception { "run", "-d", "--rm", + "--add-host=host.docker.internal:host-gateway", "-p", hostPort + ":" + SQUID_PORT, "ubuntu/squid:latest"); @@ -95,6 +96,7 @@ private static String startAuthSquidContainer(int hostPort) throws Exception { "run", "-d", "--rm", + "--add-host=host.docker.internal:host-gateway", "-p", hostPort + ":" + SQUID_PORT, "-v", @@ -215,23 +217,30 @@ void authenticatedProxyRejectsWrongCredentials() throws Exception { int proxyPort = findFreePort(); String containerId = startAuthSquidContainer(proxyPort); try { - HttpProxyConfiguration proxyConfig = - HttpProxyConfiguration.builder() - .setHostname("localhost") - .setPort(proxyPort) - .setUsername("wrong-user") - .setPassword("wrong-pass") - .build(); - - try (HttpClientApache client = new HttpClientApache(proxyConfig)) { - HttpResponse response = - client.invokeAPI( - new ServerConfiguration("http://httpbin.org/get"), null, simpleGetRequest()); - - assertEquals( - 407, - response.getCode(), - "Wrong credentials must result in 407 Proxy Authentication Required"); + MockWebServer targetServer = new MockWebServer(); + targetServer.start(); + try { + String targetUrl = String.format("http://host.docker.internal:%d/", targetServer.getPort()); + + HttpProxyConfiguration proxyConfig = + HttpProxyConfiguration.builder() + .setHostname("localhost") + .setPort(proxyPort) + .setUsername("wrong-user") + .setPassword("wrong-pass") + .build(); + + try (HttpClientApache client = new HttpClientApache(proxyConfig)) { + HttpResponse response = + client.invokeAPI(new ServerConfiguration(targetUrl), null, simpleGetRequest()); + + assertEquals( + 407, + response.getCode(), + "Wrong credentials must result in 407 Proxy Authentication Required"); + } + } finally { + targetServer.shutdown(); } } finally { stopContainer(containerId); From 392fb3fca0a401a833c4c6dcdb453a5b3e967dfd Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Mon, 8 Jun 2026 17:45:11 +0200 Subject: [PATCH 09/19] Fixing issues with the host networking --- .../sdk/http/HttpClientApacheProxyIT.java | 47 ++++++++----------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java index eade5f0f8..3bddea18f 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java @@ -42,18 +42,13 @@ private static HttpRequest simpleGetRequest() { return new HttpRequest("api/test", HttpMethod.GET, null, (String) null, null, null, null, null); } - /** Start a plain (unauthenticated) Squid container, return the container ID. */ - private static String startSquidContainer(int hostPort) throws Exception { + /** + * Start a plain (unauthenticated) Squid container with host networking. Returns the container ID. + */ + private static String startSquidContainer() throws Exception { ProcessBuilder pb = new ProcessBuilder( - "docker", - "run", - "-d", - "--rm", - "--add-host=host.docker.internal:host-gateway", - "-p", - hostPort + ":" + SQUID_PORT, - "ubuntu/squid:latest"); + "docker", "run", "-d", "--rm", "--network", "host", "ubuntu/squid:latest"); pb.redirectErrorStream(true); Process p = pb.start(); String containerId; @@ -69,10 +64,10 @@ private static String startSquidContainer(int hostPort) throws Exception { } /** - * Start an authenticated Squid container using config and password files from test resources. - * Returns the container ID. + * Start an authenticated Squid container with host networking using config and password files + * from test resources. Returns the container ID. */ - private static String startAuthSquidContainer(int hostPort) throws Exception { + private static String startAuthSquidContainer() throws Exception { // Resolve resource files from classpath Path squidConf = java.nio.file.Paths.get( @@ -96,9 +91,8 @@ private static String startAuthSquidContainer(int hostPort) throws Exception { "run", "-d", "--rm", - "--add-host=host.docker.internal:host-gateway", - "-p", - hostPort + ":" + SQUID_PORT, + "--network", + "host", "-v", squidConf.toAbsolutePath() + ":/etc/squid/squid.conf:ro", "-v", @@ -135,8 +129,7 @@ private static int findFreePort() throws Exception { @Test void unauthenticatedProxyRoutesTraffic() throws Exception { - int proxyPort = findFreePort(); - String containerId = startSquidContainer(proxyPort); + String containerId = startSquidContainer(); try { MockWebServer targetServer = new MockWebServer(); targetServer.start(); @@ -146,10 +139,10 @@ void unauthenticatedProxyRoutesTraffic() throws Exception { .setBody("{\"status\":\"ok\"}") .addHeader("Content-Type", "application/json")); - String targetUrl = String.format("http://host.docker.internal:%d/", targetServer.getPort()); + String targetUrl = String.format("http://localhost:%d/", targetServer.getPort()); HttpProxyConfiguration proxyConfig = - HttpProxyConfiguration.builder().setHostname("localhost").setPort(proxyPort).build(); + HttpProxyConfiguration.builder().setHostname("localhost").setPort(SQUID_PORT).build(); try (HttpClientApache client = new HttpClientApache(proxyConfig)) { HttpResponse response = @@ -171,8 +164,7 @@ void unauthenticatedProxyRoutesTraffic() throws Exception { @Test void authenticatedProxyRoutesTrafficAfterChallenge() throws Exception { - int proxyPort = findFreePort(); - String containerId = startAuthSquidContainer(proxyPort); + String containerId = startAuthSquidContainer(); try { MockWebServer targetServer = new MockWebServer(); targetServer.start(); @@ -182,12 +174,12 @@ void authenticatedProxyRoutesTrafficAfterChallenge() throws Exception { .setBody("{\"status\":\"ok\"}") .addHeader("Content-Type", "application/json")); - String targetUrl = String.format("http://host.docker.internal:%d/", targetServer.getPort()); + String targetUrl = String.format("http://localhost:%d/", targetServer.getPort()); HttpProxyConfiguration proxyConfig = HttpProxyConfiguration.builder() .setHostname("localhost") - .setPort(proxyPort) + .setPort(SQUID_PORT) .setUsername("proxyuser") .setPassword("proxypass") .build(); @@ -214,18 +206,17 @@ void authenticatedProxyRoutesTrafficAfterChallenge() throws Exception { @Test void authenticatedProxyRejectsWrongCredentials() throws Exception { - int proxyPort = findFreePort(); - String containerId = startAuthSquidContainer(proxyPort); + String containerId = startAuthSquidContainer(); try { MockWebServer targetServer = new MockWebServer(); targetServer.start(); try { - String targetUrl = String.format("http://host.docker.internal:%d/", targetServer.getPort()); + String targetUrl = String.format("http://localhost:%d/", targetServer.getPort()); HttpProxyConfiguration proxyConfig = HttpProxyConfiguration.builder() .setHostname("localhost") - .setPort(proxyPort) + .setPort(SQUID_PORT) .setUsername("wrong-user") .setPassword("wrong-pass") .build(); From 6db4acb3d32288cc34d9514b5ebd99ead8a50fa6 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Thu, 25 Jun 2026 08:51:04 +0200 Subject: [PATCH 10/19] Refactor proxy tests and move e2e to mockserver --- .../java/com/sinch/sdk/SinchClientTest.java | 29 +- .../test/java/com/sinch/sdk/e2e/Config.java | 3 + .../sinch/sdk/e2e/domains/proxy/ProxyIT.java | 16 ++ .../sdk/e2e/domains/proxy/ProxySteps.java | 106 ++++++++ .../sdk/http/HttpClientApacheProxyIT.java | 250 +++--------------- .../sdk/http/HttpClientApacheProxyTest.java | 105 +++++--- .../sinch/sdk/http/HttpClientApacheTest.java | 50 ++-- .../src/test/resources/squid-auth/passwords | 1 - .../src/test/resources/squid-auth/squid.conf | 6 - pom.xml | 1 + 10 files changed, 273 insertions(+), 294 deletions(-) create mode 100644 client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxyIT.java create mode 100644 client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxySteps.java delete mode 100644 client/src/test/resources/squid-auth/passwords delete mode 100644 client/src/test/resources/squid-auth/squid.conf diff --git a/client/src/test/java/com/sinch/sdk/SinchClientTest.java b/client/src/test/java/com/sinch/sdk/SinchClientTest.java index deee2cf0e..8baee2d6a 100644 --- a/client/src/test/java/com/sinch/sdk/SinchClientTest.java +++ b/client/src/test/java/com/sinch/sdk/SinchClientTest.java @@ -309,11 +309,6 @@ void proxyConfigurationPreservedInConfiguration() { "Proxy port must survive SinchClient construction"); } - /** - * Verifies that {@link SinchClient#getHttpClient()} (called via reflection) creates an {@link - * HttpClientApache} when proxy configuration is present — i.e. the proxy config is wired from - * {@link Configuration} into the HTTP-client factory. - */ @Test void proxyConfigurationWiredIntoHttpClient() throws Exception { HttpProxyConfiguration proxy = @@ -324,20 +319,22 @@ void proxyConfigurationWiredIntoHttpClient() throws Exception { Configuration configuration = Configuration.builder().setHttpProxyConfiguration(proxy).build(); SinchClient sinchClient = new SinchClient(configuration); - // Trigger lazy initialization of the internal HttpClientApache via reflection Method getHttpClient = SinchClient.class.getDeclaredMethod("getHttpClient"); getHttpClient.setAccessible(true); - Object httpClient = getHttpClient.invoke(sinchClient); + 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); - assertNotNull(httpClient, "getHttpClient() must return a non-null HttpClientApache"); assertInstanceOf( - HttpClientApache.class, httpClient, "getHttpClient() must return an HttpClientApache"); - - // Verify the stored field in SinchClient was initialised (not null) - Field httpClientField = SinchClient.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - assertNotNull( - httpClientField.get(sinchClient), - "SinchClient.httpClient field must be initialised after getHttpClient()"); + 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..0f2eb1ccb 100644 --- a/client/src/test/java/com/sinch/sdk/e2e/Config.java +++ b/client/src/test/java/com/sinch/sdk/e2e/Config.java @@ -37,6 +37,9 @@ 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; + private final SinchClient client; private final SinchClient clientServicePlanId; 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..c82e648f5 --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxyIT.java @@ -0,0 +1,16 @@ +package com.sinch.sdk.e2e.domains.proxy; + +import static io.cucumber.junit.platform.engine.Constants.GLUE_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") +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..8cc15e33d --- /dev/null +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxySteps.java @@ -0,0 +1,106 @@ +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 com.sinch.sdk.models.Configuration; +import com.sinch.sdk.models.HttpProxyConfiguration; +import com.sinch.sdk.models.NumberLookupContext; +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 { + + private static final String PROXY_HOST = "localhost"; + private static final String PROXY_USERNAME = "user"; + private static final String PROXY_PASSWORD = "password"; + private static final String PROXY_AUTH_URL = "http://authentication-server:1080/oauth2/token"; + private static final String PROXY_NUMBER_LOOKUP_HOST_NAME = "http://proxy-target-server:1080"; + + private static final SinchClient clientProxyUnauthenticated = + new SinchClient( + proxyConfiguration( + HttpProxyConfiguration.builder() + .setHostname(PROXY_HOST) + .setPort(Config.PROXY_UNAUTHENTICATED_PORT) + .build())); + + private static final SinchClient clientProxyAuthenticated = + new SinchClient( + proxyConfiguration( + HttpProxyConfiguration.builder() + .setHostname(PROXY_HOST) + .setPort(Config.PROXY_AUTHENTICATED_PORT) + .setUsername(PROXY_USERNAME) + .setPassword(PROXY_PASSWORD) + .build())); + + NumberLookupResponse response; + + @Given("the proxy server is available") + public void proxyServerAvailable() { + Assertions.assertNotNull(clientProxyUnauthenticated, "Unauthenticated proxy client"); + Assertions.assertNotNull(clientProxyAuthenticated, "Authenticated proxy client"); + } + + @When("I send a Number Lookup request through the unauthenticated proxy") + public void lookupThroughUnauthenticatedProxy() { + response = lookup(clientProxyUnauthenticated); + } + + @When("I send a Number Lookup request through the authenticated proxy") + public void lookupThroughAuthenticatedProxy() { + response = lookup(clientProxyAuthenticated); + } + + @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().lookup().lookup(request); + } + + private static Configuration proxyConfiguration(HttpProxyConfiguration proxy) { + return Configuration.builder() + .setOAuthUrl(PROXY_AUTH_URL) + .setProjectId(Config.PROJECT_ID) + .setKeyId(Config.KEY_ID) + .setKeySecret(Config.KEY_SECRET) + .setNumberLookupContext( + NumberLookupContext.builder().setNumberLookupUrl(PROXY_NUMBER_LOOKUP_HOST_NAME).build()) + .setHttpProxyConfiguration(proxy) + .build(); + } +} diff --git a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java index 3bddea18f..620b1c517 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java @@ -6,235 +6,71 @@ 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.models.HttpProxyConfiguration; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.ServerSocket; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.concurrent.TimeUnit; -import okhttp3.mockwebserver.MockResponse; -import okhttp3.mockwebserver.MockWebServer; -import okhttp3.mockwebserver.RecordedRequest; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -/** - * E2E proxy tests using Docker directly with a real Squid proxy. - * - *

Requires Docker to be running. Included in the failsafe integration-test phase alongside other - * {@code *IT} tests. - */ class HttpClientApacheProxyIT { - private static final int SQUID_PORT = 3128; - - @BeforeAll - static void requireDocker() throws Exception { - Process p = new ProcessBuilder("docker", "info").redirectErrorStream(true).start(); - boolean finished = p.waitFor(5, TimeUnit.SECONDS); - Assumptions.assumeTrue( - finished && p.exitValue() == 0, "Docker is not available, skipping proxy E2E tests"); - } + private static final String PROXY_HOST = "localhost"; + private static final String TARGET_URL = "http://proxy-target-server:1080/"; private static HttpRequest simpleGetRequest() { - return new HttpRequest("api/test", HttpMethod.GET, null, (String) null, null, null, null, null); - } - - /** - * Start a plain (unauthenticated) Squid container with host networking. Returns the container ID. - */ - private static String startSquidContainer() throws Exception { - ProcessBuilder pb = - new ProcessBuilder( - "docker", "run", "-d", "--rm", "--network", "host", "ubuntu/squid:latest"); - pb.redirectErrorStream(true); - Process p = pb.start(); - String containerId; - try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { - containerId = r.readLine(); - } - assertTrue(p.waitFor(30, TimeUnit.SECONDS), "docker run must finish"); - assertEquals(0, p.exitValue(), "docker run must succeed"); - assertNotNull(containerId, "container ID must be returned"); - // Give Squid a moment to start listening - Thread.sleep(3000); - return containerId.trim(); - } - - /** - * Start an authenticated Squid container with host networking using config and password files - * from test resources. Returns the container ID. - */ - private static String startAuthSquidContainer() throws Exception { - // Resolve resource files from classpath - Path squidConf = - java.nio.file.Paths.get( - HttpClientApacheProxyIT.class - .getClassLoader() - .getResource("squid-auth/squid.conf") - .toURI()); - Path passwords = - java.nio.file.Paths.get( - HttpClientApacheProxyIT.class - .getClassLoader() - .getResource("squid-auth/passwords") - .toURI()); - - assertTrue(Files.exists(squidConf), "squid.conf must exist on classpath"); - assertTrue(Files.exists(passwords), "passwords must exist on classpath"); - - ProcessBuilder pb = - new ProcessBuilder( - "docker", - "run", - "-d", - "--rm", - "--network", - "host", - "-v", - squidConf.toAbsolutePath() + ":/etc/squid/squid.conf:ro", - "-v", - passwords.toAbsolutePath() + ":/etc/squid/passwords:ro", - "ubuntu/squid:latest"); - pb.redirectErrorStream(true); - Process p = pb.start(); - String containerId; - try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { - containerId = r.readLine(); - } - assertTrue(p.waitFor(30, TimeUnit.SECONDS), "docker run must finish"); - assertEquals(0, p.exitValue(), "docker run must succeed"); - assertNotNull(containerId, "container ID must be returned"); - Thread.sleep(3000); - return containerId.trim(); - } - - private static void stopContainer(String containerId) { - try { - new ProcessBuilder("docker", "stop", containerId) - .redirectErrorStream(true) - .start() - .waitFor(15, TimeUnit.SECONDS); - } catch (Exception ignored) { - } - } - - private static int findFreePort() throws Exception { - try (ServerSocket s = new ServerSocket(0)) { - return s.getLocalPort(); - } + return new HttpRequest("health", HttpMethod.GET, null, (String) null, null, null, null, null); } @Test void unauthenticatedProxyRoutesTraffic() throws Exception { - String containerId = startSquidContainer(); - try { - MockWebServer targetServer = new MockWebServer(); - targetServer.start(); - try { - targetServer.enqueue( - new MockResponse() - .setBody("{\"status\":\"ok\"}") - .addHeader("Content-Type", "application/json")); - - String targetUrl = String.format("http://localhost:%d/", targetServer.getPort()); - - HttpProxyConfiguration proxyConfig = - HttpProxyConfiguration.builder().setHostname("localhost").setPort(SQUID_PORT).build(); + 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(targetUrl), null, simpleGetRequest()); + 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"); - } - - RecordedRequest request = targetServer.takeRequest(5, TimeUnit.SECONDS); - assertNotNull(request, "Target server must receive the proxied request"); - } finally { - targetServer.shutdown(); - } - } finally { - stopContainer(containerId); + assertEquals(200, response.getCode(), "Request through unauthenticated proxy must succeed"); } } @Test - void authenticatedProxyRoutesTrafficAfterChallenge() throws Exception { - String containerId = startAuthSquidContainer(); - try { - MockWebServer targetServer = new MockWebServer(); - targetServer.start(); - try { - targetServer.enqueue( - new MockResponse() - .setBody("{\"status\":\"ok\"}") - .addHeader("Content-Type", "application/json")); - - String targetUrl = String.format("http://localhost:%d/", targetServer.getPort()); - - HttpProxyConfiguration proxyConfig = - HttpProxyConfiguration.builder() - .setHostname("localhost") - .setPort(SQUID_PORT) - .setUsername("proxyuser") - .setPassword("proxypass") - .build(); - - try (HttpClientApache client = new HttpClientApache(proxyConfig)) { - HttpResponse response = - client.invokeAPI(new ServerConfiguration(targetUrl), null, simpleGetRequest()); - - assertEquals( - 200, - response.getCode(), - "Request through authenticated proxy must succeed after 407 challenge"); - } - - RecordedRequest request = targetServer.takeRequest(5, TimeUnit.SECONDS); - assertNotNull(request, "Target server must receive the proxied request"); - } finally { - targetServer.shutdown(); - } - } finally { - stopContainer(containerId); + 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 { - String containerId = startAuthSquidContainer(); - try { - MockWebServer targetServer = new MockWebServer(); - targetServer.start(); - try { - String targetUrl = String.format("http://localhost:%d/", targetServer.getPort()); - - HttpProxyConfiguration proxyConfig = - HttpProxyConfiguration.builder() - .setHostname("localhost") - .setPort(SQUID_PORT) - .setUsername("wrong-user") - .setPassword("wrong-pass") - .build(); - - try (HttpClientApache client = new HttpClientApache(proxyConfig)) { - HttpResponse response = - client.invokeAPI(new ServerConfiguration(targetUrl), null, simpleGetRequest()); - - assertEquals( - 407, - response.getCode(), - "Wrong credentials must result in 407 Proxy Authentication Required"); - } - } finally { - targetServer.shutdown(); - } - } finally { - stopContainer(containerId); + 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/http/HttpClientApacheProxyTest.java b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java index 842b7f187..01e40bcc5 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java +++ b/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyTest.java @@ -17,23 +17,14 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -/** - * Integration tests verifying that {@link HttpClientApache} routes traffic through a configured - * HTTP proxy and handles proxy authentication challenges. - * - *

Each test spins up its own {@link MockWebServer} that plays the role of the proxy, so tests - * are fully isolated and safe to run in parallel. - */ class HttpClientApacheProxyTest { MockWebServer mockProxy; - String proxyBaseUrl; @BeforeEach void setUp() throws IOException { mockProxy = new MockWebServer(); mockProxy.start(); - proxyBaseUrl = String.format("http://localhost:%s/", mockProxy.getPort()); } @AfterEach @@ -41,14 +32,6 @@ void tearDown() throws IOException { mockProxy.shutdown(); } - /** - * Verifies that when a proxy is configured, the HTTP connection is established with the proxy - * host rather than the target host. Using the proxy server's address as both proxy and target - * keeps the test self-contained — if the proxy setting were ignored, Apache would still connect - * to the same MockWebServer directly and the request would be received anyway. The real proof is - * in {@link #authenticatedProxyCredentialsSentAfterChallenge()} where Apache must handle the - * 407/retry cycle correctly. - */ @Test void unauthenticatedProxyRequestRoutedThroughProxy() throws Exception { HttpProxyConfiguration proxySettings = @@ -61,27 +44,35 @@ void unauthenticatedProxyRequestRoutedThroughProxy() throws Exception { new MockResponse().setBody("{}").addHeader("Content-Type", "application/json")); try (HttpClientApache proxyClient = new HttpClientApache(proxySettings)) { - proxyClient.invokeAPI( - new ServerConfiguration(proxyBaseUrl), + new ServerConfiguration("http://foo.com"), null, - new HttpRequest("api/path", HttpMethod.GET, null, (String) null, null, null, null, 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"); - assertNotNull(request.getPath(), "Recorded request must have a path"); + 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"); } - /** - * Verifies that when the proxy returns a 407 challenge, Apache retries with the correct {@code - * Proxy-Authorization} credentials and the call ultimately succeeds. - * - *

MockWebServer plays the role of an authenticating proxy: it first returns 407 with a {@code - * Proxy-Authenticate: Basic} challenge, then accepts the retry and returns 200. The test asserts - * that exactly two requests were received and that the retry carries the expected Basic - * credentials. - */ @Test void authenticatedProxyCredentialsSentAfterChallenge() throws Exception { HttpProxyConfiguration proxySettings = @@ -101,13 +92,29 @@ void authenticatedProxyCredentialsSentAfterChallenge() throws Exception { try (HttpClientApache proxyClient = new HttpClientApache(proxySettings)) { proxyClient.invokeAPI( - new ServerConfiguration(proxyBaseUrl), + new ServerConfiguration("http://foo.com"), null, - new HttpRequest("api/path", HttpMethod.GET, null, (String) null, null, null, null, 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 receive the initial request"); + 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"); @@ -125,9 +132,13 @@ void authenticatedProxyCredentialsSentAfterChallenge() throws Exception { "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)"); } - /** A proxy with credentials but no 407 challenge should work fine (no retry needed). */ @Test void authenticatedProxyNoChallengeSucceedsDirectly() throws Exception { HttpProxyConfiguration proxySettings = @@ -142,14 +153,32 @@ void authenticatedProxyNoChallengeSucceedsDirectly() throws Exception { new MockResponse().setBody("{}").addHeader("Content-Type", "application/json")); try (HttpClientApache proxyClient = new HttpClientApache(proxySettings)) { - proxyClient.invokeAPI( - new ServerConfiguration(proxyBaseUrl), + new ServerConfiguration("http://foo.com"), null, - new HttpRequest("api/path", HttpMethod.GET, null, (String) null, null, null, null, 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 receive the request"); + 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 b2a3f941c..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<>(); @@ -137,19 +136,18 @@ void testInvokeApi407DoesNotTriggerOAuthRefresh() throws Exception { doReturn(proxyAuthResponse).when(client).processRequest(any(), any()); - 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")); Map authManagers = new HashMap<>(); authManagers.put(OAuthManager.SCHEMA_KEYWORD_BEARER, mockAuthManager); diff --git a/client/src/test/resources/squid-auth/passwords b/client/src/test/resources/squid-auth/passwords deleted file mode 100644 index 5553772ed..000000000 --- a/client/src/test/resources/squid-auth/passwords +++ /dev/null @@ -1 +0,0 @@ -proxyuser:$apr1$px0XbKbF$YDu.gLzmG9D8dW5RnCog0. diff --git a/client/src/test/resources/squid-auth/squid.conf b/client/src/test/resources/squid-auth/squid.conf deleted file mode 100644 index 70a7665a4..000000000 --- a/client/src/test/resources/squid-auth/squid.conf +++ /dev/null @@ -1,6 +0,0 @@ -auth_param basic program /usr/lib/squid/basic_ncsa_auth /etc/squid/passwords -auth_param basic realm proxy -acl authenticated proxy_auth REQUIRED -http_access allow authenticated -http_access deny all -http_port 3128 diff --git a/pom.xml b/pom.xml index aa3c20343..c638ec79e 100644 --- a/pom.xml +++ b/pom.xml @@ -298,6 +298,7 @@ com.sinch.sdk.e2e.domains.verification.v1.VerificationIT com.sinch.sdk.e2e.domains.numberlookup.v2.NumberLookupIT com.sinch.sdk.http.HttpClientApacheProxyIT + com.sinch.sdk.e2e.domains.proxy.ProxyIT From df48b0bbc585729db7c3e20f562df99b79b48505 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Mon, 29 Jun 2026 14:26:04 +0200 Subject: [PATCH 11/19] Refactoring names for proxy --- .../test/java/com/sinch/sdk/e2e/Config.java | 50 +++++++++++++++++++ .../proxy}/HttpClientApacheProxyIT.java | 5 +- .../sdk/e2e/domains/proxy/ProxySteps.java | 49 +++--------------- pom.xml | 2 +- 4 files changed, 60 insertions(+), 46 deletions(-) rename client/src/test/java/com/sinch/sdk/{http => e2e/domains/proxy}/HttpClientApacheProxyIT.java (93%) 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 0f2eb1ccb..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; @@ -39,9 +40,18 @@ public class Config { 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() { @@ -84,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 { @@ -97,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/http/HttpClientApacheProxyIT.java b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/HttpClientApacheProxyIT.java similarity index 93% rename from client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java rename to client/src/test/java/com/sinch/sdk/e2e/domains/proxy/HttpClientApacheProxyIT.java index 620b1c517..3064112ed 100644 --- a/client/src/test/java/com/sinch/sdk/http/HttpClientApacheProxyIT.java +++ b/client/src/test/java/com/sinch/sdk/e2e/domains/proxy/HttpClientApacheProxyIT.java @@ -1,4 +1,4 @@ -package com.sinch.sdk.http; +package com.sinch.sdk.e2e.domains.proxy; import static org.junit.jupiter.api.Assertions.*; @@ -7,13 +7,14 @@ 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-target-server:1080/"; + 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); 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 index 8cc15e33d..ddef78d0b 100644 --- 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 @@ -7,9 +7,6 @@ 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 com.sinch.sdk.models.Configuration; -import com.sinch.sdk.models.HttpProxyConfiguration; -import com.sinch.sdk.models.NumberLookupContext; import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; @@ -17,46 +14,24 @@ public class ProxySteps { - private static final String PROXY_HOST = "localhost"; - private static final String PROXY_USERNAME = "user"; - private static final String PROXY_PASSWORD = "password"; - private static final String PROXY_AUTH_URL = "http://authentication-server:1080/oauth2/token"; - private static final String PROXY_NUMBER_LOOKUP_HOST_NAME = "http://proxy-target-server:1080"; - - private static final SinchClient clientProxyUnauthenticated = - new SinchClient( - proxyConfiguration( - HttpProxyConfiguration.builder() - .setHostname(PROXY_HOST) - .setPort(Config.PROXY_UNAUTHENTICATED_PORT) - .build())); - - private static final SinchClient clientProxyAuthenticated = - new SinchClient( - proxyConfiguration( - HttpProxyConfiguration.builder() - .setHostname(PROXY_HOST) - .setPort(Config.PROXY_AUTHENTICATED_PORT) - .setUsername(PROXY_USERNAME) - .setPassword(PROXY_PASSWORD) - .build())); - NumberLookupResponse response; @Given("the proxy server is available") public void proxyServerAvailable() { - Assertions.assertNotNull(clientProxyUnauthenticated, "Unauthenticated proxy client"); - Assertions.assertNotNull(clientProxyAuthenticated, "Authenticated proxy client"); + 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(clientProxyUnauthenticated); + response = lookup(Config.getSinchClientProxyUnauthenticated()); } @When("I send a Number Lookup request through the authenticated proxy") public void lookupThroughAuthenticatedProxy() { - response = lookup(clientProxyAuthenticated); + response = lookup(Config.getSinchClientProxyAuthenticated()); } @Then("the response is successfully returned through the unauthenticated proxy") @@ -91,16 +66,4 @@ private static NumberLookupResponse lookup(SinchClient client) { NumberLookupRequest request = NumberLookupRequest.builder().setNumber("+12016666666").build(); return client.lookup().lookup().lookup(request); } - - private static Configuration proxyConfiguration(HttpProxyConfiguration proxy) { - return Configuration.builder() - .setOAuthUrl(PROXY_AUTH_URL) - .setProjectId(Config.PROJECT_ID) - .setKeyId(Config.KEY_ID) - .setKeySecret(Config.KEY_SECRET) - .setNumberLookupContext( - NumberLookupContext.builder().setNumberLookupUrl(PROXY_NUMBER_LOOKUP_HOST_NAME).build()) - .setHttpProxyConfiguration(proxy) - .build(); - } } diff --git a/pom.xml b/pom.xml index c638ec79e..5dc84ec20 100644 --- a/pom.xml +++ b/pom.xml @@ -297,8 +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.http.HttpClientApacheProxyIT com.sinch.sdk.e2e.domains.proxy.ProxyIT + com.sinch.sdk.e2e.domains.proxy.HttpClientApacheProxyIT From 5d95c0fed6fd7d3a823cfcc325dbb80b9f611e62 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Tue, 30 Jun 2026 11:20:21 +0200 Subject: [PATCH 12/19] Fixed new Proxy test using NumberLookup --- .../test/java/com/sinch/sdk/e2e/domains/proxy/ProxySteps.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ddef78d0b..d498e65ea 100644 --- 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 @@ -64,6 +64,6 @@ private void assertResponse(String expectedTraceId) { private static NumberLookupResponse lookup(SinchClient client) { NumberLookupRequest request = NumberLookupRequest.builder().setNumber("+12016666666").build(); - return client.lookup().lookup().lookup(request); + return client.lookup().v2().lookup(request); } } From dd060ef99ce2d5c3d9cec3c73669cc7b30185eb1 Mon Sep 17 00:00:00 2001 From: Eduardo San Segundo Date: Tue, 30 Jun 2026 11:33:05 +0200 Subject: [PATCH 13/19] Removed duplicated Getting started on README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 0ddd04d98..1115b0691 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ For more information on the SDK, refer to the dedicated [Java SDK documentation - [Client lifecycle](#client-lifecycle) - [Proxy configuration](#proxy-configuration) - [Supported APIs](#supported-apis) -- [Getting started](#getting-started) - [Logging](#logging) - [Handling Exceptions](#handling-exceptions) - [Third-party dependencies](#third-party-dependencies) From 8529f65257b66bacef11ea37a99916ebe9c78a42 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 30 Jun 2026 19:12:02 +0200 Subject: [PATCH 14/19] feat (proxy): PR comments --- client/src/main/com/sinch/sdk/http/HttpClientApache.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/src/main/com/sinch/sdk/http/HttpClientApache.java b/client/src/main/com/sinch/sdk/http/HttpClientApache.java index d79d36c5b..23ba19401 100644 --- a/client/src/main/com/sinch/sdk/http/HttpClientApache.java +++ b/client/src/main/com/sinch/sdk/http/HttpClientApache.java @@ -216,8 +216,7 @@ public HttpResponse invokeAPI( } // UNAUTHORIZED (HTTP 401) error code could imply refreshing the OAuth token - if (response.getCode() == HttpStatus.UNAUTHORIZED - && authManagersByOasSecuritySchemes != null) { + if (response.getCode() == HttpStatus.UNAUTHORIZED) { boolean couldRetryRequest = processUnauthorizedResponse(httpRequest, response, authManagersByOasSecuritySchemes); if (couldRetryRequest) { @@ -249,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)) From a1d5bdabb795d2652738e995afc96ca0f76071cb Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 30 Jun 2026 19:16:04 +0200 Subject: [PATCH 15/19] test: Activate // tests for proxy --- .../src/test/java/com/sinch/sdk/e2e/domains/proxy/ProxyIT.java | 2 ++ pom.xml | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) 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 index c82e648f5..80db3d35c 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -13,4 +14,5 @@ @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/pom.xml b/pom.xml index 5dc84ec20..84273c1ca 100644 --- a/pom.xml +++ b/pom.xml @@ -312,7 +312,6 @@ ${maven-surefire-plugin.version} ${skipUTs} - all From 253aca62749285bc267d4dc4546d7ac5237fa757 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Tue, 30 Jun 2026 19:22:59 +0200 Subject: [PATCH 16/19] refactor: Constructor parameters order --- client/src/main/com/sinch/sdk/models/Configuration.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/main/com/sinch/sdk/models/Configuration.java b/client/src/main/com/sinch/sdk/models/Configuration.java index 1a4c1ba32..05d26aef1 100644 --- a/client/src/main/com/sinch/sdk/models/Configuration.java +++ b/client/src/main/com/sinch/sdk/models/Configuration.java @@ -23,13 +23,13 @@ private Configuration( ApplicationCredentials applicationCredentials, SmsServicePlanCredentials smsServicePlanCredentials, String oauthUrl, + HttpProxyConfiguration httpProxyConfiguration, NumbersContext numbersContext, SmsContext smsContext, VerificationContext verificationContext, VoiceContext voiceContext, ConversationContext conversationContext, - NumberLookupContext numberLookupContext, - HttpProxyConfiguration httpProxyConfiguration) { + NumberLookupContext numberLookupContext) { this.unifiedCredentials = unifiedCredentials; this.applicationCredentials = applicationCredentials; this.smsServicePlanCredentials = smsServicePlanCredentials; @@ -574,13 +574,13 @@ 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, null != voiceContext ? voiceContext.build() : null, null != conversationContext ? conversationContext.build() : null, - null != numberLookupContext ? numberLookupContext.build() : null, - httpProxyConfiguration); + null != numberLookupContext ? numberLookupContext.build() : null); } } } From 1551af2bd8bfc0b26264fafa7878eb9ffff961c4 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Wed, 1 Jul 2026 08:44:39 +0200 Subject: [PATCH 17/19] doc: README update --- README.md | 98 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 1115b0691..04f7344ab 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,11 @@ For more information on the SDK, refer to the dedicated [Java SDK documentation - [Prerequisites](#prerequisites) - [Installation](#installation) -- [Getting started](#getting-started) - - [Client initialization](#client-initialization) - - [Client lifecycle](#client-lifecycle) - - [Proxy configuration](#proxy-configuration) - [Supported APIs](#supported-apis) +- [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) @@ -171,50 +169,6 @@ SinchClient client = new SinchClient(configuration); > 2. Use the Conversation API, which works with project access keys. > 3. Contact your account manager -### Proxy configuration - -If your network environment routes outbound traffic through an HTTP proxy, provide proxy configuration via `HttpProxyConfiguration` on the `Configuration` builder. - -**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) - .setPassword(PARAM_PROXY_PASSWORD) - .build()) - .build(); -SinchClient client = new SinchClient(configuration); -``` - -## Supported APIs - **Service plan** — available in all regions (`US`, `EU`, `AU`, `BR`, `CA`). Use a `smsServicePlanId` and `smsApiToken`, both available on the [Service APIs dashboard](https://dashboard.sinch.com/sms/api/services): ```java @@ -444,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: From e06c492cc593da21b7caf036ce53a75ec6180bad Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Wed, 1 Jul 2026 08:44:53 +0200 Subject: [PATCH 18/19] doc: Fix CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b501408e..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`. From 53801d7322216520913e625748fea11a7bd685bd Mon Sep 17 00:00:00 2001 From: Jean-Pierre Portier Date: Wed, 1 Jul 2026 10:13:47 +0200 Subject: [PATCH 19/19] doc: fix comments --- client/src/main/com/sinch/sdk/http/HttpClientApache.java | 2 +- .../src/main/com/sinch/sdk/models/HttpProxyConfiguration.java | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/client/src/main/com/sinch/sdk/http/HttpClientApache.java b/client/src/main/com/sinch/sdk/http/HttpClientApache.java index 23ba19401..bf3f1dad2 100644 --- a/client/src/main/com/sinch/sdk/http/HttpClientApache.java +++ b/client/src/main/com/sinch/sdk/http/HttpClientApache.java @@ -202,7 +202,7 @@ public HttpResponse invokeAPI( LOGGER.finest("connection response: " + response); // HTTP 407 (Proxy Authentication Required) is normally handled transparently by Apache - // HttpClient via DefaultProxyRoutePlanner + BasicCredentialsProvider (the 407→retry cycle + // 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 diff --git a/client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java b/client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java index 51a56547a..519767e9c 100644 --- a/client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java +++ b/client/src/main/com/sinch/sdk/models/HttpProxyConfiguration.java @@ -33,7 +33,6 @@ * @since 2.1 */ public class HttpProxyConfiguration { - private final String hostname; private final int port; private final String username;