diff --git a/src/main/java/io/split/android/client/SplitClientConfig.java b/src/main/java/io/split/android/client/SplitClientConfig.java index 00af53115..d6e0e7c9d 100644 --- a/src/main/java/io/split/android/client/SplitClientConfig.java +++ b/src/main/java/io/split/android/client/SplitClientConfig.java @@ -1279,7 +1279,9 @@ private HttpProxy parseProxyHost(String proxyUri) { } } String host = String.format("%s%s", uri.getHost(), uri.getPath()); - return new HttpProxy(host, port, username, password); + return HttpProxy.newBuilder(host, port) + .basicAuth(username, password) + .build(); } catch (IllegalArgumentException e) { Logger.e("Proxy URI not valid: " + e.getLocalizedMessage()); throw new IllegalArgumentException(); diff --git a/src/main/java/io/split/android/client/network/HttpClientImpl.java b/src/main/java/io/split/android/client/network/HttpClientImpl.java index ca0a1d46d..e66aa30c8 100644 --- a/src/main/java/io/split/android/client/network/HttpClientImpl.java +++ b/src/main/java/io/split/android/client/network/HttpClientImpl.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import java.io.InputStream; import java.net.InetSocketAddress; import java.net.Proxy; import java.net.URI; @@ -28,7 +29,11 @@ public class HttpClientImpl implements HttpClient { @Nullable private final Proxy mProxy; @Nullable + private final HttpProxy mHttpProxy; + @Nullable private final SplitUrlConnectionAuthenticator mProxyAuthenticator; + @Nullable + private final ProxyCredentialsProvider mProxyCredentialsProvider; private final long mReadTimeout; private final long mConnectionTimeout; @Nullable @@ -42,14 +47,17 @@ public class HttpClientImpl implements HttpClient { HttpClientImpl(@Nullable HttpProxy proxy, @Nullable SplitAuthenticator proxyAuthenticator, + @Nullable ProxyCredentialsProvider proxyCredentialsProvider, long readTimeout, long connectionTimeout, @Nullable DevelopmentSslConfig developmentSslConfig, @Nullable SSLSocketFactory sslSocketFactory, @NonNull UrlSanitizer urlSanitizer, @Nullable CertificateChecker certificateChecker) { + mHttpProxy = proxy; mProxy = initializeProxy(proxy); mProxyAuthenticator = initializeProxyAuthenticator(proxy, proxyAuthenticator); + mProxyCredentialsProvider = proxyCredentialsProvider; mReadTimeout = readTimeout; mConnectionTimeout = connectionTimeout; mDevelopmentSslConfig = developmentSslConfig; @@ -73,7 +81,9 @@ public HttpRequest request(URI uri, HttpMethod requestMethod, String body, Map headers, - boolean useProxyAuthentication) throws IOException { + private static final ProxyCacertConnectionHandler mConnectionHandler = new ProxyCacertConnectionHandler(); + + static HttpURLConnection createConnection(@NonNull URL url, + @Nullable Proxy proxy, + @Nullable HttpProxy httpProxy, + @Nullable SplitUrlConnectionAuthenticator proxyAuthenticator, + @NonNull HttpMethod method, + @NonNull Map headers, + boolean useProxyAuthentication, + @Nullable SSLSocketFactory sslSocketFactory, + @Nullable ProxyCredentialsProvider proxyCredentialsProvider, + @Nullable String body) throws IOException { + + if (httpProxy != null && sslSocketFactory != null && (httpProxy.getCaCertStream() != null || httpProxy.getClientCertStream() != null)) { + try { + HttpResponse response = mConnectionHandler.executeRequest( + httpProxy, + url, + method, + headers, + body, + sslSocketFactory, + proxyCredentialsProvider + ); + + return new HttpResponseConnectionAdapter(url, response, response.getServerCertificates()); + } catch (UnsupportedOperationException e) { + // Fall through to standard handling + } + } + + return openConnection(proxy, httpProxy, proxyAuthenticator, url, method, headers, useProxyAuthentication); + } + + private static HttpURLConnection openConnection(@Nullable Proxy proxy, + @Nullable HttpProxy httpProxy, + @Nullable SplitUrlConnectionAuthenticator proxyAuthenticator, + @NonNull URL url, + @NonNull HttpMethod method, + @NonNull Map headers, + boolean useProxyAuthentication) throws IOException { + + // Check if we need custom SSL proxy handling + if (httpProxy != null && (httpProxy.getCaCertStream() != null || httpProxy.getClientCertStream() != null)) { + throw new IOException("SSL proxy scenarios require custom handling - use executeRequest method instead"); + } + + // Standard HttpURLConnection proxy handling HttpURLConnection connection; if (proxy != null) { connection = (HttpURLConnection) url.openConnection(proxy); @@ -84,7 +126,7 @@ static void checkPins(HttpURLConnection connection, @Nullable CertificateChecker private static void addHeaders(HttpURLConnection request, Map headers) { for (Map.Entry entry : headers.entrySet()) { - if (entry == null) { + if (entry == null || entry.getKey() == null) { continue; } diff --git a/src/main/java/io/split/android/client/network/HttpRequestImpl.java b/src/main/java/io/split/android/client/network/HttpRequestImpl.java index 6db18413d..a9e850fc0 100644 --- a/src/main/java/io/split/android/client/network/HttpRequestImpl.java +++ b/src/main/java/io/split/android/client/network/HttpRequestImpl.java @@ -4,7 +4,8 @@ import static io.split.android.client.network.HttpRequestHelper.applySslConfig; import static io.split.android.client.network.HttpRequestHelper.applyTimeouts; -import static io.split.android.client.network.HttpRequestHelper.openConnection; +import static io.split.android.client.network.HttpRequestHelper.createConnection; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -14,6 +15,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; +import java.net.HttpRetryException; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.ProtocolException; @@ -43,7 +45,11 @@ public class HttpRequestImpl implements HttpRequest { @Nullable private final Proxy mProxy; @Nullable + private final HttpProxy mHttpProxy; + @Nullable private final SplitUrlConnectionAuthenticator mProxyAuthenticator; + @Nullable + private final ProxyCredentialsProvider mProxyCredentialsProvider; private final long mReadTimeout; private final long mConnectionTimeout; @Nullable @@ -58,7 +64,9 @@ public class HttpRequestImpl implements HttpRequest { @Nullable String body, @NonNull Map headers, @Nullable Proxy proxy, + @Nullable HttpProxy httpProxy, @Nullable SplitUrlConnectionAuthenticator proxyAuthenticator, + @Nullable ProxyCredentialsProvider proxyCredentialsProvider, long readTimeout, long connectionTimeout, @Nullable DevelopmentSslConfig developmentSslConfig, @@ -71,7 +79,9 @@ public class HttpRequestImpl implements HttpRequest { mUrlSanitizer = checkNotNull(urlSanitizer); mHeaders = new HashMap<>(checkNotNull(headers)); mProxy = proxy; + mHttpProxy = httpProxy; mProxyAuthenticator = proxyAuthenticator; + mProxyCredentialsProvider = proxyCredentialsProvider; mReadTimeout = readTimeout; mConnectionTimeout = connectionTimeout; mDevelopmentSslConfig = developmentSslConfig; @@ -178,7 +188,26 @@ private HttpURLConnection setUpConnection(boolean authenticate) throws IOExcepti throw new IOException("Error parsing URL"); } - HttpURLConnection connection = openConnection(mProxy, mProxyAuthenticator, url, mHttpMethod, mHeaders, authenticate); + HttpURLConnection connection; + try { + connection = createConnection( + url, + mProxy, + mHttpProxy, + mProxyAuthenticator, + mHttpMethod, + mHeaders, + authenticate, + mSslSocketFactory, + mProxyCredentialsProvider, + mBody + ); + } catch (HttpRetryException e) { + if (mProxyAuthenticator == null) { + throw e; + } + connection = createConnection(url, mProxy, mHttpProxy, mProxyAuthenticator, mHttpMethod, mHeaders, authenticate, null, null, null); + } applyTimeouts(mReadTimeout, mConnectionTimeout, connection); applySslConfig(mSslSocketFactory, mDevelopmentSslConfig, connection); diff --git a/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java b/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java index 60453013b..1203c6467 100644 --- a/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java +++ b/src/main/java/io/split/android/client/network/HttpStreamRequestImpl.java @@ -1,11 +1,11 @@ package io.split.android.client.network; import static io.split.android.client.network.HttpRequestHelper.checkPins; +import static io.split.android.client.network.HttpRequestHelper.createConnection; import static io.split.android.client.utils.Utils.checkNotNull; import static io.split.android.client.network.HttpRequestHelper.applySslConfig; import static io.split.android.client.network.HttpRequestHelper.applyTimeouts; -import static io.split.android.client.network.HttpRequestHelper.openConnection; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -52,6 +52,10 @@ public class HttpStreamRequestImpl implements HttpStreamRequest { @Nullable private final CertificateChecker mCertificateChecker; private final AtomicBoolean mWasRetried = new AtomicBoolean(false); + @Nullable + private final HttpProxy mHttpProxy; + @Nullable + private final ProxyCredentialsProvider mProxyCredentialsProvider; HttpStreamRequestImpl(@NonNull URI uri, @NonNull Map headers, @@ -61,7 +65,9 @@ public class HttpStreamRequestImpl implements HttpStreamRequest { @Nullable DevelopmentSslConfig developmentSslConfig, @Nullable SSLSocketFactory sslSocketFactory, @NonNull UrlSanitizer urlSanitizer, - @Nullable CertificateChecker certificateChecker) { + @Nullable CertificateChecker certificateChecker, + @Nullable HttpProxy httpProxy, + @Nullable ProxyCredentialsProvider proxyCredentialsProvider) { mUri = checkNotNull(uri); mHttpMethod = HttpMethod.GET; mProxy = proxy; @@ -72,6 +78,8 @@ public class HttpStreamRequestImpl implements HttpStreamRequest { mDevelopmentSslConfig = developmentSslConfig; mSslSocketFactory = sslSocketFactory; mCertificateChecker = certificateChecker; + mHttpProxy = httpProxy; + mProxyCredentialsProvider = proxyCredentialsProvider; } @Override @@ -139,7 +147,18 @@ private HttpURLConnection setUpConnection(boolean useProxyAuthenticator) throws throw new IOException("Error parsing URL"); } - HttpURLConnection connection = openConnection(mProxy, mProxyAuthenticator, url, mHttpMethod, mHeaders, useProxyAuthenticator); + HttpURLConnection connection = createConnection( + url, + mProxy, + mHttpProxy, + mProxyAuthenticator, + mHttpMethod, + mHeaders, + useProxyAuthenticator, + mSslSocketFactory, + mProxyCredentialsProvider, + null + ); applyTimeouts(HttpStreamRequestImpl.STREAMING_READ_TIMEOUT_IN_MILLISECONDS, mConnectionTimeout, connection); applySslConfig(mSslSocketFactory, mDevelopmentSslConfig, connection); connection.connect(); diff --git a/src/main/java/io/split/android/client/network/ProxyCacertConnectionHandler.java b/src/main/java/io/split/android/client/network/ProxyCacertConnectionHandler.java new file mode 100644 index 000000000..44ff3e102 --- /dev/null +++ b/src/main/java/io/split/android/client/network/ProxyCacertConnectionHandler.java @@ -0,0 +1,142 @@ +package io.split.android.client.network; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.net.HttpRetryException; +import java.net.Socket; +import java.net.URL; +import java.security.cert.Certificate; +import java.util.Map; + +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +import io.split.android.client.utils.logger.Logger; + +/** + * Handles PROXY_CACERT SSL proxy connections. + *

+ * This handler establishes SSL tunnels through SSL proxies using custom CA certificates + * for proxy authentication, then executes HTTP requests through the SSL tunnel. + */ +class ProxyCacertConnectionHandler { + + public static final String HTTPS = "https"; + public static final String HTTP = "http"; + public static final int PORT_HTTPS = 443; + public static final int PORT_HTTP = 80; + private final HttpOverTunnelExecutor mTunnelExecutor; + + public ProxyCacertConnectionHandler() { + mTunnelExecutor = new HttpOverTunnelExecutor(); + } + + @NonNull + public HttpResponse executeRequest(@NonNull HttpProxy httpProxy, + @NonNull URL targetUrl, + @NonNull HttpMethod method, + @NonNull Map headers, + @Nullable String body, + @NonNull SSLSocketFactory sslSocketFactory, + @Nullable ProxyCredentialsProvider proxyCredentialsProvider) throws IOException { + + try { + SslProxyTunnelEstablisher tunnelEstablisher = new SslProxyTunnelEstablisher(); + Socket tunnelSocket = null; + Socket finalSocket = null; + Certificate[] serverCertificates = null; + + try { + tunnelSocket = tunnelEstablisher.establishTunnel( + httpProxy.getHost(), + httpProxy.getPort(), + targetUrl.getHost(), + getTargetPort(targetUrl), + sslSocketFactory, + proxyCredentialsProvider + ); + + Logger.v("SSL tunnel established successfully"); + + finalSocket = tunnelSocket; + + // If the origin is HTTPS, wrap the tunnel socket with a new SSLSocket (system CA) + if (HTTPS.equalsIgnoreCase(targetUrl.getProtocol())) { + Logger.v("Wrapping tunnel socket with new SSLSocket for origin server handshake"); + try { + // Use the provided SSLSocketFactory, which is configured to trust the origin's CA + finalSocket = sslSocketFactory.createSocket( + tunnelSocket, + targetUrl.getHost(), + getTargetPort(targetUrl), + true // autoClose + ); + if (finalSocket instanceof SSLSocket) { + SSLSocket originSslSocket = (SSLSocket) finalSocket; + originSslSocket.setUseClientMode(true); + originSslSocket.startHandshake(); + + // Capture server certificates after successful handshake + try { + serverCertificates = originSslSocket.getSession().getPeerCertificates(); + } catch (Exception certEx) { + Logger.w("Could not capture origin server certificates: " + certEx.getMessage()); + } + } else { + throw new IOException("Failed to create SSLSocket to origin"); + } + Logger.v("SSL handshake with origin server completed"); + } catch (Exception sslEx) { + Logger.e("Failed to establish SSL connection to origin: " + sslEx.getMessage()); + throw new IOException("Failed to establish SSL connection to origin server", sslEx); + } + } + + return mTunnelExecutor.executeRequest( + finalSocket, + targetUrl, + method, + headers, + body, + serverCertificates + ); + } finally { + // If we have are tunelling, finalSocket is the tunnel socket + if (finalSocket != null && finalSocket != tunnelSocket) { + try { + finalSocket.close(); + } catch (IOException e) { + Logger.w("Failed to close origin SSL socket: " + e.getMessage()); + } + } + + if (tunnelSocket != null) { + try { + tunnelSocket.close(); + } catch (IOException e) { + Logger.w("Failed to close tunnel socket: " + e.getMessage()); + } + } + } + } catch (Exception e) { + if (e instanceof HttpRetryException) { + throw (HttpRetryException) e; + } + throw new IOException("Failed to execute request through custom tunnel", e); + } + } + + private static int getTargetPort(@NonNull URL targetUrl) { + int port = targetUrl.getPort(); + if (port == -1) { + if (HTTPS.equalsIgnoreCase(targetUrl.getProtocol())) { + return PORT_HTTPS; + } else if (HTTP.equalsIgnoreCase(targetUrl.getProtocol())) { + return PORT_HTTP; + } + } + return port; + } +} diff --git a/src/test/java/io/split/android/client/network/HttpClientTest.java b/src/test/java/io/split/android/client/network/HttpClientTest.java index c78b52696..1ad68cd2f 100644 --- a/src/test/java/io/split/android/client/network/HttpClientTest.java +++ b/src/test/java/io/split/android/client/network/HttpClientTest.java @@ -52,12 +52,12 @@ public class HttpClientTest { private MockWebServer mWebServer; private MockWebServer mProxyServer; private HttpClient client; - private UrlSanitizer mUrlSanitizer; + private UrlSanitizer mUrlSanitizerMock; @Before public void setup() throws IOException { - mUrlSanitizer = mock(UrlSanitizer.class); - when(mUrlSanitizer.getUrl(any())).thenAnswer(new Answer() { + mUrlSanitizerMock = mock(UrlSanitizer.class); + when(mUrlSanitizerMock.getUrl(any())).thenAnswer(new Answer() { @Override public URL answer(InvocationOnMock invocation) throws Throwable { URI argument = invocation.getArgument(0); @@ -277,8 +277,8 @@ public MockResponse dispatch(RecordedRequest request) { HttpClient client = new HttpClientImpl.Builder() .setContext(mock(Context.class)) - .setUrlSanitizer(mUrlSanitizer) - .setProxy(new HttpProxy(mProxyServer.getHostName(), mProxyServer.getPort())) + .setUrlSanitizer(mUrlSanitizerMock) + .setProxy(HttpProxy.newBuilder(mProxyServer.getHostName(), mProxyServer.getPort()).build()) .build(); HttpRequest request = client.request(mWebServer.url("/test1/").uri(), HttpMethod.GET); @@ -312,7 +312,7 @@ public MockResponse dispatch(RecordedRequest request) { HttpClient client = new HttpClientImpl.Builder() .setContext(mock(Context.class)) - .setUrlSanitizer(mUrlSanitizer) + .setUrlSanitizer(mUrlSanitizerMock) .setProxyAuthenticator(new SplitAuthenticator() { @Override public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest request) { @@ -322,7 +322,7 @@ public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest return request; } }) - .setProxy(new HttpProxy(mProxyServer.getHostName(), mProxyServer.getPort())) + .setProxy(HttpProxy.newBuilder(mProxyServer.getHostName(), mProxyServer.getPort()).build()) .build(); HttpRequest request = client.request(mWebServer.url("/test1/").uri(), HttpMethod.GET); @@ -367,7 +367,7 @@ public MockResponse dispatch(RecordedRequest request) { HttpClient client = new HttpClientImpl.Builder() .setContext(mock(Context.class)) - .setUrlSanitizer(mUrlSanitizer) + .setUrlSanitizer(mUrlSanitizerMock) .setProxyAuthenticator(new SplitAuthenticator() { @Override public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest request) { @@ -377,7 +377,7 @@ public SplitAuthenticatedRequest authenticate(@NonNull SplitAuthenticatedRequest return request; } }) - .setProxy(new HttpProxy(mProxyServer.getHostName(), mProxyServer.getPort())) + .setProxy(HttpProxy.newBuilder(mProxyServer.getHostName(), mProxyServer.getPort()).build()) .build(); HttpRequest request = client.request(mWebServer.url("/test1/").uri(), HttpMethod.POST, "{}"); @@ -456,7 +456,7 @@ public MockResponse dispatch(RecordedRequest request) throws InterruptedExceptio mWebServer.start(); client = new HttpClientImpl.Builder() - .setUrlSanitizer(mUrlSanitizer) + .setUrlSanitizer(mUrlSanitizerMock) .build(); } diff --git a/src/test/java/io/split/android/client/network/HttpClientTunnellingProxyTest.java b/src/test/java/io/split/android/client/network/HttpClientTunnellingProxyTest.java new file mode 100644 index 000000000..3144e9718 --- /dev/null +++ b/src/test/java/io/split/android/client/network/HttpClientTunnellingProxyTest.java @@ -0,0 +1,647 @@ +package io.split.android.client.network; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.net.URL; +import java.nio.file.Files; +import java.security.KeyStore; +import java.util.Base64; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import okhttp3.tls.HeldCertificate; + +public class HttpClientTunnellingProxyTest { + + private UrlSanitizer mUrlSanitizerMock; + private Base64Decoder mBas64Decoder; + + @Before + public void setUp() { + + mUrlSanitizerMock = mock(UrlSanitizer.class); + when(mUrlSanitizerMock.getUrl(any())).thenAnswer(new Answer() { + @Override + public URL answer(InvocationOnMock invocation) throws Throwable { + URI argument = invocation.getArgument(0); + + return new URL(argument.toString()); + } + }); + mBas64Decoder = new Base64Decoder() { + @Override + public byte[] decode(String base64) { + return Base64.getDecoder().decode(base64); + } + }; + } + + @Test + public void proxyCacertProxyTunnelling() throws Exception { + // 1. Create separate CA and server certs for proxy and origin + HeldCertificate proxyCa = new HeldCertificate.Builder() + .commonName("Test Proxy CA") + .certificateAuthority(0) + .build(); + HeldCertificate proxyServerCert = new HeldCertificate.Builder() + .commonName("localhost") + .signedBy(proxyCa) + .build(); + HeldCertificate originCa = new HeldCertificate.Builder() + .commonName("Test Origin CA") + .certificateAuthority(0) + .build(); + HeldCertificate originCert = new HeldCertificate.Builder() + .commonName("localhost") + .signedBy(originCa) + .build(); + + // 2. Start HTTP origin server (not HTTPS to avoid SSL layering issues) + MockWebServer originServer = new MockWebServer(); + CountDownLatch originLatch = new CountDownLatch(1); + final String[] methodAndPath = new String[2]; + originServer.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + methodAndPath[0] = request.getMethod(); + methodAndPath[1] = request.getPath(); + originLatch.countDown(); + return new MockResponse().setBody("from origin!"); + } + }); + // Use HTTP instead of HTTPS to test tunnel establishment without SSL layering issues + originServer.start(); + + // 3. Start SSL tunnel proxy (server-only SSL, no client cert required) + TunnelProxySslServerOnly tunnelProxy = new TunnelProxySslServerOnly(0, proxyServerCert); + tunnelProxy.start(); + while (tunnelProxy.mServerSocket == null || tunnelProxy.mServerSocket.getLocalPort() == 0) { + Thread.sleep(10); + } + int assignedProxyPort = tunnelProxy.mServerSocket.getLocalPort(); + + // 4. Write BOTH proxy CA and origin CA certs to temp file (for combined trust store) + File caCertFile = File.createTempFile("proxy-ca", ".pem"); + try (FileWriter writer = new FileWriter(caCertFile)) { + writer.write(proxyCa.certificatePem()); + writer.write(originCa.certificatePem()); + } + + // 5. Configure HttpProxy with PROXY_CACERT + HttpProxy proxy = HttpProxy.newBuilder("localhost", assignedProxyPort) + .proxyCacert(Files.newInputStream(caCertFile.toPath())) + .build(); + + // 6. Build client (let builder/factory handle trust) + HttpClient client = new HttpClientImpl.Builder() + .setProxy(proxy) + .setBase64Decoder(mBas64Decoder) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + // 7. Make a request to the origin server (should tunnel via SSL proxy) + URI uri = originServer.url("/test").uri(); + HttpRequest req = client.request(uri, HttpMethod.GET); + HttpResponse resp = req.execute(); + assertNotNull(resp); + assertEquals(200, resp.getHttpStatus()); + assertEquals("from origin!", resp.getData()); + + // Assert that the tunnel was established and the origin received the request + assertTrue("TunnelProxy did not tunnel the request in time", tunnelProxy.getTunnelLatch().await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue("Origin server did not receive the request in time", originLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals("GET", methodAndPath[0]); + assertEquals("/test", methodAndPath[1]); + + tunnelProxy.stopProxy(); + originServer.shutdown(); + } + + @Test + public void proxyCacertProxyTunnelling_SslOverSsl() throws Exception { + // 1. Create separate CA and server certs for proxy and origin + HeldCertificate proxyCa = new HeldCertificate.Builder() + .commonName("Test Proxy CA") + .certificateAuthority(0) + .build(); + HeldCertificate proxyServerCert = new HeldCertificate.Builder() + .commonName("localhost") + .signedBy(proxyCa) + .build(); + HeldCertificate originCa = new HeldCertificate.Builder() + .commonName("Test Origin CA") + .certificateAuthority(0) + .build(); + HeldCertificate originCert = new HeldCertificate.Builder() + .commonName("localhost") + .signedBy(originCa) + .build(); + + // 2. Start HTTPS origin server + MockWebServer originServer = new MockWebServer(); + originServer.useHttps(createSslSocketFactory(originCert), false); // Use HTTPS for SSL-over-SSL + CountDownLatch originLatch = new CountDownLatch(1); + final String[] methodAndPath = new String[2]; + originServer.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + methodAndPath[0] = request.getMethod(); + methodAndPath[1] = request.getPath(); + originLatch.countDown(); + return new MockResponse().setBody("from https origin!"); + } + }); + originServer.start(); + + // 3. Start SSL tunnel proxy (server-only SSL, no client cert required) + TunnelProxySslServerOnly tunnelProxy = new TunnelProxySslServerOnly(0, proxyServerCert); + tunnelProxy.start(); + while (tunnelProxy.mServerSocket == null || tunnelProxy.mServerSocket.getLocalPort() == 0) { + Thread.sleep(10); + } + int assignedProxyPort = tunnelProxy.mServerSocket.getLocalPort(); + + // 4. Write BOTH proxy CA and origin CA certs to temp file (for combined trust store) + File caCertFile = File.createTempFile("proxy-ca", ".pem"); + try (FileWriter writer = new FileWriter(caCertFile)) { + writer.write(proxyCa.certificatePem()); + writer.write(originCa.certificatePem()); // Client needs to trust origin CA as well + } + + // 5. Configure HttpProxy with PROXY_CACERT + HttpProxy proxy = HttpProxy.newBuilder("localhost", assignedProxyPort) + .proxyCacert(Files.newInputStream(caCertFile.toPath())) + .build(); + + // 6. Build client (let builder/factory handle trust) + HttpClient client = new HttpClientImpl.Builder() + .setProxy(proxy) + .setBase64Decoder(mBas64Decoder) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + // 7. Make a request to the HTTPS origin server (should tunnel via SSL proxy) + URI uri = originServer.url("/test").uri(); + HttpRequest req = client.request(uri, HttpMethod.GET); + HttpResponse resp = req.execute(); + assertNotNull(resp); + assertEquals(200, resp.getHttpStatus()); + assertEquals("from https origin!", resp.getData()); + + // Assert that the tunnel was established and the origin received the request + assertTrue("TunnelProxy did not tunnel the request in time", tunnelProxy.getTunnelLatch().await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue("Origin server did not receive the request in time", originLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals("GET", methodAndPath[0]); + assertEquals("/test", methodAndPath[1]); + + tunnelProxy.stopProxy(); + originServer.shutdown(); + } + + /** + * Negative test: mTLS proxy requires client certificate, but client presents none. + * The proxy should reject the connection, and the client should throw SSLHandshakeException. + */ + @Test + public void proxyMtlsProxyTunnelling_rejectsNoClientCert() throws Exception { + // 1. Create CA, proxy/server, and origin certs + HeldCertificate proxyCa = new HeldCertificate.Builder() + .commonName("Test Proxy CA") + .certificateAuthority(0) + .build(); + HeldCertificate proxyServerCert = new HeldCertificate.Builder() + .commonName("localhost") + .signedBy(proxyCa) + .build(); + HeldCertificate originCa = new HeldCertificate.Builder() + .commonName("Test Origin CA") + .certificateAuthority(0) + .build(); + HeldCertificate originCert = new HeldCertificate.Builder() + .commonName("localhost") + .signedBy(originCa) + .build(); + + // Write proxy server cert and key to temp files + File proxyCertFile = File.createTempFile("proxy-server", ".crt"); + File proxyKeyFile = File.createTempFile("proxy-server", ".key"); + try (FileWriter writer = new FileWriter(proxyCertFile)) { + writer.write(proxyServerCert.certificatePem()); + } + try (FileWriter writer = new FileWriter(proxyKeyFile)) { + writer.write(proxyServerCert.privateKeyPkcs8Pem()); + } + // Write proxy CA cert (for client auth) to temp file + File proxyCaFile = File.createTempFile("proxy-ca", ".crt"); + try (FileWriter writer = new FileWriter(proxyCaFile)) { + writer.write(proxyCa.certificatePem()); + } + + // 2. Start HTTPS origin server + MockWebServer originServer = new MockWebServer(); + originServer.useHttps(createSslSocketFactory(originCert), false); + originServer.start(); + + // 3. Start mTLS tunnel proxy + TunnelProxySsl tunnelProxy = new TunnelProxySsl(0, proxyServerCert, proxyCa); + tunnelProxy.start(); + while (tunnelProxy.mServerSocket == null || tunnelProxy.mServerSocket.getLocalPort() == 0) { + Thread.sleep(10); + } + int assignedProxyPort = tunnelProxy.mServerSocket.getLocalPort(); + + // 4. Configure HttpProxy WITHOUT client cert (should be rejected) + HttpProxy proxy = HttpProxy.newBuilder("localhost", assignedProxyPort) + .proxyCacert(Files.newInputStream(proxyCaFile.toPath())) // only trust, no client auth + .build(); + + // 5. Build client (let builder/factory handle trust) + HttpClient client = new HttpClientImpl.Builder() + .setProxy(proxy) + .setBase64Decoder(mBas64Decoder) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + // 6. Make a request to the origin server (should fail at proxy handshake) + URI uri = originServer.url("/test").uri(); + HttpRequest req = client.request(uri, HttpMethod.GET); + boolean handshakeFailed = false; + try { + req.execute(); + } catch (Exception e) { + handshakeFailed = true; + } + assertTrue("Expected SSL handshake to fail due to missing client certificate", handshakeFailed); + + tunnelProxy.stopProxy(); + tunnelProxy.join(); + originServer.shutdown(); + proxyCertFile.delete(); + proxyKeyFile.delete(); + proxyCaFile.delete(); + } + + /** + * Positive test: mTLS proxy requires client certificate, and client presents a valid certificate. + * The proxy should accept the connection, tunnel should be established, and the request should reach the origin. + */ + @Test + public void proxyMtlsProxyTunnelling() throws Exception { + // 1. Create CA, proxy/server, client, and origin certs + HeldCertificate proxyCa = new HeldCertificate.Builder() + .commonName("Test Proxy CA") + .certificateAuthority(0) + .build(); + HeldCertificate proxyServerCert = new HeldCertificate.Builder() + .commonName("localhost") + .signedBy(proxyCa) + .build(); + HeldCertificate clientCert = new HeldCertificate.Builder() + .commonName("Test Client") + .signedBy(proxyCa) + .build(); + HeldCertificate originCa = new HeldCertificate.Builder() + .commonName("Test Origin CA") + .certificateAuthority(0) + .build(); + HeldCertificate originCert = new HeldCertificate.Builder() + .commonName("localhost") + .signedBy(originCa) + .build(); + + // Write proxy server cert and key to temp files + File proxyCertFile = File.createTempFile("proxy-server", ".crt"); + File proxyKeyFile = File.createTempFile("proxy-server", ".key"); + try (FileWriter writer = new FileWriter(proxyCertFile)) { + writer.write(proxyServerCert.certificatePem()); + } + try (FileWriter writer = new FileWriter(proxyKeyFile)) { + writer.write(proxyServerCert.privateKeyPkcs8Pem()); + } + // Write proxy CA cert (for client auth) to temp file + File proxyCaFile = File.createTempFile("proxy-ca", ".crt"); + try (FileWriter writer = new FileWriter(proxyCaFile)) { + writer.write(proxyCa.certificatePem()); + } + + // Write client certificate and key to separate files (PEM format) + File clientCertFile = File.createTempFile("client", ".crt"); + File clientKeyFile = File.createTempFile("client", ".key"); + try (FileWriter writer = new FileWriter(clientCertFile)) { + writer.write(clientCert.certificatePem()); + } + try (FileWriter writer = new FileWriter(clientKeyFile)) { + writer.write(clientCert.privateKeyPkcs8Pem()); + } + + // 2. Start HTTP origin server (not HTTPS to avoid SSL layering issues) + MockWebServer originServer = new MockWebServer(); + CountDownLatch originLatch = new CountDownLatch(1); + final String[] methodAndPath = new String[2]; + originServer.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + methodAndPath[0] = request.getMethod(); + methodAndPath[1] = request.getPath(); + originLatch.countDown(); + return new MockResponse().setBody("from origin!"); + } + }); + // Use HTTP instead of HTTPS to test tunnel establishment without SSL layering issues + originServer.start(); + + // 3. Start mTLS tunnel proxy + TunnelProxySsl tunnelProxy = new TunnelProxySsl(0, proxyServerCert, proxyCa); + tunnelProxy.start(); + while (tunnelProxy.mServerSocket == null || tunnelProxy.mServerSocket.getLocalPort() == 0) { + Thread.sleep(10); + } + int assignedProxyPort = tunnelProxy.mServerSocket.getLocalPort(); + + // 4. Configure HttpProxy with mTLS (client cert, key, and CA) + HttpProxy proxy = HttpProxy.newBuilder("localhost", assignedProxyPort) + .mtlsAuth( + Files.newInputStream(clientCertFile.toPath()), + Files.newInputStream(clientKeyFile.toPath()), + Files.newInputStream(proxyCaFile.toPath()) + ) + .build(); + + // 5. Build client (let builder/factory handle trust) + HttpClient client = new HttpClientImpl.Builder() + .setProxy(proxy) + .setBase64Decoder(mBas64Decoder) + .setUrlSanitizer(mUrlSanitizerMock) + .build(); + + // 6. Make a request to the origin server (should tunnel via proxy) + URI uri = originServer.url("/test").uri(); + HttpRequest req = client.request(uri, HttpMethod.GET); + HttpResponse resp = req.execute(); + assertNotNull(resp); + assertEquals(200, resp.getHttpStatus()); + assertEquals("from origin!", resp.getData()); + + // Assert that the tunnel was established and the origin received the request + assertTrue("TunnelProxy did not tunnel the request in time", tunnelProxy.getTunnelLatch().await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue("Origin server did not receive the request in time", originLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals("GET", methodAndPath[0]); + assertEquals("/test", methodAndPath[1]); + + tunnelProxy.stopProxy(); + tunnelProxy.join(); + originServer.shutdown(); + proxyCertFile.delete(); + proxyKeyFile.delete(); + proxyCaFile.delete(); + } + + // Helper to create SSLSocketFactory from HeldCertificate + private static SSLSocketFactory createSslSocketFactory(HeldCertificate cert) throws Exception { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("key", cert.keyPair().getPrivate(), "password".toCharArray(), new java.security.cert.Certificate[]{cert.certificate()}); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, "password".toCharArray()); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(kmf.getKeyManagers(), null, null); + + return sslContext.getSocketFactory(); + } + + /** + * TunnelProxySslServerOnly is an SSL proxy that presents a server certificate but doesn't require client certificates. + * This is used for testing proxy_cacert functionality where the client validates the proxy's certificate. + */ + static class TunnelProxySslServerOnly extends TunnelProxy { + private final HeldCertificate mServerCert; + private final AtomicBoolean mRunning = new AtomicBoolean(true); + + public TunnelProxySslServerOnly(int port, HeldCertificate serverCert) { + super(port); + this.mServerCert = serverCert; + } + + @Override + public void run() { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("key", mServerCert.keyPair().getPrivate(), "password".toCharArray(), new java.security.cert.Certificate[]{mServerCert.certificate()}); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, "password".toCharArray()); + + // No client certificate validation - use default trust manager + sslContext.init(kmf.getKeyManagers(), null, null); + + javax.net.ssl.SSLServerSocketFactory factory = sslContext.getServerSocketFactory(); + mServerSocket = factory.createServerSocket(mPort); + mPort = mServerSocket.getLocalPort(); // Update mPort with the actual assigned port + + // Don't require client auth - this is server-only SSL + ((javax.net.ssl.SSLServerSocket) mServerSocket).setWantClientAuth(false); + ((javax.net.ssl.SSLServerSocket) mServerSocket).setNeedClientAuth(false); + + System.out.println("[TunnelProxySslServerOnly] Listening on port: " + mServerSocket.getLocalPort()); + while (mRunning.get()) { + Socket client = mServerSocket.accept(); + System.out.println("[TunnelProxySslServerOnly] Accepted connection from: " + client.getRemoteSocketAddress()); + new Thread(() -> handle(client)).start(); + } + } catch (IOException e) { + System.out.println("[TunnelProxySslServerOnly] Server socket closed or error: " + e); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + /** + * TunnelProxySsl is a minimal SSL/TLS proxy supporting mTLS (client authentication). + * It uses an SSLServerSocket and requires client certificates signed by the provided CA. + * Only for use in mTLS proxy integration tests. + */ + private static class TunnelProxySsl extends TunnelProxy { + private final HeldCertificate mServerCert; + private final HeldCertificate mClientCa; + private final AtomicBoolean mRunning = new AtomicBoolean(true); + + public TunnelProxySsl(int port, HeldCertificate serverCert, HeldCertificate clientCa) { + super(port); + this.mServerCert = serverCert; + this.mClientCa = clientCa; + } + @Override + public void run() { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("key", mServerCert.keyPair().getPrivate(), "password".toCharArray(), new java.security.cert.Certificate[]{mServerCert.certificate()}); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, "password".toCharArray()); + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + trustStore.setCertificateEntry("ca", mClientCa.certificate()); + javax.net.ssl.TrustManagerFactory tmf = javax.net.ssl.TrustManagerFactory.getInstance(javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null); + javax.net.ssl.SSLServerSocketFactory factory = sslContext.getServerSocketFactory(); + mServerSocket = factory.createServerSocket(mPort); + ((javax.net.ssl.SSLServerSocket) mServerSocket).setNeedClientAuth(true); + System.out.println("[TunnelProxySsl] Listening on port: " + mServerSocket.getLocalPort()); + while (mRunning.get()) { + Socket client = mServerSocket.accept(); + System.out.println("[TunnelProxySsl] Accepted connection from: " + client.getRemoteSocketAddress()); + new Thread(() -> handle(client)).start(); + } + } catch (IOException e) { + System.out.println("[TunnelProxySsl] Server socket closed or error: " + e); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + /** + * Minimal CONNECT-capable proxy for HTTPS tunneling in tests. + * Listens on a port, accepts CONNECT requests, and pipes bytes between the client and the requested target. + * Used to simulate a real HTTPS proxy for end-to-end CA trust validation. + */ + private static class TunnelProxy extends Thread { + // Latch to signal that a CONNECT tunnel was established + private final CountDownLatch mTunnelLatch = new CountDownLatch(1); + // Port to listen on (0 = auto-assign) + protected int mPort; + // The server socket for accepting connections + public ServerSocket mServerSocket; + // Flag to control proxy shutdown + private final AtomicBoolean mRunning = new AtomicBoolean(true); + + /** + * Create a new TunnelProxy listening on the given port. + * @param port Port to listen on (0 = auto-assign) + */ + TunnelProxy(int port) { mPort = port; } + + /** + * Main accept loop. For each incoming client, start a handler thread. + */ + public void run() { + try { + mServerSocket = new ServerSocket(mPort); + System.out.println("[TunnelProxy] Listening on port: " + mServerSocket.getLocalPort()); + while (mRunning.get()) { + Socket client = mServerSocket.accept(); + System.out.println("[TunnelProxy] Accepted connection from: " + client.getRemoteSocketAddress()); + // Each client handled in its own thread + new Thread(() -> handle(client)).start(); + } + } catch (IOException ignored) { + System.out.println("[TunnelProxy] Server socket closed or error: " + ignored); + } + } + + /** + * Handles a single client connection. Waits for CONNECT, then establishes a tunnel. + */ + void handle(Socket client) { + try (BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream())); + OutputStream out = client.getOutputStream()) { + String line = in.readLine(); + // Only handle CONNECT requests (as sent by HTTPS clients to a proxy) + if (line != null && line.startsWith("CONNECT")) { + mTunnelLatch.countDown(); + System.out.println("[TunnelProxy] Received CONNECT: " + line); + out.write("HTTP/1.1 200 Connection Established\r\n\r\n".getBytes()); + out.flush(); + String[] parts = line.split(" "); + String[] hostPort = parts[1].split(":"); + // Open a socket to the requested target (origin server) + Socket target = new Socket(hostPort[0], Integer.parseInt(hostPort[1])); + System.out.println("[TunnelProxy] Established tunnel to: " + hostPort[0] + ":" + hostPort[1]); + // Pipe bytes in both directions (client <-> target) until closed + Thread t1 = new Thread(() -> { + try { + pipe(client, target); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + Thread t2 = new Thread(() -> { + try { + pipe(target, client); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + t1.start(); t2.start(); + try { t1.join(); t2.join(); } catch (InterruptedException ignored) {} + System.out.println("[TunnelProxy] Tunnel closed for: " + hostPort[0] + ":" + hostPort[1]); + target.close(); + } + } catch (Exception ignored) { } + } + + /** + * Copies bytes from inSocket to outSocket until EOF. + * Used to relay data in both directions for the tunnel. + */ + private void pipe(Socket inSocket, Socket outSocket) throws IOException { + try (InputStream in = inSocket.getInputStream(); OutputStream out = outSocket.getOutputStream()) { + byte[] buf = new byte[1024]; + int len; + while ((len = in.read(buf)) != -1) { + out.write(buf, 0, len); + out.flush(); + } + } catch (IOException ignored) { } + } + + /** + * Stops the proxy by closing the server socket and setting the running flag to false. + */ + public void stopProxy() throws IOException { + mRunning.set(false); + if (mServerSocket != null && !mServerSocket.isClosed()) { + mServerSocket.close(); + System.out.println("[TunnelProxy] Proxy stopped."); + } + } + + public CountDownLatch getTunnelLatch() { + return mTunnelLatch; + } + } +} diff --git a/src/test/java/io/split/android/client/network/HttpRequestHelperTest.java b/src/test/java/io/split/android/client/network/HttpRequestHelperTest.java new file mode 100644 index 000000000..b3f08fb55 --- /dev/null +++ b/src/test/java/io/split/android/client/network/HttpRequestHelperTest.java @@ -0,0 +1,112 @@ +package io.split.android.client.network; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.Proxy; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSocketFactory; + +public class HttpRequestHelperTest { + + @Mock + private HttpURLConnection mockConnection; + @Mock + private HttpsURLConnection mockHttpsConnection; + @Mock + private URL mockUrl; + @Mock + private SplitUrlConnectionAuthenticator mockAuthenticator; + @Mock + private SSLSocketFactory mockSslSocketFactory; + @Mock + private DevelopmentSslConfig mockDevelopmentSslConfig; + @Mock + private CertificateChecker mockCertificateChecker; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + when(mockUrl.openConnection()).thenReturn(mockConnection); + when(mockUrl.openConnection(any(Proxy.class))).thenReturn(mockConnection); + when(mockAuthenticator.authenticate(any(HttpURLConnection.class))).thenReturn(mockConnection); + } + + @Test + public void addHeaders() throws Exception { + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + headers.put("Authorization", "Bearer token123"); + headers.put(null, "This should be ignored"); + + Method addHeadersMethod = HttpRequestHelper.class.getDeclaredMethod( + "addHeaders", HttpURLConnection.class, Map.class); + addHeadersMethod.setAccessible(true); + addHeadersMethod.invoke(null, mockConnection, headers); + + verify(mockConnection).addRequestProperty("Content-Type", "application/json"); + verify(mockConnection).addRequestProperty("Authorization", "Bearer token123"); + verify(mockConnection, never()).addRequestProperty(null, "This should be ignored"); + } + + @Test + public void applyTimeouts() { + HttpRequestHelper.applyTimeouts(5000, 3000, mockConnection); + verify(mockConnection).setReadTimeout(5000); + verify(mockConnection).setConnectTimeout(3000); + + HttpRequestHelper.applyTimeouts(0, 0, mockConnection); + verify(mockConnection, times(1)).setReadTimeout(any(Integer.class)); + verify(mockConnection, times(1)).setConnectTimeout(any(Integer.class)); + + HttpRequestHelper.applyTimeouts(-1000, -500, mockConnection); + verify(mockConnection, times(1)).setReadTimeout(any(Integer.class)); + verify(mockConnection, times(1)).setConnectTimeout(any(Integer.class)); + } + + @Test + public void applySslConfigWithDevelopmentSslConfig() { + when(mockDevelopmentSslConfig.getSslSocketFactory()).thenReturn(mockSslSocketFactory); + + HttpRequestHelper.applySslConfig(null, mockDevelopmentSslConfig, mockHttpsConnection); + + verify(mockHttpsConnection).setSSLSocketFactory(mockSslSocketFactory); + verify(mockHttpsConnection).setHostnameVerifier(any()); + } + + @Test + public void pinsAreCheckedWithCertificateChecker() throws SSLPeerUnverifiedException { + HttpRequestHelper.checkPins(mockHttpsConnection, mockCertificateChecker); + + verify(mockCertificateChecker).checkPins(mockHttpsConnection); + } + + @Test + public void pinsAreNotCheckedWithoutCertificateChecker() throws SSLPeerUnverifiedException { + HttpRequestHelper.checkPins(mockHttpsConnection, null); + + verify(mockCertificateChecker, never()).checkPins(any()); + } + + @Test + public void pinsAreNotCheckedForNonHttpsConnections() throws SSLPeerUnverifiedException { + HttpRequestHelper.checkPins(mockConnection, mockCertificateChecker); + + verify(mockCertificateChecker, never()).checkPins(any()); + } +}