diff --git a/sdm/src/main/java/com/sap/cds/sdm/constants/SDMErrorMessages.java b/sdm/src/main/java/com/sap/cds/sdm/constants/SDMErrorMessages.java index 14cae539f..8ff266c24 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/constants/SDMErrorMessages.java +++ b/sdm/src/main/java/com/sap/cds/sdm/constants/SDMErrorMessages.java @@ -143,6 +143,12 @@ private SDMErrorMessages() { public static final String INVALID_SECONDARY_PROPERTIES_FOR_MOVE_SUFFIX = ". Attachment rolled back to source."; public static final String SDM_MOVE_OPERATION_FAILED = "SDM move operation failed"; + public static final String FAILED_TO_COPY_ATTACHMENTS_PREFIX = + "Failed to copy the following attachments:\n"; + public static final String INVALID_SECONDARY_PROPERTIES_FOR_COPY_PREFIX = + "Invalid secondary properties detected: "; + public static final String INVALID_SECONDARY_PROPERTIES_FOR_COPY_SUFFIX = + ". Attachment not copied."; public static final String FAILED_TO_ACCESS_ERROR_KEY_FIELDS = "Failed to access SDM error key fields"; public static final String FAILED_TO_ACCESS_ERROR_MESSAGES_FIELDS = diff --git a/sdm/src/main/java/com/sap/cds/sdm/constants/SDMUIErrorKeys.java b/sdm/src/main/java/com/sap/cds/sdm/constants/SDMUIErrorKeys.java index 8eead9791..c98e89c7e 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/constants/SDMUIErrorKeys.java +++ b/sdm/src/main/java/com/sap/cds/sdm/constants/SDMUIErrorKeys.java @@ -87,6 +87,12 @@ private SDMUIErrorKeys() {} "SDM.invalidSecondaryPropertiesForMovePrefix"; public static final String INVALID_SECONDARY_PROPERTIES_FOR_MOVE_SUFFIX_KEY = "SDM.invalidSecondaryPropertiesForMoveSuffix"; + public static final String FAILED_TO_COPY_ATTACHMENTS_PREFIX_KEY = + "SDM.failedToCopyAttachmentsPrefix"; + public static final String INVALID_SECONDARY_PROPERTIES_FOR_COPY_PREFIX_KEY = + "SDM.invalidSecondaryPropertiesForCopyPrefix"; + public static final String INVALID_SECONDARY_PROPERTIES_FOR_COPY_SUFFIX_KEY = + "SDM.invalidSecondaryPropertiesForCopySuffix"; public static final String MAX_COUNT_ERROR_MESSAGE_KEY = "SDM.maxCountErrorMessage"; public static Map getAllUIErrorKeys() { diff --git a/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMCustomServiceHandler.java b/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMCustomServiceHandler.java index b393a30a9..d7789e67c 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMCustomServiceHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/service/handler/SDMCustomServiceHandler.java @@ -127,14 +127,42 @@ public void copyAttachments(AttachmentCopyEventContext context) throws IOExcepti Boolean isSystemUser = context.getSystemUser(); SDMCredentials sdmCredentials = tokenHandler.getSDMCredentials(); + + List objectIds = context.getObjectIds(); + + if (!customPropertiesInSDM.isEmpty()) { + List> copyFailures = + findAttachmentsWithInvalidSecondaryProperties( + objectIds, customPropertiesInSDM, repositoryId, sdmCredentials, isSystemUser); + if (!copyFailures.isEmpty()) { + buildAndWarnCopyFailures(copyFailures, context); + // Remove invalid objectIds and proceed with valid ones + Set invalidObjectIds = + copyFailures.stream() + .map(failure -> failure.get(OBJECT_ID_KEY)) + .collect(Collectors.toSet()); + objectIds = + objectIds.stream() + .filter(id -> !invalidObjectIds.contains(id)) + .collect(Collectors.toList()); + if (objectIds.isEmpty()) { + context.setCompleted(); + logger.debug("END: Copy attachments event - all attachments have invalid properties"); + return; + } + logger.info( + "Proceeding with {} valid attachments after filtering out {} invalid ones", + objectIds.size(), + invalidObjectIds.size()); + } + } + // Check if folder exists before trying to create it boolean folderExists = sdmService.getFolderIdByPath(folderName, repositoryId, sdmCredentials, isSystemUser) != null; String folderId = ensureFolderExists(folderName, repositoryId, sdmCredentials, isSystemUser); - List objectIds = context.getObjectIds(); - CopyAttachmentsRequest request = CopyAttachmentsRequest.builder() .context(context) @@ -178,6 +206,97 @@ public void copyAttachments(AttachmentCopyEventContext context) throws IOExcepti logger.debug("END: Copy attachments event"); } + /** + * Checks source attachments for invalid secondary properties before copy. Returns a list of + * failures if any attachment has invalid properties; returns empty list if all are valid + */ + private List> findAttachmentsWithInvalidSecondaryProperties( + List objectIds, + Set customPropertiesInSDM, + String repositoryId, + SDMCredentials sdmCredentials, + Boolean isSystemUser) + throws IOException { + List secondaryTypes = + sdmService.getSecondaryTypes(repositoryId, sdmCredentials, isSystemUser); + List validSecondaryProperties = + sdmService.getValidSecondaryProperties( + secondaryTypes, sdmCredentials, repositoryId, isSystemUser); + + List> failures = new ArrayList<>(); + for (String objectId : objectIds) { + List invalidProperties = + getInvalidPropertiesForObject( + objectId, + customPropertiesInSDM, + validSecondaryProperties, + sdmCredentials, + isSystemUser); + if (!invalidProperties.isEmpty()) { + Map failure = new HashMap<>(); + failure.put(OBJECT_ID_KEY, objectId); + failure.put( + FAILURE_REASON_KEY, + SDMUtils.getErrorMessage("INVALID_SECONDARY_PROPERTIES_FOR_COPY_PREFIX") + + String.join(", ", invalidProperties) + + SDMUtils.getErrorMessage("INVALID_SECONDARY_PROPERTIES_FOR_COPY_SUFFIX")); + failures.add(failure); + } + } + return failures; + } + + /** + * Gets the list of invalid secondary properties for a single object from SDM. Returns empty list + * if all properties are valid or if metadata cannot be fetched + */ + private List getInvalidPropertiesForObject( + String objectId, + Set customPropertiesInSDM, + List validSecondaryProperties, + SDMCredentials sdmCredentials, + Boolean isSystemUser) { + try { + JSONObject sdmMetadata = sdmService.getObject(objectId, sdmCredentials, isSystemUser); + if (sdmMetadata == null || !sdmMetadata.has("succinctProperties")) { + return Collections.emptyList(); + } + JSONObject succinctProperties = sdmMetadata.getJSONObject("succinctProperties"); + Set sdmResponseProperties = new HashSet<>(succinctProperties.keySet()); + + List invalidProperties = new ArrayList<>(); + for (String targetSdmProperty : customPropertiesInSDM) { + if (sdmResponseProperties.contains(targetSdmProperty) + && !validSecondaryProperties.contains(targetSdmProperty)) { + invalidProperties.add(targetSdmProperty); + } + } + return invalidProperties; + } catch (IOException e) { + logger.error( + "Copy validation - Failed to fetch metadata for attachment {}: {}", + objectId, + e.getMessage()); + return Collections.emptyList(); + } + } + + /** Builds and emits a warning message for copy failures */ + private void buildAndWarnCopyFailures( + List> copyFailures, AttachmentCopyEventContext context) { + StringBuilder warningMessage = + new StringBuilder(SDMUtils.getErrorMessage("FAILED_TO_COPY_ATTACHMENTS_PREFIX")); + for (Map failure : copyFailures) { + warningMessage + .append("- ObjectId: ") + .append(failure.get(OBJECT_ID_KEY)) + .append(", Reason: ") + .append(failure.get(FAILURE_REASON_KEY)) + .append("\n"); + } + context.getMessages().warn(warningMessage.toString()); + } + /** * Moves attachments from source entity to target entity in SDM. Executes moves in parallel, * updates database records, and cleans up source metadata. If any step fails, the operation is diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMCustomServiceHandlerTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMCustomServiceHandlerTest.java index 437410755..d992c9a86 100644 --- a/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMCustomServiceHandlerTest.java +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/service/handler/SDMCustomServiceHandlerTest.java @@ -325,6 +325,172 @@ void testCopyAttachments_AttachmentCopyFails_FolderExists_AttachmentsDeleted() assertTrue(ex.getMessage().contains("Copy failed")); } + @Test + void testCopyAttachments_InvalidSecondaryProperty_BlocksCopy() throws IOException { + // Mock SDMCredentials + SDMCredentials sdmCredentials = mock(SDMCredentials.class); + when(tokenHandler.getSDMCredentials()).thenReturn(sdmCredentials); + + // Mock folder id retrieval + when(sdmService.getFolderIdByPath(any(), any(), any(), anyBoolean())).thenReturn(FOLDER_ID); + + // Create mock context with custom property annotations on the entity + AttachmentCopyEventContext context = createMockContextWithCustomProperties(); + when(context.getObjectIds()).thenReturn(List.of(OBJECT_ID)); + + // Mock secondary types and valid secondary properties from SDM + when(sdmService.getSecondaryTypes(any(), any(), anyBoolean())) + .thenReturn(List.of("secondaryType1")); + when(sdmService.getValidSecondaryProperties(any(), any(), any(), anyBoolean())) + .thenReturn(List.of("validProp1", "validProp2")); + + // Mock getObject: SDM response contains "invalidCustomProp" which is NOT in valid list + JSONObject sdmMetadata = new JSONObject(); + JSONObject succinctProperties = new JSONObject(); + succinctProperties.put("cmis:name", "test.pdf"); + succinctProperties.put("invalidCustomProp", "someValue"); + sdmMetadata.put("succinctProperties", succinctProperties); + when(sdmService.getObject(eq(OBJECT_ID), any(), anyBoolean())).thenReturn(sdmMetadata); + + // Act + sdmCustomServiceHandler.copyAttachments(context); + + // Assert: copy should be blocked entirely, no copy operation performed + verify(sdmService, never()) + .copyAttachment(any(), any(SDMCredentials.class), anyBoolean(), any()); + // Warning message should be issued + verify(context.getMessages(), times(1)).warn(any(String.class)); + verify(context, times(1)).setCompleted(); + } + + @Test + void testCopyAttachments_MixedValidAndInvalidSecondaryProps_CopiesValidRejectsInvalid() + throws IOException { + // Mock SDMCredentials + SDMCredentials sdmCredentials = mock(SDMCredentials.class); + when(tokenHandler.getSDMCredentials()).thenReturn(sdmCredentials); + + // Mock folder id retrieval + when(sdmService.getFolderIdByPath(any(), any(), any(), anyBoolean())).thenReturn(FOLDER_ID); + + String validObjectId = "validObjectId"; + String invalidObjectId = "invalidObjectId"; + + // Create mock context with custom property annotations + AttachmentCopyEventContext context = createMockContextWithCustomProperties(); + when(context.getObjectIds()).thenReturn(List.of(validObjectId, invalidObjectId)); + + // Mock secondary types and valid secondary properties + when(sdmService.getSecondaryTypes(any(), any(), anyBoolean())) + .thenReturn(List.of("secondaryType1")); + when(sdmService.getValidSecondaryProperties(any(), any(), any(), anyBoolean())) + .thenReturn(List.of("validProp1", "validProp2")); + + // Mock getObject for valid attachment (does NOT have "invalidCustomProp" in response) + JSONObject validMetadata = new JSONObject(); + JSONObject validProps = new JSONObject(); + validProps.put("cmis:name", "valid.pdf"); + validProps.put("validProp1", "someValue"); + validMetadata.put("succinctProperties", validProps); + when(sdmService.getObject(eq(validObjectId), any(), anyBoolean())).thenReturn(validMetadata); + + // Mock getObject for invalid attachment (HAS "invalidCustomProp" which is NOT in valid list) + JSONObject invalidMetadata = new JSONObject(); + JSONObject invalidProps = new JSONObject(); + invalidProps.put("cmis:name", "invalid.pdf"); + invalidProps.put("invalidCustomProp", "someValue"); + invalidMetadata.put("succinctProperties", invalidProps); + when(sdmService.getObject(eq(invalidObjectId), any(), anyBoolean())) + .thenReturn(invalidMetadata); + + // Mock attachment metadata and copy for the valid attachment + CmisDocument cmisDocument = new CmisDocument(); + cmisDocument.setType("sap-icon://document"); + when(dbQuery.getAttachmentForObjectID(any(), any(), any(AttachmentCopyEventContext.class))) + .thenReturn(cmisDocument); + + Map attachmentData = new HashMap<>(); + attachmentData.put("cmis:name", "valid.pdf"); + attachmentData.put("cmis:contentStreamMimeType", "application/pdf"); + attachmentData.put("cmis:objectId", "newCopiedObjectId"); + when(sdmService.copyAttachment(any(), any(SDMCredentials.class), anyBoolean(), any())) + .thenReturn(attachmentData); + + // Act + sdmCustomServiceHandler.copyAttachments(context); + + // Assert: valid attachment should be copied, invalid one should be warned about + verify(sdmService, times(1)) + .copyAttachment(any(), any(SDMCredentials.class), anyBoolean(), any()); + verify(context.getMessages(), times(1)).warn(any(String.class)); + verify(draftService, times(1)).newDraft(any()); + verify(context, times(1)).setCompleted(); + } + + private AttachmentCopyEventContext createMockContextWithCustomProperties() { + AttachmentCopyEventContext context = mock(AttachmentCopyEventContext.class); + CdsElement mockAssociationElement = mock(CdsElement.class); + CdsAssociationType mockAssociationType = mock(CdsAssociationType.class); + CqnElementRef mockCqnElementRef = mock(CqnElementRef.class); + + when(context.getParentEntity()).thenReturn("prefix.someIdentifier." + FACET); + when(context.getCompositionName()).thenReturn(FACET); + when(context.getUpId()).thenReturn(UP_ID); + when(context.getSystemUser()).thenReturn(true); + when(context.getObjectIds()).thenReturn(List.of(OBJECT_ID)); + + // Mock CdsModel and relevant entities and associations + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + CdsEntity draftEntity = mock(CdsEntity.class); + CdsEntity targetEntity = mock(CdsEntity.class); + CdsEntity compositionEntity = mock(CdsEntity.class); + + // Mock composition element and its type + CdsElement compositionElement = mock(CdsElement.class); + CdsAssociationType compositionType = mock(CdsAssociationType.class); + + // Setup expected behavior for model and parent entity + when(context.getModel()).thenReturn(model); + when(model.findEntity("prefix.someIdentifier." + FACET)).thenReturn(Optional.of(parentEntity)); + when(model.findEntity("prefix.someIdentifier." + FACET + "." + FACET)) + .thenReturn(Optional.of(compositionEntity)); + when(model.findEntity(endsWith("_drafts"))).thenReturn(Optional.of(draftEntity)); + + // Mock the composition entity with a custom property annotation + CdsElement customPropertyElement = mock(CdsElement.class); + com.sap.cds.reflect.CdsAnnotation nameAnnotation = + mock(com.sap.cds.reflect.CdsAnnotation.class); + com.sap.cds.reflect.CdsType elementType = mock(com.sap.cds.reflect.CdsType.class); + when(elementType.isAssociation()).thenReturn(false); + when(customPropertyElement.getType()).thenReturn(elementType); + when(customPropertyElement.getName()).thenReturn("customField"); + when(customPropertyElement.findAnnotation("SDM.Attachments.AdditionalProperty.name")) + .thenReturn(Optional.of(nameAnnotation)); + when(nameAnnotation.getValue()).thenReturn("invalidCustomProp"); + when(compositionEntity.elements()).thenReturn(Stream.of(customPropertyElement)); + + // Mock the composition element in parent entity + when(parentEntity.findElement(FACET)).thenReturn(Optional.of(compositionElement)); + when(compositionElement.getType()).thenReturn(compositionType); + when(compositionType.isAssociation()).thenReturn(true); + when(compositionType.getTarget()).thenReturn(targetEntity); + when(targetEntity.getQualifiedName()).thenReturn("target.entity.name"); + + // Mock the draft entity's up_ association + when(draftEntity.findAssociation("up_")).thenReturn(Optional.of(mockAssociationElement)); + when(mockAssociationElement.getType()).thenReturn(mockAssociationType); + when(mockAssociationType.refs()).thenReturn(Stream.of(mockCqnElementRef)); + when(mockCqnElementRef.path()).thenReturn("ID"); + + // Mock messages + com.sap.cds.services.messages.Messages messages = + mock(com.sap.cds.services.messages.Messages.class); + when(context.getMessages()).thenReturn(messages); + + return context; + } + private AttachmentCopyEventContext createMockContext() { AttachmentCopyEventContext context = mock(AttachmentCopyEventContext.class); CdsElement mockAssociationElement = mock(CdsElement.class);