9423246134cabcfccf42ee73042658e3bca091bc
[cps.git] /
1 /*
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
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
21 package org.onap.cps.ncmp.impl.data.policyexecutor
22
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
39
40 import java.util.concurrent.TimeoutException
41
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
46
47 class PolicyExecutorSpec extends Specification {
48
49     def mockWebClient = Mock(WebClient)
50     def mockRequestBodyUriSpec = Mock(WebClient.RequestBodyUriSpec)
51     def mockResponseSpec = Mock(WebClient.ResponseSpec)
52     def spiedObjectMapper = Spy(ObjectMapper)
53
54     PolicyExecutor objectUnderTest = new PolicyExecutor(mockWebClient, spiedObjectMapper)
55
56     def logAppender = Spy(ListAppender<ILoggingEvent>)
57
58     def someValidJson = '{"Hello":"World"}'
59
60     def setup() {
61         setupLogger()
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
70     }
71
72     def cleanup() {
73         ((Logger) LoggerFactory.getLogger(PolicyExecutor)).detachAndStopAllAppenders()
74     }
75
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'
84             noExceptionThrown()
85         where: 'all write operations are tested'
86             operationType << [ CREATE, DELETE, PATCH, UPDATE ]
87     }
88
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'
98     }
99
100     def 'Permission check with non-2xx response and "allow" default decision.'() {
101         given: 'non-2xx http response'
102             mockErrorResponse()
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'
108             noExceptionThrown()
109     }
110
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
124     }
125
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'
133     }
134
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
149     }
150
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
165     }
166
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'
175             noExceptionThrown()
176         where: 'the following exceptions are thrown during the request'
177             scenario       | cause
178             'timeout'      | new TimeoutException()
179             'unknown host' | new UnknownHostException()
180     }
181
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
191     }
192
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')
200     }
201
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'
209     }
210
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
215     }
216
217     def mockErrorResponse() {
218         def webClientResponseException = Mock(WebClientResponseException)
219         webClientResponseException.getStatusCode() >> HttpStatus.I_AM_A_TEAPOT
220         mockResponseSpec.toEntity(*_) >> { throw webClientResponseException }
221         return webClientResponseException
222     }
223
224     def setupLogger() {
225         def logger = LoggerFactory.getLogger(PolicyExecutor)
226         logger.setLevel(Level.TRACE)
227         logger.addAppender(logAppender)
228         logAppender.start()
229     }
230
231     def getLogEntry(index) {
232         logAppender.list[index].formattedMessage
233     }
234 }