UatExecutor does not support complex test scenarios
[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.skyscreamer.jsonassert.JSONAssert
56 import org.skyscreamer.jsonassert.JSONCompareMode
57 import org.slf4j.Logger
58 import org.slf4j.LoggerFactory
59 import org.springframework.core.env.ConfigurableEnvironment
60 import org.springframework.http.MediaType
61 import org.springframework.stereotype.Component
62 import org.springframework.util.Base64Utils
63 import java.util.concurrent.ConcurrentHashMap
64
65 /**
66  * Assumptions:
67  *
68  * - Application HTTP service is bound to loopback interface;
69  * - Password is either defined in plain (with "{noop}" prefix), or it's the same of username.
70  *
71  * @author Eliezio Oliveira
72  */
73 @Component
74 open class UatExecutor(
75     private val environment: ConfigurableEnvironment,
76     private val restClientFactory: BluePrintRestLibPropertyService,
77     private val mapper: ObjectMapper
78 ) {
79
80     companion object {
81
82         private const val NOOP_PASSWORD_PREFIX = "{noop}"
83         private const val PROPERTY_IN_UAT = "IN_UAT"
84         private val TIMES_SPEC_REGEX = "([<>]=?)?\\s*(\\d+)".toRegex()
85         private val log: Logger = LoggerFactory.getLogger(UatExecutor::class.java)
86         private val mockLoggingListener = MockInvocationLogger(markerOf(COLOR_MOCKITO))
87     }
88
89     // use lazy evaluation to postpone until localServerPort is injected by Spring
90     private val baseUrl: String by lazy {
91         "http://127.0.0.1:${localServerPort()}"
92     }
93
94     @Throws(AssertionError::class)
95     fun execute(uatSpec: String, cbaBytes: ByteArray) {
96         val uat = UatDefinition.load(mapper, uatSpec)
97         execute(uat, cbaBytes)
98     }
99
100     /**
101      *
102      * The UAT can range from minimum to completely defined.
103      *
104      * @return an updated UAT with all NB and SB messages.
105      */
106     @Throws(AssertionError::class)
107     fun execute(uat: UatDefinition, cbaBytes: ByteArray): UatDefinition {
108         val defaultHeaders = listOf(BasicHeader(HttpHeaders.AUTHORIZATION, clientAuthToken()))
109         val httpClient = HttpClientBuilder.create()
110             .setDefaultHeaders(defaultHeaders)
111             .build()
112         // Only if externalServices are defined
113         val mockInterceptor = MockPreInterceptor()
114         // Always defined and used, whatever the case
115         val spyInterceptor = SpyPostInterceptor(mapper)
116         restClientFactory.setInterceptors(mockInterceptor, spyInterceptor)
117         try {
118             markUatBegin()
119             // Configure mocked external services and save their expectations for further validation
120             val expectationsPerClient = uat.externalServices.associateBy(
121                 { service ->
122                     createRestClientMock(service.expectations).also { restClient ->
123                         // side-effect: register restClient to override real instance
124                         mockInterceptor.registerMock(service.selector, restClient)
125                     }
126                 },
127                 { service -> service.expectations }
128             )
129
130             val newProcesses = httpClient.use { client ->
131                 uploadBlueprint(client, cbaBytes)
132
133                 // Run processes
134                 uat.processes.map { process ->
135                     log.info("Executing process '${process.name}'")
136                     val responseNormalizer = JsonNormalizer.getNormalizer(mapper, process.responseNormalizerSpec)
137                     val actualResponse = processBlueprint(
138                         client, process,
139                         process.expectedResponse, responseNormalizer
140                     )
141                     ProcessDefinition(
142                         process.name,
143                         process.request,
144                         actualResponse,
145                         process.responseNormalizerSpec
146                     )
147                 }
148             }
149
150             // Validate requests to external services
151             for ((mockClient, expectations) in expectationsPerClient) {
152                 expectations.forEach { expectation ->
153                     val request = expectation.request
154                     verify(mockClient, evalVerificationMode(expectation.times)).exchangeResource(
155                         eq(request.method),
156                         eq(request.path),
157                         any(),
158                         argThat(RequiredMapEntriesMatcher(request.headers))
159                     )
160                 }
161                 // Don't mind the invocations to the overloaded exchangeResource(String, String, String)
162                 verify(mockClient, atLeast(0)).exchangeResource(any(), any(), any())
163                 verifyNoMoreInteractions(mockClient)
164             }
165
166             val newExternalServices = spyInterceptor.getSpies()
167                 .map(SpyService::asServiceDefinition)
168
169             return UatDefinition(newProcesses, newExternalServices)
170         } finally {
171             restClientFactory.clearInterceptors()
172             markUatEnd()
173         }
174     }
175
176     private fun markUatBegin() {
177         System.setProperty(PROPERTY_IN_UAT, "1")
178     }
179
180     private fun markUatEnd() {
181         System.clearProperty(PROPERTY_IN_UAT)
182     }
183
184     private fun createRestClientMock(restExpectations: List<ExpectationDefinition>):
185         BlueprintWebClientService {
186             val restClient = mock<BlueprintWebClientService>(
187                 defaultAnswer = Answers.RETURNS_SMART_NULLS,
188                 // our custom verboseLogging handler
189                 invocationListeners = arrayOf(mockLoggingListener)
190             )
191
192             // Delegates to overloaded exchangeResource(String, String, String, Map<String, String>)
193             whenever(restClient.exchangeResource(any(), any(), any()))
194                 .thenAnswer { invocation ->
195                     val method = invocation.arguments[0] as String
196                     val path = invocation.arguments[1] as String
197                     val request = invocation.arguments[2] as String
198                     restClient.exchangeResource(method, path, request, emptyMap())
199                 }
200             for (expectation in restExpectations) {
201                 var stubbing = whenever(
202                     restClient.exchangeResource(
203                         eq(expectation.request.method),
204                         eq(expectation.request.path),
205                         argThat(JsonMatcher(expectation.request.body.toString())),
206                         any()
207                     )
208                 )
209                 for (response in expectation.responses) {
210                     stubbing = stubbing.thenReturn(WebClientResponse(response.status, response.body.toString()))
211                 }
212             }
213             return restClient
214         }
215
216     @Throws(AssertionError::class)
217     private fun uploadBlueprint(client: HttpClient, cbaBytes: ByteArray) {
218         val multipartEntity = MultipartEntityBuilder.create()
219             .setMode(HttpMultipartMode.BROWSER_COMPATIBLE)
220             .addBinaryBody("file", cbaBytes, ContentType.DEFAULT_BINARY, "cba.zip")
221             .build()
222         val request = HttpPost("$baseUrl/api/v1/blueprint-model/publish").apply {
223             entity = multipartEntity
224         }
225         client.execute(request) { response ->
226             val statusLine = response.statusLine
227             assertThat(statusLine.statusCode, equalTo(HttpStatus.SC_OK))
228         }
229     }
230
231     @Throws(AssertionError::class)
232     private fun processBlueprint(
233         client: HttpClient,
234         process: ProcessDefinition,
235         expectedResponse: JsonNode?,
236         responseNormalizer: (String) -> String
237     ): JsonNode {
238         val stringEntity = StringEntity(mapper.writeValueAsString(process.request), ContentType.APPLICATION_JSON)
239         val request = HttpPost("$baseUrl/api/v1/execution-service/process").apply {
240             entity = stringEntity
241         }
242         val response = client.execute(request) { response ->
243             val statusLine = response.statusLine
244             val expectedCode = expectedResponse?.get("status")?.get("code")?.intValue()
245             assertThat("${process.name}", statusLine.statusCode, equalTo(expectedCode ?: HttpStatus.SC_OK))
246             val entity = response.entity
247             assertThat("${process.name} Response contains no content", entity, notNullValue())
248             entity.content.bufferedReader().use { it.readText() }
249         }
250         val actualResponse = responseNormalizer(response)
251         if (expectedResponse != null) {
252             assertJsonEquals(expectedResponse, actualResponse, process.name)
253         }
254         return mapper.readTree(actualResponse)!!
255     }
256
257     private fun evalVerificationMode(times: String): VerificationMode {
258         val matchResult = TIMES_SPEC_REGEX.matchEntire(times) ?: throw InvalidUatDefinition(
259             "Time specification '$times' does not follow expected format $TIMES_SPEC_REGEX"
260         )
261         val counter = matchResult.groups[2]!!.value.toInt()
262         return when (matchResult.groups[1]?.value) {
263             ">=" -> atLeast(counter)
264             ">" -> atLeast(counter + 1)
265             "<=" -> atMost(counter)
266             "<" -> atMost(counter - 1)
267             else -> times(counter)
268         }
269     }
270
271     @Throws(AssertionError::class)
272     private fun assertJsonEquals(expected: JsonNode?, actual: String, msg: String = ""): Boolean {
273         // special case
274         if ((expected == null) && actual.isBlank()) {
275             return true
276         }
277         // general case
278         JSONAssert.assertEquals(msg, expected?.toString(), actual, JSONCompareMode.LENIENT)
279         // assertEquals throws an exception whenever match fails
280         return true
281     }
282
283     private fun localServerPort(): Int =
284         (
285             environment.getProperty("local.server.port")
286                 ?: environment.getRequiredProperty("blueprint.httpPort")
287             ).toInt()
288
289     private fun clientAuthToken(): String {
290         val username = environment.getRequiredProperty("security.user.name")
291         val password = environment.getRequiredProperty("security.user.password")
292         val plainPassword = when {
293             password.startsWith(NOOP_PASSWORD_PREFIX) -> password.substring(
294                 NOOP_PASSWORD_PREFIX.length
295             )
296             else -> username
297         }
298         return "Basic " + Base64Utils.encodeToString("$username:$plainPassword".toByteArray())
299     }
300
301     open class MockPreInterceptor : BluePrintRestLibPropertyService.PreInterceptor {
302
303         private val mocks = ConcurrentHashMap<String, BlueprintWebClientService>()
304
305         override fun getInstance(jsonNode: JsonNode): BlueprintWebClientService? {
306             TODO("jsonNode-keyed services not yet supported")
307         }
308
309         override fun getInstance(selector: String): BlueprintWebClientService? =
310             mocks[selector]
311
312         fun registerMock(selector: String, client: BlueprintWebClientService) {
313             mocks[selector] = client
314         }
315     }
316
317     open class SpyPostInterceptor(private val mapper: ObjectMapper) : BluePrintRestLibPropertyService.PostInterceptor {
318
319         private val spies = ConcurrentHashMap<String, SpyService>()
320
321         override fun getInstance(jsonNode: JsonNode, service: BlueprintWebClientService): BlueprintWebClientService {
322             TODO("jsonNode-keyed services not yet supported")
323         }
324
325         override fun getInstance(selector: String, service: BlueprintWebClientService): BlueprintWebClientService {
326             var spiedService = spies[selector]
327             if (spiedService != null) {
328                 // inject the service here as realService: needed for "stateful services" (e.g. holding a session token)
329                 spiedService.realService = service
330                 return spiedService
331             }
332
333             spiedService = SpyService(mapper, selector, service)
334             spies[selector] = spiedService
335
336             return spiedService
337         }
338         fun getSpies(): List<SpyService> =
339             spies.values.toList()
340     }
341
342     open class SpyService(
343         private val mapper: ObjectMapper,
344         val selector: String,
345         var realService: BlueprintWebClientService
346     ) :
347         BlueprintWebClientService by realService {
348
349         private val expectations: MutableList<ExpectationDefinition> = mutableListOf()
350
351         override fun exchangeResource(methodType: String, path: String, request: String): WebClientResponse<String> =
352             exchangeResource(
353                 methodType, path, request,
354                 DEFAULT_HEADERS
355             )
356
357         override fun exchangeResource(
358             methodType: String,
359             path: String,
360             request: String,
361             headers: Map<String, String>
362         ): WebClientResponse<String> {
363             val requestDefinition =
364                 RequestDefinition(methodType, path, headers, toJson(request))
365             val realAnswer = realService.exchangeResource(methodType, path, request, headers)
366             val responseBody = when {
367                 // TODO: confirm if we need to normalize the response here
368                 realAnswer.status == HttpStatus.SC_OK -> toJson(realAnswer.body)
369                 else -> null
370             }
371             val responseDefinition =
372                 ResponseDefinition(realAnswer.status, responseBody)
373             expectations.add(
374                 ExpectationDefinition(
375                     requestDefinition,
376                     responseDefinition
377                 )
378             )
379             return realAnswer
380         }
381
382         override suspend fun <T> retry(times: Int, initialDelay: Long, delay: Long, block: suspend (Int) -> T): T {
383             return super.retry(times, initialDelay, delay, block)
384         }
385
386         fun asServiceDefinition() =
387             ServiceDefinition(selector, expectations)
388
389         private fun toJson(str: String): JsonNode? {
390             return when {
391                 str.isNotBlank() -> mapper.readTree(str)
392                 else -> null
393             }
394         }
395
396         companion object {
397
398             private val DEFAULT_HEADERS = mapOf(
399                 HttpHeaders.CONTENT_TYPE to MediaType.APPLICATION_JSON_VALUE,
400                 HttpHeaders.ACCEPT to MediaType.APPLICATION_JSON_VALUE
401             )
402         }
403     }
404 }