From 13c0dbbca628cb22ed4e2e0650531a056d5c317f Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 21 Jan 2026 16:51:38 +0100 Subject: [PATCH 1/2] restrict media types Update cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java Co-authored-by: hyperspace-insights[bot] <209611008+hyperspace-insights[bot]@users.noreply.github.com> Update cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelper.java Co-authored-by: hyperspace-insights[bot] <209611008+hyperspace-insights[bot]@users.noreply.github.com> fix review comments by hyperspace update readme, revert the types update to var remove redundant empty lines --- README.md | 32 + .../helper/AttachmentValidationHelper.java | 152 +++++ .../ModifyApplicationHandlerHelper.java | 113 +++- .../CreateAttachmentsHandlerTest.java | 7 +- .../UpdateAttachmentsHandlerTest.java | 44 +- .../AttachmentValidationHelperTest.java | 148 +++++ .../ModifyApplicationHandlerHelperTest.java | 562 +++++++++++++----- .../DraftPatchAttachmentsHandlerTest.java | 12 +- integration-tests/db/data-model.cds | 15 +- ...iaTypesAttachmentsValidationDraftTest.java | 176 ++++++ .../DraftOdataRequestValidationBase.java | 213 +++---- ...ypesAttachmentsValidationNonDraftTest.java | 188 ++++++ .../OdataRequestValidationBase.java | 133 ++--- .../helper/RootEntityBuilder.java | 10 + integration-tests/srv/test-service.cds | 10 +- samples/bookshop/srv/attachments.cds | 14 +- 16 files changed, 1402 insertions(+), 427 deletions(-) create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelper.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java create mode 100644 integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/AcceptedMediaTypesAttachmentsValidationDraftTest.java create mode 100644 integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/AcceptedMediaTypesAttachmentsValidationNonDraftTest.java diff --git a/README.md b/README.md index 9af444bd..6f74d9c2 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu * [Storage Targets](#storage-targets) * [Malware Scanner](#malware-scanner) * [Specify the maximum file size](#specify-the-maximum-file-size) + * [Restrict allowed MIME types](#restrict-allowed-mime-types) * [Outbox](#outbox) * [Restore Endpoint](#restore-endpoint) * [Motivation](#motivation) @@ -207,6 +208,37 @@ annotate Books.attachments with { The default is 400MB +### Restrict allowed MIME types + +You can restrict which MIME types are allowed for attachments by annotating the content property with @Core.AcceptableMediaTypes. This validation is performed during file upload. + +```cds +entity Books { + ... + attachments: Composition of many Attachments; +} + +annotate Books.attachments with { + content @Core.AcceptableMediaTypes : ['image/jpeg', 'image/png', 'application/pdf']; +} +``` + +Wildcard patterns are supported: + +```cds +annotate Books.attachments with { + content @Core.AcceptableMediaTypes : ['image/*', 'application/pdf']; +} +``` + +To allow all MIME types (default behavior), either omit the annotation or use: + +```cds +annotate Books.attachments with { + content @Core.AcceptableMediaTypes : ['*/*']; +} +``` + ### Outbox In this plugin the [persistent outbox](https://cap.cloud.sap/docs/java/outbox#persistent) is used to mark attachments as diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelper.java new file mode 100644 index 00000000..5d09a6ee --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelper.java @@ -0,0 +1,152 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. +*/ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import java.net.URLConnection; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.ErrorStatuses; + +public class AttachmentValidationHelper { + + public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; + public static final Map EXT_TO_MEDIA_TYPE = Map.ofEntries( + Map.entry("aac", "audio/aac"), + Map.entry("abw", "application/x-abiword"), + Map.entry("arc", "application/octet-stream"), + Map.entry("avi", "video/x-msvideo"), + Map.entry("azw", "application/vnd.amazon.ebook"), + Map.entry("bin", "application/octet-stream"), + Map.entry("png", "image/png"), + Map.entry("gif", "image/gif"), + Map.entry("bmp", "image/bmp"), + Map.entry("bz", "application/x-bzip"), + Map.entry("bz2", "application/x-bzip2"), + Map.entry("csh", "application/x-csh"), + Map.entry("css", "text/css"), + Map.entry("csv", "text/csv"), + Map.entry("doc", "application/msword"), + Map.entry("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + Map.entry("odp", "application/vnd.oasis.opendocument.presentation"), + Map.entry("ods", "application/vnd.oasis.opendocument.spreadsheet"), + Map.entry("odt", "application/vnd.oasis.opendocument.text"), + Map.entry("epub", "application/epub+zip"), + Map.entry("gz", "application/gzip"), + Map.entry("htm", "text/html"), + Map.entry("html", "text/html"), + Map.entry("ico", "image/x-icon"), + Map.entry("ics", "text/calendar"), + Map.entry("jar", "application/java-archive"), + Map.entry("jpg", "image/jpeg"), + Map.entry("jpeg", "image/jpeg"), + Map.entry("js", "text/javascript"), + Map.entry("json", "application/json"), + Map.entry("mid", "audio/midi"), + Map.entry("midi", "audio/midi"), + Map.entry("mjs", "text/javascript"), + Map.entry("mov", "video/quicktime"), + Map.entry("mp3", "audio/mpeg"), + Map.entry("mp4", "video/mp4"), + Map.entry("mpeg", "video/mpeg"), + Map.entry("mpkg", "application/vnd.apple.installer+xml"), + Map.entry("otf", "font/otf"), + Map.entry("pdf", "application/pdf"), + Map.entry("ppt", "application/vnd.ms-powerpoint"), + Map.entry("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"), + Map.entry("rar", "application/x-rar-compressed"), + Map.entry("rtf", "application/rtf"), + Map.entry("svg", "image/svg+xml"), + Map.entry("tar", "application/x-tar"), + Map.entry("tif", "image/tiff"), + Map.entry("tiff", "image/tiff"), + Map.entry("ttf", "font/ttf"), + Map.entry("vsd", "application/vnd.visio"), + Map.entry("wav", "audio/wav"), + Map.entry("woff", "font/woff"), + Map.entry("woff2", "font/woff2"), + Map.entry("xhtml", "application/xhtml+xml"), + Map.entry("xls", "application/vnd.ms-excel"), + Map.entry("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + Map.entry("xml", "application/xml"), + Map.entry("zip", "application/zip"), + Map.entry("txt", "application/txt"), + Map.entry("lst", "application/txt"), + Map.entry("webp", "image/webp")); + + private static final Logger logger = LoggerFactory.getLogger(AttachmentValidationHelper.class); + + /** + * Validates the file name and resolves its media type. Ensures that the + * detected media type is part of the list of acceptable media types. + * + * @param fileName the name of the attachment file + * @param acceptableMediaTypes list of allowed media types (e.g. "image/*", + * "application/pdf") + * @return the detected media type + * @throws ServiceException if the file name is invalid or the media type is not + * allowed + */ + public static String validateMediaTypeForAttachment(String fileName, List acceptableMediaTypes) { + validateFileName(fileName); + String detectedMediaType = resolveMimeType(fileName); + validateAcceptableMediaType(acceptableMediaTypes, detectedMediaType); + return detectedMediaType; + } + + private static void validateFileName(String fileName) { + String clean = fileName.trim(); + int lastDotIndex = clean.lastIndexOf('.'); + if (lastDotIndex <= 0 || lastDotIndex == clean.length() - 1) { + throw new ServiceException( + ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, + "Invalid filename format: " + fileName); + } + } + + private static void validateAcceptableMediaType(List acceptableMediaTypes, String actualMimeType) { + if (!checkMimeTypeMatch(acceptableMediaTypes, actualMimeType)) { + throw new ServiceException( + ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, + "The attachment file type '{}' is not allowed. Allowed types are: {}", actualMimeType, + String.join(", ", acceptableMediaTypes)); + } + } + + private static String resolveMimeType(String fileName) { + String actualMimeType = URLConnection.guessContentTypeFromName(fileName); + + if (actualMimeType == null) { + String fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + actualMimeType = EXT_TO_MEDIA_TYPE.get(fileExtension); + + if (actualMimeType == null) { + logger.warn("Could not determine mime type for file: {}. Setting mime type to default: {}", + fileName, DEFAULT_MEDIA_TYPE); + actualMimeType = DEFAULT_MEDIA_TYPE; + } + } + return actualMimeType; + } + + private static boolean checkMimeTypeMatch(Collection acceptableMediaTypes, String mimeType) { + if (acceptableMediaTypes == null || acceptableMediaTypes.isEmpty() || acceptableMediaTypes.contains("*/*")) + return true; + + String baseMimeType = mimeType.trim().toLowerCase(); + + return acceptableMediaTypes.stream().anyMatch(type -> { + String normalizedType = type.trim().toLowerCase(); + return normalizedType.endsWith("/*") + ? baseMimeType.startsWith(normalizedType.substring(0, normalizedType.length() - 2) + "/") + : baseMimeType.equals(normalizedType); + }); + } + +} \ No newline at end of file diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index a325b7cc..7af80aef 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -3,6 +3,8 @@ */ package com.sap.cds.feature.attachments.handler.applicationservice.helper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.sap.cds.CdsData; import com.sap.cds.CdsDataProcessor; import com.sap.cds.CdsDataProcessor.Converter; @@ -20,23 +22,29 @@ import java.io.InputStream; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + public final class ModifyApplicationHandlerHelper { - private static final Filter VALMAX_FILTER = - (path, element, type) -> - element.getName().contentEquals("content") - && element.findAnnotation("Validation.Maximum").isPresent(); + private static final Filter VALMAX_FILTER = hasAnnotationOnContent("Validation.Maximum"); + private static final Filter ACCEPTABLE_MEDIA_TYPES_FILTER = hasAnnotationOnContent("Core.AcceptableMediaTypes"); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final Logger logger = LoggerFactory.getLogger(ModifyApplicationHandlerHelper.class); /** * Handles attachments for entities. * - * @param entity the {@link CdsEntity entity} to handle attachments for - * @param data the given list of {@link CdsData data} + * @param entity the {@link CdsEntity entity} to handle attachments + * for + * @param data the given list of {@link CdsData data} * @param existingAttachments the given list of existing {@link CdsData data} - * @param eventFactory the {@link ModifyAttachmentEventFactory} to create the corresponding event - * @param eventContext the current {@link EventContext} + * @param eventFactory the {@link ModifyAttachmentEventFactory} to create + * the corresponding event + * @param eventContext the current {@link EventContext} */ public static void handleAttachmentForEntities( CdsEntity entity, @@ -45,17 +53,15 @@ public static void handleAttachmentForEntities( ModifyAttachmentEventFactory eventFactory, EventContext eventContext) { // Condense existing attachments to get a flat list for matching - List condensedExistingAttachments = - ApplicationHandlerHelper.condenseAttachments(existingAttachments, entity); - - Converter converter = - (path, element, value) -> - handleAttachmentForEntity( - condensedExistingAttachments, - eventFactory, - eventContext, - path, - (InputStream) value); + List condensedExistingAttachments = ApplicationHandlerHelper.condenseAttachments(existingAttachments, + entity); + + Converter converter = (path, element, value) -> handleAttachmentForEntity( + condensedExistingAttachments, + eventFactory, + eventContext, + path, + (InputStream) value); CdsDataProcessor.create() .addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter) @@ -65,11 +71,13 @@ public static void handleAttachmentForEntities( /** * Handles attachments for a single entity. * - * @param existingAttachments the list of existing {@link Attachments} to check against - * @param eventFactory the {@link ModifyAttachmentEventFactory} to create the corresponding event - * @param eventContext the current {@link EventContext} - * @param path the {@link Path} of the attachment - * @param content the content of the attachment + * @param existingAttachments the list of existing {@link Attachments} to check + * against + * @param eventFactory the {@link ModifyAttachmentEventFactory} to create + * the corresponding event + * @param eventContext the current {@link EventContext} + * @param path the {@link Path} of the attachment + * @param content the content of the attachment * @return the processed content as an {@link InputStream} */ public static InputStream handleAttachmentForEntity( @@ -87,9 +95,8 @@ public static InputStream handleAttachmentForEntity( eventContext.put( "attachment.MaxSize", maxSizeStr); // make max size available in context for error handling later - ServiceException TOO_LARGE_EXCEPTION = - new ServiceException( - ExtendedErrorStatuses.CONTENT_TOO_LARGE, "AttachmentSizeExceeded", maxSizeStr); + ServiceException TOO_LARGE_EXCEPTION = new ServiceException( + ExtendedErrorStatuses.CONTENT_TOO_LARGE, "AttachmentSizeExceeded", maxSizeStr); if (contentLength != null) { try { @@ -100,10 +107,15 @@ public static InputStream handleAttachmentForEntity( throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Invalid Content-Length header"); } } - CountingInputStream wrappedContent = - content != null ? new CountingInputStream(content, maxSizeStr) : null; - ModifyAttachmentEvent eventToProcess = - eventFactory.getEvent(wrappedContent, contentId, attachment); + CountingInputStream wrappedContent = content != null ? new CountingInputStream(content, maxSizeStr) : null; + + // Acceptable media types should be restricted + String fileName = getFileName(path, attachment); + List allowedTypes = getEntityAcceptableMediaTypes(path.target().entity(), + existingAttachments); + AttachmentValidationHelper.validateMediaTypeForAttachment(fileName, allowedTypes); + + ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(wrappedContent, contentId, attachment); try { return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext); } catch (Exception e) { @@ -133,6 +145,43 @@ private static String getValMaxValue(CdsEntity entity, List d return annotationValue.get() == null ? "400MB" : annotationValue.get(); } + public static String getFileName(Path path, Attachments attachment) throws ServiceException { + String fileName = Optional.ofNullable(attachment.getFileName()) + .orElseGet(() -> (String) path.target().values().get(Attachments.FILE_NAME)); + + if (fileName == null || fileName.isBlank()) { + logger.warn("Filename could not be determined from existing attachment or path values."); + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); + } + return fileName; + } + + static List getEntityAcceptableMediaTypes(CdsEntity entity, List data) { + AtomicReference> annotationValue = new AtomicReference<>(); + CdsDataProcessor.create() + .addValidator( + ACCEPTABLE_MEDIA_TYPES_FILTER, + (path, element, value) -> { + element + .findAnnotation("Core.AcceptableMediaTypes") + .ifPresent(annotation -> { + List types = OBJECT_MAPPER.convertValue(annotation.getValue(), + new TypeReference>() { + }); + annotationValue.set(types); + }); + }) + .process(data, entity); + + return Optional.ofNullable(annotationValue.get()) + .orElse(List.of("*/*")); + } + + private static Filter hasAnnotationOnContent(String annotationName) { + return (path, element, type) -> "content".contentEquals(element.getName()) && + element.findAnnotation(annotationName).isPresent(); + } + private static Attachments getExistingAttachment( Map keys, List existingAttachments) { return existingAttachments.stream() @@ -144,4 +193,4 @@ private static Attachments getExistingAttachment( private ModifyApplicationHandlerHelper() { // avoid instantiation } -} +} \ No newline at end of file diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java index b3d80a93..3b0ef327 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java @@ -117,6 +117,7 @@ void eventProcessorCalledForCreate() throws IOException { try (var testStream = new ByteArrayInputStream("testString".getBytes(StandardCharsets.UTF_8))) { var attachment = Attachments.create(); attachment.setContent(testStream); + attachment.setFileName("test.pdf"); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); cut.processBefore(createContext, List.of(attachment)); @@ -231,6 +232,7 @@ void handlerCalledForMediaEventInAssociationIdsAreSet() { var attachment = Attachments.create(); var content = mock(InputStream.class); attachment.setContent(content); + attachment.setFileName("test.pdf"); items.setAttachments(List.of(attachment)); events.setItems(List.of(items)); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); @@ -257,7 +259,7 @@ void readonlyFieldsAreUsedFromOwnContext() { var attachment = Attachments.create(); attachment.setContent(testStream); attachment.put("DRAFT_READONLY_CONTEXT", readonlyFields); - + attachment.setFileName("test.pdf"); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); cut.processBefore(createContext, List.of(attachment)); @@ -307,8 +309,7 @@ void classHasCorrectAnnotation() { @Test void methodHasCorrectAnnotations() throws NoSuchMethodException { - var method = - cut.getClass().getDeclaredMethod("processBefore", CdsCreateEventContext.class, List.class); + var method = cut.getClass().getDeclaredMethod("processBefore", CdsCreateEventContext.class, List.class); var createBeforeAnnotation = method.getAnnotation(Before.class); var createHandlerOrderAnnotation = method.getAnnotation(HandlerOrder.class); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java index af0f30ce..5b9c3f01 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java @@ -81,9 +81,8 @@ void setup() { attachmentsReader = mock(AttachmentsReader.class); attachmentService = mock(AttachmentService.class); storageReader = mock(ThreadDataStorageReader.class); - cut = - new UpdateAttachmentsHandler( - eventFactory, attachmentsReader, attachmentService, storageReader); + cut = new UpdateAttachmentsHandler( + eventFactory, attachmentsReader, attachmentService, storageReader); event = mock(ModifyAttachmentEvent.class); updateContext = mock(CdsUpdateEventContext.class); @@ -115,6 +114,7 @@ void eventProcessorCalledForUpdate() { var attachment = Attachments.create(); attachment.setContent(testStream); attachment.setId(id); + attachment.setFileName("test.pdf"); when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) .thenReturn(List.of(attachment)); @@ -138,6 +138,7 @@ void readonlyFieldsAreUsedFromOwnContext() { var testStream = mock(InputStream.class); var attachment = Attachments.create(); attachment.setContent(testStream); + attachment.setFileName("test.pdf"); attachment.put("DRAFT_READONLY_CONTEXT", readonlyUpdateFields); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); @@ -262,7 +263,7 @@ void existingDataFoundAndUsed() { var target = updateContext.getTarget(); // Return root with nested attachments so condenseAttachments can find them when(attachmentsReader.readAttachments( - eq(model), eq(target), any(CqnFilterableStatement.class))) + eq(model), eq(target), any(CqnFilterableStatement.class))) .thenReturn(List.of(Attachments.of(root))); cut.processBefore(updateContext, List.of(root)); @@ -299,6 +300,7 @@ void noExistingDataFound() { root.setId(id); var attachment = Attachments.create(); // No ID set - this is a new attachment + attachment.setFileName("test.pdf"); attachment.setContent(testStream); root.setAttachments(List.of(attachment)); @@ -321,6 +323,7 @@ void noKeysNoException() { var attachment = Attachments.create(); var testStream = mock(InputStream.class); attachment.setContent(testStream); + attachment.setFileName("test.pdf"); root.setAttachments(List.of(attachment)); List roots = List.of(root); @@ -333,6 +336,7 @@ void selectIsUsedWithFilterAndWhere() { attachment.setId(UUID.randomUUID().toString()); attachment.put(UP_ID, "test_full"); attachment.setContent(mock(InputStream.class)); + attachment.setFileName("test.pdf"); var entityWithKeys = CQL.entity(Attachment_.CDS_NAME).matching(getAttachmentKeyMap(attachment)); CqnUpdate update = Update.entity(entityWithKeys).byId("test"); var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); @@ -356,6 +360,7 @@ void selectIsUsedWithFilter() { attachment.setId(UUID.randomUUID().toString()); attachment.put(UP_ID, "test_filter"); attachment.setContent(mock(InputStream.class)); + attachment.setFileName("test.pdf"); var entityWithKeys = CQL.entity(Attachment_.CDS_NAME).matching(getAttachmentKeyMap(attachment)); CqnUpdate update = Update.entity(entityWithKeys); var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); @@ -378,6 +383,7 @@ void selectIsUsedWithWhere() { attachment.setId(UUID.randomUUID().toString()); attachment.put(UP_ID, "test_where"); attachment.setContent(mock(InputStream.class)); + attachment.setFileName("test.pdf"); CqnUpdate update = Update.entity(Attachment_.CDS_NAME).byId("test"); var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); mockTargetInUpdateContext(serviceEntity, update); @@ -400,9 +406,9 @@ void selectIsUsedWithAttachmentId() { attachment.setId(UUID.randomUUID().toString()); attachment.put(UP_ID, "test_up_id"); attachment.setContent(mock(InputStream.class)); + attachment.setFileName("test.pdf"); var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); - CqnUpdate update = - Update.entity(Attachment_.class).where(entity -> entity.ID().eq(attachment.getId())); + CqnUpdate update = Update.entity(Attachment_.class).where(entity -> entity.ID().eq(attachment.getId())); mockTargetInUpdateContext(serviceEntity, update); when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) .thenReturn(List.of(attachment)); @@ -422,18 +428,20 @@ void selectIsCorrectForMultipleAttachments() { attachment1.setId(UUID.randomUUID().toString()); attachment1.put(UP_ID, "test_multiple 2"); attachment1.setContent(mock(InputStream.class)); + attachment1.setFileName("test_1.pdf"); + var attachment2 = Attachments.create(); attachment2.setId(UUID.randomUUID().toString()); attachment2.put(UP_ID, "test_multiple 2"); attachment2.setContent(mock(InputStream.class)); - CqnUpdate update = - Update.entity(Attachment_.class) - .where( - attachment -> - attachment - .ID() - .eq(attachment1.getId()) - .or(attachment.ID().eq(attachment2.getId()))); + attachment2.setFileName("test_2.pdf"); + + CqnUpdate update = Update.entity(Attachment_.class) + .where( + attachment -> attachment + .ID() + .eq(attachment1.getId()) + .or(attachment.ID().eq(attachment2.getId()))); var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); mockTargetInUpdateContext(serviceEntity, update); when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) @@ -504,8 +512,7 @@ void classHasCorrectAnnotation() { @Test void methodHasCorrectAnnotations() throws NoSuchMethodException { - var method = - cut.getClass().getDeclaredMethod("processBefore", CdsUpdateEventContext.class, List.class); + var method = cut.getClass().getDeclaredMethod("processBefore", CdsUpdateEventContext.class, List.class); var updateBeforeAnnotation = method.getAnnotation(Before.class); var updateHandlerOrderAnnotation = method.getAnnotation(HandlerOrder.class); @@ -521,6 +528,7 @@ private RootTable fillRootData(InputStream testStream, String id) { attachment.setId(UUID.randomUUID().toString()); attachment.put("up__ID", root.getId()); attachment.setContent(testStream); + attachment.setFileName("test.pdf"); root.setAttachments(List.of(attachment)); return root; } @@ -532,8 +540,8 @@ private String getEntityAndMockContext(String cdsName) { private String mockTargetInUpdateContext(CdsEntity serviceEntity) { var id = UUID.randomUUID().toString(); - var update = - Update.entity(serviceEntity.getQualifiedName()).where(entity -> entity.get("ID").eq(id)); + var update = Update.entity(serviceEntity.getQualifiedName()) + .where(entity -> entity.get("ID").eq(id)); mockTargetInUpdateContext(serviceEntity, update); return id; } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java new file mode 100644 index 00000000..5bc4204a --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java @@ -0,0 +1,148 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. +*/ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; + +class AttachmentValidationHelperTest { + + // ---------- constructor ---------- + + @Test + void constructorShouldBeCallableForCoverage() { + new AttachmentValidationHelper(); + } + + // ---------- validateMimeTypeForAttachment ---------- + @Test + void shouldAcceptMimeTypeFromURLConnection() { + String result = AttachmentValidationHelper.validateMediaTypeForAttachment( + "document.pdf", + List.of("application/pdf")); + assertEquals("application/pdf", result); + } + + @Test + void shouldUseExtensionMapWhenURLConnectionDoesNotDetectMimeType() { + String result = AttachmentValidationHelper.validateMediaTypeForAttachment( + "image.webp", + List.of("image/webp")); + assertEquals("image/webp", result); + } + + @Test + void shouldSupportWildCardTypes() { + String result = AttachmentValidationHelper.validateMediaTypeForAttachment( + "image.jpeg", + List.of("image/*")); + assertEquals("image/jpeg", result); + } + + @Test + void shouldAllowAllMimeTypesWithStarSlashStar() { + String result = AttachmentValidationHelper.validateMediaTypeForAttachment( + "anyfile.jpeg", + List.of("*/*")); + assertEquals("image/jpeg", result); + } + + @Test + void shouldAllowRandomInvalidExtensionWithStarSlashStar() { + String result = AttachmentValidationHelper.validateMediaTypeForAttachment( + "anyfile.anyext", + List.of("*/*")); + assertEquals(AttachmentValidationHelper.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void shouldAllowRandomInvalidExtensionWithNullAcceptableMediaTypes() { + String result = AttachmentValidationHelper.validateMediaTypeForAttachment( + "anyfile.anyext", + null); + assertEquals(AttachmentValidationHelper.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void shouldNotAllowRandomInvalidExtensionWithStarSlashStar() { + ServiceException ex = assertThrows(ServiceException.class, + () -> AttachmentValidationHelper.validateMediaTypeForAttachment( + "anyfile.anyext", + List.of("application/pdf"))); + + assertTrue(ex.getErrorStatus().equals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE)); + } + + @Test + void shouldAllowAllMimeTypesIfNoRestrictionsGiven() { + String result = AttachmentValidationHelper.validateMediaTypeForAttachment( + "anyfile.anyext", + List.of()); + assertEquals(AttachmentValidationHelper.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void shouldUseDefaultMimeTypeWhenExtensionIsUnknown() { + String result = AttachmentValidationHelper.validateMediaTypeForAttachment( + "file.unknownext", + List.of(AttachmentValidationHelper.DEFAULT_MEDIA_TYPE)); + assertEquals(AttachmentValidationHelper.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void shouldThrowExceptionWhenInvalidFileName() { + ServiceException ex = assertThrows(ServiceException.class, + () -> AttachmentValidationHelper.validateMediaTypeForAttachment( + "invalidfilename", + List.of("application/pdf"))); + + assertTrue(ex.getErrorStatus().equals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE)); + } + + @Test + void shouldThrowExceptionWhenFilenameEndsWithDot() { + ServiceException ex = assertThrows(ServiceException.class, + () -> AttachmentValidationHelper.validateMediaTypeForAttachment( + "file.", + List.of("application/pdf"))); + + assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + } + + @Test + void shouldHandleUppercaseExtension() { + String result = AttachmentValidationHelper.validateMediaTypeForAttachment( + "photo.JPG", + List.of("image/jpeg")); + + assertEquals("image/jpeg", result); + } + + @Test + void shouldThrowExceptionWhenMimeTypeNotAllowed() { + ServiceException ex = assertThrows(ServiceException.class, + () -> AttachmentValidationHelper.validateMediaTypeForAttachment( + "document.pdf", + List.of("image/png"))); + + assertTrue(ex.getMessage().contains("not allowed")); + } + + @Test + void shouldThrowExceptionWhenDefaultMimeTypeNotAllowed() { + ServiceException ex = assertThrows(ServiceException.class, + () -> AttachmentValidationHelper.validateMediaTypeForAttachment( + "file.unknownext", + List.of("application/pdf"))); + + assertTrue(ex.getMessage().contains("not allowed")); + } + +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java index f95901e1..be2a4385 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java @@ -7,15 +7,25 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; +import com.sap.cds.CdsData; +import com.sap.cds.CdsDataProcessor; +import com.sap.cds.CdsDataProcessor.Filter; +import com.sap.cds.CdsDataProcessor.Validator; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEvent; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.ql.cqn.Path; import com.sap.cds.ql.cqn.ResolvedSegment; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.EventContext; @@ -24,178 +34,402 @@ import com.sap.cds.services.runtime.CdsRuntime; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.lang.reflect.Field; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; class ModifyApplicationHandlerHelperTest { - private static CdsRuntime runtime; - private ModifyAttachmentEventFactory eventFactory; - private EventContext eventContext; - private ParameterInfo parameterInfo; - private Path path; - private ResolvedSegment target; - private ModifyAttachmentEvent event; - - @BeforeAll - static void classSetup() { - runtime = RuntimeHelper.runtime; - } - - @BeforeEach - void setup() { - eventFactory = mock(ModifyAttachmentEventFactory.class); - eventContext = mock(EventContext.class); - parameterInfo = mock(ParameterInfo.class); - path = mock(Path.class); - target = mock(ResolvedSegment.class); - event = mock(ModifyAttachmentEvent.class); - when(eventContext.getParameterInfo()).thenReturn(parameterInfo); - when(path.target()).thenReturn(target); - when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); - } - - @Test - void serviceExceptionDueToContentLength() { - // Arrange: Get EventItems entity which has @Validation.Maximum: '10KB' on - // sizeLimitedAttachments.content - String attachmentEntityName = "unit.test.TestService.EventItems.sizeLimitedAttachments"; - CdsEntity entity = runtime.getCdsModel().findEntity(attachmentEntityName).orElseThrow(); - - // Create attachment data - var attachment = Attachments.create(); - attachment.setId(UUID.randomUUID().toString()); - attachment.setContent(mock(InputStream.class)); - - // Setup path mock to return EventItems entity and attachment values - when(target.entity()).thenReturn(entity); - when(target.values()).thenReturn(attachment); - when(target.keys()).thenReturn(Map.of(Attachments.ID, attachment.getId())); - - // Set Content-Length header to exceed 10KB (10240 bytes) - when(parameterInfo.getHeader("Content-Length")).thenReturn("20000"); - - var existingAttachments = List.of(attachment); - - // Act & Assert - var exception = - assertThrows( - ServiceException.class, - () -> - ModifyApplicationHandlerHelper.handleAttachmentForEntity( - existingAttachments, - eventFactory, - eventContext, - path, - attachment.getContent())); - - assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); - } - - @Test - void serviceExceptionDueToLimitExceeded() { - // Arrange: Use the attachment entity with @Validation.Maximum: '10KB' - String attachmentEntityName = "unit.test.TestService.EventItems.sizeLimitedAttachments"; - CdsEntity entity = runtime.getCdsModel().findEntity(attachmentEntityName).orElseThrow(); - - var attachment = Attachments.create(); - attachment.setId(UUID.randomUUID().toString()); - - // Content that exceeds 10KB (10240 bytes) when read - byte[] largeContent = new byte[15000]; // 15KB - var content = new ByteArrayInputStream(largeContent); - attachment.setContent(content); - - when(target.entity()).thenReturn(entity); - when(target.values()).thenReturn(attachment); - when(target.keys()).thenReturn(Map.of(Attachments.ID, attachment.getId())); - - // NO Content-Length header - limit will be checked during streaming - when(parameterInfo.getHeader("Content-Length")).thenReturn(null); - - // Make event.processEvent() read from the stream, triggering the limit check - when(event.processEvent(any(), any(), any(), any())) - .thenAnswer( - invocation -> { - InputStream wrappedContent = invocation.getArgument(1); - if (wrappedContent != null) { - // Read all bytes - this will trigger CountingInputStream to throw - byte[] buffer = new byte[1024]; - while (wrappedContent.read(buffer) != -1) { - // Keep reading until exception or EOF + private static CdsRuntime runtime; + private ModifyAttachmentEventFactory eventFactory; + private EventContext eventContext; + private ParameterInfo parameterInfo; + private Path path; + private ResolvedSegment target; + private ModifyAttachmentEvent event; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + @BeforeEach + void setup() { + eventFactory = mock(ModifyAttachmentEventFactory.class); + eventContext = mock(EventContext.class); + parameterInfo = mock(ParameterInfo.class); + path = mock(Path.class); + target = mock(ResolvedSegment.class); + event = mock(ModifyAttachmentEvent.class); + when(eventContext.getParameterInfo()).thenReturn(parameterInfo); + when(path.target()).thenReturn(target); + when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); + } + + @Test + void serviceExceptionDueToContentLength() { + // Arrange: Get EventItems entity which has @Validation.Maximum: '10KB' on + // sizeLimitedAttachments.content + String attachmentEntityName = "unit.test.TestService.EventItems.sizeLimitedAttachments"; + CdsEntity entity = runtime.getCdsModel().findEntity(attachmentEntityName).orElseThrow(); + + // Create attachment data + var attachment = Attachments.create(); + attachment.setId(UUID.randomUUID().toString()); + attachment.setContent(mock(InputStream.class)); + + // Setup path mock to return EventItems entity and attachment values + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(attachment); + when(target.keys()).thenReturn(Map.of(Attachments.ID, attachment.getId())); + + // Set Content-Length header to exceed 10KB (10240 bytes) + when(parameterInfo.getHeader("Content-Length")).thenReturn("20000"); + + var existingAttachments = List.of(attachment); + + // Act & Assert + var exception = assertThrows( + ServiceException.class, + () -> ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, + eventFactory, + eventContext, + path, + attachment.getContent())); + + assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); + } + + @Test + void serviceExceptionDueToLimitExceeded() { + // Arrange: Use the attachment entity with @Validation.Maximum: '10KB' + String attachmentEntityName = "unit.test.TestService.EventItems.sizeLimitedAttachments"; + CdsEntity entity = runtime.getCdsModel().findEntity(attachmentEntityName).orElseThrow(); + + var attachment = Attachments.create(); + attachment.setId(UUID.randomUUID().toString()); + + // Content that exceeds 10KB (10240 bytes) when read + byte[] largeContent = new byte[15000]; // 15KB + var content = new ByteArrayInputStream(largeContent); + attachment.setContent(content); + attachment.setFileName("test.pdf"); + + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(attachment); + when(target.keys()).thenReturn(Map.of(Attachments.ID, attachment.getId())); + + // NO Content-Length header - limit will be checked during streaming + when(parameterInfo.getHeader("Content-Length")).thenReturn(null); + + // Make event.processEvent() read from the stream, triggering the limit check + when(event.processEvent(any(), any(), any(), any())) + .thenAnswer( + invocation -> { + InputStream wrappedContent = invocation.getArgument(1); + if (wrappedContent != null) { + // Read all bytes - this will trigger CountingInputStream to throw + byte[] buffer = new byte[1024]; + while (wrappedContent.read(buffer) != -1) { + // Keep reading until exception or EOF + } + } + return null; + }); + + var existingAttachments = List.of(attachment); + + // Act & Assert + var exception = assertThrows( + ServiceException.class, + () -> ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, eventFactory, eventContext, path, content)); + + assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); + } + + @Test + void defaultValMaxValueUsed() { + String attachmentEntityName = "unit.test.TestService.EventItems.defaultSizeLimitedAttachments"; + CdsEntity entity = runtime.getCdsModel().findEntity(attachmentEntityName).orElseThrow(); + + var attachment = Attachments.create(); + attachment.setFileName("test.pdf"); + attachment.setId(UUID.randomUUID().toString()); + var content = mock(InputStream.class); + attachment.setContent(content); + + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(attachment); + when(target.keys()).thenReturn(Map.of(Attachments.ID, attachment.getId())); + + when(parameterInfo.getHeader("Content-Length")).thenReturn("399000000"); // 399MB + + var existingAttachments = List.of(); + + // Act & Assert: No exception should be thrown as default is 400MB + assertDoesNotThrow( + () -> ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, eventFactory, eventContext, path, content)); + } + + @Test + void malformedContentLengthHeader() { + String attachmentEntityName = "unit.test.TestService.EventItems.sizeLimitedAttachments"; + CdsEntity entity = runtime.getCdsModel().findEntity(attachmentEntityName).orElseThrow(); + + var attachment = Attachments.create(); + attachment.setId(UUID.randomUUID().toString()); + var content = mock(InputStream.class); + attachment.setContent(content); + + when(target.entity()).thenReturn(entity); + when(target.values()).thenReturn(attachment); + when(target.keys()).thenReturn(Map.of(Attachments.ID, attachment.getId())); + + when(parameterInfo.getHeader("Content-Length")).thenReturn("invalid-number"); + + var existingAttachments = List.of(); + + // Act & Assert + var exception = assertThrows( + ServiceException.class, + () -> ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, eventFactory, eventContext, path, content)); + + assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); + } + + // ------------------- Tests for getFileName ------------------ + @Test + void shouldReturnFilenameFromAttachment() throws ServiceException { + Path path = mock(Path.class); + Attachments attachment = mock(Attachments.class); + when(attachment.getFileName()).thenReturn("test.pdf"); + String result = ModifyApplicationHandlerHelper.getFileName(path, attachment); + assertThat(result).isEqualTo("test.pdf"); + } + + @Test + void shouldReturnFilenameFromPathWhenAttachmentFilenameIsNull() throws ServiceException { + Path path = mock(Path.class); + ResolvedSegment target = mock(ResolvedSegment.class); + Attachments attachment = mock(Attachments.class); + + when(attachment.getFileName()).thenReturn(null); + when(path.target()).thenReturn(target); + + Map values = new HashMap<>(); + values.put(Attachments.FILE_NAME, "test.txt"); + when(target.values()).thenReturn(values); + + String result = ModifyApplicationHandlerHelper.getFileName(path, attachment); + + assertThat(result).isEqualTo("test.txt"); + } + + @Test + void shouldThrowExceptionWhenFilenameIsNullEverywhere() { + Path path = mock(Path.class); + ResolvedSegment target = mock(ResolvedSegment.class); + Attachments attachment = mock(Attachments.class); + + when(attachment.getFileName()).thenReturn(null); + when(path.target()).thenReturn(target); + + Map values = new HashMap<>(); + values.put(Attachments.FILE_NAME, null); + when(target.values()).thenReturn(values); + + ServiceException ex = assertThrows( + ServiceException.class, + () -> ModifyApplicationHandlerHelper.getFileName(path, attachment)); + + assertThat(ex.getMessage()).isEqualTo("Filename is missing"); + } + + @Test + void shouldThrowExceptionWhenAttachmentFilenameIsBlank() { + Path path = mock(Path.class); + Attachments attachment = mock(Attachments.class); + + when(attachment.getFileName()).thenReturn(" "); + + ServiceException ex = assertThrows( + ServiceException.class, + () -> ModifyApplicationHandlerHelper.getFileName(path, attachment)); + + assertThat(ex.getMessage()).isEqualTo("Filename is missing"); + } + + @Test + void shouldThrowExceptionWhenPathFilenameIsBlank() { + Path path = mock(Path.class); + ResolvedSegment target = mock(ResolvedSegment.class); + Attachments attachment = mock(Attachments.class); + + when(attachment.getFileName()).thenReturn(null); + when(path.target()).thenReturn(target); + + Map values = new HashMap<>(); + values.put(Attachments.FILE_NAME, " "); + when(target.values()).thenReturn(values); + + ServiceException ex = assertThrows( + ServiceException.class, + () -> ModifyApplicationHandlerHelper.getFileName(path, attachment)); + + assertThat(ex.getMessage()).isEqualTo("Filename is missing"); + } + + // ---------Tests for ACCEPTABLE_MEDIA_TYPES_FILTER--------- + + private Filter getAllowedMimeTypesFilter() throws Exception { + Field field = ModifyApplicationHandlerHelper.class + .getDeclaredField("ACCEPTABLE_MEDIA_TYPES_FILTER"); + field.setAccessible(true); + return (Filter) field.get(null); + } + + @Test + void shouldReturnWildcardWhenDataListIsEmpty() { + CdsEntity entity = mock(CdsEntity.class); + List result = ModifyApplicationHandlerHelper.getEntityAcceptableMediaTypes(entity, List.of()); + assertThat(result).containsExactly("*/*"); + } + + @Test + void filterAcceptsContentElementWithAnnotation() throws Exception { + Filter filter = getAllowedMimeTypesFilter(); + + CdsElement element = mock(CdsElement.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + + when(element.getName()).thenReturn("content"); + when(element.findAnnotation("Core.AcceptableMediaTypes")) + .thenReturn(Optional.of(annotation)); + + boolean result = filter.test(null, element, null); + + assertThat(result).isTrue(); + } + + @Test + void filterRejectsNonContentElement() throws Exception { + Filter filter = getAllowedMimeTypesFilter(); + + CdsElement element = mock(CdsElement.class); + + when(element.getName()).thenReturn("fileName"); + when(element.findAnnotation("Core.AcceptableMediaTypes")) + .thenReturn(Optional.of(mock(CdsAnnotation.class))); + + boolean result = filter.test(null, element, null); + + assertThat(result).isFalse(); + } + + @Test + void filterRejectsWhenAnnotationMissing() throws Exception { + Filter filter = getAllowedMimeTypesFilter(); + + CdsElement element = mock(CdsElement.class); + + when(element.getName()).thenReturn("content"); + when(element.findAnnotation("Core.AcceptableMediaTypes")) + .thenReturn(Optional.empty()); + + boolean result = filter.test(null, element, null); + + assertThat(result).isFalse(); + } + + // ----------------- Tests for getEntityAcceptableMediaTypes ----------- + + @Test + void shouldReturnAcceptableMimeTypesFromAnnotation() throws Exception { + CdsEntity entity = mock(CdsEntity.class); + CdsData data = mock(CdsData.class); + + CdsElement element = mock(CdsElement.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + + when(element.getName()).thenReturn("content"); + when(element.findAnnotation("Core.AcceptableMediaTypes")) + .thenReturn(Optional.of(annotation)); + when(annotation.getValue()).thenReturn(List.of("image/png", "application/pdf")); + + CdsDataProcessor processor = mock(CdsDataProcessor.class); + + try (MockedStatic mocked = mockStatic(CdsDataProcessor.class)) { + + mocked.when(CdsDataProcessor::create).thenReturn(processor); + + doAnswer(invocation -> { + Filter filter = invocation.getArgument(0); + + @SuppressWarnings("unchecked") + Validator validator = invocation.getArgument(1); + + // simulate processor visiting the content element + if (filter.test(null, element, null)) { + validator.validate(null, element, null); } - } - return null; - }); - - var existingAttachments = List.of(attachment); - - // Act & Assert - var exception = - assertThrows( - ServiceException.class, - () -> - ModifyApplicationHandlerHelper.handleAttachmentForEntity( - existingAttachments, eventFactory, eventContext, path, content)); - - assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); - } - - @Test - void defaultValMaxValueUsed() { - String attachmentEntityName = "unit.test.TestService.EventItems.defaultSizeLimitedAttachments"; - CdsEntity entity = runtime.getCdsModel().findEntity(attachmentEntityName).orElseThrow(); - - var attachment = Attachments.create(); - attachment.setId(UUID.randomUUID().toString()); - var content = mock(InputStream.class); - attachment.setContent(content); - - when(target.entity()).thenReturn(entity); - when(target.values()).thenReturn(attachment); - when(target.keys()).thenReturn(Map.of(Attachments.ID, attachment.getId())); - - when(parameterInfo.getHeader("Content-Length")).thenReturn("399000000"); // 399MB - - var existingAttachments = List.of(); - - // Act & Assert: No exception should be thrown as default is 400MB - assertDoesNotThrow( - () -> - ModifyApplicationHandlerHelper.handleAttachmentForEntity( - existingAttachments, eventFactory, eventContext, path, content)); - } - - @Test - void malformedContentLengthHeader() { - String attachmentEntityName = "unit.test.TestService.EventItems.sizeLimitedAttachments"; - CdsEntity entity = runtime.getCdsModel().findEntity(attachmentEntityName).orElseThrow(); - - var attachment = Attachments.create(); - attachment.setId(UUID.randomUUID().toString()); - var content = mock(InputStream.class); - attachment.setContent(content); - - when(target.entity()).thenReturn(entity); - when(target.values()).thenReturn(attachment); - when(target.keys()).thenReturn(Map.of(Attachments.ID, attachment.getId())); - - when(parameterInfo.getHeader("Content-Length")).thenReturn("invalid-number"); - - var existingAttachments = List.of(); - - // Act & Assert - var exception = - assertThrows( - ServiceException.class, - () -> - ModifyApplicationHandlerHelper.handleAttachmentForEntity( - existingAttachments, eventFactory, eventContext, path, content)); - - assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); - } + + return processor; + }).when(processor).addValidator(any(), any()); + + // bind explicitly to List-based overload + doNothing().when(processor).process(anyList(), any(CdsEntity.class)); + + List result = ModifyApplicationHandlerHelper.getEntityAcceptableMediaTypes(entity, List.of(data)); + + assertThat(result).containsExactlyInAnyOrder("image/png", "application/pdf"); + } + } + + @Test + void shouldReturnWildcardWhenNoAcceptableMediaTypesAnnotationPresent() throws Exception { + CdsEntity entity = mock(CdsEntity.class); + CdsData data = mock(CdsData.class); + + CdsElement element = mock(CdsElement.class); + when(element.getName()).thenReturn("content"); + when(element.findAnnotation("Core.AcceptableMediaTypes")) + .thenReturn(Optional.empty()); + + CdsDataProcessor processor = mock(CdsDataProcessor.class); + + try (MockedStatic mocked = mockStatic(CdsDataProcessor.class)) { + + mocked.when(CdsDataProcessor::create).thenReturn(processor); + + doAnswer(invocation -> { + Filter filter = invocation.getArgument(0); + + Validator validator = invocation.getArgument(1); + + if (filter.test(null, element, null)) { + validator.validate(null, element, null); + } + + return processor; + }).when(processor).addValidator(any(), any()); + + doNothing().when(processor).process(anyList(), any(CdsEntity.class)); + + List result = ModifyApplicationHandlerHelper.getEntityAcceptableMediaTypes(entity, List.of(data)); + + assertThat(result).containsExactly("*/*"); + } + } + } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index e861b074..9b44c1f2 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -72,7 +72,9 @@ void setup() { @Test void draftEntityReadAndUsed() { getEntityAndMockContext(RootTable_.CDS_NAME); - var root = buildRooWithAttachment(Attachments.create()); + Attachments attachment = Attachments.create(); + var root = buildRooWithAttachment(attachment); + attachment.setFileName("test.pdf"); when(persistence.run(any(CqnSelect.class))).thenReturn(mock(Result.class)); cut.processBeforeDraftPatch(eventContext, List.of(Attachments.of(root))); @@ -90,6 +92,7 @@ void draftEntityUsed() { getEntityAndMockContext(draftAttachmentName); var attachment = Attachments.create(); attachment.setContent(mock(InputStream.class)); + attachment.setFileName("test.pdf"); when(persistence.run(any(CqnSelect.class))).thenReturn(mock(Result.class)); cut.processBeforeDraftPatch(eventContext, List.of(attachment)); @@ -106,6 +109,7 @@ void selectedDataUsedForEventFactory() { var root = buildRooWithAttachment(attachment); var content = attachment.getContent(); var result = mock(Result.class); + attachment.setFileName("test.pdf"); when(persistence.run(any(CqnSelect.class))).thenReturn(result); when(result.listOf(Attachments.class)).thenReturn(List.of(attachment)); @@ -125,6 +129,7 @@ void contentIdUsedForEventFactory() { var attachment = Attachments.create(); var root = buildRooWithAttachment(attachment); attachment.setContentId(UUID.randomUUID().toString()); + attachment.setFileName("test.pdf"); var content = attachment.getContent(); var result = mock(Result.class); when(persistence.run(any(CqnSelect.class))).thenReturn(result); @@ -162,9 +167,8 @@ void classHasCorrectAnnotations() { @Test void methodHasCorrectAnnotations() throws NoSuchMethodException { - var method = - cut.getClass() - .getDeclaredMethod("processBeforeDraftPatch", DraftPatchEventContext.class, List.class); + var method = cut.getClass() + .getDeclaredMethod("processBeforeDraftPatch", DraftPatchEventContext.class, List.class); var beforeAnnotation = method.getAnnotation(Before.class); var handlerOrderAnnotation = method.getAnnotation(HandlerOrder.class); diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index f6c35e19..3a9cbb4f 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -1,19 +1,20 @@ namespace test.data.model; using {cuid} from '@sap/cds/common'; -using {sap.attachments.Attachments} from `com.sap.cds/cds-feature-attachments`; +using {sap.attachments.Attachments} from`com.sap.cds/cds-feature-attachments`; entity AttachmentEntity : Attachments { parentKey : UUID; } entity Roots : cuid { - title : String; - attachments : Composition of many AttachmentEntity - on attachments.parentKey = $self.ID; - items : Composition of many Items - on items.parentID = $self.ID; - sizeLimitedAttachments : Composition of many Attachments; + title : String; + attachments : Composition of many AttachmentEntity + on attachments.parentKey = $self.ID; + items : Composition of many Items + on items.parentID = $self.ID; + sizeLimitedAttachments : Composition of many Attachments; + mediaValidatedAttachments : Composition of many Attachments; // for media type validation test } entity Items : cuid { diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/AcceptedMediaTypesAttachmentsValidationDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/AcceptedMediaTypesAttachmentsValidationDraftTest.java new file mode 100644 index 00000000..d31665f0 --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/AcceptedMediaTypesAttachmentsValidationDraftTest.java @@ -0,0 +1,176 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. +*/ +package com.sap.cds.feature.attachments.integrationtests.draftservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sap.cds.CdsData; +import com.sap.cds.Struct; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testdraftservice.DraftRoots; +import com.sap.cds.feature.attachments.integrationtests.common.MockHttpRequestHelper; +import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles(Profiles.TEST_HANDLER_DISABLED) +class AcceptedMediaTypesAttachmentsValidationDraftTest extends DraftOdataRequestValidationBase { + + private static final String BASE_URL = MockHttpRequestHelper.ODATA_BASE_URL + "TestDraftService/"; + private static final String BASE_ROOT_URL = BASE_URL + "DraftRoots"; + + @Test + void uploadingAllowedMediaTypeShouldSucceed() throws Exception { + DraftRoots draftRoot = createNewDraftWithMediaValidatedAttachment( + "Root with mediaValidatedAttachments", + "test.jpeg", + MediaType.IMAGE_JPEG.toString()); + Attachments attachment = draftRoot.getMediaValidatedAttachments().get(0); + + String url = buildDraftMediaValidatedAttachmentContentUrl( + draftRoot.getId(), + attachment.getId()); + + requestHelper.setContentType(MediaType.IMAGE_JPEG); + + requestHelper.executePutWithMatcher( + url, + "fake-jpeg-content".getBytes(StandardCharsets.UTF_8), + status().isNoContent()); + } + + @Test + void uploadingNotAllowedMediaTypeShouldFail() throws Exception { + DraftRoots draftRoot = createNewDraftWithMediaValidatedAttachment( + "Root with pdf attachment", + "test.pdf", + MediaType.APPLICATION_PDF.toString()); + Attachments attachment = draftRoot.getMediaValidatedAttachments().get(0); + + String url = buildDraftMediaValidatedAttachmentContentUrl( + draftRoot.getId(), + attachment.getId()); + + requestHelper.setContentType(MediaType.APPLICATION_PDF); + + requestHelper.executePutWithMatcher( + url, + "fake-pdf-content".getBytes(StandardCharsets.UTF_8), + status().isUnsupportedMediaType()); + } + + // helper methods + private DraftRoots createNewDraftWithMediaValidatedAttachment(String title, String fileName, String mimeType) + throws Exception { + + // Create new draft root + CdsData responseRootCdsData = requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, + "{}"); + DraftRoots draftRoot = Struct.access(responseRootCdsData).as(DraftRoots.class); + draftRoot.setTitle(title); + String rootUrl = getRootUrl(draftRoot.getId(), false); + requestHelper.executePatchWithODataResponseAndAssertStatusOk(rootUrl, draftRoot.toJson()); + + // Create attachment + Attachments attachment = Attachments.create(); + attachment.setFileName(fileName); + attachment.setMimeType(mimeType); + + String attachmentUrl = rootUrl + "/mediaValidatedAttachments"; + CdsData responseAttachmentCdsData = requestHelper.executePostWithODataResponseAndAssertStatusCreated( + attachmentUrl, attachment.toJson()); + + Attachments createdAttachment = Struct.access(responseAttachmentCdsData).as(Attachments.class); + + // Attach to draft root + draftRoot.setMediaValidatedAttachments(java.util.List.of(createdAttachment)); + return draftRoot; + } + + private String getRootUrl(String rootId, boolean isActiveEntity) { + return BASE_ROOT_URL + "(ID=" + rootId + ",IsActiveEntity=" + isActiveEntity + ")"; + } + + private String buildDraftMediaValidatedAttachmentContentUrl(String rootId, String attachmentId) { + return BASE_ROOT_URL + + "(ID=" + + rootId + + ",IsActiveEntity=false)" + + "/mediaValidatedAttachments(ID=" + + attachmentId + + ",up__ID=" + + rootId + + ",IsActiveEntity=false)" + + "/content"; + } + + // methods we do not need, but should override + + @Override + public void verifyTwoCreateAndRevertedDeleteEvents() { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyTwoCreateAndDeleteEvents(String param1, String param2) { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyEventContextEmptyForEvent(String... params) { + // No service handler is present, so no actions are required. + } + + @Override + public void clearServiceHandlerContext() { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyOnlyTwoCreateEvents(String param1, String param2) { + // No service handler is present, so no actions are required. + } + + @Override + protected void verifyContentId(String contentId, String attachmentId) { + assertThat(contentId).isEqualTo(attachmentId); + } + + @Override + protected void verifyContent(InputStream attachment, String testContent) throws IOException { + if (Objects.nonNull(testContent)) { + assertThat(attachment.readAllBytes()) + .isEqualTo(testContent.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } else { + assertThat(attachment).isNull(); + } + } + + @Override + public void verifyTwoReadEvents() { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyOnlyTwoDeleteEvents(String param1, String param2) { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyNoAttachmentEventsCalled() { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyTwoUpdateEvents(String param1, String param2, String param3, String param4) { + // No service handler is present, so no actions are required. + } +} diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java index 843fa2bb..b926b1be 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java @@ -42,18 +42,21 @@ @AutoConfigureMockMvc abstract class DraftOdataRequestValidationBase { - protected static final Logger logger = - LoggerFactory.getLogger(DraftOdataRequestValidationBase.class); + protected static final Logger logger = LoggerFactory.getLogger(DraftOdataRequestValidationBase.class); private static final String BASE_URL = MockHttpRequestHelper.ODATA_BASE_URL + "TestDraftService/"; private static final String BASE_ROOT_URL = BASE_URL + "DraftRoots"; @Autowired(required = false) protected TestPluginAttachmentsServiceHandler serviceHandler; - @Autowired protected MockHttpRequestHelper requestHelper; - @Autowired protected PersistenceService persistenceService; - @Autowired private TableDataDeleter dataDeleter; - @Autowired private TestPersistenceHandler testPersistenceHandler; + @Autowired + protected MockHttpRequestHelper requestHelper; + @Autowired + protected PersistenceService persistenceService; + @Autowired + private TableDataDeleter dataDeleter; + @Autowired + private TestPersistenceHandler testPersistenceHandler; @AfterEach void teardown() { @@ -107,16 +110,14 @@ void contentCanBeReadFromDraft() throws Exception { clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); - var attachmentUrl = - getAttachmentBaseUrl( - selectedRoot.getItems().get(0).getId(), - selectedRoot.getItems().get(0).getAttachments().get(0).getId(), - false) - + "/content"; - var attachmentEntityUrl = - getAttachmentEntityBaseUrl( - selectedRoot.getItems().get(0).getAttachmentEntities().get(0).getId(), false) - + "/content"; + var attachmentUrl = getAttachmentBaseUrl( + selectedRoot.getItems().get(0).getId(), + selectedRoot.getItems().get(0).getAttachments().get(0).getId(), + false) + + "/content"; + var attachmentEntityUrl = getAttachmentEntityBaseUrl( + selectedRoot.getItems().get(0).getAttachmentEntities().get(0).getId(), false) + + "/content"; Awaitility.await() .atMost(60, TimeUnit.SECONDS) @@ -128,9 +129,8 @@ void contentCanBeReadFromDraft() throws Exception { var attachmentEntityResponse = requestHelper.executeGet(attachmentEntityUrl); var attachmentResponseContent = getResponseContent(attachmentResponse); var attachmentEntityResponseContent = getResponseContent(attachmentEntityResponse); - var result = - attachmentResponseContent.equals(testContentAttachment) - && attachmentEntityResponseContent.equals(testContentAttachmentEntity); + var result = attachmentResponseContent.equals(testContentAttachment) + && attachmentEntityResponseContent.equals(testContentAttachmentEntity); if (!result) { logger.info( "Attachment response content: {}, Attachment Test Content: {}, Attachment Entity response content: {}, Attachment Entity Test Content: {}", @@ -154,16 +154,15 @@ void contentCanBeReadFromDraft() throws Exception { @Test void deleteAttachmentAndActivateDraft() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); var itemAttachment = selectedRoot.getItems().get(0).getAttachments().get(0); var itemAttachmentEntity = selectedRoot.getItems().get(0).getAttachmentEntities().get(0); - var attachmentDeleteUrl = - getAttachmentBaseUrl(selectedRoot.getItems().get(0).getId(), itemAttachment.getId(), false); + var attachmentDeleteUrl = getAttachmentBaseUrl(selectedRoot.getItems().get(0).getId(), itemAttachment.getId(), + false); var attachmentEntityDeleteUrl = getAttachmentEntityBaseUrl(itemAttachmentEntity.getId(), false); requestHelper.executeDeleteWithMatcher(attachmentDeleteUrl, status().isNoContent()); @@ -180,8 +179,7 @@ void deleteAttachmentAndActivateDraft() throws Exception { @Test void updateAttachmentAndActivateDraft() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); @@ -203,15 +201,14 @@ void updateAttachmentAndActivateDraft() throws Exception { assertThat(selectedRootAfterUpdate.getItems().get(0).getAttachments().get(0).getFileName()) .isEqualTo(changedAttachmentFileName); assertThat( - selectedRootAfterUpdate.getItems().get(0).getAttachmentEntities().get(0).getFileName()) + selectedRootAfterUpdate.getItems().get(0).getAttachmentEntities().get(0).getFileName()) .isEqualTo(changedAttachmentEntityFileName); verifyNoAttachmentEventsCalled(); } @Test void updateAttachmentAndCancelDraft() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); @@ -233,15 +230,14 @@ void updateAttachmentAndCancelDraft() throws Exception { assertThat(selectedRootAfterUpdate.getItems().get(0).getAttachments().get(0).getFileName()) .isEqualTo(originAttachmentFileName); assertThat( - selectedRootAfterUpdate.getItems().get(0).getAttachmentEntities().get(0).getFileName()) + selectedRootAfterUpdate.getItems().get(0).getAttachmentEntities().get(0).getFileName()) .isEqualTo(originAttachmentEntityFileName); verifyNoAttachmentEventsCalled(); } @Test void createAttachmentAndActivateDraft() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); @@ -261,8 +257,7 @@ void createAttachmentAndActivateDraft() throws Exception { @Test void createAttachmentAndCancelDraft() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); @@ -282,9 +277,8 @@ void createAttachmentAndCancelDraft() throws Exception { @Test void deleteContentInDraft() throws Exception { - var selectedRoot = - deepCreateAndActivate( - "testContent attachment for delete", "testContent attachmentEntity for delete"); + var selectedRoot = deepCreateAndActivate( + "testContent attachment for delete", "testContent attachmentEntity for delete"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); var itemAttachment = selectedRoot.getItems().get(0).getAttachments().get(0); @@ -305,8 +299,7 @@ void deleteContentInDraft() throws Exception { @Test void doNotDeleteContentInCancelledDraft() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); @@ -328,8 +321,7 @@ void doNotDeleteContentInCancelledDraft() throws Exception { @Test void updateContentInDraft() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); @@ -363,12 +355,12 @@ void updateContentInDraft() throws Exception { assertThat(selectedRootAfterDeletion.getItems().get(0).getAttachments().get(0).getContentId()) .isNotEmpty(); assertThat( - selectedRootAfterDeletion - .getItems() - .get(0) - .getAttachmentEntities() - .get(0) - .getContentId()) + selectedRootAfterDeletion + .getItems() + .get(0) + .getAttachmentEntities() + .get(0) + .getContentId()) .isNotEmpty(); } @@ -439,7 +431,7 @@ void deleteItemAndCancelDraft() throws Exception { .isNotEmpty(); assertThat(selectedRootAfterDelete.getItems().get(0).getAttachmentEntities()).isNotEmpty(); assertThat( - selectedRootAfterDelete.getItems().get(0).getAttachmentEntities().get(0).getContentId()) + selectedRootAfterDelete.getItems().get(0).getAttachmentEntities().get(0).getContentId()) .isNotEmpty(); verifyNoAttachmentEventsCalled(); } @@ -462,16 +454,14 @@ void noEventsForForDeletedRoot() throws Exception { assertThat(result).isEmpty(); var attachmentContentId = selectedRoot.getItems().get(0).getAttachments().get(0).getContentId(); - var attachmentEntityContentId = - selectedRoot.getItems().get(0).getAttachmentEntities().get(0).getContentId(); + var attachmentEntityContentId = selectedRoot.getItems().get(0).getAttachmentEntities().get(0).getContentId(); verifyOnlyTwoDeleteEvents(attachmentContentId, attachmentEntityContentId); } @Test void errorInTransactionAfterCreateCallsDelete() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); @@ -486,8 +476,7 @@ void errorInTransactionAfterCreateCallsDelete() throws Exception { @Test void errorInTransactionAfterCreateCallsDeleteAndNothingForCancel() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); @@ -538,8 +527,7 @@ void errorInTransactionAfterUpdateCallsDeleteEvenIfDraftIsCancelled() throws Exc @Test void createAndDeleteAttachmentWorks() throws Exception { - var selectedRoot = - deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); + var selectedRoot = deepCreateAndActivate("testContent attachment", "testContent attachmentEntity"); clearServiceHandlerContext(); createNewDraftForExistingRoot(selectedRoot.getId()); @@ -555,21 +543,18 @@ void createAndDeleteAttachmentWorks() throws Exception { var existingAttachment = selectedRoot.getItems().get(0).getAttachments().get(0); var existingAttachmentEntity = selectedRoot.getItems().get(0).getAttachmentEntities().get(0); - var newAttachment = - draftRoot.getItems().get(0).getAttachments().stream() - .filter(attachment -> !attachment.getId().equals(existingAttachment.getId())) - .findAny() - .orElseThrow(); - var newAttachmentEntity = - draftRoot.getItems().get(0).getAttachmentEntities().stream() - .filter( - attachmentEntity -> - !attachmentEntity.getId().equals(existingAttachmentEntity.getId())) - .findAny() - .orElseThrow(); - - var attachmentDeleteUrl = - getAttachmentBaseUrl(selectedRoot.getItems().get(0).getId(), newAttachment.getId(), false); + var newAttachment = draftRoot.getItems().get(0).getAttachments().stream() + .filter(attachment -> !attachment.getId().equals(existingAttachment.getId())) + .findAny() + .orElseThrow(); + var newAttachmentEntity = draftRoot.getItems().get(0).getAttachmentEntities().stream() + .filter( + attachmentEntity -> !attachmentEntity.getId().equals(existingAttachmentEntity.getId())) + .findAny() + .orElseThrow(); + + var attachmentDeleteUrl = getAttachmentBaseUrl(selectedRoot.getItems().get(0).getId(), newAttachment.getId(), + false); var attachmentEntityDeleteUrl = getAttachmentEntityBaseUrl(newAttachmentEntity.getId(), false); requestHelper.executeDeleteWithMatcher(attachmentDeleteUrl, status().isNoContent()); @@ -595,8 +580,7 @@ protected DraftRoots deepCreateAndActivate( } private DraftRoots createNewDraft() throws Exception { - var responseRootCdsData = - requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, "{}"); + var responseRootCdsData = requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, "{}"); return Struct.access(responseRootCdsData).as(DraftRoots.class); } @@ -621,8 +605,7 @@ private Items createItem(String rootUrl) throws Exception { var item = Items.create(); item.setTitle("some item"); var itemUrl = rootUrl + "/items"; - var responseItemCdsData = - requestHelper.executePostWithODataResponseAndAssertStatusCreated(itemUrl, item.toJson()); + var responseItemCdsData = requestHelper.executePostWithODataResponseAndAssertStatusCreated(itemUrl, item.toJson()); return Struct.access(responseItemCdsData).as(Items.class); } @@ -661,9 +644,8 @@ private Attachments createAttachment(String itemId) throws Exception { itemAttachment.setFileName("itemAttachment.txt"); var attachmentPostUrl = BASE_URL + "Items(ID=" + itemId + ",IsActiveEntity=false)/attachments"; - var responseAttachmentCdsData = - requestHelper.executePostWithODataResponseAndAssertStatusCreated( - attachmentPostUrl, itemAttachment.toJson()); + var responseAttachmentCdsData = requestHelper.executePostWithODataResponseAndAssertStatusCreated( + attachmentPostUrl, itemAttachment.toJson()); return Struct.access(responseAttachmentCdsData).as(Attachments.class); } @@ -696,8 +678,7 @@ private void putNewContentForAttachmentEntity( private void putNewContentForAttachmentEntity( String testContentAttachmentEntity, String attachmentId, ResultMatcher matcher) throws Exception { - var attachmentEntityPutUrl = - BASE_URL + "/AttachmentEntity(ID=" + attachmentId + ",IsActiveEntity=false)/content"; + var attachmentEntityPutUrl = BASE_URL + "/AttachmentEntity(ID=" + attachmentId + ",IsActiveEntity=false)/content"; requestHelper.setContentType("image/jpeg"); requestHelper.executePutWithMatcher( attachmentEntityPutUrl, @@ -711,9 +692,8 @@ private AttachmentEntity createAttachmentEntity(Items responseItem) throws Excep itemAttachmentEntity.setFileName("itemAttachmentEntity.txt"); var attachmentEntityPostUrl = getItemUrl(responseItem, false) + "/attachmentEntities"; - var responseAttachmentEntityCdsData = - requestHelper.executePostWithODataResponseAndAssertStatusCreated( - attachmentEntityPostUrl, itemAttachmentEntity.toJson()); + var responseAttachmentEntityCdsData = requestHelper.executePostWithODataResponseAndAssertStatusCreated( + attachmentEntityPostUrl, itemAttachmentEntity.toJson()); return Struct.access(responseAttachmentEntityCdsData).as(AttachmentEntity.class); } @@ -764,33 +744,29 @@ private DraftRoots selectStoredRootData(DraftRoots responseRoot) { } private DraftRoots selectStoredRootData(String entityName, DraftRoots responseRoot) { - var select = - Select.from(entityName) - .where(root -> root.get(DraftRoots.ID).eq(responseRoot.getId())) - .columns( - StructuredType::_all, - root -> - root.to(DraftRoots.ITEMS) - .expand( - StructuredType::_all, - item -> item.to(Items.ATTACHMENTS).expand(), - item -> item.to(Items.ATTACHMENT_ENTITIES).expand())); + var select = Select.from(entityName) + .where(root -> root.get(DraftRoots.ID).eq(responseRoot.getId())) + .columns( + StructuredType::_all, + root -> root.to(DraftRoots.ITEMS) + .expand( + StructuredType::_all, + item -> item.to(Items.ATTACHMENTS).expand(), + item -> item.to(Items.ATTACHMENT_ENTITIES).expand())); return persistenceService.run(select).single(DraftRoots.class); } protected void readAndValidateActiveContent( DraftRoots selectedRoot, String attachmentContent, String attachmentEntityContent) throws Exception { - var attachmentUrl = - getAttachmentBaseUrl( - selectedRoot.getItems().get(0).getId(), - selectedRoot.getItems().get(0).getAttachments().get(0).getId(), - true) - + "/content"; - var attachmentEntityUrl = - getAttachmentEntityBaseUrl( - selectedRoot.getItems().get(0).getAttachmentEntities().get(0).getId(), true) - + "/content"; + var attachmentUrl = getAttachmentBaseUrl( + selectedRoot.getItems().get(0).getId(), + selectedRoot.getItems().get(0).getAttachments().get(0).getId(), + true) + + "/content"; + var attachmentEntityUrl = getAttachmentEntityBaseUrl( + selectedRoot.getItems().get(0).getAttachmentEntities().get(0).getId(), true) + + "/content"; Awaitility.await() .atMost(60, TimeUnit.SECONDS) @@ -801,12 +777,10 @@ protected void readAndValidateActiveContent( var attachmentResponse = requestHelper.executeGet(attachmentUrl); var attachmentEntityResponse = requestHelper.executeGet(attachmentEntityUrl); var attachmentContentAsString = attachmentResponse.getResponse().getContentAsString(); - var attachmentEntityContentAsString = - attachmentEntityResponse.getResponse().getContentAsString(); + var attachmentEntityContentAsString = attachmentEntityResponse.getResponse().getContentAsString(); - var booleanResult = - attachmentContentAsString.equals(attachmentContent) - && attachmentEntityContentAsString.equals(attachmentEntityContent); + var booleanResult = attachmentContentAsString.equals(attachmentContent) + && attachmentEntityContentAsString.equals(attachmentEntityContent); if (!booleanResult) { logger.info( @@ -832,11 +806,9 @@ protected void readAndValidateActiveContent( private void deleteContent( DraftRoots selectedRoot, Attachments itemAttachment, AttachmentEntity itemAttachmentEntity) throws Exception { - var attachmentUrl = - getAttachmentBaseUrl(selectedRoot.getItems().get(0).getId(), itemAttachment.getId(), false) - + "/content"; - var attachmentEntityUrl = - getAttachmentEntityBaseUrl(itemAttachmentEntity.getId(), false) + "/content"; + var attachmentUrl = getAttachmentBaseUrl(selectedRoot.getItems().get(0).getId(), itemAttachment.getId(), false) + + "/content"; + var attachmentEntityUrl = getAttachmentEntityBaseUrl(itemAttachmentEntity.getId(), false) + "/content"; requestHelper.executeDeleteWithMatcher(attachmentUrl, status().isNoContent()); requestHelper.executeDeleteWithMatcher(attachmentEntityUrl, status().isNoContent()); @@ -866,8 +838,7 @@ private void updateFileName( String changedAttachmentEntityFileName, HttpStatus httpStatus) throws Exception { - var attachmentUrl = - getAttachmentBaseUrl(selectedRoot.getItems().get(0).getId(), itemAttachment.getId(), false); + var attachmentUrl = getAttachmentBaseUrl(selectedRoot.getItems().get(0).getId(), itemAttachment.getId(), false); var attachmentEntityUrl = getAttachmentEntityBaseUrl(itemAttachmentEntity.getId(), false); requestHelper.executePatchWithODataResponseAndAssertStatus( @@ -911,12 +882,12 @@ private void verifyNothingHasChangedInDraft( assertThat(selectedRootAfterDeletion.getItems().get(0).getAttachments().get(0).getContentId()) .isNotEmpty(); assertThat( - selectedRootAfterDeletion - .getItems() - .get(0) - .getAttachmentEntities() - .get(0) - .getContentId()) + selectedRootAfterDeletion + .getItems() + .get(0) + .getAttachmentEntities() + .get(0) + .getContentId()) .isNotEmpty(); } diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/AcceptedMediaTypesAttachmentsValidationNonDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/AcceptedMediaTypesAttachmentsValidationNonDraftTest.java new file mode 100644 index 00000000..13a548eb --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/AcceptedMediaTypesAttachmentsValidationNonDraftTest.java @@ -0,0 +1,188 @@ +/* +* © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. +*/ +package com.sap.cds.feature.attachments.integrationtests.nondraftservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sap.cds.Result; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.AttachmentEntity; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots_; +import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; +import com.sap.cds.feature.attachments.integrationtests.nondraftservice.helper.AttachmentsBuilder; +import com.sap.cds.feature.attachments.integrationtests.nondraftservice.helper.RootEntityBuilder; +import com.sap.cds.ql.Select; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MvcResult; + +@ActiveProfiles(Profiles.TEST_HANDLER_DISABLED) +class AcceptedMediaTypesAttachmentsValidationNonDraftTest extends OdataRequestValidationBase { + + @Test + void uploadingAllowedMediaTypeShouldSucceed() throws Exception { + // Arrange: Create root with mediaValidatedAttachments + Roots serviceRoot = buildServiceRootWithMediaValidatedAttachments("test.jpeg", MediaType.IMAGE_JPEG.toString()); + postServiceRoot(serviceRoot); + + Roots selectedRoot = selectStoredRootWithMediaValidatedAttachments(); + Attachments attachment = selectedRoot.getMediaValidatedAttachments().get(0); + + // Act & Assert: Upload content with allowed media type + String url = buildNavigationMediaValidationAttachmentUrl(selectedRoot.getId(), attachment.getId()) + "/content"; + requestHelper.setContentType(MediaType.IMAGE_JPEG); + + requestHelper.executePutWithMatcher( + url, + "fake-jpeg-content".getBytes(StandardCharsets.UTF_8), + status().isNoContent()); + } + + @Test + void uploadingNotAllowedMediaTypeShouldFail() throws Exception { + Roots serviceRoot = buildServiceRootWithMediaValidatedAttachments("test.pdf", + MediaType.APPLICATION_PDF.toString()); + postServiceRoot(serviceRoot); + + Roots selectedRoot = selectStoredRootWithMediaValidatedAttachments(); + Attachments attachment = selectedRoot.getMediaValidatedAttachments().get(0); + + // Act & Assert: Upload with disallowed media type fails + String url = buildNavigationMediaValidationAttachmentUrl(selectedRoot.getId(), attachment.getId()) + "/content"; + requestHelper.setContentType(MediaType.APPLICATION_PDF); + + requestHelper.executePutWithMatcher( + url, + "fake-jpeg-content".getBytes(StandardCharsets.UTF_8), + status().isUnsupportedMediaType()); + } + + // Helper methods + private String buildNavigationMediaValidationAttachmentUrl(String rootId, String attachmentId) { + return "/odata/v4/TestService/Roots(" + + rootId + + ")/mediaValidatedAttachments(ID=" + + attachmentId + + ",up__ID=" + + rootId + + ")"; + } + + private Roots buildServiceRootWithMediaValidatedAttachments(String fileName, String mimeType) { + return RootEntityBuilder.create() + .setTitle("Root with mediaValidatedAttachments") + .addMediaValidatedAttachments( + AttachmentsBuilder.create().setFileName(fileName).setMimeType(mimeType)) + .build(); + } + + private Roots selectStoredRootWithMediaValidatedAttachments() { + Select select = Select.from( + Roots_.class) + .columns(r -> r._all(), r -> r.mediaValidatedAttachments().expand()); + + Result result = persistenceService.run(select); + return result.single(Roots.class); + } + + // Required abstract method implementations + @Override + protected void executeContentRequestAndValidateContent(String url, String content) + throws Exception { + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .until( + () -> { + MvcResult response = requestHelper.executeGet(url); + return response.getResponse().getContentAsString().equals(content); + }); + + MvcResult response = requestHelper.executeGet(url); + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + } + + // Required abstract method implementations + + @Override + protected void verifyContentId( + Attachments attachmentWithExpectedContent, String attachmentId, String contentId) { + assertThat(attachmentWithExpectedContent.getContentId()).isEqualTo(attachmentId); + } + + @Override + protected void verifyContentAndContentId( + Attachments attachment, String testContent, Attachments itemAttachment) throws IOException { + assertThat(attachment.getContent().readAllBytes()) + .isEqualTo(testContent.getBytes(StandardCharsets.UTF_8)); + assertThat(attachment.getContentId()).isEqualTo(itemAttachment.getId()); + } + + @Override + protected void verifyContentAndContentIdForAttachmentEntity( + AttachmentEntity attachment, String testContent, AttachmentEntity itemAttachment) + throws IOException { + assertThat(attachment.getContent().readAllBytes()) + .isEqualTo(testContent.getBytes(StandardCharsets.UTF_8)); + assertThat(attachment.getContentId()).isEqualTo(itemAttachment.getId()); + } + + @Override + public void verifySingleCreateAndUpdateEvent(String arg1, String arg2, String arg3) { + // No service handler is present, so no actions are required. + } + + @Override + public void clearServiceHandlerContext() { + // No service handler is present, so no actions are required. + } + + @Override + public void verifySingleReadEvent(String arg) { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyTwoDeleteEvents(AttachmentEntity entity, Attachments attachments) { + // No service handler is present, so no actions are required. + } + + @Override + public void clearServiceHandlerDocuments() { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyEventContextEmptyForEvent(String... args) { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyNoAttachmentEventsCalled() { + // No service handler is present, so no actions are required. + } + + @Override + public void verifyNumberOfEvents(String arg, int count) { + // No service handler is present, so no actions are required. + } + + @Override + public void verifySingleCreateEvent(String arg1, String arg2) { + // No service handler is present, so no actions are required. + } + + @Override + public void verifySingleDeletionEvent(String arg) { + // No service handler is present, so no actions are required. + } + +} diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java index a4774951..bd8bd5ca 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java @@ -53,10 +53,14 @@ abstract class OdataRequestValidationBase { @Autowired(required = false) protected TestPluginAttachmentsServiceHandler serviceHandler; - @Autowired protected MockHttpRequestHelper requestHelper; - @Autowired protected PersistenceService persistenceService; - @Autowired private TableDataDeleter dataDeleter; - @Autowired private TestPersistenceHandler testPersistenceHandler; + @Autowired + protected MockHttpRequestHelper requestHelper; + @Autowired + protected PersistenceService persistenceService; + @Autowired + private TableDataDeleter dataDeleter; + @Autowired + private TestPersistenceHandler testPersistenceHandler; @AfterEach void teardown() { @@ -117,9 +121,8 @@ void expandReadOfAttachmentsHasNoFilledContent() throws Exception { var item = getItemWithAttachment(selectedRoot); var url = buildExpandAttachmentUrl(selectedRoot.getId(), item.getId()); - var responseItem = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus( - url, Items.class, HttpStatus.OK); + var responseItem = requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + url, Items.class, HttpStatus.OK); assertThat(responseItem.getAttachments()).hasSameSizeAs(item.getAttachments()); assertThat(responseItem.getAttachments()) @@ -143,17 +146,15 @@ void navigationReadOfAttachmentsHasFilledContent() throws Exception { var content = putContentForAttachmentWithNavigation(selectedRoot, itemAttachment); var url = buildExpandAttachmentUrl(selectedRoot.getId(), item.getId()); - var responseItem = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus( - url, Items.class, HttpStatus.OK); + var responseItem = requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + url, Items.class, HttpStatus.OK); assertThat(responseItem.getAttachments()).hasSameSizeAs(item.getAttachments()); - var attachmentWithExpectedContent = - responseItem.getAttachments().stream() - .filter(attach -> attach.getId().equals(itemAttachment.getId())) - .findAny() - .orElseThrow(); + var attachmentWithExpectedContent = responseItem.getAttachments().stream() + .filter(attach -> attach.getId().equals(itemAttachment.getId())) + .findAny() + .orElseThrow(); assertThat(attachmentWithExpectedContent) .containsEntry("content@mediaContentType", "application/octet-stream;charset=UTF-8") .containsEntry(Attachments.FILE_NAME, itemAttachment.getFileName()); @@ -176,9 +177,8 @@ void navigationReadOfAttachmentsReturnsContent() throws Exception { var selectedItemAfterChange = selectItem(item); var itemAttachmentAfterChange = getRandomItemAttachment(selectedItemAfterChange); - var url = - buildNavigationAttachmentUrl(selectedRoot.getId(), item.getId(), itemAttachment.getId()) - + "/content"; + var url = buildNavigationAttachmentUrl(selectedRoot.getId(), item.getId(), itemAttachment.getId()) + + "/content"; executeContentRequestAndValidateContent(url, content); verifySingleReadEvent(itemAttachmentAfterChange.getContentId()); } @@ -201,9 +201,8 @@ void navigationDeleteOfContentClears() throws Exception { itemAttachmentAfterChange.getContentId()); var expandUrl = buildExpandAttachmentUrl(selectedRoot.getId(), item.getId()); - var responseItem = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus( - expandUrl, Items.class, HttpStatus.OK); + var responseItem = requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + expandUrl, Items.class, HttpStatus.OK); assertThat(responseItem.getAttachments()).hasSameSizeAs(item.getAttachments()); assertThat(responseItem.getAttachments()) @@ -229,13 +228,11 @@ void navigationDeleteOfAttachmentClearsContentField() throws Exception { var selectedItemAfterChange = selectItem(item); var itemAttachmentAfterChange = getRandomItemAttachment(selectedItemAfterChange); - var url = - buildNavigationAttachmentUrl(selectedRoot.getId(), item.getId(), itemAttachment.getId()); + var url = buildNavigationAttachmentUrl(selectedRoot.getId(), item.getId(), itemAttachment.getId()); requestHelper.executeDelete(url); var expandUrl = buildExpandAttachmentUrl(selectedRoot.getId(), item.getId()); - var responseItem = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus( - expandUrl, Items.class, HttpStatus.OK); + var responseItem = requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + expandUrl, Items.class, HttpStatus.OK); assertThat(responseItem.getAttachments()).hasSize(1); assertThat(responseItem.getAttachments()) @@ -262,8 +259,7 @@ void navigationDeleteCallsTwiceReturnsError() throws Exception { var selectedItemAfterChange = selectItem(item); var itemAttachmentAfterChange = getRandomItemAttachment(selectedItemAfterChange); - var url = - buildNavigationAttachmentUrl(selectedRoot.getId(), item.getId(), itemAttachment.getId()); + var url = buildNavigationAttachmentUrl(selectedRoot.getId(), item.getId(), itemAttachment.getId()); requestHelper.executeDelete(url); var result = requestHelper.executeDelete(url); @@ -281,9 +277,8 @@ void directReadOfAttachmentsHasNoContentFilled() throws Exception { var itemAttachment = getRandomItemAttachmentEntity(item); var url = buildDirectAttachmentEntityUrl(itemAttachment.getId()); - var responseAttachment = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus( - url, Attachments.class, HttpStatus.OK); + var responseAttachment = requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + url, Attachments.class, HttpStatus.OK); assertThat(responseAttachment.get("content@mediaContentType")).isNull(); assertThat(responseAttachment.getContentId()).isNull(); @@ -303,9 +298,8 @@ void directReadOfAttachmentsHasFilledContent() throws Exception { clearServiceHandlerContext(); var url = buildDirectAttachmentEntityUrl(itemAttachment.getId()); - var responseAttachment = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus( - url, Attachments.class, HttpStatus.OK); + var responseAttachment = requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + url, Attachments.class, HttpStatus.OK); assertThat(responseAttachment) .containsEntry("content@mediaContentType", "application/octet-stream;charset=UTF-8") @@ -350,9 +344,8 @@ void directDeleteOfContentClears() throws Exception { itemAttachmentAfterChange.getContentId()); var expandUrl = buildExpandAttachmentUrl(selectedRoot.getId(), item.getId()); - var responseItem = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus( - expandUrl, Items.class, HttpStatus.OK); + var responseItem = requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + expandUrl, Items.class, HttpStatus.OK); assertThat(responseItem.getAttachmentEntities()).hasSameSizeAs(item.getAttachmentEntities()); assertThat(responseItem.getAttachmentEntities()) @@ -381,9 +374,8 @@ void directDeleteOfAttachmentClearsContentField() throws Exception { var url = buildDirectAttachmentEntityUrl(itemAttachment.getId()); requestHelper.executeDelete(url); var expandUrl = buildExpandAttachmentUrl(selectedRoot.getId(), item.getId()); - var responseItem = - requestHelper.executeGetWithSingleODataResponseAndAssertStatus( - expandUrl, Items.class, HttpStatus.OK); + var responseItem = requestHelper.executeGetWithSingleODataResponseAndAssertStatus( + expandUrl, Items.class, HttpStatus.OK); assertThat(responseItem.getAttachmentEntities()).isEmpty(); verifySingleDeletionEvent(itemAttachmentAfterChange.getContentId()); @@ -431,8 +423,7 @@ void rootDeleteDeletesAllContents() throws Exception { var itemAttachmentEntityAfterChange = getRandomItemAttachmentEntity(selectedItemAfterChange); var itemAttachmentAfterChange = getRandomItemAttachment(selectedItemAfterChange); - var url = - MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots(" + selectedRoot.getId() + ")"; + var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots(" + selectedRoot.getId() + ")"; requestHelper.executeDeleteWithMatcher(url, status().isNoContent()); verifyTwoDeleteEvents(itemAttachmentEntityAfterChange, itemAttachmentAfterChange); @@ -541,7 +532,7 @@ void updateContentWithErrorResetsForUrlsWithoutNavigation() throws Exception { } @ParameterizedTest - @CsvSource({"status,INFECTED", "contentId,TEST"}) + @CsvSource({ "status,INFECTED", "contentId,TEST" }) void statusCannotBeUpdated(String field, String value) throws Exception { var serviceRoot = buildServiceRootWithDeepData(); postServiceRoot(serviceRoot); @@ -648,17 +639,15 @@ protected void postServiceRoot(Roots serviceRoot) throws Exception { } protected Roots selectStoredRootWithDeepData() { - CqnSelect select = - Select.from(Roots_.class) - .columns( - StructuredType::_all, - root -> root.attachments().expand(), - root -> - root.items() - .expand( - StructuredType::_all, - item -> item.attachments().expand(), - item -> item.attachmentEntities().expand())); + CqnSelect select = Select.from(Roots_.class) + .columns( + StructuredType::_all, + root -> root.attachments().expand(), + root -> root.items() + .expand( + StructuredType::_all, + item -> item.attachments().expand(), + item -> item.attachmentEntities().expand())); var result = persistenceService.run(select); return result.single(Roots.class); } @@ -726,18 +715,15 @@ protected String putContentForAttachmentWithNavigation( private String putContentForAttachmentWithNavigation( Roots selectedRoot, Attachments itemAttachment, ResultMatcher matcher) throws Exception { - var selectedItem = - selectedRoot.getItems().stream() - .filter( - item -> - item.getAttachments().stream() - .anyMatch(attach -> attach.getId().equals(itemAttachment.getId()))) - .findAny() - .orElseThrow(); - var url = - buildNavigationAttachmentUrl( - selectedRoot.getId(), selectedItem.getId(), itemAttachment.getId()) - + "/content"; + var selectedItem = selectedRoot.getItems().stream() + .filter( + item -> item.getAttachments().stream() + .anyMatch(attach -> attach.getId().equals(itemAttachment.getId()))) + .findAny() + .orElseThrow(); + var url = buildNavigationAttachmentUrl( + selectedRoot.getId(), selectedItem.getId(), itemAttachment.getId()) + + "/content"; var testContent = "testContent" + itemAttachment.getNote(); requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); @@ -775,9 +761,8 @@ protected String putContentForSizeLimitedAttachment(Roots selectedRoot, Attachme protected String putContentForSizeLimitedAttachment( Roots selectedRoot, Attachments attachment, ResultMatcher matcher) throws Exception { - var url = - buildNavigationSizeLimitedAttachmentUrl(selectedRoot.getId(), attachment.getId()) - + "/content"; + var url = buildNavigationSizeLimitedAttachmentUrl(selectedRoot.getId(), attachment.getId()) + + "/content"; var testContent = "testContent" + attachment.getNote(); requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); requestHelper.executePutWithMatcher(url, testContent.getBytes(StandardCharsets.UTF_8), matcher); @@ -816,10 +801,9 @@ private String buildDirectAttachmentEntityUrl(String attachmentId) { private Attachments selectUpdatedAttachmentWithExpand( Roots selectedRoot, Attachments itemAttachment) { - CqnSelect attachmentSelect = - Select.from(Items_.class) - .where(a -> a.ID().eq(selectedRoot.getItems().get(0).getId())) - .columns(item -> item.attachments().expand()); + CqnSelect attachmentSelect = Select.from(Items_.class) + .where(a -> a.ID().eq(selectedRoot.getItems().get(0).getId())) + .columns(item -> item.attachments().expand()); var result = persistenceService.run(attachmentSelect); var items = result.single(Items.class); return items.getAttachments().stream() @@ -829,8 +813,7 @@ private Attachments selectUpdatedAttachmentWithExpand( } private AttachmentEntity selectUpdatedAttachment(AttachmentEntity itemAttachment) { - CqnSelect attachmentSelect = - Select.from(AttachmentEntity_.class).where(a -> a.ID().eq(itemAttachment.getId())); + CqnSelect attachmentSelect = Select.from(AttachmentEntity_.class).where(a -> a.ID().eq(itemAttachment.getId())); var result = persistenceService.run(attachmentSelect); return result.single(AttachmentEntity.class); } diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java index cf91a423..6ad44b61 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java @@ -38,6 +38,16 @@ public RootEntityBuilder addSizeLimitedAttachments(AttachmentsBuilder... attachm } Arrays.stream(attachments) .forEach(attachment -> rootEntity.getSizeLimitedAttachments().add(attachment.build())); + + return this; + } + + public RootEntityBuilder addMediaValidatedAttachments(AttachmentsBuilder... attachments) { + if (rootEntity.getMediaValidatedAttachments() == null) { + rootEntity.setMediaValidatedAttachments(new ArrayList<>()); + } + Arrays.stream(attachments) + .forEach(attachment -> rootEntity.getMediaValidatedAttachments().add(attachment.build())); return this; } diff --git a/integration-tests/srv/test-service.cds b/integration-tests/srv/test-service.cds index e4974ac0..e753306c 100644 --- a/integration-tests/srv/test-service.cds +++ b/integration-tests/srv/test-service.cds @@ -2,7 +2,15 @@ using test.data.model as db from '../db/data-model'; annotate db.Roots.sizeLimitedAttachments with { content @Validation.Maximum: '5MB'; -}; +} + +// Media type validation for attachments - for testing purposes. +annotate db.Roots.mediaValidatedAttachments with { + content @(Core.AcceptableMediaTypes: [ + 'image/jpeg', + 'image/png' + ]); +} service TestService { entity Roots as projection on db.Roots; diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 04f4d554..7cc46669 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -4,15 +4,25 @@ using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; // Extend Books entity to support file attachments (images, PDFs, documents) // Each book can have multiple attachments via composition relationship extend my.Books with { - attachments : Composition of many Attachments; + attachments : Composition of many Attachments; @UI.Hidden - sizeLimitedAttachments : Composition of many Attachments; + sizeLimitedAttachments : Composition of many Attachments; + @UI.Hidden + mediaValidatedAttachments : Composition of many Attachments; } annotate my.Books.sizeLimitedAttachments with { content @Validation.Maximum: '5MB'; } +// Media type validation for attachments +annotate my.Books.mediaValidatedAttachments with { + content @(Core.AcceptableMediaTypes: [ + 'image/jpeg', + 'image/png' + ]); +} + // Add UI component for attachments table to the Browse Books App using {CatalogService as service} from '../app/services'; From 42a06b6b219ec612de75bcef1a5eae0f06158100 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Thu, 22 Jan 2026 17:49:55 +0100 Subject: [PATCH 2/2] remove extra brackets --- samples/bookshop/srv/attachments.cds | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 7cc46669..1f453323 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -17,10 +17,10 @@ annotate my.Books.sizeLimitedAttachments with { // Media type validation for attachments annotate my.Books.mediaValidatedAttachments with { - content @(Core.AcceptableMediaTypes: [ + content @Core.AcceptableMediaTypes: [ 'image/jpeg', 'image/png' - ]); + ]; } // Add UI component for attachments table to the Browse Books App