1909d033541757e778fa8a728bed41b9ffd95b10
[ccsdk/cds.git] /
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.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
70
71 /**
72  * Assumptions:
73  *
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.
76  *
77  * @author Eliezio Oliveira
78  */
79 @Component
80 open class UatExecutor(
81     private val environment: ConfigurableEnvironment,
82     private val restClientFactory: BluePrintRestLibPropertyService,
83     private val mapper: ObjectMapper
84 ) {
85
86     companion object {
87
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))
93     }
94
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()}"
98     }
99
100     @Throws(AssertionError::class)
101     fun execute(uatSpec: String, cbaBytes: ByteArray) {
102         val uat = UatDefinition.load(mapper, uatSpec)
103         execute(uat, cbaBytes)
104     }
105
106     /**
107      *
108      * The UAT can range from minimum to completely defined.
109      *
110      * @return an updated UAT with all NB and SB messages.
111      */
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)
117             .build()
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)
123         try {
124             markUatBegin()
125             // Configure mocked external services and save their expectations for further validation
126             val expectationsPerClient = uat.externalServices.associateBy(
127                 { service ->
128                     createRestClientMock(service.expectations).also { restClient ->
129                         // side-effect: register restClient to override real instance
130                         mockInterceptor.registerMock(service.selector, restClient)
131                     }
132                 },
133                 { service -> service.expectations }
134             )
135
136             val newProcesses = httpClient.use { client ->
137                 uploadBlueprint(client, cbaBytes)
138
139                 // Run processes
140                 uat.processes.map { process ->
141                     log.info("Executing process '${process.name}'")
142                     val responseNormalizer = JsonNormalizer.getNormalizer(mapper, process.responseNormalizerSpec)
143                     val actualResponse = processBlueprint(
144                         client, process,
145                         process.expectedResponse, responseNormalizer
146                     )
147                     ProcessDefinition(
148                         process.name,
149                         process.request,
150                         actualResponse,
151                         process.responseNormalizerSpec
152                     )
153                 }
154             }
155
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(
162                             eq(request.method),
163                             eq(request.path),
164                             any(),
165                             argThat(RequiredMapEntriesMatcher(request.headers))
166                         )
167                     } else if (request.requestType == UPLOAD_BINARY_FILE) {
168                         verify(mockClient, evalVerificationMode(expectation.times)).uploadBinaryFile(
169                             eq(request.path),
170                             any()
171                         )
172                     }
173                 }
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)
177             }
178
179             val newExternalServices = spyInterceptor.getSpies()
180                 .map(SpyService::asServiceDefinition)
181
182             return UatDefinition(newProcesses, newExternalServices)
183         } finally {
184             restClientFactory.clearInterceptors()
185             markUatEnd()
186         }
187     }
188
189     private fun markUatBegin() {
190         System.setProperty(PROPERTY_IN_UAT, "1")
191     }
192
193     private fun markUatEnd() {
194         System.clearProperty(PROPERTY_IN_UAT)
195     }
196
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)
203             )
204
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)
221                             .exchangeResource(
222                                 eq(expectation.request.method),
223                                 eq(expectation.request.path),
224                                 argThat(JsonMatcher(expectation.request.body.toString())),
225                                 any()
226                             )
227                     }
228                 }
229             }
230
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)
238                             .uploadBinaryFile(
239                                 eq(expectation.request.path),
240                                 any()
241                             )
242                     }
243                 }
244             }
245             return restClient
246         }
247
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")
253             .build()
254         val request = HttpPost("$baseUrl/api/v1/blueprint-model/publish").apply {
255             entity = multipartEntity
256         }
257         client.execute(request) { response ->
258             val statusLine = response.statusLine
259             assertThat(statusLine.statusCode, equalTo(HttpStatus.SC_OK))
260         }
261     }
262
263     @Throws(AssertionError::class)
264     private fun processBlueprint(
265         client: HttpClient,
266         process: ProcessDefinition,
267         expectedResponse: JsonNode?,
268         responseNormalizer: (String) -> String
269     ): JsonNode {
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
273         }
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() }
281         }
282         val actualResponse = responseNormalizer(response)
283         if (expectedResponse != null) {
284             assertJsonEquals(expectedResponse, actualResponse, process.name)
285         }
286         return mapper.readTree(actualResponse)!!
287     }
288
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"
292         )
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)
300         }
301     }
302
303     @Throws(AssertionError::class)
304     private fun assertJsonEquals(expected: JsonNode?, actual: String, msg: String = ""): Boolean {
305         // special case
306         if ((expected == null) && actual.isBlank()) {
307             return true
308         }
309         // general case
310         JSONAssert.assertEquals(msg, expected?.toString(), actual, JSONCompareMode.LENIENT)
311         // assertEquals throws an exception whenever match fails
312         return true
313     }
314
315     private fun localServerPort(): Int =
316         (
317             environment.getProperty("local.server.port")
318                 ?: environment.getRequiredProperty("blueprint.httpPort")
319             ).toInt()
320
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
327             )
328             else -> username
329         }
330         return "Basic " + Base64.getEncoder().encodeToString("$username:$plainPassword".toByteArray())
331     }
332
333     open class MockPreInterceptor : BluePrintRestLibPropertyService.PreInterceptor {
334
335         private val mocks = ConcurrentHashMap<String, BlueprintWebClientService>()
336
337         override fun getInstance(jsonNode: JsonNode): BlueprintWebClientService? {
338             TODO("jsonNode-keyed services not yet supported")
339         }
340
341         override fun getInstance(selector: String): BlueprintWebClientService? =
342             mocks[selector]
343
344         fun registerMock(selector: String, client: BlueprintWebClientService) {
345             mocks[selector] = client
346         }
347     }
348
349     open class SpyPostInterceptor(private val mapper: ObjectMapper) : BluePrintRestLibPropertyService.PostInterceptor {
350
351         private val spies = ConcurrentHashMap<String, SpyService>()
352
353         override fun getInstance(jsonNode: JsonNode, service: BlueprintWebClientService): BlueprintWebClientService {
354             TODO("jsonNode-keyed services not yet supported")
355         }
356
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
362                 return spiedService
363             }
364
365             spiedService = SpyService(mapper, selector, service)
366             spies[selector] = spiedService
367
368             return spiedService
369         }
370         fun getSpies(): List<SpyService> =
371             spies.values.toList()
372     }
373
374     open class SpyService(
375         private val mapper: ObjectMapper,
376         val selector: String,
377         var realService: BlueprintWebClientService
378     ) :
379         BlueprintWebClientService by realService {
380
381         private val expectations: MutableList<ExpectationDefinition> = mutableListOf()
382
383         override fun exchangeResource(methodType: String, path: String, request: String): WebClientResponse<String> =
384             exchangeResource(
385                 methodType, path, request,
386                 DEFAULT_HEADERS
387             )
388
389         override fun exchangeResource(
390             methodType: String,
391             path: String,
392             request: String,
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)
401                 else -> null
402             }
403             val responseDefinition =
404                 ResponseDefinition(realAnswer.status, responseBody)
405             expectations.add(
406                 ExpectationDefinition(
407                     requestDefinition,
408                     responseDefinition
409                 )
410             )
411             return realAnswer
412         }
413
414         override fun uploadBinaryFile(path: String, filePath: Path):
415             WebClientResponse<String> {
416                 val method = HttpMethod.POST.name()
417                 val headers = DEFAULT_HEADERS
418                 val request = ""
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)
425                     else -> null
426                 }
427                 val responseDefinition =
428                     ResponseDefinition(realAnswer.status, responseBody)
429                 expectations.add(
430                     ExpectationDefinition(
431                         requestDefinition,
432                         responseDefinition
433                     )
434                 )
435                 return realAnswer
436             }
437
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)
440         }
441
442         fun asServiceDefinition() =
443             ServiceDefinition(selector, expectations)
444
445         private fun toJson(str: String): JsonNode? {
446             return when {
447                 str.isNotBlank() -> mapper.readTree(str)
448                 else -> null
449             }
450         }
451
452         companion object {
453
454             private val DEFAULT_HEADERS = mapOf(
455                 HttpHeaders.CONTENT_TYPE to MediaType.APPLICATION_JSON_VALUE,
456                 HttpHeaders.ACCEPT to MediaType.APPLICATION_JSON_VALUE
457             )
458         }
459     }
460 }