2 * ============LICENSE_START=======================================================
3 * Copyright (C) 2024-2025 OpenInfra Foundation Europe. All rights reserved.
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=========================================================
21 package org.onap.cps.ncmp.impl.data.policyexecutor
23 import com.fasterxml.jackson.core.JsonProcessingException;
24 import ch.qos.logback.classic.Level
25 import ch.qos.logback.classic.Logger
26 import ch.qos.logback.classic.spi.ILoggingEvent
27 import ch.qos.logback.core.read.ListAppender
28 import com.fasterxml.jackson.databind.JsonNode
29 import com.fasterxml.jackson.databind.ObjectMapper
30 import org.onap.cps.ncmp.api.exceptions.NcmpException
31 import org.onap.cps.ncmp.api.exceptions.PolicyExecutorException
32 import org.onap.cps.ncmp.api.exceptions.ProvMnSException
33 import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle
34 import org.onap.cps.ncmp.impl.provmns.RequestPathParameters
35 import org.onap.cps.ncmp.impl.provmns.model.PatchItem
36 import org.onap.cps.ncmp.impl.provmns.model.ResourceOneOf
37 import org.onap.cps.utils.JsonObjectMapper
38 import org.slf4j.LoggerFactory
39 import org.springframework.http.HttpStatus
40 import org.springframework.http.ResponseEntity
41 import org.springframework.web.reactive.function.client.WebClient
42 import org.springframework.web.reactive.function.client.WebClientRequestException
43 import org.springframework.web.reactive.function.client.WebClientResponseException
44 import reactor.core.publisher.Mono
45 import spock.lang.Specification
46 import java.util.concurrent.TimeoutException
48 import static org.onap.cps.ncmp.api.data.models.OperationType.CREATE
49 import static org.onap.cps.ncmp.api.data.models.OperationType.DELETE
50 import static org.onap.cps.ncmp.api.data.models.OperationType.PATCH
51 import static org.onap.cps.ncmp.api.data.models.OperationType.UPDATE
53 class PolicyExecutorSpec extends Specification {
55 def mockWebClient = Mock(WebClient)
56 def mockRequestBodyUriSpec = Mock(WebClient.RequestBodyUriSpec)
57 def mockResponseSpec = Mock(WebClient.ResponseSpec)
58 def spiedObjectMapper = Spy(ObjectMapper)
59 def jsonObjectMapper = new JsonObjectMapper(spiedObjectMapper)
61 PolicyExecutor objectUnderTest = new PolicyExecutor(mockWebClient, spiedObjectMapper,jsonObjectMapper)
63 def logAppender = Spy(ListAppender<ILoggingEvent>)
65 def someValidJson = '{"Hello":"World"}'
69 objectUnderTest.enabled = true
70 objectUnderTest.serverAddress = 'some host'
71 objectUnderTest.serverPort = 'some port'
72 mockWebClient.post() >> mockRequestBodyUriSpec
73 mockRequestBodyUriSpec.uri(*_) >> mockRequestBodyUriSpec
74 mockRequestBodyUriSpec.header(*_) >> mockRequestBodyUriSpec
75 mockRequestBodyUriSpec.body(*_) >> mockRequestBodyUriSpec
76 mockRequestBodyUriSpec.retrieve() >> mockResponseSpec
80 ((Logger) LoggerFactory.getLogger(PolicyExecutor)).detachAndStopAllAppenders()
83 def 'Permission check with "allow" decision.'() {
84 given: 'allow response'
85 mockResponse([permissionResult:'allow'], HttpStatus.OK)
86 when: 'permission is checked for an operation'
87 objectUnderTest.checkPermission(new YangModelCmHandle(), operationType, 'my credentials','my resource',someValidJson)
88 then: 'system logs the operation is allowed'
89 assert getLogEntry(4) == 'Operation allowed.'
90 and: 'no exception occurs'
92 where: 'all write operations are tested'
93 operationType << [ CREATE, DELETE, PATCH, UPDATE ]
96 def 'Permission check with "other" decision (not allowed).'() {
97 given: 'other response'
98 mockResponse([permissionResult:'other', id:123, message:'I dont like Mondays' ], HttpStatus.OK)
99 when: 'permission is checked for an operation'
100 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials','my resource',someValidJson)
101 then: 'Policy Executor exception is thrown'
102 def thrownException = thrown(PolicyExecutorException)
103 assert thrownException.message == 'Operation not allowed. Decision id 123 : other'
104 assert thrownException.details == 'I dont like Mondays'
107 def 'Permission check with non-2xx response and "allow" default decision.'() {
108 given: 'non-2xx http response'
110 and: 'the configured default decision is "allow"'
111 objectUnderTest.defaultDecision = 'allow'
112 when: 'permission is checked for an operation'
113 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials','my resource',someValidJson)
114 then: 'No exception is thrown'
118 def 'Permission check with non-2xx response and "other" default decision.'() {
119 given: 'non-2xx http response'
120 def webClientException = mockErrorResponse()
121 and: 'the configured default decision is NOT "allow"'
122 objectUnderTest.defaultDecision = 'deny by default'
123 when: 'permission is checked for an operation'
124 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials', 'my resource', someValidJson)
125 then: 'Policy Executor exception is thrown'
126 def thrownException = thrown(PolicyExecutorException)
127 assert thrownException.message == 'Operation not allowed. Decision id N/A : deny by default'
128 assert thrownException.details == 'Policy Executor returned HTTP Status code 418. Falling back to configured default decision: deny by default'
129 and: 'the cause is the original web client exception'
130 assert thrownException.cause == webClientException
133 def 'Permission check with invalid response from Policy Executor.'() {
134 given: 'invalid response from Policy executor'
135 mockResponseSpec.toEntity(*_) >> Mono.just(new ResponseEntity<>(null, HttpStatus.OK))
136 when: 'permission is checked for an operation'
137 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
138 then: 'system logs the expected message'
139 assert getLogEntry(3) == 'No valid response body from Policy Executor, ignored'
142 def 'Permission check with timeout exception.'() {
143 given: 'a timeout during the request'
144 def timeoutException = new TimeoutException()
145 mockResponseSpec.toEntity(*_) >> { throw new RuntimeException(timeoutException) }
146 and: 'the configured default decision is NOT "allow"'
147 objectUnderTest.defaultDecision = 'deny by default'
148 when: 'permission is checked for an operation'
149 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
150 then: 'Policy Executor exception is thrown'
151 def thrownException = thrown(PolicyExecutorException)
152 assert thrownException.message == 'Operation not allowed. Decision id N/A : deny by default'
153 assert thrownException.details == 'Policy Executor request timed out. Falling back to configured default decision: deny by default'
154 and: 'the cause is the original time out exception'
155 assert thrownException.cause == timeoutException
158 def 'Permission check with unknown host.'() {
159 given: 'a unknown host exception during the request'
160 def unknownHostException = new UnknownHostException()
161 mockResponseSpec.toEntity(*_) >> { throw new RuntimeException(unknownHostException) }
162 and: 'the configured default decision is NOT "allow"'
163 objectUnderTest.defaultDecision = 'deny by default'
164 when: 'permission is checked for an operation'
165 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
166 then: 'Policy Executor exception is thrown'
167 def thrownException = thrown(PolicyExecutorException)
168 assert thrownException.message == 'Operation not allowed. Decision id N/A : deny by default'
169 assert thrownException.details == 'Unexpected error during Policy Executor call. Falling back to configured default decision: deny by default'
170 and: 'the cause is the original unknown host exception'
171 assert thrownException.cause == unknownHostException
174 def 'Permission check with #scenario exception and default decision "allow".'() {
175 given: 'a #scenario exception during the request'
176 mockResponseSpec.toEntity(*_) >> { throw new RuntimeException(cause)}
177 and: 'the configured default decision is "allow"'
178 objectUnderTest.defaultDecision = 'allow'
179 when: 'permission is checked for an operation'
180 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
181 then: 'no exception is thrown'
183 where: 'the following exceptions are thrown during the request'
185 'timeout' | new TimeoutException()
186 'unknown host' | new UnknownHostException()
189 def 'Permission check with other runtime exception.'() {
190 given: 'some other runtime exception'
191 def originalException = new RuntimeException()
192 mockResponseSpec.toEntity(*_) >> { throw originalException}
193 when: 'permission is checked for an operation'
194 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
195 then: 'The original exception is thrown'
196 def thrownException = thrown(RuntimeException)
197 assert thrownException == originalException
200 def 'Permission check with an invalid change request json.'() {
201 when: 'permission is checked for an invalid change request'
202 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', 'invalid json string')
203 then: 'an ncmp exception thrown'
204 def ncmpException = thrown(NcmpException)
205 ncmpException.message == 'Cannot convert Change Request data to Object'
206 ncmpException.details.contains('invalid json string')
209 def 'Permission check feature disabled.'() {
210 given: 'feature is disabled'
211 objectUnderTest.enabled = false
212 when: 'permission is checked for an operation'
213 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials','my resource',someValidJson)
214 then: 'system logs that the feature not enabled'
215 assert getLogEntry(0) == 'Policy Executor Enabled: false'
218 def 'Permission check with web client request exception.'() {
219 given: 'a WebClientRequestException is thrown during the Policy Executor call'
220 def webClientRequestException = Mock(WebClientRequestException)
221 webClientRequestException.getMessage() >> "some error message"
222 mockResponseSpec.toEntity(*_) >> { throw webClientRequestException }
223 objectUnderTest.defaultDecision = 'deny'
224 when: 'permission is checked for a write operation'
225 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
226 then: 'a PolicyExecutorException is thrown with the expected fallback message'
227 def thrownException = thrown(PolicyExecutorException)
228 thrownException.message == 'Operation not allowed. Decision id N/A : deny'
229 thrownException.details == 'Network or I/O error while attempting to contact Policy Executor. Falling back to configured default decision: deny'
230 thrownException.cause == webClientRequestException
233 def 'Build policy executor patch operation details from ProvMnS request parameters where #scenario.'() {
234 given: 'a provMnsRequestParameter and a patchItem list'
235 def path = new RequestPathParameters(uriLdnFirstPart: 'someUriLdnFirstPart', className: 'someClassName', id: 'someId')
236 def resource = new ResourceOneOf(id: 'someResourceId', attributes: ['someAttribute1:someValue1', 'someAttribute2:someValue2'], objectClass: objectClass)
237 def patchItemsList = [new PatchItem(op: 'ADD', 'path':'someUriLdnFirstPart', value: resource), new PatchItem(op: 'REPLACE', 'path':'someUriLdnFirstPart', value: resource)]
238 when: 'a configurationManagementOperation is created and converted to JSON'
239 def result = objectUnderTest.buildPatchOperationDetails(path, patchItemsList)
240 then: 'the result is as expected (using json to compare)'
241 def expectedJsonString = '{"permissionId":"Some Permission Id","changeRequestFormat":"cm-legacy","operations":[{"operation":"CREATE","targetIdentifier":"someUriLdnFirstPart","changeRequest":{"' + changeRequestClassReference + '":[{"id":"someId","attributes":["someAttribute1:someValue1","someAttribute2:someValue2"]}]}},{"operation":"UPDATE","targetIdentifier":"someUriLdnFirstPart","changeRequest":{"' + changeRequestClassReference + '":[{"id":"someId","attributes":["someAttribute1:someValue1","someAttribute2:someValue2"]}]}}]}'
242 assert expectedJsonString == jsonObjectMapper.asJsonString(result)
244 scenario | objectClass || changeRequestClassReference
245 'objectClass is populated' | 'someObjectClass' || 'someObjectClass'
246 'objectClass is empty' | '' || 'someClassName'
247 'objectClass is null' | null || 'someClassName'
250 def 'Build policy executor patch operation details from ProvMnS request parameters with invalid op.'() {
251 given: 'a provMnsRequestParameter and a patchItem list'
252 def path = new RequestPathParameters(uriLdnFirstPart: 'someUriLdnFirstPart', className: 'someClassName', id: 'someId')
253 def patchItemsList = [new PatchItem(op: 'TEST', 'path':'someUriLdnFirstPart')]
254 when: 'a configurationManagementOperation is created and converted to JSON'
255 def result = objectUnderTest.buildPatchOperationDetails(path, patchItemsList)
256 then: 'the result is as expected (using json to compare)'
257 def expectedJsonString = '{"permissionId":"Some Permission Id","changeRequestFormat":"cm-legacy","operations":[]}'
258 assert expectedJsonString == jsonObjectMapper.asJsonString(result)
261 def 'Build policy executor create operation details from ProvMnS request parameters where #scenario.'() {
262 given: 'a provMnsRequestParameter and a resource'
263 def path = new RequestPathParameters(uriLdnFirstPart: 'someUriLdnFirstPart', className: 'someClassName', id: 'someId')
264 def resource = new ResourceOneOf(id: 'someResourceId', attributes: ['someAttribute1:someValue1', 'someAttribute2:someValue2'], objectClass: objectClass)
265 when: 'a configurationManagementOperation is created and converted to JSON'
266 def result = objectUnderTest.buildCreateOperationDetails(CREATE, path, resource)
267 then: 'the result is as expected (using json to compare)'
268 String expectedJsonString = '{"operation":"CREATE","targetIdentifier":"someUriLdnFirstPart","changeRequest":{"' + changeRequestClassReference + '":[{"id":"someId","attributes":["someAttribute1:someValue1","someAttribute2:someValue2"]}]}}'
269 assert jsonObjectMapper.asJsonString(result) == expectedJsonString
271 scenario | objectClass || changeRequestClassReference
272 'objectClass is populated' | 'someObjectClass' || 'someObjectClass'
273 'objectClass is empty' | '' || 'someClassName'
274 'objectClass is null' | null || 'someClassName'
277 def 'Build Policy Executor Operation Details with a exception during conversion'() {
278 given: 'a provMnsRequestParameter and a resource'
279 def path = new RequestPathParameters(uriLdnFirstPart: 'someUriLdnFirstPart', className: 'someClassName', id: 'someId')
280 def resource = new ResourceOneOf(id: 'someResourceId', attributes: ['someAttribute1:someValue1', 'someAttribute2:someValue2'])
281 and: 'json object mapper throws an exception'
282 def originalException = new JsonProcessingException('some-exception')
283 spiedObjectMapper.readValue(*_) >> {throw originalException}
284 when: 'a configurationManagementOperation is created and converted to JSON'
285 objectUnderTest.buildCreateOperationDetails(CREATE, path, resource)
286 then: 'the expected exception is throw and matches the original'
290 def mockResponse(mockResponseAsMap, httpStatus) {
291 JsonNode jsonNode = spiedObjectMapper.readTree(spiedObjectMapper.writeValueAsString(mockResponseAsMap))
292 def mono = Mono.just(new ResponseEntity<>(jsonNode, httpStatus))
293 mockResponseSpec.toEntity(*_) >> mono
296 def mockErrorResponse() {
297 def webClientResponseException = Mock(WebClientResponseException)
298 webClientResponseException.getStatusCode() >> HttpStatus.I_AM_A_TEAPOT
299 mockResponseSpec.toEntity(*_) >> { throw webClientResponseException }
300 return webClientResponseException
304 def logger = LoggerFactory.getLogger(PolicyExecutor)
305 logger.setLevel(Level.TRACE)
306 logger.addAppender(logAppender)
310 def getLogEntry(index) {
311 logAppender.list[index].formattedMessage