Skip to content

Commit

Permalink
Applied the patch - needs review and testing.
Browse files Browse the repository at this point in the history
  • Loading branch information
Dave Smith committed Dec 4, 2017
1 parent a07ada3 commit 2b49a0d
Show file tree
Hide file tree
Showing 47 changed files with 830 additions and 415 deletions.
76 changes: 76 additions & 0 deletions SSL_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
The changes to Scala Pact of SSL Support are as follows

## The SslContextmap
Implicitly in scope should be an SslContextmap. This is a map from strings to SSL Contexts. Example code to create one is

```
val thisSslContext = SslContextMap.makeSslContext("./src/test/resources/localhost.jks", "somePassWord", "./src/main/resources/truststore.jks", "somePassWord")
implicit val sslContextMap: SslContextMap = new SslContextMap(Map("default" -> thisSslContext))
```

## Specifying the SSL Context for the scala pact mock server
When creating a fact using 'forgePact' there is a new method on the forger called addSslContextForServer.
These is every chance that this will be a different SSL context to the one used by the clients making calls

```
forgePact
.between("risk assessment service")
.and("risk assessment client")
.addSslContextForServer("default")
.addInteraction(
```

## Specifying the SSL Context when forging a pact for the Http Clients
The interactions allow an SslContextName to be added for each interaction. This has no effect at all when creating the pacts, but is added to the pact json and
is used when validating pacts (see below). Ideally the certificates used will match the certificates used by the code
under test

```
.addInteraction(
interaction
.description("Risk Assessment API says NotOk")
.given("Risk Assessment API suspects fraud")
.withSsl("someSslName")
```

This means that when the pact verifier is running, it can change which ssl certificate it uses.

## Storing the SSL context name in the pact json
The scala pact mock server `.addSslContextName` doesn't need to be stored. The `withSsl("someSslName")` does. Because the pact json is to be
shared across many languages and software projects, it's not a good idea to change it. Thus the `someSslName` is coded up as a fake header. This fake header will not be actually sent by the verifier. The fake header has the name `pact-ssl-context`

## Validation with SSL context name
This is requires the use of a unit test at the moment. For example:

```
val thisSslContext = makeSslContext("./src/test/resources/localhost.jks", "somePassWord", "./src/main/resources/truststore.jks", "somePassWord")
implicit val sslContextMap: SslContextMap = new SslContextMap(Map("default" -> thisSslContext))
verifyPact
.withPactSource( .. some source ... )
.noSetupRequired
.runVerificationAgainst("https", "localhost", sslPort, 40 seconds)
```
This code will load the JSON file. If the interaction has a `pact-ssl-context` it will use that
header to select an SSL context from the SslContextMap that is in scope.

# KNOWN ISSUES

## Pact Verifier doesn't currently verify SSL
To do this I'd have to know how to setup SSL Context Maps in SBT and pull them into commands

## Assumption that all the pacts are either http or https
The run verification against method above assumes that all the pacts are http or https. It would probably
be better if the following worked. It currently doesn't

```
verifyPact
.withPactSource( .. some source ... )
.noSetupRequired
.runVerificationAgainst("https", "localhost", sslPort, 40 seconds)
.runVerificationAgainst("http", "localhost", httpPort, 40 seconds)
```

## The Pact JSON uses a customer-header to describe which SSL Context is being used
This is probably better than using a custom modification to the JSON. Perhaps it should be made configurable...

10 changes: 9 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ lazy val commonSettings = Seq(
)
)

lazy val mockSettings = Seq(
libraryDependencies += "org.scalamock" %% "scalamock" % "4.0.0" % Test
)

lazy val publishSettings = Seq(
publishTo := {
val nexus = "https://oss.sonatype.org/"
Expand Down Expand Up @@ -199,6 +203,7 @@ lazy val http4s0170 =
(project in file("scalapact-http4s-0-17-0"))
.settings(commonSettings: _*)
.settings(publishSettings: _*)
.settings(mockSettings: _*)
.cross

//lazy val http4s0170_2_10 = http4s0170(scala210).dependsOn(shared_2_10)
Expand Down Expand Up @@ -277,7 +282,7 @@ lazy val framework_2_12 =
framework(scala212)
.dependsOn(core_2_12)
.dependsOn(argonaut62_2_12 % "provided")
.dependsOn(http4s0150a_2_12 % "provided")
.dependsOn(http4s0170_2_12 % "provided")
.project

lazy val standalone =
Expand Down Expand Up @@ -312,6 +317,9 @@ lazy val scalaPactProject =
framework_2_11,
framework_2_12,
standalone,
shared_2_10,
shared_2_11,
shared_2_12,
docs,
http4s0150a_2_10,
http4s0150a_2_11,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class InteractionManager extends IInteractionManager {
private var interactions = List.empty[Interaction]

def findMatchingInteraction(request: InteractionRequest, strictMatching: Boolean): Either[String, Interaction] =
matchRequest(strictMatching, interactions, request)
matchRequest(strictMatching, interactions.map(_.withoutSslHeader), request)

def getInteractions: List[Interaction] = interactions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ import RightBiasEither._

object Verifier {

def verify(loadPactFiles: String => ScalaPactSettings => ConfigAndPacts, pactVerifySettings: PactVerifySettings)(implicit pactReader: IPactReader): ScalaPactSettings => Boolean = arguments => {
def verify(loadPactFiles: String => ScalaPactSettings => ConfigAndPacts, pactVerifySettings: PactVerifySettings)(implicit pactReader: IPactReader, sslContextMap: SslContextMap): ScalaPactSettings => Boolean = arguments => {

val pacts: List[Pact] = if(arguments.localPactFilePath.isDefined) {
val pacts: List[Pact] = if (arguments.localPactFilePath.isDefined) {
println(s"Attempting to use local pact files at: '${arguments.localPactFilePath.getOrElse("<path missing>")}'".white.bold)
loadPactFiles("pacts")(arguments).pacts
} else {
Expand All @@ -22,7 +22,7 @@ object Verifier {
pactVerifySettings.consumerNames.map(c => VersionedConsumer(c, "/latest")) ++
pactVerifySettings.versionedConsumerNames.map(vc => vc.copy(version = "/version/" + vc.version))

val latestPacts : List[Pact] = versionConsumers.map { consumer =>
val latestPacts: List[Pact] = versionConsumers.map { consumer =>
ValidatedDetails.buildFrom(consumer.name, pactVerifySettings.providerName, pactVerifySettings.pactBrokerAddress, consumer.version) match {
case Left(l) =>
println(l.red)
Expand Down Expand Up @@ -52,7 +52,7 @@ object Verifier {
.providerState
.map(p => ProviderState(p, PartialFunction(pactVerifySettings.providerStates)))

val result = (doRequest(arguments, maybeProviderState) andThen attemptMatch(arguments.giveStrictMode, List(interaction)))(interaction.request)
val result = (doRequest(arguments, maybeProviderState) andThen attemptMatch(arguments.giveStrictMode, List(interaction))) (interaction.request)

PactVerifyResultInContext(result, interaction.description)
}
Expand Down Expand Up @@ -107,7 +107,7 @@ object Verifier {
Left(s)
}

private def doRequest(arguments: ScalaPactSettings, maybeProviderState: Option[ProviderState]): InteractionRequest => Either[String, InteractionResponse] = interactionRequest => {
private def doRequest(arguments: ScalaPactSettings, maybeProviderState: Option[ProviderState])(implicit sslContextMap: SslContextMap): InteractionRequest => Either[String, InteractionResponse] = interactionRequest => {
val baseUrl = s"${arguments.giveProtocol}://" + arguments.giveHost + ":" + arguments.givePort.toString
val clientTimeout = arguments.giveClientTimeout

Expand All @@ -120,24 +120,24 @@ object Verifier {

val success = ps.f(ps.key)

if(success)
if (success)
println(s"Provider state ran successfully".yellow.bold)
else
println(s"Provider state run failed".red.bold)

println("--------------------".yellow.bold)

if(!success) {
if (!success) {
throw new ProviderStateFailure(ps.key)
}

case None =>
// No provider state run needed
// No provider state run needed
}

} catch {
case t: Throwable =>
if(maybeProviderState.isDefined) {
if (maybeProviderState.isDefined) {
println(s"Error executing unknown provider state function with key: ${maybeProviderState.map(_.key).getOrElse("<missing key>")}".red)
} else {
println("Error executing unknown provider state function!".red)
Expand All @@ -149,7 +149,8 @@ object Verifier {
InteractionRequest.unapply(interactionRequest) match {
case Some((Some(_), Some(_), _, _, _, _)) =>

ScalaPactHttpClient.doInteractionRequestSync(baseUrl, interactionRequest, clientTimeout)

ScalaPactHttpClient.doInteractionRequestSync(baseUrl, interactionRequest.withoutSslContextHeader, clientTimeout, interactionRequest.sslContextName)
.leftMap { t =>
println(s"Error in response: ${t.getMessage}".red)
t.getMessage
Expand All @@ -165,10 +166,9 @@ object Verifier {

}

private def fetchAndReadPact(address: String)(implicit pactReader: IPactReader): Option[Pact] = {
private def fetchAndReadPact(address: String)(implicit pactReader: IPactReader, sslContextMap: SslContextMap): Option[Pact] = {
println(s"Attempting to fetch pact from pact broker at: $address".white.bold)

ScalaPactHttpClient.doRequestSync(SimpleRequest(address, "", HttpMethod.GET, Map("Accept" -> "application/json"), None)).map {
ScalaPactHttpClient.doRequestSync(SimpleRequest(address, "", HttpMethod.GET, Map("Accept" -> "application/json"), None, sslContextName = None)).map {
case r: SimpleResponse if r.is2xx =>
val pact = r.body.map(pactReader.jsonStringToPact).flatMap {
case Right(p) => Option(p)
Expand All @@ -177,7 +177,7 @@ object Verifier {
None
}

if(pact.isEmpty) {
if (pact.isEmpty) {
println("Could not convert good response to Pact:\n" + r.body.getOrElse(""))
pact
} else pact
Expand All @@ -204,7 +204,9 @@ case class PactVerifyResultInContext(result: Either[String, Interaction], contex
class ProviderStateFailure(key: String) extends Exception()

case class ProviderState(key: String, f: String => Boolean)

case class VersionedConsumer(name: String, version: String)

case class PactVerifySettings(providerStates: (String => Boolean), pactBrokerAddress: String, projectVersion: String, providerName: String, consumerNames: List[String], versionedConsumerNames: List[VersionedConsumer])

case class ValidatedDetails(validatedAddress: ValidPactBrokerAddress, providerName: String, consumerName: String, consumerVersion: String)
Expand All @@ -213,8 +215,8 @@ object ValidatedDetails {

def buildFrom(consumerName: String, providerName: String, pactBrokerAddress: String, consumerVersion: String): Either[String, ValidatedDetails] =
for {
consumerName <- Helpers.urlEncode(consumerName)
providerName <- Helpers.urlEncode(providerName)
consumerName <- Helpers.urlEncode(consumerName)
providerName <- Helpers.urlEncode(providerName)
validatedAddress <- PactBrokerAddressValidation.checkPactBrokerAddress(pactBrokerAddress)
} yield ValidatedDetails(validatedAddress, providerName, consumerName, consumerVersion)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.itv.scalapact.shared.http

import javax.net.ssl.SSLContext

import com.itv.scalapact.shared.{SimpleRequest, SimpleResponse}
import org.http4s.{BuildInfo, Response}
import org.http4s.client.Client
Expand All @@ -13,21 +15,22 @@ object Http4sClientHelper {

import HeaderImplicitConversions._

private def blazeClientConfig(clientTimeout: Duration): BlazeClientConfig = BlazeClientConfig.defaultConfig.copy(
private def blazeClientConfig(clientTimeout: Duration, sslContext: Option[SSLContext]): BlazeClientConfig = BlazeClientConfig.defaultConfig.copy(
requestTimeout = clientTimeout,
userAgent = Option(`User-Agent`(AgentProduct("scala-pact", Option(BuildInfo.version)))),
endpointAuthentication = false,
customExecutor = None
customExecutor = None,
sslContext = sslContext
)

private val extractResponse: Response => Task[SimpleResponse] = r =>
r.bodyAsText.runLog[Task, String].map(_.mkString).map { b => SimpleResponse(r.status.code, r.headers, Some(b)) }

def defaultClient: Client =
buildPooledBlazeHttpClient(1, Duration(1, SECONDS))
buildPooledBlazeHttpClient(1, Duration(1, SECONDS), sslContext=None)

def buildPooledBlazeHttpClient(maxTotalConnections: Int, clientTimeout: Duration): Client =
PooledHttp1Client(maxTotalConnections, blazeClientConfig(clientTimeout))
def buildPooledBlazeHttpClient(maxTotalConnections: Int, clientTimeout: Duration, sslContext: Option[SSLContext]): Client =
PooledHttp1Client(maxTotalConnections, blazeClientConfig(clientTimeout, sslContext ))

val doRequest: (SimpleRequest, Client) => Task[SimpleResponse] = (request, httpClient) =>
for {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.itv.scalapact.shared.http

import java.util.concurrent.{ExecutorService, Executors}
import javax.net.ssl.SSLContext

import com.itv.scalapact.shared._
import org.http4s.dsl._
Expand All @@ -19,36 +20,45 @@ object PactStubService {
private val nThreads: Int = 50
private val executorService: ExecutorService = Executors.newFixedThreadPool(nThreads)

def startServer(interactionManager: IInteractionManager)(implicit pactReader: IPactReader, pactWriter: IPactWriter): ScalaPactSettings => Unit = config => {
def startServer(interactionManager: IInteractionManager, sslContextName: Option[String])(implicit pactReader: IPactReader, pactWriter: IPactWriter, sslContextMap: SslContextMap): ScalaPactSettings => Unit = config => {
println(("Starting ScalaPact Stubber on: http://" + config.giveHost + ":" + config.givePort.toString).white.bold)
println(("Strict matching mode: " + config.giveStrictMode.toString).white.bold)

runServer(interactionManager, nThreads)(pactReader, pactWriter)(config).awaitShutdown()
runServer(interactionManager, nThreads, sslContextName, config.givePort)(pactReader, pactWriter, sslContextMap)(config).awaitShutdown()
}

def runServer(interactionManager: IInteractionManager, connectionPoolSize: Int)(implicit pactReader: IPactReader, pactWriter: IPactWriter): ScalaPactSettings => IPactServer = config => PactServer {
implicit class BlazeBuilderPimper(blazeBuilder: BlazeBuilder) {
def withOptionalSsl(sslContextName: Option[String])(implicit sslContextMap: SslContextMap): BlazeBuilder = {
val ssl = sslContextMap(sslContextName).fold(blazeBuilder)(ssl => throw new RuntimeException("Use of SSl contexts is not supported by this version of HTTP4S"))
println(s"withOptionalSsl($ssl)")
ssl
}
}

def runServer(interactionManager: IInteractionManager, connectionPoolSize: Int, sslContextName: Option[String], port: Int)(implicit pactReader: IPactReader, pactWriter: IPactWriter, sslContextMap: SslContextMap): ScalaPactSettings => IPactServer = config => PactServer {
BlazeBuilder
.bindHttp(config.givePort, config.giveHost)
.bindHttp(port, config.giveHost)
.withServiceExecutor(executorService)
.withIdleTimeout(60.seconds)
.withOptionalSsl(sslContextName)
.withConnectorPoolSize(connectionPoolSize)
.mountService(PactStubService.service(interactionManager, config.giveStrictMode), "/")
.mountService(PactStubService.service(interactionManager, config.giveStrictMode, sslContextName), "/")
.run
}

def stopServer: IPactServer => Unit = server =>
server.shutdown()

private val isAdminCall: Request => Boolean = request =>
request.headers.get(CaseInsensitiveString("X-Pact-Admin")).exists(h => h.value == "true")
request.headers.get(CaseInsensitiveString("X-Pact-Admin")).exists(h => h.value == "true")

private def service(interactionManager: IInteractionManager, strictMatching: Boolean)(implicit pactReader: IPactReader, pactWriter: IPactWriter): HttpService =
private def service(interactionManager: IInteractionManager, strictMatching: Boolean, sslContextName: Option[String])(implicit pactReader: IPactReader, pactWriter: IPactWriter): HttpService =
HttpService.lift { req =>
matchRequestWithResponse(interactionManager, strictMatching, req)
matchRequestWithResponse(interactionManager, strictMatching, req, sslContextName)
}

private def matchRequestWithResponse(interactionManager: IInteractionManager, strictMatching: Boolean, req: Request)(implicit pactReader: IPactReader, pactWriter: IPactWriter): scalaz.concurrent.Task[Response] = {
if(isAdminCall(req)) {
private def matchRequestWithResponse(interactionManager: IInteractionManager, strictMatching: Boolean, req: Request, sslContextName: Option[String])(implicit pactReader: IPactReader, pactWriter: IPactWriter): scalaz.concurrent.Task[Response] = {
if (isAdminCall(req)) {

req.method.name.toUpperCase match {
case m if m == "GET" && req.pathInfo.startsWith("/stub/status") =>
Expand Down Expand Up @@ -79,24 +89,23 @@ object PactStubService {

}
else {

interactionManager.findMatchingInteraction(
InteractionRequest(
method = Option(req.method.name.toUpperCase),
headers = req.headers,
query = if(req.params.isEmpty) None else Option(req.params.toList.map(p => p._1 + "=" + p._2).mkString("&")),
query = if (req.params.isEmpty) None else Option(req.params.toList.map(p => p._1 + "=" + p._2).mkString("&")),
path = Option(req.pathInfo),
body = req.bodyAsText.runLog[Task, String].map(body => Option(body.mkString)).unsafePerformSync,
matchingRules = None
),
strictMatching = strictMatching
) match {
case Right(ir) =>
Http4sRequestResponseFactory.buildResponse(
status = IntAndReason(ir.response.status.getOrElse(200), None),
headers = ir.response.headers.getOrElse(Map.empty),
body = ir.response.body
)
Http4sRequestResponseFactory.buildResponse(
status = IntAndReason(ir.response.status.getOrElse(200), None),
headers = ir.response.headers.getOrElse(Map.empty),
body = ir.response.body
)

case Left(message) =>
Http4sRequestResponseFactory.buildResponse(
Expand Down
Loading

0 comments on commit 2b49a0d

Please sign in to comment.