Update Patch Operation to make multiple calls to Policy Executor 34/142634/4
authorleventecsanyi <levente.csanyi@est.tech>
Wed, 3 Dec 2025 12:21:56 +0000 (13:21 +0100)
committerleventecsanyi <levente.csanyi@est.tech>
Mon, 8 Dec 2025 15:19:56 +0000 (16:19 +0100)
  - refatored OperationDetailsFactory
  - updated testware

Issue-ID: CPS-3071
Change-Id: Ic078c7c8125dc1424e3c9e379fc617b07b8f6fec
Signed-off-by: leventecsanyi <levente.csanyi@est.tech>
cps-ncmp-rest/src/main/java/org/onap/cps/ncmp/rest/controller/ProvMnsController.java
cps-ncmp-rest/src/test/groovy/org/onap/cps/ncmp/rest/controller/ProvMnsControllerSpec.groovy
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/data/policyexecutor/CreateOperationDetails.java
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/data/policyexecutor/DeleteOperationDetails.java
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetails.java [moved from cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/data/policyexecutor/PatchOperationsDetails.java with 87% similarity]
cps-ncmp-service/src/main/java/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactory.java
cps-ncmp-service/src/test/groovy/org/onap/cps/ncmp/impl/data/policyexecutor/OperationDetailsFactorySpec.groovy

index 1bad56b..47a7a73 100644 (file)
@@ -114,13 +114,8 @@ public class ProvMnsController implements ProvMnS {
                 alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId(
                     requestPathParameters.toAlternateId(), "/"));
             checkTarget(yangModelCmHandle);
-            policyExecutor.checkPermission(yangModelCmHandle,
-                OperationType.CREATE,
-                NO_AUTHORIZATION,
-                requestPathParameters.toAlternateId(),
-                jsonObjectMapper.asJsonString(operationDetailsFactory.buildPatchOperationDetails(requestPathParameters,
-                                                                                                 patchItems))
-            );
+            operationDetailsFactory
+                    .checkPermissionForEachPatchItem(requestPathParameters, patchItems, yangModelCmHandle);
             final UrlTemplateParameters urlTemplateParameters =
                 parametersBuilder.createUrlTemplateParametersForWrite(yangModelCmHandle, requestPathParameters);
             return dmiRestClient.synchronousPatchOperation(RequiredDmiService.DATA, patchItems,
index 9291855..b028aa8 100644 (file)
@@ -201,8 +201,8 @@ class ProvMnsControllerSpec extends Specification {
             alternateIdMatcher.getCmHandleIdByLongestMatchingAlternateId('/someUriLdnFirstPart/someClassName=someId', "/") >> 'cm-1'
         and: 'persistence service returns valid yangModelCmHandle'
             inventoryPersistence.getYangModelCmHandle('cm-1') >> new YangModelCmHandle(id:'cm-1', dmiServiceName: 'someDmiService', dataProducerIdentifier: 'someDataProducerId', compositeState: new CompositeState(cmHandleState: READY))
-        and: 'policy executor throws exception (denied)'
-            policyExecutor.checkPermission(*_) >> {throw new RuntimeException()}
+        and: 'the permission is denied (Policy Executor throws an exception)'
+            operationDetailsFactory.checkPermissionForEachPatchItem(*_) >> {throw new RuntimeException()}
         when: 'patch data resource request is performed'
             def response = mvc.perform(patch(url)
                     .contentType(new MediaType('application', 'json-patch+json'))
index 60a944d..8f85eae 100644 (file)
@@ -27,4 +27,4 @@ import java.util.Map;
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public record CreateOperationDetails(String operation,
                                      String targetIdentifier,
-                                     Map<String, List<OperationEntry>> changeRequest) {}
+                                     Map<String, List<OperationEntry>> changeRequest) implements OperationDetails {}
index b24583d..62547b5 100644 (file)
@@ -20,4 +20,8 @@
 
 package org.onap.cps.ncmp.impl.data.policyexecutor;
 
-public record DeleteOperationDetails(String operationType, String targetIdentifier) {}
\ No newline at end of file
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record DeleteOperationDetails(String operation,
+                                     String targetIdentifier) implements OperationDetails {}
index bdb96c0..72c177b 100644 (file)
@@ -24,7 +24,6 @@ import com.fasterxml.jackson.core.JsonProcessingException;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.base.Strings;
-import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -32,6 +31,7 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.onap.cps.ncmp.api.data.models.OperationType;
 import org.onap.cps.ncmp.api.exceptions.ProvMnSException;
+import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle;
 import org.onap.cps.ncmp.impl.provmns.RequestPathParameters;
 import org.onap.cps.ncmp.impl.provmns.model.PatchItem;
 import org.onap.cps.utils.JsonObjectMapper;
@@ -44,33 +44,42 @@ public class OperationDetailsFactory {
 
     private static final String ATTRIBUTE_NAME_SEPARATOR = "/";
     private static final String REGEX_FOR_LEADING_AND_TRAILING_SEPARATORS = "(^/)|(/$)";
+    private static final String NO_AUTHORIZATION = null;
+    private static final String UNSUPPORTED_OPERATION = "UNSUPPORTED_OP";
 
     private final JsonObjectMapper jsonObjectMapper;
     private final ObjectMapper objectMapper;
+    private final PolicyExecutor policyExecutor;
 
     /**
-     * Build a PatchOperationDetails object from ProvMnS request details.
+     * Build an operation details object from ProvMnS request details and send it to Policy Executor.
      *
      * @param requestPathParameters    request parameters including uri-ldn-first-part, className and id
      * @param patchItems               provided request list of patch Items
-     * @return CreateOperationDetails object
+     * @param yangModelCmHandle        representation of the cm handle to check
      */
-    public PatchOperationsDetails buildPatchOperationDetails(final RequestPathParameters requestPathParameters,
-                                                             final List<PatchItem> patchItems) {
-        final List<Object> operations = new ArrayList<>(patchItems.size());
+    public void checkPermissionForEachPatchItem(final RequestPathParameters requestPathParameters,
+                                                final List<PatchItem> patchItems,
+                                                final YangModelCmHandle yangModelCmHandle) {
+        OperationDetails operationDetails;
         for (final PatchItem patchItem : patchItems) {
             switch (patchItem.getOp()) {
-                case ADD -> operations.add(buildCreateOperationDetails(OperationType.CREATE, requestPathParameters,
-                                        patchItem.getValue()));
-                case REPLACE -> operations.add(buildCreateOperationDetailsForUpdate(OperationType.UPDATE,
-                                        requestPathParameters,
-                                        patchItem));
-                case REMOVE -> operations.add(buildDeleteOperationDetails(requestPathParameters.toAlternateId()));
-                default -> throw new ProvMnSException("UNSUPPORTED_OP",
-                    "Unsupported Patch Operation Type: " + patchItem.getOp().getValue());
+                case ADD -> operationDetails = buildCreateOperationDetails(OperationType.CREATE, requestPathParameters,
+                        patchItem.getValue());
+                case REPLACE -> operationDetails = buildCreateOperationDetailsForUpdate(OperationType.UPDATE,
+                        requestPathParameters,
+                        patchItem);
+                case REMOVE -> operationDetails = buildDeleteOperationDetails(requestPathParameters.toAlternateId());
+                default -> throw new ProvMnSException(UNSUPPORTED_OPERATION,
+                        "Unsupported Patch Operation Type: " + patchItem.getOp().getValue());
             }
+            policyExecutor.checkPermission(yangModelCmHandle,
+                    OperationType.fromOperationName(operationDetails.operation()),
+                    NO_AUTHORIZATION,
+                    requestPathParameters.toAlternateId(),
+                    jsonObjectMapper.asJsonString(operationDetails)
+            );
         }
-        return new PatchOperationsDetails("Some Permission Id", "cm-legacy", operations);
     }
 
     /**
index 61a3cee..89fe5ec 100644 (file)
@@ -23,7 +23,8 @@ package org.onap.cps.ncmp.impl.data.policyexecutor
 import com.fasterxml.jackson.core.JsonProcessingException
 import com.fasterxml.jackson.databind.ObjectMapper
 import org.onap.cps.ncmp.api.exceptions.NcmpException
-import org.onap.cps.ncmp.api.exceptions.ProvMnSException;
+import org.onap.cps.ncmp.api.exceptions.ProvMnSException
+import org.onap.cps.ncmp.impl.inventory.models.YangModelCmHandle;
 import org.onap.cps.ncmp.impl.provmns.RequestPathParameters;
 import org.onap.cps.ncmp.impl.provmns.model.PatchItem;
 import org.onap.cps.ncmp.impl.provmns.model.ResourceOneOf
@@ -31,61 +32,121 @@ import org.onap.cps.utils.JsonObjectMapper;
 import spock.lang.Specification;
 
 import static org.onap.cps.ncmp.api.data.models.OperationType.CREATE;
+import static org.onap.cps.ncmp.api.data.models.OperationType.UPDATE;
+import static org.onap.cps.ncmp.api.data.models.OperationType.DELETE;
 
 class OperationDetailsFactorySpec extends Specification {
 
     def spiedObjectMapper = Spy(ObjectMapper)
     def jsonObjectMapper = new JsonObjectMapper(spiedObjectMapper)
+    def policyExecutor = Mock(PolicyExecutor)
 
-    OperationDetailsFactory objectUnderTest = new OperationDetailsFactory(jsonObjectMapper, spiedObjectMapper)
+    OperationDetailsFactory objectUnderTest = new OperationDetailsFactory(jsonObjectMapper, spiedObjectMapper, policyExecutor)
 
     static def complexValueAsResource = new ResourceOneOf(id: 'myId', attributes: ['myAttribute1:myValue1', 'myAttribute2:myValue2'], objectClass: 'myClassName')
     static def simpleValueAsResource = new ResourceOneOf(id: 'myId', attributes: ['simpleAttribute:1'], objectClass: 'myClassName')
+    static def yangModelCmHandle = new YangModelCmHandle(id: 'someId')
 
+    def 'Build create operation details with all properties.'() {
+        given: 'request parameters and resource'
+            def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'my uri', className: 'class in uri', id: 'my id')
+            def resource = new ResourceOneOf(id: 'some resource id', objectClass: 'class in resource')
+        when: 'create operation details are built'
+            def result = objectUnderTest.buildCreateOperationDetails(CREATE, requestPathParameters, resource)
+        then: 'all details are correct'
+            assert result.targetIdentifier == 'my uri'
+            assert result.changeRequest.keySet()[0] == 'class in resource'
+            assert result.changeRequest['class in resource'][0].id == 'my id'
+    }
 
-    def 'Build policy executor patch operation details from ProvMnS request parameters where #scenario.'() {
-        given: 'a provMnsRequestParameter and a patchItem list'
-            def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'classNameInUri', id: 'myId')
-            def resource = new ResourceOneOf(id: 'someResourceId', attributes: ['someAttribute1:someValue1', 'someAttribute2:someValue2'], objectClass: classNameInBody)
-            def patchItemsList = [new PatchItem(op: 'ADD', 'path':'myUriLdnFirstPart', value: resource), new PatchItem(op: 'REPLACE', 'path':'myUriLdnFirstPart', value: resource), new PatchItem(op: 'REMOVE', 'path':'myUriLdnFirstPart'),]
-        when: 'patch operation details are created'
-            def result = objectUnderTest.buildPatchOperationDetails(path, patchItemsList)
-        then: 'the result contain 3 operations of the correct types in the correct order'
-            result.operations.size() == 3
-        and: 'note that Add and Replace both are defined using Create Operation Details'
-            assert result.operations[0] instanceof CreateOperationDetails
-            assert result.operations[1] instanceof CreateOperationDetails
-            assert result.operations[2] instanceof DeleteOperationDetails
-        and: 'the add operation target identifier is just the uri first part'
-            assert result.operations[0]['targetIdentifier'] == 'myUriLdnFirstPart'
-        and: 'the replace operation target identifier is just the uri first part'
-            assert result.operations[1]['targetIdentifier'] == 'myUriLdnFirstPart'
-        and: 'the replace change request has the correct class name'
-            assert result.operations[1].changeRequest.keySet()[0] == expectedChangeRequestKey
-        and: 'the delete operation target identifier includes the target class and id'
-            assert result.operations[2]['targetIdentifier'] == 'myUriLdnFirstPart/classNameInUri=myId'
-        where: 'the following class names are used in the body'
-            scenario                          | classNameInBody || expectedChangeRequestKey
-            'class name in body is populated' | 'myClass'       || 'myClass'
-            'class name in body  is empty'    | ''              || 'classNameInUri'
-            'class name in body  is null'     | null            || 'classNameInUri'
+    def 'Build replace operation details with all properties where class name in body is #scenario.'() {
+        given: 'request parameters and resource'
+            def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'my uri', className: 'class in uri', id: 'some id')
+            def resource = new ResourceOneOf(id: 'some resource id', objectClass: classNameInBody)
+        when: 'replace operation details are built'
+            def result = objectUnderTest.buildCreateOperationDetails(CREATE, requestPathParameters, resource)
+        then: 'all details are correct'
+            assert result.targetIdentifier == 'my uri'
+            assert result.changeRequest.keySet()[0] == expectedChangeRequestKey
+        where:
+            scenario    | classNameInBody || expectedChangeRequestKey
+            'populated' | 'class in body' || 'class in body'
+            'empty'     | ''              || 'class in uri'
+            'null'      | null            || 'class in uri'
+    }
+
+    def 'Build delete operation details with all properties'() {
+        given: 'request parameters'
+            def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'my uri', className: 'classNameInUri', id: 'myId')
+        when: 'delete operation details are built'
+            def result = objectUnderTest.buildDeleteOperationDetails(requestPathParameters.toAlternateId())
+        then: 'all details are correct'
+            assert result.targetIdentifier == 'my uri/classNameInUri=myId'
+    }
+
+    def 'Single patch operation with #patchOperationType checks correct operation type.'() {
+        given: 'request parameters and single patch item'
+            def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'some class')
+            def resource = new ResourceOneOf(id: 'some resource id')
+            def patchItem = new PatchItem(op: patchOperationType, 'path':'some uri', value: resource)
+        when: 'patch operation is processed'
+            objectUnderTest.checkPermissionForEachPatchItem(requestPathParameters, [patchItem], yangModelCmHandle)
+        then: 'policy executor is called with correct operation type'
+            1 * policyExecutor.checkPermission(yangModelCmHandle, expectedPolicyExecutorOperationType, _, _, _)
+        where: 'following operations are used'
+            patchOperationType | expectedPolicyExecutorOperationType
+            'ADD'              | CREATE
+            'REPLACE'          | UPDATE
+    }
+
+    def 'Single patch operation with REMOVE checks correct operation type.'() {
+        given: 'request parameters and single remove patch item'
+            def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri')
+            def patchItem = new PatchItem(op: 'REMOVE')
+        when: 'patch operation is processed'
+            objectUnderTest.checkPermissionForEachPatchItem(requestPathParameters, [patchItem], yangModelCmHandle)
+        then: 'policy executor is called with DELETE operation type'
+            1 * policyExecutor.checkPermission(yangModelCmHandle, DELETE, _, _, _)
+    }
+
+    def 'Multiple patch operations invoke policy executor correct number of times in order.'() {
+        given: 'request parameters and multiple patch items'
+            def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri')
+            def resource = new ResourceOneOf(id: 'some resource id', objectClass: 'some class')
+            def patchItemsList = [
+                new PatchItem(op: 'ADD', 'path':'some uri', value: resource),
+                new PatchItem(op: 'REPLACE', 'path':'some uri', value: resource),
+                new PatchItem(op: 'REMOVE', 'path':'some uri')
+            ]
+        when: 'patch operations are processed'
+            objectUnderTest.checkPermissionForEachPatchItem(requestPathParameters, patchItemsList, yangModelCmHandle)
+        then: 'policy executor is checked for create first'
+            1 * policyExecutor.checkPermission(yangModelCmHandle, CREATE, _, _, _)
+        then: 'update is next'
+            1 * policyExecutor.checkPermission(yangModelCmHandle, UPDATE, _, _, _)
+        then: 'and finally delete'
+            1 * policyExecutor.checkPermission(yangModelCmHandle, DELETE, _, _, _)
     }
 
     def 'Build policy executor patch operation details with single replace operation and #scenario.'() {
         given: 'a requestParameter and a patchItem list'
-            def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'myClassName', id: 'myId')
-            def pathItems = [new PatchItem(op: 'REPLACE', 'path':"myUriLdnFirstPart${suffix}", value: value)]
-        when: 'patch operation details are created'
-            def result = objectUnderTest.buildPatchOperationDetails(path, pathItems)
-        then: 'the result has the correct type'
-            assert result instanceof PatchOperationsDetails
-        and: 'the change request contains the correct attributes value'
-            assert result.operations[0]['changeRequest']['myClassName'][0]['attributes'].toString() == attributesValueInOperation
+            def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'some class')
+            def pathItems = [new PatchItem(op: 'REPLACE', 'path':"some uri${suffix}", value: value)]
+        when: 'patch operation details are checked'
+            objectUnderTest.checkPermissionForEachPatchItem(requestPathParameters, pathItems, yangModelCmHandle)
+        then: 'policyExecutor is called with correct payload'
+            1 * policyExecutor.checkPermission(
+                    yangModelCmHandle,
+                    UPDATE,
+                    null,
+                    requestPathParameters.toAlternateId(),
+                    { json -> assert json.contains(attributesValueInOperation) } // check for more details eg. verify type
+            )
         where: 'attributes are set using # or resource'
             scenario                           | suffix                         | value                  || attributesValueInOperation
-            'set simple value using #'         | '#/attributes/simpleAttribute' | 1                      || '[simpleAttribute:1]'
-            'set simple value using resource'  | ''                             | simpleValueAsResource  || '[simpleAttribute:1]'
-            'set complex value using resource' | ''                             | complexValueAsResource || '[myAttribute1:myValue1, myAttribute2:myValue2]'
+            'set simple value using #'         | '#/attributes/simpleAttribute' | 1                      || '{"simpleAttribute":1}'
+            'set simple value using resource'  | ''                             | simpleValueAsResource  || '["simpleAttribute:1"]'
+            'set complex value using resource' | ''                             | complexValueAsResource || '["myAttribute1:myValue1","myAttribute2:myValue2"]'
     }
 
     def 'Build an attribute map with different depths of hierarchy with #scenario.'() {
@@ -104,43 +165,42 @@ class OperationDetailsFactorySpec extends Specification {
 
     def 'Build policy executor patch operation details from ProvMnS request parameters with invalid op.'() {
         given: 'a provMnsRequestParameter and a patchItem list'
-            def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'someClassName', id: 'someId')
-            def patchItemsList = [new PatchItem(op: 'TEST', 'path':'myUriLdnFirstPart')]
+            def path = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'some class')
+            def patchItemsList = [new PatchItem(op: 'TEST', 'path':'some uri')]
         when: 'a build is attempted with an invalid op'
-            objectUnderTest.buildPatchOperationDetails(path, patchItemsList)
+            objectUnderTest.checkPermissionForEachPatchItem(path, patchItemsList, yangModelCmHandle)
         then: 'the result is as expected (exception thrown)'
             thrown(ProvMnSException)
     }
 
-    def 'Build policy executor create operation details from ProvMnS request parameters where #scenario.'() {
+    def 'Build policy executor create operation details from ProvMnS request parameters where objectClass in resource #scenario.'() {
         given: 'a provMnsRequestParameter and a resource'
-            def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'someClassName', id: 'someId')
-            def resource = new ResourceOneOf(id: 'someResourceId', attributes: ['someAttribute1:someValue1', 'someAttribute2:someValue2'], objectClass: objectClass)
+            def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'class in uri', id:'my id')
+            def resource = new ResourceOneOf(id: 'some resource id', objectClass: objectInResouce)
         when: 'a configurationManagementOperation is created and converted to JSON'
-            def result = objectUnderTest.buildCreateOperationDetails(CREATE, path, resource)
+            def result = objectUnderTest.buildCreateOperationDetails(CREATE, requestPathParameters, resource)
         then: 'the result is as expected (using json to compare)'
-            String expectedJsonString = '{"operation":"CREATE","targetIdentifier":"myUriLdnFirstPart","changeRequest":{"' + changeRequestClassReference + '":[{"id":"someId","attributes":["someAttribute1:someValue1","someAttribute2:someValue2"]}]}}'
+            String expectedJsonString = '{"operation":"CREATE","targetIdentifier":"some uri","changeRequest":{"' + changeRequestClassReference + '":[{"id":"my id","attributes":null}]}}'
             assert jsonObjectMapper.asJsonString(result) == expectedJsonString
         where:
-            scenario                   | objectClass        || changeRequestClassReference
-            'objectClass is populated' | 'someObjectClass'  || 'someObjectClass'
-            'objectClass is empty'     | ''                 || 'someClassName'
-            'objectClass is null'      | null               || 'someClassName'
+            scenario    | objectInResouce     || changeRequestClassReference
+            'populated' | 'class in resource' || 'class in resource'
+            'empty'     | ''                  || 'class in uri'
+            'null'      | null                || 'class in uri'
     }
 
-    def 'Build Policy Executor Operation Details with a exception during conversion'() {
+    def 'Build Policy Executor Operation Details with a exception during conversion.'() {
         given: 'a provMnsRequestParameter and a resource'
-            def path = new RequestPathParameters(uriLdnFirstPart: 'myUriLdnFirstPart', className: 'someClassName', id: 'someId')
-            def resource = new ResourceOneOf(id: 'myResourceId', attributes: ['someAttribute1:someValue1', 'someAttribute2:someValue2'])
+            def requestPathParameters = new RequestPathParameters(uriLdnFirstPart: 'some uri', className: 'some class')
+            def resource = new ResourceOneOf(id: 'some resource id')
         and: 'json object mapper throws an exception'
-            def originalException = new JsonProcessingException('some-exception')
-            spiedObjectMapper.readValue(*_) >> {throw originalException}
+            spiedObjectMapper.readValue(*_) >> { throw new JsonProcessingException('original exception message') }
         when: 'a configurationManagementOperation is created and converted to JSON'
-            objectUnderTest.buildCreateOperationDetails(CREATE, path, resource)
-        then: 'the expected exception is throw and matches the original'
+            objectUnderTest.buildCreateOperationDetails(CREATE, requestPathParameters, resource)
+        then: 'the expected exception is throw and contains the original message'
             def thrown = thrown(NcmpException)
             assert thrown.message.contains('Cannot convert Resource Object')
-            assert thrown.details.contains('some-exception')
+            assert thrown.details.contains('original exception message')
     }
 
 }