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.impl.inventory.models.YangModelCmHandle
33 import org.onap.cps.ncmp.impl.provmns.RequestPathParameters
34 import org.onap.cps.ncmp.impl.provmns.model.PatchItem
35 import org.onap.cps.ncmp.impl.provmns.model.ResourceOneOf
36 import org.onap.cps.utils.JsonObjectMapper
37 import org.slf4j.LoggerFactory
38 import org.springframework.http.HttpStatus
39 import org.springframework.http.ResponseEntity
40 import org.springframework.web.reactive.function.client.WebClient
41 import org.springframework.web.reactive.function.client.WebClientRequestException
42 import org.springframework.web.reactive.function.client.WebClientResponseException
43 import reactor.core.publisher.Mono
44 import spock.lang.Specification
45 import java.util.concurrent.TimeoutException
47 import static org.onap.cps.ncmp.api.data.models.OperationType.CREATE
48 import static org.onap.cps.ncmp.api.data.models.OperationType.DELETE
49 import static org.onap.cps.ncmp.api.data.models.OperationType.PATCH
50 import static org.onap.cps.ncmp.api.data.models.OperationType.UPDATE
52 class PolicyExecutorSpec extends Specification {
54 def mockWebClient = Mock(WebClient)
55 def mockRequestBodyUriSpec = Mock(WebClient.RequestBodyUriSpec)
56 def mockResponseSpec = Mock(WebClient.ResponseSpec)
57 def spiedObjectMapper = Spy(ObjectMapper)
58 def jsonObjectMapper = new JsonObjectMapper(spiedObjectMapper)
60 PolicyExecutor objectUnderTest = new PolicyExecutor(mockWebClient, spiedObjectMapper,jsonObjectMapper)
62 def logAppender = Spy(ListAppender<ILoggingEvent>)
64 def someValidJson = '{"Hello":"World"}'
65 static def complexValueAsResource = new ResourceOneOf(id: 'myId', attributes: ['myAttribute1:myValue1', 'myAttribute2:myValue2'], objectClass: 'myClassName')
66 static def simpleValueAsResource = new ResourceOneOf(id: 'myId', attributes: ['simpleAttribute:1'], objectClass: 'myClassName')
71 objectUnderTest.enabled = true
72 objectUnderTest.serverAddress = 'some host'
73 objectUnderTest.serverPort = 'some port'
74 mockWebClient.post() >> mockRequestBodyUriSpec
75 mockRequestBodyUriSpec.uri(*_) >> mockRequestBodyUriSpec
76 mockRequestBodyUriSpec.header(*_) >> mockRequestBodyUriSpec
77 mockRequestBodyUriSpec.body(*_) >> mockRequestBodyUriSpec
78 mockRequestBodyUriSpec.retrieve() >> mockResponseSpec
82 ((Logger) LoggerFactory.getLogger(PolicyExecutor)).detachAndStopAllAppenders()
85 def 'Permission check with "allow" decision.'() {
86 given: 'allow response'
87 mockResponse([permissionResult:'allow'], HttpStatus.OK)
88 when: 'permission is checked for an operation'
89 objectUnderTest.checkPermission(new YangModelCmHandle(), operationType, 'my credentials','my resource',someValidJson)
90 then: 'system logs the operation is allowed'
91 assert getLogEntry(4) == 'Operation allowed.'
92 and: 'no exception occurs'
94 where: 'all write operations are tested'
95 operationType << [ CREATE, DELETE, PATCH, UPDATE ]
98 def 'Permission check with "other" decision (not allowed).'() {
99 given: 'other response'
100 mockResponse([permissionResult:'other', id:123, message:'I dont like Mondays' ], HttpStatus.OK)
101 when: 'permission is checked for an operation'
102 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials','my resource',someValidJson)
103 then: 'Policy Executor exception is thrown'
104 def thrownException = thrown(PolicyExecutorException)
105 assert thrownException.message == 'Operation not allowed. Decision id 123 : other'
106 assert thrownException.details == 'I dont like Mondays'
109 def 'Permission check with non-2xx response and "allow" default decision.'() {
110 given: 'non-2xx http response'
112 and: 'the configured default decision is "allow"'
113 objectUnderTest.defaultDecision = 'allow'
114 when: 'permission is checked for an operation'
115 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials','my resource',someValidJson)
116 then: 'No exception is thrown'
120 def 'Permission check with non-2xx response and "other" default decision.'() {
121 given: 'non-2xx http response'
122 def webClientException = mockErrorResponse()
123 and: 'the configured default decision is NOT "allow"'
124 objectUnderTest.defaultDecision = 'deny by default'
125 when: 'permission is checked for an operation'
126 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials', 'my resource', someValidJson)
127 then: 'Policy Executor exception is thrown'
128 def thrownException = thrown(PolicyExecutorException)
129 assert thrownException.message == 'Operation not allowed. Decision id N/A : deny by default'
130 assert thrownException.details == 'Policy Executor returned HTTP Status code 418. Falling back to configured default decision: deny by default'
131 and: 'the cause is the original web client exception'
132 assert thrownException.cause == webClientException
135 def 'Permission check with invalid response from Policy Executor.'() {
136 given: 'invalid response from Policy executor'
137 mockResponseSpec.toEntity(*_) >> Mono.just(new ResponseEntity<>(null, HttpStatus.OK))
138 when: 'permission is checked for an operation'
139 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
140 then: 'system logs the expected message'
141 assert getLogEntry(3) == 'No valid response body from Policy Executor, ignored'
144 def 'Permission check with timeout exception.'() {
145 given: 'a timeout during the request'
146 def timeoutException = new TimeoutException()
147 mockResponseSpec.toEntity(*_) >> { throw new RuntimeException(timeoutException) }
148 and: 'the configured default decision is NOT "allow"'
149 objectUnderTest.defaultDecision = 'deny by default'
150 when: 'permission is checked for an operation'
151 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
152 then: 'Policy Executor exception is thrown'
153 def thrownException = thrown(PolicyExecutorException)
154 assert thrownException.message == 'Operation not allowed. Decision id N/A : deny by default'
155 assert thrownException.details == 'Policy Executor request timed out. Falling back to configured default decision: deny by default'
156 and: 'the cause is the original time out exception'
157 assert thrownException.cause == timeoutException
160 def 'Permission check with unknown host.'() {
161 given: 'a unknown host exception during the request'
162 def unknownHostException = new UnknownHostException()
163 mockResponseSpec.toEntity(*_) >> { throw new RuntimeException(unknownHostException) }
164 and: 'the configured default decision is NOT "allow"'
165 objectUnderTest.defaultDecision = 'deny by default'
166 when: 'permission is checked for an operation'
167 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
168 then: 'Policy Executor exception is thrown'
169 def thrownException = thrown(PolicyExecutorException)
170 assert thrownException.message == 'Operation not allowed. Decision id N/A : deny by default'
171 assert thrownException.details == 'Unexpected error during Policy Executor call. Falling back to configured default decision: deny by default'
172 and: 'the cause is the original unknown host exception'
173 assert thrownException.cause == unknownHostException
176 def 'Permission check with #scenario exception and default decision "allow".'() {
177 given: 'a #scenario exception during the request'
178 mockResponseSpec.toEntity(*_) >> { throw new RuntimeException(cause)}
179 and: 'the configured default decision is "allow"'
180 objectUnderTest.defaultDecision = 'allow'
181 when: 'permission is checked for an operation'
182 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
183 then: 'no exception is thrown'
185 where: 'the following exceptions are thrown during the request'
187 'timeout' | new TimeoutException()
188 'unknown host' | new UnknownHostException()
191 def 'Permission check with other runtime exception.'() {
192 given: 'some other runtime exception'
193 def originalException = new RuntimeException()
194 mockResponseSpec.toEntity(*_) >> { throw originalException}
195 when: 'permission is checked for an operation'
196 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
197 then: 'The original exception is thrown'
198 def thrownException = thrown(RuntimeException)
199 assert thrownException == originalException
202 def 'Permission check with an invalid change request json.'() {
203 when: 'permission is checked for an invalid change request'
204 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', 'invalid json string')
205 then: 'an ncmp exception thrown'
206 def ncmpException = thrown(NcmpException)
207 ncmpException.message == 'Cannot convert Change Request data to Object'
208 ncmpException.details.contains('invalid json string')
211 def 'Permission check feature disabled.'() {
212 given: 'feature is disabled'
213 objectUnderTest.enabled = false
214 when: 'permission is checked for an operation'
215 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials','my resource',someValidJson)
216 then: 'system logs that the feature not enabled'
217 assert getLogEntry(0) == 'Policy Executor Enabled: false'
220 def 'Permission check with web client request exception.'() {
221 given: 'a WebClientRequestException is thrown during the Policy Executor call'
222 def webClientRequestException = Mock(WebClientRequestException)
223 webClientRequestException.getMessage() >> "some error message"
224 mockResponseSpec.toEntity(*_) >> { throw webClientRequestException }
225 objectUnderTest.defaultDecision = 'deny'
226 when: 'permission is checked for a write operation'
227 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
228 then: 'a PolicyExecutorException is thrown with the expected fallback message'
229 def thrownException = thrown(PolicyExecutorException)
230 thrownException.message == 'Operation not allowed. Decision id N/A : deny'
231 thrownException.details == 'Network or I/O error while attempting to contact Policy Executor. Falling back to configured default decision: deny'
232 thrownException.cause == webClientRequestException
235 def 'Build policy executor patch operation details from ProvMnS request parameters where #scenario.'() {
236 given: 'a provMnsRequestParameter and a patchItem list'
237 def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'classNameInUri', id: 'myId')
238 def resource = new ResourceOneOf(id: 'someResourceId', attributes: ['someAttribute1:someValue1', 'someAttribute2:someValue2'], objectClass: classNameInBody)
239 def patchItemsList = [new PatchItem(op: 'ADD', 'path':'myUriLdnFirstPart', value: resource), new PatchItem(op: 'REPLACE', 'path':'myUriLdnFirstPart', value: resource), new PatchItem(op: 'REMOVE', 'path':'myUriLdnFirstPart'),]
240 when: 'patch operation details are created'
241 def result = objectUnderTest.buildPatchOperationDetails(path, patchItemsList)
242 then: 'the result contain 3 operations of the correct types in the correct order'
243 result.operations.size() == 3
244 and: 'note that Add and Replace both are defined using Create Operation Details'
245 assert result.operations[0] instanceof CreateOperationDetails
246 assert result.operations[1] instanceof CreateOperationDetails
247 assert result.operations[2] instanceof DeleteOperationDetails
248 and: 'the add operation target identifier is just the uri first part'
249 assert result.operations[0]['targetIdentifier'] == 'myUriLdnFirstPart'
250 and: 'the replace operation target identifier is just the uri first part'
251 assert result.operations[1]['targetIdentifier'] == 'myUriLdnFirstPart'
252 and: 'the replace change request has the correct class name'
253 assert result.operations[1].changeRequest.keySet()[0] == expectedChangeRequestKey
254 and: 'the delete operation target identifier includes the target class and id'
255 assert result.operations[2]['targetIdentifier'] == 'myUriLdnFirstPart/classNameInUri=myId'
256 where: 'the following class names are used in the body'
257 scenario | classNameInBody || expectedChangeRequestKey
258 'class name in body is populated' | 'myClass' || 'myClass'
259 'class name in body is empty' | '' || 'classNameInUri'
260 'class name in body is null' | null || 'classNameInUri'
263 def 'Build policy executor patch operation details with single replace operation and #scenario.'() {
264 given: 'a requestParameter and a patchItem list'
265 def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'myClassName', id: 'myId')
266 def pathItems = [new PatchItem(op: 'REPLACE', 'path':"myUriLdnFirstPart${suffix}", value: value)]
267 when: 'patch operation details are created'
268 def result = objectUnderTest.buildPatchOperationDetails(path, pathItems)
269 then: 'the result has the correct type'
270 assert result instanceof PatchOperationsDetails
271 and: 'the change request contains the correct attributes value'
272 assert result.operations[0]['changeRequest']['myClassName'][0]['attributes'].toString() == attributesValueInOperation
273 where: 'attributes are set using # or resource'
274 scenario | suffix | value || attributesValueInOperation
275 'set simple value using #' | '#/attributes/simpleAttribute' | 1 || '[simpleAttribute:1]'
276 'set simple value using resource' | '' | simpleValueAsResource || '[simpleAttribute:1]'
277 'set complex value using resource' | '' | complexValueAsResource || '[myAttribute1:myValue1, myAttribute2:myValue2]'
280 def 'Build an attribute map with different depths of hierarchy with #scenario.'() {
281 given: 'a patch item with a path'
282 def patchItem = new PatchItem(op: 'REPLACE', 'path':path, value: 123)
283 when: 'transforming the attributes'
284 def hierarchyMap = objectUnderTest.getAttributeHierarchyMap(patchItem)
285 then: 'the map depth is equal to the expected number of attributes'
286 assert hierarchyMap.get(expectedAttributeName).toString() == expectedAttributeValue
287 where: 'simple and complex attributes are tested'
288 scenario | path || expectedAttributeName || expectedAttributeValue
289 'set a simple attribute' | 'myUriLdnFirstPart#/attributes/simpleAttribute' || 'simpleAttribute' || '123'
290 'set a complex attribute' | 'myUriLdnFirstPart#/attributes/complexAttribute/simpleAttribute' || 'complexAttribute' || '[simpleAttribute:123]'
293 def 'Build policy executor patch operation details from ProvMnS request parameters with invalid op.'() {
294 given: 'a provMnsRequestParameter and a patchItem list'
295 def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'someClassName', id: 'someId')
296 def patchItemsList = [new PatchItem(op: 'TEST', 'path':'myUriLdnFirstPart')]
297 when: 'a configurationManagementOperation is created and converted to JSON'
298 def result = objectUnderTest.buildPatchOperationDetails(path, patchItemsList)
299 then: 'the result is as expected (using json to compare)'
300 def expectedJsonString = '{"permissionId":"Some Permission Id","changeRequestFormat":"cm-legacy","operations":[]}'
301 assert expectedJsonString == jsonObjectMapper.asJsonString(result)
304 def 'Build policy executor create operation details from ProvMnS request parameters where #scenario.'() {
305 given: 'a provMnsRequestParameter and a resource'
306 def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'someClassName', id: 'someId')
307 def resource = new ResourceOneOf(id: 'someResourceId', attributes: ['someAttribute1:someValue1', 'someAttribute2:someValue2'], objectClass: objectClass)
308 when: 'a configurationManagementOperation is created and converted to JSON'
309 def result = objectUnderTest.buildCreateOperationDetails(CREATE, path, resource)
310 then: 'the result is as expected (using json to compare)'
311 String expectedJsonString = '{"operation":"CREATE","targetIdentifier":"myUriLdnFirstPart","changeRequest":{"' + changeRequestClassReference + '":[{"id":"someId","attributes":["someAttribute1:someValue1","someAttribute2:someValue2"]}]}}'
312 assert jsonObjectMapper.asJsonString(result) == expectedJsonString
314 scenario | objectClass || changeRequestClassReference
315 'objectClass is populated' | 'someObjectClass' || 'someObjectClass'
316 'objectClass is empty' | '' || 'someClassName'
317 'objectClass is null' | null || 'someClassName'
320 def 'Build Policy Executor Operation Details with a exception during conversion'() {
321 given: 'a provMnsRequestParameter and a resource'
322 def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'someClassName', id: 'someId')
323 def resource = new ResourceOneOf(id: 'someResourceId', attributes: ['someAttribute1:someValue1', 'someAttribute2:someValue2'])
324 and: 'json object mapper throws an exception'
325 def originalException = new JsonProcessingException('some-exception')
326 spiedObjectMapper.readValue(*_) >> {throw originalException}
327 when: 'a configurationManagementOperation is created and converted to JSON'
328 objectUnderTest.buildCreateOperationDetails(CREATE, path, resource)
329 then: 'the expected exception is throw and matches the original'
333 def mockResponse(mockResponseAsMap, httpStatus) {
334 JsonNode jsonNode = spiedObjectMapper.readTree(spiedObjectMapper.writeValueAsString(mockResponseAsMap))
335 def mono = Mono.just(new ResponseEntity<>(jsonNode, httpStatus))
336 mockResponseSpec.toEntity(*_) >> mono
339 def mockErrorResponse() {
340 def webClientResponseException = Mock(WebClientResponseException)
341 webClientResponseException.getStatusCode() >> HttpStatus.I_AM_A_TEAPOT
342 mockResponseSpec.toEntity(*_) >> { throw webClientResponseException }
343 return webClientResponseException
347 def logger = LoggerFactory.getLogger(PolicyExecutor)
348 logger.setLevel(Level.TRACE)
349 logger.addAppender(logAppender)
353 def getLogEntry(index) {
354 logAppender.list[index].formattedMessage