357154cb10929f7298aebe5f484e51113dc19502
[ccsdk/cds.git] / ms / blueprintsprocessor / application / src / main / kotlin / org / onap / ccsdk / cds / blueprintsprocessor / uat / utils / UatExecutor.kt
1 /*-
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
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
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.
16  *
17  * SPDX-License-Identifier: Apache-2.0
18  * ============LICENSE_END=========================================================
19  */
20 package org.onap.ccsdk.cds.blueprintsprocessor.uat.utils
21
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
68
69 /**
70  * Assumptions:
71  *
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.
74  *
75  * @author Eliezio Oliveira
76  */
77 @Component
78 open class UatExecutor(
79     private val environment: ConfigurableEnvironment,
80     private val restClientFactory: BluePrintRestLibPropertyService,
81     private val mapper: ObjectMapper
82 ) {
83
84     companion object {
85
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))
91     }
92
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()}"
96     }
97
98     @Throws(AssertionError::class)
99     fun execute(uatSpec: String, cbaBytes: ByteArray) {
100         val uat = UatDefinition.load(mapper, uatSpec)
101         execute(uat, cbaBytes)
102     }
103
104     /**
105      *
106      * The UAT can range from minimum to completely defined.
107      *
108      * @return an updated UAT with all NB and SB messages.
109      */
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)
115             .build()
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)
121         try {
122             markUatBegin()
123             // Configure mocked external services and save their expectations for further validation
124             val expectationsPerClient = uat.externalServices.associateBy(
125                 { service ->
126                     createRestClientMock(service.expectations).also { restClient ->
127                         // side-effect: register restClient to override real instance
128                         mockInterceptor.registerMock(service.selector, restClient)
129                     }
130                 },
131                 { service -> service.expectations }
132             )
133
134             val newProcesses = httpClient.use { client ->
135                 uploadBlueprint(client, cbaBytes)
136
137                 // Run processes
138                 uat.processes.map { process ->
139                     log.info("Executing process '${process.name}'")
140                     val responseNormalizer = JsonNormalizer.getNormalizer(mapper, process.responseNormalizerSpec)
141                     val actualResponse = processBlueprint(
142                         client, process,
143                         process.expectedResponse, responseNormalizer
144                     )
145                     ProcessDefinition(
146                         process.name,
147                         process.request,
148                         actualResponse,
149                         process.responseNormalizerSpec
150                     )
151                 }
152             }
153
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(
160                             eq(request.method),
161                             eq(request.path),
162                             any(),
163                             argThat(RequiredMapEntriesMatcher(request.headers))
164                         )
165                     } else if (request.requestType == UPLOAD_BINARY_FILE) {
166                         verify(mockClient, evalVerificationMode(expectation.times)).uploadBinaryFile(
167                             eq(request.path),
168                             any()
169                         )
170                     }
171                 }
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)
175             }
176
177             val newExternalServices = spyInterceptor.getSpies()
178                 .map(SpyService::asServiceDefinition)
179
180             return UatDefinition(newProcesses, newExternalServices)
181         } finally {
182             restClientFactory.clearInterceptors()
183             markUatEnd()
184         }
185     }
186
187     private fun markUatBegin() {
188         System.setProperty(PROPERTY_IN_UAT, "1")
189     }
190
191     private fun markUatEnd() {
192         System.clearProperty(PROPERTY_IN_UAT)
193     }
194
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)
201             )
202
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())
210                 }
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())),
218                             any()
219                         )
220                     )
221                     for (response in expectation.responses) {
222                         stubbing = stubbing.thenReturn(WebClientResponse(response.status, response.body.toString()))
223                     }
224                 }
225             }
226
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),
232                             any()
233                         )
234                     )
235                     for (response in expectation.responses) {
236                         stubbing = stubbing.thenReturn(WebClientResponse(response.status, response.body.toString()))
237                     }
238                 }
239             }
240             return restClient
241         }
242
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")
248             .build()
249         val request = HttpPost("$baseUrl/api/v1/blueprint-model/publish").apply {
250             entity = multipartEntity
251         }
252         client.execute(request) { response ->
253             val statusLine = response.statusLine
254             assertThat(statusLine.statusCode, equalTo(HttpStatus.SC_OK))
255         }
256     }
257
258     @Throws(AssertionError::class)
259     private fun processBlueprint(
260         client: HttpClient,
261         process: ProcessDefinition,
262         expectedResponse: JsonNode?,
263         responseNormalizer: (String) -> String
264     ): JsonNode {
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
268         }
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() }
276         }
277         val actualResponse = responseNormalizer(response)
278         if (expectedResponse != null) {
279             assertJsonEquals(expectedResponse, actualResponse, process.name)
280         }
281         return mapper.readTree(actualResponse)!!
282     }
283
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"
287         )
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)
295         }
296     }
297
298     @Throws(AssertionError::class)
299     private fun assertJsonEquals(expected: JsonNode?, actual: String, msg: String = ""): Boolean {
300         // special case
301         if ((expected == null) && actual.isBlank()) {
302             return true
303         }
304         // general case
305         JSONAssert.assertEquals(msg, expected?.toString(), actual, JSONCompareMode.LENIENT)
306         // assertEquals throws an exception whenever match fails
307         return true
308     }
309
310     private fun localServerPort(): Int =
311         (
312             environment.getProperty("local.server.port")
313                 ?: environment.getRequiredProperty("blueprint.httpPort")
314             ).toInt()
315
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
322             )
323             else -> username
324         }
325         return "Basic " + Base64Utils.encodeToString("$username:$plainPassword".toByteArray())
326     }
327
328     open class MockPreInterceptor : BluePrintRestLibPropertyService.PreInterceptor {
329
330         private val mocks = ConcurrentHashMap<String, BlueprintWebClientService>()
331
332         override fun getInstance(jsonNode: JsonNode): BlueprintWebClientService? {
333             TODO("jsonNode-keyed services not yet supported")
334         }
335
336         override fun getInstance(selector: String): BlueprintWebClientService? =
337             mocks[selector]
338
339         fun registerMock(selector: String, client: BlueprintWebClientService) {
340             mocks[selector] = client
341         }
342     }
343
344     open class SpyPostInterceptor(private val mapper: ObjectMapper) : BluePrintRestLibPropertyService.PostInterceptor {
345
346         private val spies = ConcurrentHashMap<String, SpyService>()
347
348         override fun getInstance(jsonNode: JsonNode, service: BlueprintWebClientService): BlueprintWebClientService {
349             TODO("jsonNode-keyed services not yet supported")
350         }
351
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
357                 return spiedService
358             }
359
360             spiedService = SpyService(mapper, selector, service)
361             spies[selector] = spiedService
362
363             return spiedService
364         }
365         fun getSpies(): List<SpyService> =
366             spies.values.toList()
367     }
368
369     open class SpyService(
370         private val mapper: ObjectMapper,
371         val selector: String,
372         var realService: BlueprintWebClientService
373     ) :
374         BlueprintWebClientService by realService {
375
376         private val expectations: MutableList<ExpectationDefinition> = mutableListOf()
377
378         override fun exchangeResource(methodType: String, path: String, request: String): WebClientResponse<String> =
379             exchangeResource(
380                 methodType, path, request,
381                 DEFAULT_HEADERS
382             )
383
384         override fun exchangeResource(
385             methodType: String,
386             path: String,
387             request: String,
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)
396                 else -> null
397             }
398             val responseDefinition =
399                 ResponseDefinition(realAnswer.status, responseBody)
400             expectations.add(
401                 ExpectationDefinition(
402                     requestDefinition,
403                     responseDefinition
404                 )
405             )
406             return realAnswer
407         }
408
409         override fun uploadBinaryFile(path: String, filePath: Path):
410             WebClientResponse<String> {
411                 val method = HttpMethod.POST.name
412                 val headers = DEFAULT_HEADERS
413                 val request = ""
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)
420                     else -> null
421                 }
422                 val responseDefinition =
423                     ResponseDefinition(realAnswer.status, responseBody)
424                 expectations.add(
425                     ExpectationDefinition(
426                         requestDefinition,
427                         responseDefinition
428                     )
429                 )
430                 return realAnswer
431             }
432
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)
435         }
436
437         fun asServiceDefinition() =
438             ServiceDefinition(selector, expectations)
439
440         private fun toJson(str: String): JsonNode? {
441             return when {
442                 str.isNotBlank() -> mapper.readTree(str)
443                 else -> null
444             }
445         }
446
447         companion object {
448
449             private val DEFAULT_HEADERS = mapOf(
450                 HttpHeaders.CONTENT_TYPE to MediaType.APPLICATION_JSON_VALUE,
451                 HttpHeaders.ACCEPT to MediaType.APPLICATION_JSON_VALUE
452             )
453         }
454     }
455 }