2 * ============LICENSE_START=======================================================
3 * Copyright (C) 2019 Nordix Foundation.
4 * ================================================================================
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
9 * http://www.apache.org/licenses/LICENSE-2.0
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
17 * SPDX-License-Identifier: Apache-2.0
18 * ============LICENSE_END=========================================================
20 package org.onap.ccsdk.cds.blueprintsprocessor.uat.utils
22 import com.fasterxml.jackson.databind.JsonNode
23 import com.fasterxml.jackson.databind.ObjectMapper
24 import com.nhaarman.mockitokotlin2.any
25 import com.nhaarman.mockitokotlin2.argThat
26 import com.nhaarman.mockitokotlin2.atLeast
27 import com.nhaarman.mockitokotlin2.atMost
28 import com.nhaarman.mockitokotlin2.doAnswer
29 import com.nhaarman.mockitokotlin2.doReturn
30 import com.nhaarman.mockitokotlin2.eq
31 import com.nhaarman.mockitokotlin2.mock
32 import com.nhaarman.mockitokotlin2.times
33 import com.nhaarman.mockitokotlin2.verify
34 import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
35 import com.nhaarman.mockitokotlin2.whenever
36 import org.apache.http.HttpHeaders
37 import org.apache.http.HttpStatus
38 import org.apache.http.client.HttpClient
39 import org.apache.http.client.methods.HttpPost
40 import org.apache.http.entity.ContentType
41 import org.apache.http.entity.StringEntity
42 import org.apache.http.entity.mime.HttpMultipartMode
43 import org.apache.http.entity.mime.MultipartEntityBuilder
44 import org.apache.http.impl.client.HttpClientBuilder
45 import org.apache.http.message.BasicHeader
46 import org.hamcrest.CoreMatchers.equalTo
47 import org.hamcrest.CoreMatchers.notNullValue
48 import org.hamcrest.MatcherAssert.assertThat
49 import org.mockito.Answers
50 import org.mockito.verification.VerificationMode
51 import org.onap.ccsdk.cds.blueprintsprocessor.rest.service.BluePrintRestLibPropertyService
52 import org.onap.ccsdk.cds.blueprintsprocessor.rest.service.BlueprintWebClientService
53 import org.onap.ccsdk.cds.blueprintsprocessor.rest.service.BlueprintWebClientService.WebClientResponse
54 import org.onap.ccsdk.cds.blueprintsprocessor.uat.logging.LogColor.COLOR_MOCKITO
55 import org.onap.ccsdk.cds.blueprintsprocessor.uat.logging.LogColor.markerOf
56 import org.onap.ccsdk.cds.blueprintsprocessor.uat.logging.MockInvocationLogger
57 import org.onap.ccsdk.cds.blueprintsprocessor.uat.utils.RequestType.EXCHANGE_RESOURCE
58 import org.onap.ccsdk.cds.blueprintsprocessor.uat.utils.RequestType.UPLOAD_BINARY_FILE
59 import org.skyscreamer.jsonassert.JSONAssert
60 import org.skyscreamer.jsonassert.JSONCompareMode
61 import org.slf4j.Logger
62 import org.slf4j.LoggerFactory
63 import org.springframework.core.env.ConfigurableEnvironment
64 import org.springframework.http.HttpMethod
65 import org.springframework.http.MediaType
66 import org.springframework.stereotype.Component
67 import java.util.Base64
68 import java.nio.file.Path
69 import java.util.concurrent.ConcurrentHashMap
74 * - Application HTTP service is bound to loopback interface;
75 * - Password is either defined in plain (with "{noop}" prefix), or it's the same of username.
77 * @author Eliezio Oliveira
80 open class UatExecutor(
81 private val environment: ConfigurableEnvironment,
82 private val restClientFactory: BluePrintRestLibPropertyService,
83 private val mapper: ObjectMapper
88 private const val NOOP_PASSWORD_PREFIX = "{noop}"
89 private const val PROPERTY_IN_UAT = "IN_UAT"
90 private val TIMES_SPEC_REGEX = "([<>]=?)?\\s*(\\d+)".toRegex()
91 private val log: Logger = LoggerFactory.getLogger(UatExecutor::class.java)
92 private val mockLoggingListener = MockInvocationLogger(markerOf(COLOR_MOCKITO))
95 // use lazy evaluation to postpone until localServerPort is injected by Spring
96 private val baseUrl: String by lazy {
97 "http://127.0.0.1:${localServerPort()}"
100 @Throws(AssertionError::class)
101 fun execute(uatSpec: String, cbaBytes: ByteArray) {
102 val uat = UatDefinition.load(mapper, uatSpec)
103 execute(uat, cbaBytes)
108 * The UAT can range from minimum to completely defined.
110 * @return an updated UAT with all NB and SB messages.
112 @Throws(AssertionError::class)
113 fun execute(uat: UatDefinition, cbaBytes: ByteArray): UatDefinition {
114 val defaultHeaders = listOf(BasicHeader(HttpHeaders.AUTHORIZATION, clientAuthToken()))
115 val httpClient = HttpClientBuilder.create()
116 .setDefaultHeaders(defaultHeaders)
118 // Only if externalServices are defined
119 val mockInterceptor = MockPreInterceptor()
120 // Always defined and used, whatever the case
121 val spyInterceptor = SpyPostInterceptor(mapper)
122 restClientFactory.setInterceptors(mockInterceptor, spyInterceptor)
125 // Configure mocked external services and save their expectations for further validation
126 val expectationsPerClient = uat.externalServices.associateBy(
128 createRestClientMock(service.expectations).also { restClient ->
129 // side-effect: register restClient to override real instance
130 mockInterceptor.registerMock(service.selector, restClient)
133 { service -> service.expectations }
136 val newProcesses = httpClient.use { client ->
137 uploadBlueprint(client, cbaBytes)
140 uat.processes.map { process ->
141 log.info("Executing process '${process.name}'")
142 val responseNormalizer = JsonNormalizer.getNormalizer(mapper, process.responseNormalizerSpec)
143 val actualResponse = processBlueprint(
145 process.expectedResponse, responseNormalizer
151 process.responseNormalizerSpec
156 // Validate requests to external services
157 for ((mockClient, expectations) in expectationsPerClient) {
158 expectations.forEach { expectation ->
159 val request = expectation.request
160 if (request.requestType == EXCHANGE_RESOURCE) {
161 verify(mockClient, evalVerificationMode(expectation.times)).exchangeResource(
165 argThat(RequiredMapEntriesMatcher(request.headers))
167 } else if (request.requestType == UPLOAD_BINARY_FILE) {
168 verify(mockClient, evalVerificationMode(expectation.times)).uploadBinaryFile(
174 // Don't mind the invocations to the overloaded exchangeResource(String, String, String)
175 verify(mockClient, atLeast(0)).exchangeResource(any(), any(), any())
176 verifyNoMoreInteractions(mockClient)
179 val newExternalServices = spyInterceptor.getSpies()
180 .map(SpyService::asServiceDefinition)
182 return UatDefinition(newProcesses, newExternalServices)
184 restClientFactory.clearInterceptors()
189 private fun markUatBegin() {
190 System.setProperty(PROPERTY_IN_UAT, "1")
193 private fun markUatEnd() {
194 System.clearProperty(PROPERTY_IN_UAT)
197 private fun createRestClientMock(restExpectations: List<ExpectationDefinition>):
198 BlueprintWebClientService {
199 val restClient = mock<BlueprintWebClientService>(
200 defaultAnswer = Answers.RETURNS_SMART_NULLS,
201 // our custom verboseLogging handler
202 invocationListeners = arrayOf(mockLoggingListener)
205 // Delegates to overloaded exchangeResource(String, String, String, Map<String, String>)
206 // Use doAnswer/doReturn to avoid WrongTypeOfReturnValue caused by MockInvocationLogger
207 // calling toString() on the SmartNull return value during Mockito's stubbing-recording phase
208 doAnswer { invocation ->
209 val method = invocation.arguments[0] as String
210 val path = invocation.arguments[1] as String
211 val request = invocation.arguments[2] as String
212 restClient.exchangeResource(method, path, request, emptyMap())
213 }.whenever(restClient).exchangeResource(any(), any(), any())
214 for (expectation in restExpectations) {
215 if (expectation.request.requestType == EXCHANGE_RESOURCE) {
216 val responses = expectation.responses
217 .map { response -> WebClientResponse(response.status, response.body.toString()) }
218 if (responses.isNotEmpty()) {
219 doReturn(responses[0], *responses.drop(1).toTypedArray())
220 .whenever(restClient)
222 eq(expectation.request.method),
223 eq(expectation.request.path),
224 argThat(JsonMatcher(expectation.request.body.toString())),
231 for (expectation in restExpectations) {
232 if (expectation.request.requestType == UPLOAD_BINARY_FILE) {
233 val responses = expectation.responses
234 .map { response -> WebClientResponse(response.status, response.body.toString()) }
235 if (responses.isNotEmpty()) {
236 doReturn(responses[0], *responses.drop(1).toTypedArray())
237 .whenever(restClient)
239 eq(expectation.request.path),
248 @Throws(AssertionError::class)
249 private fun uploadBlueprint(client: HttpClient, cbaBytes: ByteArray) {
250 val multipartEntity = MultipartEntityBuilder.create()
251 .setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
252 .addBinaryBody("file", cbaBytes, ContentType.DEFAULT_BINARY, "cba.zip")
254 val request = HttpPost("$baseUrl/api/v1/blueprint-model/publish").apply {
255 entity = multipartEntity
257 client.execute(request) { response ->
258 val statusLine = response.statusLine
259 assertThat(statusLine.statusCode, equalTo(HttpStatus.SC_OK))
263 @Throws(AssertionError::class)
264 private fun processBlueprint(
266 process: ProcessDefinition,
267 expectedResponse: JsonNode?,
268 responseNormalizer: (String) -> String
270 val stringEntity = StringEntity(mapper.writeValueAsString(process.request), ContentType.APPLICATION_JSON)
271 val request = HttpPost("$baseUrl/api/v1/execution-service/process").apply {
272 entity = stringEntity
274 val response = client.execute(request) { response ->
275 val statusLine = response.statusLine
276 val expectedCode = expectedResponse?.get("status")?.get("code")?.intValue()
277 assertThat("${process.name}", statusLine.statusCode, equalTo(expectedCode ?: HttpStatus.SC_OK))
278 val entity = response.entity
279 assertThat("${process.name} Response contains no content", entity, notNullValue())
280 entity.content.bufferedReader().use { it.readText() }
282 val actualResponse = responseNormalizer(response)
283 if (expectedResponse != null) {
284 assertJsonEquals(expectedResponse, actualResponse, process.name)
286 return mapper.readTree(actualResponse)!!
289 private fun evalVerificationMode(times: String): VerificationMode {
290 val matchResult = TIMES_SPEC_REGEX.matchEntire(times) ?: throw InvalidUatDefinition(
291 "Time specification '$times' does not follow expected format $TIMES_SPEC_REGEX"
293 val counter = matchResult.groups[2]!!.value.toInt()
294 return when (matchResult.groups[1]?.value) {
295 ">=" -> atLeast(counter)
296 ">" -> atLeast(counter + 1)
297 "<=" -> atMost(counter)
298 "<" -> atMost(counter - 1)
299 else -> times(counter)
303 @Throws(AssertionError::class)
304 private fun assertJsonEquals(expected: JsonNode?, actual: String, msg: String = ""): Boolean {
306 if ((expected == null) && actual.isBlank()) {
310 JSONAssert.assertEquals(msg, expected?.toString(), actual, JSONCompareMode.LENIENT)
311 // assertEquals throws an exception whenever match fails
315 private fun localServerPort(): Int =
317 environment.getProperty("local.server.port")
318 ?: environment.getRequiredProperty("blueprint.httpPort")
321 private fun clientAuthToken(): String {
322 val username = environment.getRequiredProperty("security.user.name")
323 val password = environment.getRequiredProperty("security.user.password")
324 val plainPassword = when {
325 password.startsWith(NOOP_PASSWORD_PREFIX) -> password.substring(
326 NOOP_PASSWORD_PREFIX.length
330 return "Basic " + Base64.getEncoder().encodeToString("$username:$plainPassword".toByteArray())
333 open class MockPreInterceptor : BluePrintRestLibPropertyService.PreInterceptor {
335 private val mocks = ConcurrentHashMap<String, BlueprintWebClientService>()
337 override fun getInstance(jsonNode: JsonNode): BlueprintWebClientService? {
338 TODO("jsonNode-keyed services not yet supported")
341 override fun getInstance(selector: String): BlueprintWebClientService? =
344 fun registerMock(selector: String, client: BlueprintWebClientService) {
345 mocks[selector] = client
349 open class SpyPostInterceptor(private val mapper: ObjectMapper) : BluePrintRestLibPropertyService.PostInterceptor {
351 private val spies = ConcurrentHashMap<String, SpyService>()
353 override fun getInstance(jsonNode: JsonNode, service: BlueprintWebClientService): BlueprintWebClientService {
354 TODO("jsonNode-keyed services not yet supported")
357 override fun getInstance(selector: String, service: BlueprintWebClientService): BlueprintWebClientService {
358 var spiedService = spies[selector]
359 if (spiedService != null) {
360 // inject the service here as realService: needed for "stateful services" (e.g. holding a session token)
361 spiedService.realService = service
365 spiedService = SpyService(mapper, selector, service)
366 spies[selector] = spiedService
370 fun getSpies(): List<SpyService> =
371 spies.values.toList()
374 open class SpyService(
375 private val mapper: ObjectMapper,
376 val selector: String,
377 var realService: BlueprintWebClientService
379 BlueprintWebClientService by realService {
381 private val expectations: MutableList<ExpectationDefinition> = mutableListOf()
383 override fun exchangeResource(methodType: String, path: String, request: String): WebClientResponse<String> =
385 methodType, path, request,
389 override fun exchangeResource(
393 headers: Map<String, String>
394 ): WebClientResponse<String> {
395 val requestDefinition =
396 RequestDefinition(methodType, path, headers, toJson(request), EXCHANGE_RESOURCE)
397 val realAnswer = realService.exchangeResource(methodType, path, request, headers)
398 val responseBody = when {
399 // TODO: confirm if we need to normalize the response here
400 realAnswer.status == HttpStatus.SC_OK -> toJson(realAnswer.body)
403 val responseDefinition =
404 ResponseDefinition(realAnswer.status, responseBody)
406 ExpectationDefinition(
414 override fun uploadBinaryFile(path: String, filePath: Path):
415 WebClientResponse<String> {
416 val method = HttpMethod.POST.name()
417 val headers = DEFAULT_HEADERS
419 val requestDefinition =
420 RequestDefinition(method, path, headers, toJson(request), UPLOAD_BINARY_FILE)
421 val realAnswer = realService.uploadBinaryFile(path, filePath)
422 val responseBody = when {
423 // TODO: confirm if we need to normalize the response here
424 realAnswer.status == HttpStatus.SC_OK -> toJson(realAnswer.body)
427 val responseDefinition =
428 ResponseDefinition(realAnswer.status, responseBody)
430 ExpectationDefinition(
438 override suspend fun <T> retry(times: Int, initialDelay: Long, delay: Long, block: suspend (Int) -> T): T {
439 return super.retry(times, initialDelay, delay, block)
442 fun asServiceDefinition() =
443 ServiceDefinition(selector, expectations)
445 private fun toJson(str: String): JsonNode? {
447 str.isNotBlank() -> mapper.readTree(str)
454 private val DEFAULT_HEADERS = mapOf(
455 HttpHeaders.CONTENT_TYPE to MediaType.APPLICATION_JSON_VALUE,
456 HttpHeaders.ACCEPT to MediaType.APPLICATION_JSON_VALUE