From df85809a5ce1626c387d845ff6ba69cedb74822e Mon Sep 17 00:00:00 2001 From: Ronald Holshausen Date: Thu, 21 May 2020 14:49:57 +1000 Subject: [PATCH] feat: enable HTTPS support with JUnit 5 consumer tests #1093 --- consumer/junit5/build.gradle | 1 + .../consumer/junit5/PactConsumerTestExt.kt | 62 +++++++--- .../junit5/PactConsumerTestExtSpec.groovy | 2 +- .../consumer/junit5/ArticlesHttpsTest.java | 109 ++++++++++++++++++ .../com/dius/pact/consumer/MockHttpServer.kt | 15 ++- .../consumer/model/MockHttpsProviderConfig.kt | 8 +- .../au/com/dius/pact/core/support/Utils.kt | 31 +++++ 7 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 consumer/junit5/src/test/java/au/com/dius/pact/consumer/junit5/ArticlesHttpsTest.java diff --git a/consumer/junit5/build.gradle b/consumer/junit5/build.gradle index cb8d79dfd0..b1b6e3e475 100644 --- a/consumer/junit5/build.gradle +++ b/consumer/junit5/build.gradle @@ -17,4 +17,5 @@ dependencies { testCompile('org.spockframework:spock-core:2.0-M2-groovy-3.0') { exclude group: 'org.codehaus.groovy' } + testCompile "org.apache.httpcomponents:httpclient:${project.httpClientVersion}" } diff --git a/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/PactConsumerTestExt.kt b/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/PactConsumerTestExt.kt index 9a9ec8ab7c..eed224944a 100644 --- a/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/PactConsumerTestExt.kt +++ b/consumer/junit5/src/main/kotlin/au/com/dius/pact/consumer/junit5/PactConsumerTestExt.kt @@ -1,13 +1,16 @@ package au.com.dius.pact.consumer.junit5 +import au.com.dius.pact.consumer.AbstractBaseMockServer import au.com.dius.pact.consumer.BaseMockServer import au.com.dius.pact.consumer.ConsumerPactBuilder import au.com.dius.pact.consumer.MessagePactBuilder import au.com.dius.pact.consumer.MockServer import au.com.dius.pact.consumer.PactConsumerConfig +import au.com.dius.pact.consumer.PactTestRun import au.com.dius.pact.consumer.PactVerificationResult import au.com.dius.pact.consumer.junit.JUnitTestSupport import au.com.dius.pact.consumer.mockServer +import au.com.dius.pact.consumer.model.MockHttpsProviderConfig import au.com.dius.pact.consumer.model.MockProviderConfig import au.com.dius.pact.core.model.BasePact import au.com.dius.pact.core.model.DefaultPactWriter @@ -90,7 +93,12 @@ annotation class PactTestFor( /** * Type of provider (synchronous HTTP or asynchronous messages) */ - val providerType: ProviderType = ProviderType.UNSPECIFIED + val providerType: ProviderType = ProviderType.UNSPECIFIED, + + /** + * If HTTPS should be used. If enabled, a mock server with a self-signed cert will be started. + */ + val https: Boolean = false ) data class ProviderInfo @JvmOverloads constructor( @@ -98,19 +106,32 @@ data class ProviderInfo @JvmOverloads constructor( val hostInterface: String = "", val port: String = "", val pactVersion: PactSpecVersion? = null, - val providerType: ProviderType? = null + val providerType: ProviderType? = null, + val https: Boolean = false ) { - fun mockServerConfig() = - MockProviderConfig.httpConfig(if (hostInterface.isEmpty()) MockProviderConfig.LOCALHOST else hostInterface, - if (port.isEmpty()) 0 else port.toInt(), pactVersion ?: PactSpecVersion.V3) + fun mockServerConfig() = if (https) { + MockHttpsProviderConfig.httpsConfig( + if (hostInterface.isEmpty()) MockProviderConfig.LOCALHOST else hostInterface, + if (port.isEmpty()) 0 else port.toInt(), + pactVersion ?: PactSpecVersion.V3 + ) + } else { + MockProviderConfig.httpConfig( + if (hostInterface.isEmpty()) MockProviderConfig.LOCALHOST else hostInterface, + if (port.isEmpty()) 0 else port.toInt(), + pactVersion ?: PactSpecVersion.V3 + ) + } fun merge(other: ProviderInfo): ProviderInfo { return copy(providerName = if (providerName.isNotEmpty()) providerName else other.providerName, hostInterface = if (hostInterface.isNotEmpty()) hostInterface else other.hostInterface, port = if (port.isNotEmpty()) port else other.port, pactVersion = pactVersion ?: other.pactVersion, - providerType = providerType ?: other.providerType) + providerType = providerType ?: other.providerType, + https = https || other.https + ) } companion object { @@ -123,15 +144,24 @@ data class ProviderInfo @JvmOverloads constructor( when (annotation.providerType) { ProviderType.UNSPECIFIED -> null else -> annotation.providerType - }) + }, annotation.https) } } -class JUnit5MockServerSupport(private val baseMockServer: BaseMockServer) : MockServer by baseMockServer, +class JUnit5MockServerSupport(private val baseMockServer: BaseMockServer) : AbstractBaseMockServer(), ExtensionContext.Store.CloseableResource { override fun close() { baseMockServer.stop() } + + override fun start() = baseMockServer.start() + override fun stop() = baseMockServer.stop() + override fun waitForServer() = baseMockServer.waitForServer() + override fun getUrl() = baseMockServer.getUrl() + override fun getPort() = baseMockServer.getPort() + override fun runAndWritePact(pact: RequestResponsePact, pactVersion: PactSpecVersion, testFn: PactTestRun) = + baseMockServer.runAndWritePact(pact, pactVersion, testFn) + override fun validateMockServerState(testResult: Any?) = baseMockServer.validateMockServerState(testResult) } class PactConsumerTestExt : Extension, BeforeTestExecutionCallback, BeforeAllCallback, ParameterResolver, AfterTestExecutionCallback, AfterAllCallback { @@ -184,21 +214,23 @@ class PactConsumerTestExt : Extension, BeforeTestExecutionCallback, BeforeAllCal val (providerInfo, pactMethod) = lookupProviderInfo(context) logger.debug { "providerInfo = $providerInfo" } - setupMockServer(providerInfo, pactMethod, context) + if (providerInfo.providerType != ProviderType.ASYNCH) { + val mockServer = setupMockServer(providerInfo, pactMethod, context) + mockServer.start() + mockServer.waitForServer() + } } - private fun setupMockServer(providerInfo: ProviderInfo, pactMethod: String, context: ExtensionContext): MockServer? { + private fun setupMockServer(providerInfo: ProviderInfo, pactMethod: String, context: ExtensionContext): AbstractBaseMockServer { val store = context.getStore(NAMESPACE) - return if (providerInfo.providerType != ProviderType.ASYNCH && store["mockServer"] == null) { + return if (store["mockServer"] == null) { val config = providerInfo.mockServerConfig() store.put("mockServerConfig", config) - val mockServer = mockServer(lookupPact(providerInfo, pactMethod, context) as RequestResponsePact, config) as BaseMockServer - mockServer.start() - mockServer.waitForServer() + val mockServer = mockServer(lookupPact(providerInfo, pactMethod, context) as RequestResponsePact, config) store.put("mockServer", JUnit5MockServerSupport(mockServer)) mockServer } else { - store["mockServer"] as MockServer? + store["mockServer"] as AbstractBaseMockServer } } diff --git a/consumer/junit5/src/test/groovy/au/com/dius/pact/consumer/junit5/PactConsumerTestExtSpec.groovy b/consumer/junit5/src/test/groovy/au/com/dius/pact/consumer/junit5/PactConsumerTestExtSpec.groovy index 1c9ea995b6..de606d70c5 100644 --- a/consumer/junit5/src/test/groovy/au/com/dius/pact/consumer/junit5/PactConsumerTestExtSpec.groovy +++ b/consumer/junit5/src/test/groovy/au/com/dius/pact/consumer/junit5/PactConsumerTestExtSpec.groovy @@ -28,7 +28,7 @@ class PactConsumerTestExtSpec extends Specification { def parameter = PactConsumerTestExtSpec.getMethod(testMethod, model).parameters[0] def parameterContext = [getParameter: { parameter } ] as ParameterContext def providerInfo = new ProviderInfo('test', 'localhost', '0', PactSpecVersion.V3, - providerType) + providerType, false) def store = [get: { arg -> arg == 'providerInfo' ? providerInfo : model.newInstance(new Provider(), new Consumer(), []) diff --git a/consumer/junit5/src/test/java/au/com/dius/pact/consumer/junit5/ArticlesHttpsTest.java b/consumer/junit5/src/test/java/au/com/dius/pact/consumer/junit5/ArticlesHttpsTest.java new file mode 100644 index 0000000000..249296466a --- /dev/null +++ b/consumer/junit5/src/test/java/au/com/dius/pact/consumer/junit5/ArticlesHttpsTest.java @@ -0,0 +1,109 @@ +package au.com.dius.pact.consumer.junit5; + +import au.com.dius.pact.consumer.MockServer; +import au.com.dius.pact.consumer.dsl.PactDslJsonBody; +import au.com.dius.pact.consumer.dsl.PactDslWithProvider; +import au.com.dius.pact.core.model.RequestResponsePact; +import au.com.dius.pact.core.model.annotations.Pact; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.io.IOUtils; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.fluent.Request; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.conn.ssl.SSLSocketFactory; +import org.apache.http.conn.ssl.TrustSelfSignedStrategy; +import org.apache.http.impl.client.HttpClientBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.startsWith; + +@ExtendWith(PactConsumerTestExt.class) +@PactTestFor(providerName = "ArticlesProvider", https = true) +public class ArticlesHttpsTest { + private Map headers = MapUtils.putAll(new HashMap<>(), new String[] { + "Content-Type", "application/json" + }); + + @BeforeEach + public void setUp(MockServer mockServer) { + assertThat(mockServer, is(notNullValue())); + assertThat(mockServer.getUrl(), startsWith("https://")); + } + + @Pact(consumer = "ArticlesConsumer") + public RequestResponsePact articles(PactDslWithProvider builder) { + return builder + .given("Articles exist") + .uponReceiving("retrieving article data") + .path("/articles.json") + .method("GET") + .willRespondWith() + .headers(headers) + .status(200) + .body( + new PactDslJsonBody() + .minArrayLike("articles", 1) + .object("variants") + .eachKeyLike("0032") + .stringType("description", "sample description") + .closeObject() + .closeObject() + .closeObject() + .closeArray() + ) + .toPact(); + } + + @Pact(consumer = "ArticlesConsumer") + public RequestResponsePact articlesDoNotExist(PactDslWithProvider builder) { + return builder + .given("No articles exist") + .uponReceiving("retrieving article data") + .path("/articles.json") + .method("GET") + .willRespondWith() + .headers(headers) + .status(404) + .toPact(); + } + + @Test + @PactTestFor(pactMethod = "articles") + void testArticles(MockServer mockServer) throws IOException, GeneralSecurityException { + HttpResponse httpResponse = get(mockServer.getUrl() + "/articles.json"); + assertThat(httpResponse.getStatusLine().getStatusCode(), is(equalTo(200))); + assertThat(IOUtils.toString(httpResponse.getEntity().getContent()), + is(equalTo("{\"articles\":[{\"variants\":{\"0032\":{\"description\":\"sample description\"}}}]}"))); + } + + private HttpResponse get(String url) throws IOException, GeneralSecurityException { + return httpClient().execute(new HttpGet(url)); + } + + private HttpClient httpClient() throws GeneralSecurityException { + SSLSocketFactory socketFactory = new SSLSocketFactory(new TrustSelfSignedStrategy()); + return HttpClientBuilder.create() + .setSSLSocketFactory(socketFactory) + .build(); + } + + @Test + @PactTestFor(pactMethod = "articlesDoNotExist") + void testArticlesDoNotExist(MockServer mockServer) throws IOException, GeneralSecurityException { + HttpResponse httpResponse = get(mockServer.getUrl() + "/articles.json"); + assertThat(httpResponse.getStatusLine().getStatusCode(), is(equalTo(404))); + assertThat(IOUtils.toString(httpResponse.getEntity().getContent()), is(equalTo(""))); + } +} diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/MockHttpServer.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/MockHttpServer.kt index 38b677f1e0..d5c8ebe079 100755 --- a/consumer/src/main/kotlin/au/com/dius/pact/consumer/MockHttpServer.kt +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/MockHttpServer.kt @@ -39,7 +39,7 @@ import java.util.concurrent.ConcurrentLinkedQueue /** * Returns a mock server for the pact and config */ -fun mockServer(pact: RequestResponsePact, config: MockProviderConfig): MockServer { +fun mockServer(pact: RequestResponsePact, config: MockProviderConfig): BaseMockServer { return when (config) { is MockHttpsProviderConfig -> when (config.mockServerImplementation) { MockServerImplementation.KTorServer -> KTorMockServer(pact, config) @@ -75,16 +75,19 @@ interface MockServer { fun validateMockServerState(testResult: Any?): PactVerificationResult } -abstract class BaseMockServer(val pact: RequestResponsePact, val config: MockProviderConfig) : MockServer { +abstract class AbstractBaseMockServer : MockServer { + abstract fun start() + abstract fun stop() + abstract fun waitForServer() +} + +abstract class BaseMockServer(val pact: RequestResponsePact, val config: MockProviderConfig) : AbstractBaseMockServer() { private val mismatchedRequests = ConcurrentHashMap>() private val matchedRequests = ConcurrentLinkedQueue() private val requestMatcher = RequestMatching(pact.interactions) - abstract fun start() - abstract fun stop() - - fun waitForServer() { + override fun waitForServer() { val sf = SSLSocketFactory(TrustSelfSignedStrategy()) val httpclient = HttpClientBuilder.create() .setConnectionManager(BasicHttpClientConnectionManager(RegistryBuilder.create() diff --git a/consumer/src/main/kotlin/au/com/dius/pact/consumer/model/MockHttpsProviderConfig.kt b/consumer/src/main/kotlin/au/com/dius/pact/consumer/model/MockHttpsProviderConfig.kt index ed80c2d938..defdbdeff4 100644 --- a/consumer/src/main/kotlin/au/com/dius/pact/consumer/model/MockHttpsProviderConfig.kt +++ b/consumer/src/main/kotlin/au/com/dius/pact/consumer/model/MockHttpsProviderConfig.kt @@ -1,6 +1,7 @@ package au.com.dius.pact.consumer.model import au.com.dius.pact.core.model.PactSpecVersion +import au.com.dius.pact.core.support.Utils.randomPort import java.io.File import java.security.KeyStore @@ -23,8 +24,13 @@ class MockHttpsProviderConfig @JvmOverloads constructor( @JvmOverloads fun httpsConfig(hostname: String = LOCALHOST, port: Int = 0, pactVersion: PactSpecVersion = PactSpecVersion.V3): MockHttpsProviderConfig { val jksFile = File.createTempFile("PactTest", ".jks") + val p = if (port == 0) { + randomPort() + } else { + port + } val keystore = io.ktor.network.tls.certificates.generateCertificate(jksFile, "SHA1withRSA", "PactTest", "changeit", "changeit", 1024) - return MockHttpsProviderConfig(hostname, port, pactVersion, keystore, "PactTest", "changeit", "changeit", + return MockHttpsProviderConfig(hostname, p, pactVersion, keystore, "PactTest", "changeit", "changeit", MockServerImplementation.KTorServer) } } diff --git a/core/support/src/main/kotlin/au/com/dius/pact/core/support/Utils.kt b/core/support/src/main/kotlin/au/com/dius/pact/core/support/Utils.kt index b9cfb186da..d4c29b294b 100644 --- a/core/support/src/main/kotlin/au/com/dius/pact/core/support/Utils.kt +++ b/core/support/src/main/kotlin/au/com/dius/pact/core/support/Utils.kt @@ -1,5 +1,8 @@ package au.com.dius.pact.core.support +import org.apache.commons.lang3.RandomUtils +import java.io.IOException +import java.net.ServerSocket import kotlin.reflect.full.cast object Utils { @@ -31,4 +34,32 @@ object Utils { default } } + + fun randomPort(lower: Int = 10000, upper: Int = 60000): Int { + var port: Int? = null + var count = 0 + while (port == null && count < 20) { + val randomPort = RandomUtils.nextInt(lower, upper) + if (portAvailable(randomPort)) { + port = randomPort + } + count++ + } + + return port ?: 0 + } + + fun portAvailable(p: Int): Boolean { + var socket: ServerSocket? = null + return try { + socket = ServerSocket(p) + true + } catch (_: IOException) { + false + } finally { + try { + socket?.close() + } catch (_: Throwable) { } + } + } }