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.eq
29 import com.nhaarman.mockitokotlin2.mock
30 import com.nhaarman.mockitokotlin2.times
31 import com.nhaarman.mockitokotlin2.verify
32 import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions
33 import com.nhaarman.mockitokotlin2.whenever
34 import org.apache.http.HttpHeaders
35 import org.apache.http.HttpStatus
36 import org.apache.http.client.HttpClient
37 import org.apache.http.client.methods.HttpPost
38 import org.apache.http.entity.ContentType
39 import org.apache.http.entity.StringEntity
40 import org.apache.http.entity.mime.HttpMultipartMode
41 import org.apache.http.entity.mime.MultipartEntityBuilder
42 import org.apache.http.impl.client.HttpClientBuilder
43 import org.apache.http.message.BasicHeader
44 import org.hamcrest.CoreMatchers.equalTo
45 import org.hamcrest.CoreMatchers.notNullValue
46 import org.hamcrest.MatcherAssert.assertThat
47 import org.mockito.Answers
48 import org.mockito.verification.VerificationMode
49 import org.onap.ccsdk.cds.blueprintsprocessor.rest.service.BluePrintRestLibPropertyService
50 import org.onap.ccsdk.cds.blueprintsprocessor.rest.service.BlueprintWebClientService
51 import org.onap.ccsdk.cds.blueprintsprocessor.rest.service.BlueprintWebClientService.WebClientResponse
52 import org.onap.ccsdk.cds.blueprintsprocessor.uat.logging.LogColor.COLOR_MOCKITO
53 import org.onap.ccsdk.cds.blueprintsprocessor.uat.logging.LogColor.markerOf
54 import org.onap.ccsdk.cds.blueprintsprocessor.uat.logging.MockInvocationLogger
55 import org.onap.ccsdk.cds.blueprintsprocessor.uat.utils.RequestType.EXCHANGE_RESOURCE
56 import org.onap.ccsdk.cds.blueprintsprocessor.uat.utils.RequestType.UPLOAD_BINARY_FILE
57 import org.skyscreamer.jsonassert.JSONAssert
58 import org.skyscreamer.jsonassert.JSONCompareMode
59 import org.slf4j.Logger
60 import org.slf4j.LoggerFactory
61 import org.springframework.core.env.ConfigurableEnvironment
62 import org.springframework.http.HttpMethod
63 import org.springframework.http.MediaType
64 import org.springframework.stereotype.Component
65 import org.springframework.util.Base64Utils
66 import java.nio.file.Path
67 import java.util.concurrent.ConcurrentHashMap
72 * - Application HTTP service is bound to loopback interface;
73 * - Password is either defined in plain (with "{noop}" prefix), or it's the same of username.
75 * @author Eliezio Oliveira
78 open class UatExecutor(
79 private val environment: ConfigurableEnvironment,
80 private val restClientFactory: BluePrintRestLibPropertyService,
81 private val mapper: ObjectMapper
86 private const val NOOP_PASSWORD_PREFIX = "{noop}"
87 private const val PROPERTY_IN_UAT = "IN_UAT"
88 private val TIMES_SPEC_REGEX = "([<>]=?)?\\s*(\\d+)".toRegex()
89 private val log: Logger = LoggerFactory.getLogger(UatExecutor::class.java)
90 private val mockLoggingListener = MockInvocationLogger(markerOf(COLOR_MOCKITO))
93 // use lazy evaluation to postpone until localServerPort is injected by Spring
94 private val baseUrl: String by lazy {
95 "http://127.0.0.1:${localServerPort()}"
98 @Throws(AssertionError::class)
99 fun execute(uatSpec: String, cbaBytes: ByteArray) {
100 val uat = UatDefinition.load(mapper, uatSpec)
101 execute(uat, cbaBytes)
106 * The UAT can range from minimum to completely defined.
108 * @return an updated UAT with all NB and SB messages.
110 @Throws(AssertionError::class)
111 fun execute(uat: UatDefinition, cbaBytes: ByteArray): UatDefinition {
112 val defaultHeaders = listOf(BasicHeader(HttpHeaders.AUTHORIZATION, clientAuthToken()))
113 val httpClient = HttpClientBuilder.create()
114 .setDefaultHeaders(defaultHeaders)
116 // Only if externalServices are defined
117 val mockInterceptor = MockPreInterceptor()
118 // Always defined and used, whatever the case
119 val spyInterceptor = SpyPostInterceptor(mapper)
120 restClientFactory.setInterceptors(mockInterceptor, spyInterceptor)
123 // Configure mocked external services and save their expectations for further validation
124 val expectationsPerClient = uat.externalServices.associateBy(
126 createRestClientMock(service.expectations).also { restClient ->
127 // side-effect: register restClient to override real instance
128 mockInterceptor.registerMock(service.selector, restClient)
131 { service -> service.expectations }
134 val newProcesses = httpClient.use { client ->
135 uploadBlueprint(client, cbaBytes)
138 uat.processes.map { process ->
139 log.info("Executing process '${process.name}'")
140 val responseNormalizer = JsonNormalizer.getNormalizer(mapper, process.responseNormalizerSpec)
141 val actualResponse = processBlueprint(
143 process.expectedResponse, responseNormalizer
149 process.responseNormalizerSpec
154 // Validate requests to external services
155 for ((mockClient, expectations) in expectationsPerClient) {
156 expectations.forEach { expectation ->
157 val request = expectation.request
158 if (request.requestType == EXCHANGE_RESOURCE) {
159 verify(mockClient, evalVerificationMode(expectation.times)).exchangeResource(
163 argThat(RequiredMapEntriesMatcher(request.headers))
165 } else if (request.requestType == UPLOAD_BINARY_FILE) {
166 verify(mockClient, evalVerificationMode(expectation.times)).uploadBinaryFile(
172 // Don't mind the invocations to the overloaded exchangeResource(String, String, String)
173 verify(mockClient, atLeast(0)).exchangeResource(any(), any(), any())
174 verifyNoMoreInteractions(mockClient)
177 val newExternalServices = spyInterceptor.getSpies()
178 .map(SpyService::asServiceDefinition)
180 return UatDefinition(newProcesses, newExternalServices)
182 restClientFactory.clearInterceptors()
187 private fun markUatBegin() {
188 System.setProperty(PROPERTY_IN_UAT, "1")
191 private fun markUatEnd() {
192 System.clearProperty(PROPERTY_IN_UAT)
195 private fun createRestClientMock(restExpectations: List<ExpectationDefinition>):
196 BlueprintWebClientService {
197 val restClient = mock<BlueprintWebClientService>(
198 defaultAnswer = Answers.RETURNS_SMART_NULLS,
199 // our custom verboseLogging handler
200 invocationListeners = arrayOf(mockLoggingListener)
203 // Delegates to overloaded exchangeResource(String, String, String, Map<String, String>)
204 whenever(restClient.exchangeResource(any(), any(), any()))
205 .thenAnswer { invocation ->
206 val method = invocation.arguments[0] as String
207 val path = invocation.arguments[1] as String
208 val request = invocation.arguments[2] as String
209 restClient.exchangeResource(method, path, request, emptyMap())
211 for (expectation in restExpectations) {
212 if (expectation.request.requestType == EXCHANGE_RESOURCE) {
213 var stubbing = whenever(
214 restClient.exchangeResource(
215 eq(expectation.request.method),
216 eq(expectation.request.path),
217 argThat(JsonMatcher(expectation.request.body.toString())),
221 for (response in expectation.responses) {
222 stubbing = stubbing.thenReturn(WebClientResponse(response.status, response.body.toString()))
227 for (expectation in restExpectations) {
228 if (expectation.request.requestType == UPLOAD_BINARY_FILE) {
229 var stubbing = whenever(
230 restClient.uploadBinaryFile(
231 eq(expectation.request.path),
235 for (response in expectation.responses) {
236 stubbing = stubbing.thenReturn(WebClientResponse(response.status, response.body.toString()))
243 @Throws(AssertionError::class)
244 private fun uploadBlueprint(client: HttpClient, cbaBytes: ByteArray) {
245 val multipartEntity = MultipartEntityBuilder.create()
246 .setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
247 .addBinaryBody("file", cbaBytes, ContentType.DEFAULT_BINARY, "cba.zip")
249 val request = HttpPost("$baseUrl/api/v1/blueprint-model/publish").apply {
250 entity = multipartEntity
252 client.execute(request) { response ->
253 val statusLine = response.statusLine
254 assertThat(statusLine.statusCode, equalTo(HttpStatus.SC_OK))
258 @Throws(AssertionError::class)
259 private fun processBlueprint(
261 process: ProcessDefinition,
262 expectedResponse: JsonNode?,
263 responseNormalizer: (String) -> String
265 val stringEntity = StringEntity(mapper.writeValueAsString(process.request), ContentType.APPLICATION_JSON)
266 val request = HttpPost("$baseUrl/api/v1/execution-service/process").apply {
267 entity = stringEntity
269 val response = client.execute(request) { response ->
270 val statusLine = response.statusLine
271 val expectedCode = expectedResponse?.get("status")?.get("code")?.intValue()
272 assertThat("${process.name}", statusLine.statusCode, equalTo(expectedCode ?: HttpStatus.SC_OK))
273 val entity = response.entity
274 assertThat("${process.name} Response contains no content", entity, notNullValue())
275 entity.content.bufferedReader().use { it.readText() }
277 val actualResponse = responseNormalizer(response)
278 if (expectedResponse != null) {
279 assertJsonEquals(expectedResponse, actualResponse, process.name)
281 return mapper.readTree(actualResponse)!!
284 private fun evalVerificationMode(times: String): VerificationMode {
285 val matchResult = TIMES_SPEC_REGEX.matchEntire(times) ?: throw InvalidUatDefinition(
286 "Time specification '$times' does not follow expected format $TIMES_SPEC_REGEX"
288 val counter = matchResult.groups[2]!!.value.toInt()
289 return when (matchResult.groups[1]?.value) {
290 ">=" -> atLeast(counter)
291 ">" -> atLeast(counter + 1)
292 "<=" -> atMost(counter)
293 "<" -> atMost(counter - 1)
294 else -> times(counter)
298 @Throws(AssertionError::class)
299 private fun assertJsonEquals(expected: JsonNode?, actual: String, msg: String = ""): Boolean {
301 if ((expected == null) && actual.isBlank()) {
305 JSONAssert.assertEquals(msg, expected?.toString(), actual, JSONCompareMode.LENIENT)
306 // assertEquals throws an exception whenever match fails
310 private fun localServerPort(): Int =
312 environment.getProperty("local.server.port")
313 ?: environment.getRequiredProperty("blueprint.httpPort")
316 private fun clientAuthToken(): String {
317 val username = environment.getRequiredProperty("security.user.name")
318 val password = environment.getRequiredProperty("security.user.password")
319 val plainPassword = when {
320 password.startsWith(NOOP_PASSWORD_PREFIX) -> password.substring(
321 NOOP_PASSWORD_PREFIX.length
325 return "Basic " + Base64Utils.encodeToString("$username:$plainPassword".toByteArray())
328 open class MockPreInterceptor : BluePrintRestLibPropertyService.PreInterceptor {
330 private val mocks = ConcurrentHashMap<String, BlueprintWebClientService>()
332 override fun getInstance(jsonNode: JsonNode): BlueprintWebClientService? {
333 TODO("jsonNode-keyed services not yet supported")
336 override fun getInstance(selector: String): BlueprintWebClientService? =
339 fun registerMock(selector: String, client: BlueprintWebClientService) {
340 mocks[selector] = client
344 open class SpyPostInterceptor(private val mapper: ObjectMapper) : BluePrintRestLibPropertyService.PostInterceptor {
346 private val spies = ConcurrentHashMap<String, SpyService>()
348 override fun getInstance(jsonNode: JsonNode, service: BlueprintWebClientService): BlueprintWebClientService {
349 TODO("jsonNode-keyed services not yet supported")
352 override fun getInstance(selector: String, service: BlueprintWebClientService): BlueprintWebClientService {
353 var spiedService = spies[selector]
354 if (spiedService != null) {
355 // inject the service here as realService: needed for "stateful services" (e.g. holding a session token)
356 spiedService.realService = service
360 spiedService = SpyService(mapper, selector, service)
361 spies[selector] = spiedService
365 fun getSpies(): List<SpyService> =
366 spies.values.toList()
369 open class SpyService(
370 private val mapper: ObjectMapper,
371 val selector: String,
372 var realService: BlueprintWebClientService
374 BlueprintWebClientService by realService {
376 private val expectations: MutableList<ExpectationDefinition> = mutableListOf()
378 override fun exchangeResource(methodType: String, path: String, request: String): WebClientResponse<String> =
380 methodType, path, request,
384 override fun exchangeResource(
388 headers: Map<String, String>
389 ): WebClientResponse<String> {
390 val requestDefinition =
391 RequestDefinition(methodType, path, headers, toJson(request), EXCHANGE_RESOURCE)
392 val realAnswer = realService.exchangeResource(methodType, path, request, headers)
393 val responseBody = when {
394 // TODO: confirm if we need to normalize the response here
395 realAnswer.status == HttpStatus.SC_OK -> toJson(realAnswer.body)
398 val responseDefinition =
399 ResponseDefinition(realAnswer.status, responseBody)
401 ExpectationDefinition(
409 override fun uploadBinaryFile(path: String, filePath: Path):
410 WebClientResponse<String> {
411 val method = HttpMethod.POST.name
412 val headers = DEFAULT_HEADERS
414 val requestDefinition =
415 RequestDefinition(method, path, headers, toJson(request), UPLOAD_BINARY_FILE)
416 val realAnswer = realService.uploadBinaryFile(path, filePath)
417 val responseBody = when {
418 // TODO: confirm if we need to normalize the response here
419 realAnswer.status == HttpStatus.SC_OK -> toJson(realAnswer.body)
422 val responseDefinition =
423 ResponseDefinition(realAnswer.status, responseBody)
425 ExpectationDefinition(
433 override suspend fun <T> retry(times: Int, initialDelay: Long, delay: Long, block: suspend (Int) -> T): T {
434 return super.retry(times, initialDelay, delay, block)
437 fun asServiceDefinition() =
438 ServiceDefinition(selector, expectations)
440 private fun toJson(str: String): JsonNode? {
442 str.isNotBlank() -> mapper.readTree(str)
449 private val DEFAULT_HEADERS = mapOf(
450 HttpHeaders.CONTENT_TYPE to MediaType.APPLICATION_JSON_VALUE,
451 HttpHeaders.ACCEPT to MediaType.APPLICATION_JSON_VALUE