diff --git a/pom.xml b/pom.xml index 86741e5b4..f121c1fc5 100644 --- a/pom.xml +++ b/pom.xml @@ -34,6 +34,7 @@ 4.0.12 4.35.0 24.0.0 + 3.13.2 @@ -205,9 +206,9 @@ - com.squareup.okhttp3 - mockwebserver - ${okhttpVersion} + org.wiremock + wiremock + ${wiremock.version} test diff --git a/src/main/groovy/com/cloudogu/gitops/dependencyinjection/HttpClientFactory.groovy b/src/main/groovy/com/cloudogu/gitops/dependencyinjection/HttpClientFactory.groovy index 585a1fa85..97db911da 100644 --- a/src/main/groovy/com/cloudogu/gitops/dependencyinjection/HttpClientFactory.groovy +++ b/src/main/groovy/com/cloudogu/gitops/dependencyinjection/HttpClientFactory.groovy @@ -14,6 +14,7 @@ import okhttp3.logging.HttpLoggingInterceptor import org.jetbrains.annotations.NotNull import org.slf4j.LoggerFactory +import javax.net.ssl.HostnameVerifier import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager @@ -35,6 +36,8 @@ class HttpClientFactory { builder.sslSocketFactory(context.socketFactory, context.trustManager) } + builder.hostnameVerifier({ hostname, session -> true } as HostnameVerifier) + return builder.build() } diff --git a/src/main/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClient.groovy b/src/main/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClient.groovy index 6d1b1606f..bc5ae8d94 100644 --- a/src/main/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClient.groovy +++ b/src/main/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClient.groovy @@ -22,7 +22,14 @@ class JenkinsApiClient { Config config, @Named("jenkins") OkHttpClient client ) { - this.client = client + + if (config.application.insecure) { + this.client = client.newBuilder() + .hostnameVerifier({ hostname, session -> true }) + .build() + } else { + this.client = client + } this.config = config } diff --git a/src/main/groovy/com/cloudogu/gitops/okhttp/RetryInterceptor.groovy b/src/main/groovy/com/cloudogu/gitops/okhttp/RetryInterceptor.groovy index 8a42c0b69..32124d1c1 100644 --- a/src/main/groovy/com/cloudogu/gitops/okhttp/RetryInterceptor.groovy +++ b/src/main/groovy/com/cloudogu/gitops/okhttp/RetryInterceptor.groovy @@ -15,7 +15,7 @@ class RetryInterceptor implements Interceptor { private int retries private int waitPeriodInMs - // Number of retries in uncommonly high, because we might have to outlive a unexpected Jenkins restart + // Number of retries in uncommonly high, because we might have to outlive a unexpected Jenkins restart RetryInterceptor(int retries = 180, int waitPeriodInMs = 2000) { this.waitPeriodInMs = waitPeriodInMs this.retries = retries @@ -23,38 +23,57 @@ class RetryInterceptor implements Interceptor { @Override Response intercept(@NotNull Chain chain) throws IOException { - def i = 0; + def i = 0 Response response = null + IOException lastException = null + do { try { response = chain.proceed(chain.request()) + if (response.code() !in getStatusCodesToRetry()) { - break + // Success or non-retriable error - return the response + return response } log.trace("Retry HTTP Request to {} due to status code {}", chain.request().url().toString(), response.code()) + response.close() } catch (SocketTimeoutException e) { - // fallthrough to retry + lastException = e log.trace("Retry HTTP Request to {} due to SocketTimeoutException: {}", chain.request().url().toString(), e.message) } - response?.close() - Thread.sleep(waitPeriodInMs) + + // Wait before next retry (but not after the last attempt) + if (i < retries) { + Thread.sleep(waitPeriodInMs) + } ++i - } while(i < retries) - return response + } while(i <= retries) + + // If we got here, all retries failed + if (response != null) { + // Return the last failed response + return response + } else if (lastException != null) { + // All attempts resulted in timeout - throw the last exception + throw lastException + } else { + // This should never happen, but as a safety net + throw new IOException("Request failed after ${retries} retries") + } } private List getStatusCodesToRetry() { return [ - // list of codes if from curl --retry - 408, // Request Timeout - 429, // Too Many Requests - 500, // Internal Server Error - 502, // Bad Gateway - 503, // Service Unavailable - 504, // Gateway Timeout + // list of codes from curl --retry + 408, // Request Timeout + 429, // Too Many Requests + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout ] } -} +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/common/MockWebServerHttpsFactory.groovy b/src/test/groovy/com/cloudogu/gitops/common/MockWebServerHttpsFactory.groovy deleted file mode 100644 index a1e782f53..000000000 --- a/src/test/groovy/com/cloudogu/gitops/common/MockWebServerHttpsFactory.groovy +++ /dev/null @@ -1,16 +0,0 @@ -package com.cloudogu.gitops.common - -import okhttp3.tls.HandshakeCertificates -import okhttp3.tls.HeldCertificate - -class MockWebServerHttpsFactory { - static HandshakeCertificates createSocketFactory() { - def heldCertificate = new HeldCertificate.Builder() - .addSubjectAlternativeName(InetAddress.getByName('localhost').getCanonicalHostName()) - .build() - - return new HandshakeCertificates.Builder() - .heldCertificate(heldCertificate) - .build() - } -} diff --git a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApiTest.groovy b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApiTest.groovy index 07b698d34..cf35de66b 100644 --- a/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApiTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/git/providers/scmmanager/api/UsersApiTest.groovy @@ -1,59 +1,66 @@ package com.cloudogu.gitops.git.providers.scmmanager.api -import com.cloudogu.gitops.common.MockWebServerHttpsFactory import com.cloudogu.gitops.config.Credentials -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach +import com.github.tomakehurst.wiremock.junit5.WireMockExtension import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension import javax.net.ssl.SSLHandshakeException +import static com.github.tomakehurst.wiremock.client.WireMock.* +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig import static groovy.test.GroovyAssert.shouldFail import static org.assertj.core.api.Assertions.assertThat class UsersApiTest { - private MockWebServer webServer = new MockWebServer() - private Credentials credentials = new Credentials("user", "pass") - @AfterEach - void tearDown() { - webServer.shutdown() - } + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig() + .dynamicPort() + .dynamicHttpsPort()) + .build() + + private Credentials credentials = new Credentials("user", "pass") @Test void 'allows self-signed certificates when using insecure option'() { - webServer.useHttps(MockWebServerHttpsFactory.createSocketFactory().sslSocketFactory(), false) - - def api = usersApi(true) - webServer.enqueue(new MockResponse().setResponseCode(204)) + wireMock.stubFor(delete(urlPathEqualTo("/scm/api/v2/users/test-user")) + .willReturn(aResponse().withStatus(204))) + def api = usersApi(true, true) // insecure=true, useHttps=true def resp = api.delete('test-user').execute() assertThat(resp.isSuccessful()).isTrue() - assertThat(webServer.requestCount).isEqualTo(1) + wireMock.verify(1, deleteRequestedFor(urlPathEqualTo("/scm/api/v2/users/test-user"))) } @Test void 'does not allow self-signed certificates by default'() { - webServer.useHttps(MockWebServerHttpsFactory.createSocketFactory().sslSocketFactory(), false) + wireMock.stubFor(delete(urlPathEqualTo("/scm/api/v2/users/test-user")) + .willReturn(aResponse().withStatus(204))) - def api = usersApi(false) + def api = usersApi(false, true) // insecure=false, useHttps=true shouldFail(SSLHandshakeException) { api.delete('test-user').execute() } - assertThat(webServer.requestCount).isEqualTo(0) - } + wireMock.verify(0, deleteRequestedFor(urlPathEqualTo("/scm/api/v2/users/test-user"))) + } - private UsersApi usersApi(boolean insecure) { - def client = new ScmManagerApiClient(apiBaseUrl(), credentials, insecure) + private UsersApi usersApi(boolean insecure, boolean useHttps = false) { + def client = new ScmManagerApiClient(apiBaseUrl(useHttps), credentials, insecure) return client.usersApi() } - private String apiBaseUrl() { - return "${webServer.url('scm')}/api/" + private String apiBaseUrl(boolean useHttps) { + if (useHttps) { + // Use the proper HTTPS port from WireMock + def httpsPort = wireMock.httpsPort + return "https://localhost:${httpsPort}/scm/api/" + } else { + return "${wireMock.baseUrl()}/scm/api/" + } } - } \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClientTest.groovy b/src/test/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClientTest.groovy index b83f4c0e6..c8fb6de73 100644 --- a/src/test/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClientTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/jenkins/JenkinsApiClientTest.groovy @@ -1,221 +1,275 @@ package com.cloudogu.gitops.jenkins -import com.cloudogu.gitops.common.MockWebServerHttpsFactory import com.cloudogu.gitops.config.Config - +import com.github.tomakehurst.wiremock.junit5.WireMockExtension import io.micronaut.context.ApplicationContext import okhttp3.FormBody import okhttp3.JavaNetCookieJar import okhttp3.OkHttpClient -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension -import java.nio.charset.Charset +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import java.security.SecureRandom +import java.security.cert.X509Certificate +import static com.github.tomakehurst.wiremock.client.WireMock.* +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig import static groovy.test.GroovyAssert.shouldFail import static org.assertj.core.api.Assertions.assertThat class JenkinsApiClientTest { - private MockWebServer webServer = new MockWebServer() - @AfterEach - void tearDown() { - webServer.shutdown() - } + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig() + .dynamicPort() + .dynamicHttpsPort()) + .build() @Test void 'runs script with crumb'() { - webServer.setDispatcher { request -> - switch (request.path) { - case "/jenkins/crumbIssuer/api/json": - return new MockResponse().setBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}') - case "/jenkins/scriptText": - return new MockResponse().setBody("ok") - default: - return new MockResponse().setStatus("404") - } - } - webServer.start() + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .willReturn(aResponse() + .withStatus(200) + .withBody("ok"))) - def httpClient = new OkHttpClient.Builder().cookieJar(new JavaNetCookieJar(new CookieManager())).build() + def httpClient = getUnsafeOkHttpClient().newBuilder().cookieJar(new JavaNetCookieJar(new CookieManager())).build() def apiClient = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: webServer.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), httpClient) def result = apiClient.runScript("println('ok')") assertThat(result).isEqualTo("ok") - def crumbRequest = webServer.takeRequest() - assertThat(crumbRequest.path).isEqualTo("/jenkins/crumbIssuer/api/json") - assertThat(crumbRequest.getHeader('Authorization')).startsWith("Basic ") + wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .withHeader("Authorization", matching("Basic .*"))) - def runScriptRequest = webServer.takeRequest() - assertThat(runScriptRequest.path).isEqualTo("/jenkins/scriptText") - assertThat(runScriptRequest.getHeader('Authorization')).startsWith("Basic ") - assertThat(runScriptRequest.getHeader('Jenkins-Crumb')).startsWith("the-crumb") + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/scriptText")) + .withHeader("Authorization", matching("Basic .*")) + .withHeader("Jenkins-Crumb", equalTo("the-crumb"))) } @Test void 'adds crumb to sendRequest'() { - webServer.enqueue(new MockResponse().setBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}')) - webServer.enqueue(new MockResponse()) + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/foobar")) + .willReturn(aResponse().withStatus(200))) def client = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: webServer.url("jenkins").toString())), - new OkHttpClient()) + new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), + getUnsafeOkHttpClient()) client.postRequestWithCrumb("foobar") - assertThat(webServer.requestCount).isEqualTo(2) - webServer.takeRequest() // crumb - def request = webServer.takeRequest() - assertThat(request.method).isEqualTo("POST") - assertThat(request.headers.get("Jenkins-Crumb")).isEqualTo("the-crumb") + wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/foobar")) + .withHeader("Jenkins-Crumb", equalTo("the-crumb"))) } @Test void 'adds crumb and post data to sendRequest'() { - webServer.enqueue(new MockResponse().setBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}')) - webServer.enqueue(new MockResponse()) + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/foobar")) + .willReturn(aResponse().withStatus(200))) def client = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: webServer.url("jenkins").toString())), - new OkHttpClient()) + new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), + getUnsafeOkHttpClient()) client.postRequestWithCrumb("foobar", new FormBody.Builder().add('key', 'value with spaces').build()) - assertThat(webServer.requestCount).isEqualTo(2) - webServer.takeRequest() // crumb - def request = webServer.takeRequest() - assertThat(request.method).isEqualTo("POST") - assertThat(request.headers.get("Jenkins-Crumb")).isEqualTo("the-crumb") - assertThat(request.body.readString(Charset.defaultCharset())).isEqualTo("key=value%20with%20spaces") + wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/foobar")) + .withHeader("Jenkins-Crumb", equalTo("the-crumb")) + .withRequestBody(equalTo("key=value%20with%20spaces"))) } @Test void 'allows self-signed certificates when using insecure'() { - webServer.useHttps(MockWebServerHttpsFactory.createSocketFactory().sslSocketFactory(), false) - webServer.setDispatcher { request -> - switch (request.path) { - case "/jenkins/crumbIssuer/api/json": - return new MockResponse().setBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}') - case "/jenkins/scriptText": - return new MockResponse().setBody("ok") - default: - return new MockResponse().setStatus("404") - } - } - webServer.start() + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .willReturn(aResponse() + .withStatus(200) + .withBody("ok"))) def apiClient = ApplicationContext.run() .registerSingleton(new Config( application: new Config.ApplicationSchema( insecure: true), jenkins: new Config.JenkinsSchema( - url: webServer.url("jenkins")) + url: "${wireMock.baseUrl().replace('http://', 'https://')}/jenkins") )) .getBean(JenkinsApiClient) def result = apiClient.runScript("println('ok')") assertThat(result).isEqualTo("ok") - def crumbRequest = webServer.takeRequest() - assertThat(crumbRequest.path).isEqualTo("/jenkins/crumbIssuer/api/json") - assertThat(crumbRequest.getHeader('Authorization')).startsWith("Basic ") + wireMock.verify(1, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .withHeader("Authorization", matching("Basic .*"))) - def runScriptRequest = webServer.takeRequest() - assertThat(runScriptRequest.path).isEqualTo("/jenkins/scriptText") - assertThat(runScriptRequest.getHeader('Authorization')).startsWith("Basic ") - assertThat(runScriptRequest.getHeader('Jenkins-Crumb')).startsWith("the-crumb") + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/scriptText")) + .withHeader("Authorization", matching("Basic .*")) + .withHeader("Jenkins-Crumb", equalTo("the-crumb"))) } @Test void 'retries on invalid crumb'() { - Queue crumbQueue = new ArrayDeque() - crumbQueue.add("the-invalid-crumb") - crumbQueue.add("the-second-crumb") - webServer.setDispatcher { request -> - switch (request.path) { - case "/jenkins/crumbIssuer/api/json": - return new MockResponse().setBody('{"crumb": "'+crumbQueue.poll()+'", "crumbRequestField": "Jenkins-Crumb"}') - case "/jenkins/scriptText": - def isInvalidCrumb = request.getHeader('Jenkins-Crumb') == 'the-invalid-crumb' - def body = !isInvalidCrumb ? 'ok' : '{"servlet":"Stapler", "message":"No valid crumb was included in the request", "url":"/scriptText", "status":"403"}' - return new MockResponse().setBody(body).setResponseCode(isInvalidCrumb ? 403 : 200) - default: - return new MockResponse().setStatus("404") - } - } - webServer.start() + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .inScenario("Invalid Crumb Retry") + .whenScenarioStateIs("Started") + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}')) + .willSetStateTo("First Crumb")) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .inScenario("Invalid Crumb Retry") + .whenScenarioStateIs("First Crumb") + .withHeader("Jenkins-Crumb", equalTo("the-invalid-crumb")) + .willReturn(aResponse() + .withStatus(403) + .withBody('{"servlet":"Stapler", "message":"No valid crumb was included in the request", "url":"/scriptText", "status":"403"}')) + .willSetStateTo("Invalid Crumb Response")) + + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .inScenario("Invalid Crumb Retry") + .whenScenarioStateIs("Invalid Crumb Response") + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-second-crumb", "crumbRequestField": "Jenkins-Crumb"}')) + .willSetStateTo("Second Crumb")) - def httpClient = new OkHttpClient() + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .inScenario("Invalid Crumb Retry") + .whenScenarioStateIs("Second Crumb") + .withHeader("Jenkins-Crumb", equalTo("the-second-crumb")) + .willReturn(aResponse() + .withStatus(200) + .withBody("ok"))) + + def httpClient = getUnsafeOkHttpClient() def apiClient = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: webServer.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), httpClient) apiClient.setMaxRetries(3) apiClient.setWaitPeriodInMs(0) def result = apiClient.runScript("println('ok')") assertThat(result).isEqualTo("ok") - assertThat(crumbQueue.size()).isEqualTo(0) + + wireMock.verify(2, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(2, postRequestedFor(urlPathEqualTo("/jenkins/scriptText"))) } @Test void 'retries on invalid crumb are limited'() { - webServer.setDispatcher { request -> - switch (request.path) { - case "/jenkins/crumbIssuer/api/json": - return new MockResponse().setBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}') - case "/jenkins/scriptText": - def body = '{"servlet":"Stapler", "message":"No valid crumb was included in the request", "url":"/scriptText", "status":"403"}' - return new MockResponse().setBody(body).setResponseCode(403) - default: - return new MockResponse().setStatus("404") - } - } - webServer.start() + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) - def httpClient = new OkHttpClient() + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .willReturn(aResponse() + .withStatus(403) + .withBody('{"servlet":"Stapler", "message":"No valid crumb was included in the request", "url":"/scriptText", "status":"403"}'))) + + def httpClient = getUnsafeOkHttpClient() def apiClient = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: webServer.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), httpClient) apiClient.setMaxRetries(3) apiClient.setWaitPeriodInMs(0) - + shouldFail(RuntimeException) { apiClient.runScript("println('ok')") } - assertThat(webServer.requestCount).isEqualTo(3 /* fetch crumb */ + 3 /* call scriptText */) + + wireMock.verify(3, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(3, postRequestedFor(urlPathEqualTo("/jenkins/scriptText"))) } @Test void 'retries when fetching crumb fails'() { - def crumbRequestCounter = 0 - webServer.setDispatcher { request -> - switch (request.path) { - case "/jenkins/crumbIssuer/api/json": - if (++crumbRequestCounter > 1) { - return new MockResponse().setBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}') - } else { - return new MockResponse().setBody('error').setResponseCode(401) - } - case "/jenkins/scriptText": - return new MockResponse().setBody("ok") - default: - return new MockResponse().setStatus("404") - } - } - webServer.start() + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .inScenario("Crumb Fetch Retry") + .whenScenarioStateIs("Started") + .willReturn(aResponse() + .withStatus(401) + .withBody("error")) + .willSetStateTo("First Attempt Failed")) + + wireMock.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .inScenario("Crumb Fetch Retry") + .whenScenarioStateIs("First Attempt Failed") + .willReturn(aResponse() + .withStatus(200) + .withBody('{"crumb": "the-invalid-crumb", "crumbRequestField": "Jenkins-Crumb"}'))) + + wireMock.stubFor(post(urlPathEqualTo("/jenkins/scriptText")) + .willReturn(aResponse() + .withStatus(200) + .withBody("ok"))) - def httpClient = new OkHttpClient() + def httpClient = getUnsafeOkHttpClient() def apiClient = new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: webServer.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: "${wireMock.baseUrl()}/jenkins")), httpClient) apiClient.setMaxRetries(3) apiClient.setWaitPeriodInMs(0) def result = apiClient.runScript("println('ok')") assertThat(result).isEqualTo("ok") - assertThat(webServer.requestCount).isEqualTo(2 /* fetch crumb */ + 1 /* call scriptText */) + + wireMock.verify(2, getRequestedFor(urlPathEqualTo("/jenkins/crumbIssuer/api/json"))) + wireMock.verify(1, postRequestedFor(urlPathEqualTo("/jenkins/scriptText"))) + } + + private static OkHttpClient getUnsafeOkHttpClient() { + try { + // Create a trust manager that does not validate certificate chains + final TrustManager[] trustAllCerts = [ + new X509TrustManager() { + @Override + void checkClientTrusted(X509Certificate[] chain, String authType) {} + @Override + void checkServerTrusted(X509Certificate[] chain, String authType) {} + @Override + X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0] + } + } + ] as TrustManager[] + + // Install the all-trusting trust manager + final SSLContext sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, new SecureRandom()) + final SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory() + + return new OkHttpClient.Builder() + .sslSocketFactory(sslSocketFactory, (X509TrustManager)trustAllCerts[0]) + .hostnameVerifier { hostname, session -> true } + .build() + } catch (Exception e) { + throw new RuntimeException(e) + } } -} +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/jenkins/JobManagerTest.groovy b/src/test/groovy/com/cloudogu/gitops/jenkins/JobManagerTest.groovy index a82ad8b13..76027b657 100644 --- a/src/test/groovy/com/cloudogu/gitops/jenkins/JobManagerTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/jenkins/JobManagerTest.groovy @@ -1,97 +1,127 @@ package com.cloudogu.gitops.jenkins import com.cloudogu.gitops.config.Config +import com.github.tomakehurst.wiremock.WireMockServer import okhttp3.OkHttpClient -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.Test -import java.nio.charset.Charset - +import static com.github.tomakehurst.wiremock.client.WireMock.* +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options import static groovy.test.GroovyAssert.shouldFail import static org.assertj.core.api.Assertions.assertThat import static org.mockito.ArgumentMatchers.anyString import static org.mockito.Mockito.* class JobManagerTest { + @Test void 'creates credential'() { - def server = new MockWebServer() + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + try { - server.enqueue(new MockResponse().setBody('{"crumb":"the-crumb"}')) - server.enqueue(new MockResponse()) + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathMatching(".*createCredentials.*")) + .willReturn(ok())) + def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: server.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), new OkHttpClient())) + jobManager.createCredential('the-jobname', 'the-id', 'the-username', 'the-password', 'some description') - assertThat(server.requestCount).isEqualTo(2) - server.takeRequest() // crumb - def request = server.takeRequest() - assertThat(request.path).isEqualTo("/jenkins/job/the-jobname/credentials/store/folder/domain/_/createCredentials") - assertThat(URLDecoder.decode(request.body.readString(Charset.defaultCharset()), "utf-8")).isEqualTo('json={"credentials":{"scope":"GLOBAL","id":"the-id","username":"the-username","password":"the-password","description":"some description","$class":"com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl"}}') + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname/credentials/store/folder/domain/_/createCredentials"))) + + def requests = wireMockServer.findAll(postRequestedFor(urlPathMatching(".*createCredentials.*"))) + assertThat(requests).hasSize(1) + + def requestBody = requests[0].bodyAsString + assertThat(URLDecoder.decode(requestBody, "utf-8")) + .isEqualTo('json={"credentials":{"scope":"GLOBAL","id":"the-id","username":"the-username","password":"the-password","description":"some description","$class":"com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl"}}') + } finally { - server.shutdown() + wireMockServer.stop() } } @Test void 'throw when creating credential fails'() { - def server = new MockWebServer() + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + try { - server.enqueue(new MockResponse().setBody('{"crumb":"the-crumb"}')) - server.enqueue(new MockResponse().setResponseCode(404)) + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathMatching(".*createCredentials.*")) + .willReturn(aResponse().withStatus(404))) + def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: server.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), new OkHttpClient())) + def exception = shouldFail(RuntimeException) { jobManager.createCredential('the-jobname', 'the-id', 'the-username', 'the-password', 'some description') } assertThat(exception.getMessage()).isEqualTo('Could not create credential id=the-id,job=the-jobname. StatusCode: 404') } finally { - server.shutdown() + wireMockServer.stop() } } @Test void 'starts job'() { - def server = new MockWebServer() + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + try { - server.enqueue(new MockResponse().setBody('{"crumb":"the-crumb"}')) - server.enqueue(new MockResponse().setResponseCode(200)) + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathMatching("/jenkins/job/the-jobname/build.*")) + .willReturn(ok())) + def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: server.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), new OkHttpClient())) + jobManager.startJob('the-jobname') - assertThat(server.requestCount).isEqualTo(2) - server.takeRequest() // crumb - def request = server.takeRequest() - assertThat(request.path).isEqualTo("/jenkins/job/the-jobname/build?delay=0sec") + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname/build")) + .withQueryParam("delay", equalTo("0sec"))) + } finally { - server.shutdown() + wireMockServer.stop() } } @Test void 'throw when starting job fails'() { - def server = new MockWebServer() + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + try { - server.enqueue(new MockResponse().setBody('{"crumb":"the-crumb"}')) - server.enqueue(new MockResponse().setResponseCode(400)) + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathMatching("/jenkins/job/the-jobname/build.*")) + .willReturn(aResponse().withStatus(400))) + def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: server.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), new OkHttpClient())) + def exception = shouldFail(RuntimeException) { jobManager.startJob('the-jobname') } assertThat(exception.getMessage()).isEqualTo('Could not trigger build of Jenkins job: the-jobname. StatusCode: 400') } finally { - server.shutdown() + wireMockServer.stop() } } - @Test void 'throws when job contains invalid characters'() { def client = mock(JenkinsApiClient) @@ -102,7 +132,7 @@ class JobManagerTest { } assertThat(exception.getMessage()).isEqualTo('Job name cannot contain quotes.') } - + @Test void 'throws when job deletion fails'() { def client = mock(JenkinsApiClient) @@ -121,103 +151,115 @@ class JobManagerTest { when(client.runScript(anyString())).thenReturn("null") jobManager.deleteJob("foo") - - verify(client).runScript("print(Jenkins.instance.getItem('foo')?.delete())") + org.mockito.Mockito.verify(client).runScript("print(Jenkins.instance.getItem('foo')?.delete())") } @Test void 'checks existing Job'() { - def server = new MockWebServer() + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + try { - server.enqueue(new MockResponse().setBody('{"crumb":"the-crumb"}')) - server.enqueue(new MockResponse().setResponseCode(200)) + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) + .willReturn(ok())) + def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: server.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), new OkHttpClient())) - + def exists = jobManager.jobExists('the-jobname') - + assertThat(exists).isEqualTo(true) - assertThat(server.requestCount).isEqualTo(2) - server.takeRequest() // crumb - def request = server.takeRequest() - assertThat(request.path).isEqualTo("/jenkins/job/the-jobname") + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) } finally { - server.shutdown() + wireMockServer.stop() } } - + @Test void 'checks non-existing Job'() { - def server = new MockWebServer() + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + try { - server.enqueue(new MockResponse().setBody('{"crumb":"the-crumb"}')) - server.enqueue(new MockResponse().setResponseCode(404)) + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) + .willReturn(aResponse().withStatus(404))) + def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: server.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), new OkHttpClient())) - + def exists = jobManager.jobExists('the-jobname') - assertThat(exists).isEqualTo(false) - assertThat(server.requestCount).isEqualTo(2) - server.takeRequest() // crumb - def request = server.takeRequest() - assertThat(request.path).isEqualTo("/jenkins/job/the-jobname") + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) } finally { - server.shutdown() + wireMockServer.stop() } } @Test void 'creates Job'() { - def server = new MockWebServer() + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + try { - server.enqueue(new MockResponse().setBody('{"crumb":"the-crumb"}')) - server.enqueue(new MockResponse().setResponseCode(404)) // jobExists - server.enqueue(new MockResponse().setBody('{"crumb":"the-crumb"}')) - server.enqueue(new MockResponse().setResponseCode(200)) + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) + .willReturn(aResponse().withStatus(404))) + wireMockServer.stubFor(post(urlPathMatching("/jenkins/createItem.*")) + .willReturn(ok())) + def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: server.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), new OkHttpClient())) def created = jobManager.createJob('the-jobname', 'http://scm', 'ns', 'creds') assertThat(created).isEqualTo(true) - assertThat(server.requestCount).isEqualTo(4) - server.takeRequest() // crumb - server.takeRequest() // exists - server.takeRequest() // crumb - def request = server.takeRequest() - assertThat(request.path).isEqualTo("/jenkins/createItem?name=the-jobname") - - def body = request.body.readUtf8() - assertThat(body).contains('http://scm') - assertThat(body).contains('ns') - assertThat(body).contains('creds') + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/createItem")) + .withQueryParam("name", equalTo("the-jobname")) + .withRequestBody(containing('http://scm')) + .withRequestBody(containing('ns')) + .withRequestBody(containing('creds'))) + } finally { - server.shutdown() + wireMockServer.stop() } } - + @Test void 'ignores existing Job'() { - def server = new MockWebServer() + def wireMockServer = new WireMockServer(options().dynamicPort()) + wireMockServer.start() + try { - server.enqueue(new MockResponse().setBody('{"crumb":"the-crumb"}')) - server.enqueue(new MockResponse().setResponseCode(200)) // jobExists + wireMockServer.stubFor(get(urlPathEqualTo("/jenkins/crumbIssuer/api/json")) + .willReturn(okJson('{"crumb":"the-crumb"}'))) + + wireMockServer.stubFor(post(urlPathEqualTo("/jenkins/job/the-jobname")) + .willReturn(ok())) // 200 OK means "Job Exists" + def jobManager = new JobManager(new JenkinsApiClient( - new Config(jenkins: new Config.JenkinsSchema(url: server.url("jenkins").toString())), + new Config(jenkins: new Config.JenkinsSchema(url: wireMockServer.baseUrl() + "/jenkins")), new OkHttpClient())) def created = jobManager.createJob('the-jobname', 'http://scm', 'ns', 'creds') assertThat(created).isEqualTo(false) - assertThat(server.requestCount).isEqualTo(2) - server.takeRequest() // crumb - server.takeRequest() // exists + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/jenkins/job/the-jobname"))) + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo("/jenkins/createItem"))) + } finally { - server.shutdown() + wireMockServer.stop() } } -} +} \ No newline at end of file diff --git a/src/test/groovy/com/cloudogu/gitops/okhttp/RetryInterceptorTest.groovy b/src/test/groovy/com/cloudogu/gitops/okhttp/RetryInterceptorTest.groovy index d2c857579..ff2683ce0 100644 --- a/src/test/groovy/com/cloudogu/gitops/okhttp/RetryInterceptorTest.groovy +++ b/src/test/groovy/com/cloudogu/gitops/okhttp/RetryInterceptorTest.groovy @@ -1,74 +1,142 @@ package com.cloudogu.gitops.okhttp +import com.github.tomakehurst.wiremock.junit5.WireMockExtension import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.SocketPolicy -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit +import static com.github.tomakehurst.wiremock.client.WireMock.* +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig import static org.assertj.core.api.Assertions.assertThat class RetryInterceptorTest { - private MockWebServer webServer = new MockWebServer() - @AfterEach - void tearDown() { - webServer.shutdown() - } + @RegisterExtension + static WireMockExtension wireMock = WireMockExtension.newInstance() + .options(wireMockConfig() + .dynamicPort() + .dynamicHttpsPort()) + .build() @Test void 'retries three times on 500'() { - webServer.enqueue(new MockResponse().setResponseCode(500)) - webServer.enqueue(new MockResponse().setResponseCode(500)) - webServer.enqueue(new MockResponse().setResponseCode(200).setBody("Successful Result")) + wireMock.stubFor(get(urlEqualTo("/")) + .inScenario("Retry Scenario") + .whenScenarioStateIs("Started") + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("First Retry")) + + wireMock.stubFor(get(urlEqualTo("/")) + .inScenario("Retry Scenario") + .whenScenarioStateIs("First Retry") + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("Second Retry")) + + wireMock.stubFor(get(urlEqualTo("/")) + .inScenario("Retry Scenario") + .whenScenarioStateIs("Second Retry") + .willReturn(aResponse() + .withStatus(200) + .withBody("Successful Result"))) def client = createClient() + def response = client.newCall(new Request.Builder().url(wireMock.baseUrl()).build()).execute() + + assertThat(response.body().string()).isEqualTo("Successful Result") + wireMock.verify(3, getRequestedFor(urlEqualTo("/"))) + } + + @Test + void 'retries three times on 500 with HTTPS'() { + wireMock.stubFor(get(urlEqualTo("/secure")) + .inScenario("HTTPS Retry Scenario") + .whenScenarioStateIs("Started") + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("First Retry")) + + wireMock.stubFor(get(urlEqualTo("/secure")) + .inScenario("HTTPS Retry Scenario") + .whenScenarioStateIs("First Retry") + .willReturn(aResponse().withStatus(500)) + .willSetStateTo("Second Retry")) + + wireMock.stubFor(get(urlEqualTo("/secure")) + .inScenario("HTTPS Retry Scenario") + .whenScenarioStateIs("Second Retry") + .willReturn(aResponse() + .withStatus(200) + .withBody("Successful Result"))) - def response = client.newCall(new Request.Builder().url(webServer.url("")).build()).execute() + def client = createClient() + def httpsUrl = "https://localhost:${wireMock.httpsPort}/secure" + def response = client.newCall(new Request.Builder().url(httpsUrl).build()).execute() assertThat(response.body().string()).isEqualTo("Successful Result") + wireMock.verify(3, getRequestedFor(urlEqualTo("/secure"))) } @Test void 'retries on timeout'() { - def timeoutResponse = new MockResponse() - timeoutResponse.socketPolicy(SocketPolicy.NO_RESPONSE) - webServer.enqueue(timeoutResponse) - webServer.enqueue(new MockResponse().setResponseCode(200).setBody("Successful Result")) + wireMock.stubFor(get(urlEqualTo("/")) + .inScenario("Timeout Scenario") + .whenScenarioStateIs("Started") + .willReturn(aResponse() + .withStatus(200) + .withFixedDelay(100)) // Delay longer than read timeout + .willSetStateTo("After Timeout")) + + wireMock.stubFor(get(urlEqualTo("/")) + .inScenario("Timeout Scenario") + .whenScenarioStateIs("After Timeout") + .willReturn(aResponse() + .withStatus(200) + .withBody("Successful Result"))) def client = createClient() - - def response = client.newCall(new Request.Builder().url(webServer.url("")).build()).execute() + def response = client.newCall(new Request.Builder().url(wireMock.baseUrl()).build()).execute() assertThat(response.body().string()).isEqualTo("Successful Result") - assertThat(webServer.requestCount).isEqualTo(2) + wireMock.verify(2, getRequestedFor(urlEqualTo("/"))) } @Test void 'fails after third retry'() { - webServer.enqueue(new MockResponse().setResponseCode(500)) - webServer.enqueue(new MockResponse().setResponseCode(500)) - webServer.enqueue(new MockResponse().setResponseCode(500)) - webServer.enqueue(new MockResponse().setResponseCode(500)) - webServer.enqueue(new MockResponse().setResponseCode(200).setBody("Successful Result")) + wireMock.stubFor(get(urlEqualTo("/")) + .willReturn(aResponse().withStatus(500))) def client = createClient() - - def response = client.newCall(new Request.Builder().url(webServer.url("")).build()).execute() + def response = client.newCall(new Request.Builder().url(wireMock.baseUrl()).build()).execute() assertThat(response.code()).isEqualTo(500) - assertThat(webServer.takeRequest(1, TimeUnit.MILLISECONDS).path).isNotNull() - assertThat(webServer.takeRequest(1, TimeUnit.MILLISECONDS).path).isNotNull() + wireMock.verify(4, getRequestedFor(urlEqualTo("/"))) // Initial request + 3 retries } private OkHttpClient createClient() { + // 1. Create a TrustManager that trusts everyone + def trustAllCerts = [ + new X509TrustManager() { + void checkClientTrusted(X509Certificate[] chain, String authType) {} + void checkServerTrusted(X509Certificate[] chain, String authType) {} + X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0] } + } + ] as TrustManager[] + + def sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, new java.security.SecureRandom()) + new OkHttpClient.Builder() .addInterceptor(new RetryInterceptor(retries: 3, waitPeriodInMs: 0)) .readTimeout(50, TimeUnit.MILLISECONDS) + .sslSocketFactory(sslContext.socketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier({ hostname, session -> true } as HostnameVerifier) .build() } -} +} \ No newline at end of file