2 * ============LICENSE_START=======================================================
3 * Copyright (C) 2024 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
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 ch.qos.logback.classic.Level
24 import ch.qos.logback.classic.Logger
25 import ch.qos.logback.classic.spi.ILoggingEvent
26 import ch.qos.logback.core.read.ListAppender
27 import com.fasterxml.jackson.databind.JsonNode
28 import com.fasterxml.jackson.databind.ObjectMapper
29 import org.onap.cps.ncmp.api.exceptions.NcmpException
30 import org.onap.cps.ncmp.api.exceptions.PolicyExecutorException
31 import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle
32 import org.slf4j.LoggerFactory
33 import org.springframework.http.HttpStatus
34 import org.springframework.http.ResponseEntity
35 import org.springframework.web.reactive.function.client.WebClient
36 import org.springframework.web.reactive.function.client.WebClientResponseException
37 import reactor.core.publisher.Mono
38 import spock.lang.Specification
40 import java.util.concurrent.TimeoutException
42 import static org.onap.cps.ncmp.api.data.models.OperationType.CREATE
43 import static org.onap.cps.ncmp.api.data.models.OperationType.DELETE
44 import static org.onap.cps.ncmp.api.data.models.OperationType.PATCH
45 import static org.onap.cps.ncmp.api.data.models.OperationType.UPDATE
47 class PolicyExecutorSpec extends Specification {
49 def mockWebClient = Mock(WebClient)
50 def mockRequestBodyUriSpec = Mock(WebClient.RequestBodyUriSpec)
51 def mockResponseSpec = Mock(WebClient.ResponseSpec)
52 def spiedObjectMapper = Spy(ObjectMapper)
54 PolicyExecutor objectUnderTest = new PolicyExecutor(mockWebClient, spiedObjectMapper)
56 def logAppender = Spy(ListAppender<ILoggingEvent>)
58 def someValidJson = '{"Hello":"World"}'
62 objectUnderTest.enabled = true
63 objectUnderTest.serverAddress = 'some host'
64 objectUnderTest.serverPort = 'some port'
65 mockWebClient.post() >> mockRequestBodyUriSpec
66 mockRequestBodyUriSpec.uri(*_) >> mockRequestBodyUriSpec
67 mockRequestBodyUriSpec.header(*_) >> mockRequestBodyUriSpec
68 mockRequestBodyUriSpec.body(*_) >> mockRequestBodyUriSpec
69 mockRequestBodyUriSpec.retrieve() >> mockResponseSpec
73 ((Logger) LoggerFactory.getLogger(PolicyExecutor)).detachAndStopAllAppenders()
76 def 'Permission check with "allow" decision.'() {
77 given: 'allow response'
78 mockResponse([permissionResult:'allow'], HttpStatus.OK)
79 when: 'permission is checked for an operation'
80 objectUnderTest.checkPermission(new YangModelCmHandle(), operationType, 'my credentials','my resource',someValidJson)
81 then: 'system logs the operation is allowed'
82 assert getLogEntry(2) == 'Operation allowed.'
83 and: 'no exception occurs'
85 where: 'all write operations are tested'
86 operationType << [ CREATE, DELETE, PATCH, UPDATE ]
89 def 'Permission check with "other" decision (not allowed).'() {
90 given: 'other response'
91 mockResponse([permissionResult:'other', id:123, message:'I dont like Mondays' ], HttpStatus.OK)
92 when: 'permission is checked for an operation'
93 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials','my resource',someValidJson)
94 then: 'Policy Executor exception is thrown'
95 def thrownException = thrown(PolicyExecutorException)
96 assert thrownException.message == 'Operation not allowed. Decision id 123 : other'
97 assert thrownException.details == 'I dont like Mondays'
100 def 'Permission check with non-2xx response and "allow" default decision.'() {
101 given: 'non-2xx http response'
103 and: 'the configured default decision is "allow"'
104 objectUnderTest.defaultDecision = 'allow'
105 when: 'permission is checked for an operation'
106 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials','my resource',someValidJson)
107 then: 'No exception is thrown'
111 def 'Permission check with non-2xx response and "other" default decision.'() {
112 given: 'non-2xx http response'
113 def webClientException = mockErrorResponse()
114 and: 'the configured default decision is NOT "allow"'
115 objectUnderTest.defaultDecision = 'deny by default'
116 when: 'permission is checked for an operation'
117 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials', 'my resource', someValidJson)
118 then: 'Policy Executor exception is thrown'
119 def thrownException = thrown(PolicyExecutorException)
120 assert thrownException.message == 'Operation not allowed. Decision id N/A : deny by default'
121 assert thrownException.details == 'Policy Executor returned HTTP Status code 418. Falling back to configured default decision: deny by default'
122 and: 'the cause is the original web client exception'
123 assert thrownException.cause == webClientException
126 def 'Permission check with invalid response from Policy Executor.'() {
127 given: 'invalid response from Policy executor'
128 mockResponseSpec.toEntity(*_) >> Mono.just(new ResponseEntity<>(null, HttpStatus.OK))
129 when: 'permission is checked for an operation'
130 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
131 then: 'system logs the expected message'
132 assert getLogEntry(1) == 'No valid response body from Policy Executor, ignored'
135 def 'Permission check with timeout exception.'() {
136 given: 'a timeout during the request'
137 def timeoutException = new TimeoutException()
138 mockResponseSpec.toEntity(*_) >> { throw new RuntimeException(timeoutException) }
139 and: 'the configured default decision is NOT "allow"'
140 objectUnderTest.defaultDecision = 'deny by default'
141 when: 'permission is checked for an operation'
142 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
143 then: 'Policy Executor exception is thrown'
144 def thrownException = thrown(PolicyExecutorException)
145 assert thrownException.message == 'Operation not allowed. Decision id N/A : deny by default'
146 assert thrownException.details == 'Policy Executor request timed out. Falling back to configured default decision: deny by default'
147 and: 'the cause is the original time out exception'
148 assert thrownException.cause == timeoutException
151 def 'Permission check with unknown host.'() {
152 given: 'a unknown host exception during the request'
153 def unknownHostException = new UnknownHostException()
154 mockResponseSpec.toEntity(*_) >> { throw new RuntimeException(unknownHostException) }
155 and: 'the configured default decision is NOT "allow"'
156 objectUnderTest.defaultDecision = 'deny by default'
157 when: 'permission is checked for an operation'
158 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
159 then: 'Policy Executor exception is thrown'
160 def thrownException = thrown(PolicyExecutorException)
161 assert thrownException.message == 'Operation not allowed. Decision id N/A : deny by default'
162 assert thrownException.details == 'Cannot connect to Policy Executor (some host:some port). Falling back to configured default decision: deny by default'
163 and: 'the cause is the original unknown host exception'
164 assert thrownException.cause == unknownHostException
167 def 'Permission check with #scenario exception and default decision "allow".'() {
168 given: 'a #scenario exception during the request'
169 mockResponseSpec.toEntity(*_) >> { throw new RuntimeException(cause)}
170 and: 'the configured default decision is "allow"'
171 objectUnderTest.defaultDecision = 'allow'
172 when: 'permission is checked for an operation'
173 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
174 then: 'no exception is thrown'
176 where: 'the following exceptions are thrown during the request'
178 'timeout' | new TimeoutException()
179 'unknown host' | new UnknownHostException()
182 def 'Permission check with other runtime exception.'() {
183 given: 'some other runtime exception'
184 def originalException = new RuntimeException()
185 mockResponseSpec.toEntity(*_) >> { throw originalException}
186 when: 'permission is checked for an operation'
187 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', someValidJson)
188 then: 'The original exception is thrown'
189 def thrownException = thrown(RuntimeException)
190 assert thrownException == originalException
193 def 'Permission check with an invalid change request json.'() {
194 when: 'permission is checked for an invalid change request'
195 objectUnderTest.checkPermission(new YangModelCmHandle(), CREATE, 'my credentials', 'my resource', 'invalid json string')
196 then: 'an ncmp exception thrown'
197 def ncmpException = thrown(NcmpException)
198 ncmpException.message == 'Cannot convert Change Request data to Object'
199 ncmpException.details.contains('invalid json string')
202 def 'Permission check feature disabled.'() {
203 given: 'feature is disabled'
204 objectUnderTest.enabled = false
205 when: 'permission is checked for an operation'
206 objectUnderTest.checkPermission(new YangModelCmHandle(), PATCH, 'my credentials','my resource',someValidJson)
207 then: 'system logs that the feature not enabled'
208 assert getLogEntry(0) == 'Policy Executor Enabled: false'
211 def mockResponse(mockResponseAsMap, httpStatus) {
212 JsonNode jsonNode = spiedObjectMapper.readTree(spiedObjectMapper.writeValueAsString(mockResponseAsMap))
213 def mono = Mono.just(new ResponseEntity<>(jsonNode, httpStatus))
214 mockResponseSpec.toEntity(*_) >> mono
217 def mockErrorResponse() {
218 def webClientResponseException = Mock(WebClientResponseException)
219 webClientResponseException.getStatusCode() >> HttpStatus.I_AM_A_TEAPOT
220 mockResponseSpec.toEntity(*_) >> { throw webClientResponseException }
221 return webClientResponseException
225 def logger = LoggerFactory.getLogger(PolicyExecutor)
226 logger.setLevel(Level.TRACE)
227 logger.addAppender(logAppender)
231 def getLogEntry(index) {
232 logAppender.list[index].formattedMessage