diff --git a/README.md b/README.md index a5b3f206..af5d0704 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) @@ -214,6 +215,38 @@ The @Validation.Maximum value is a size string consisting of a number followed b 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/configuration/Registration.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java index e99586ea..d749a42f 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java @@ -1,5 +1,5 @@ /* - * © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ package com.sap.cds.feature.attachments.configuration; @@ -123,7 +123,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { boolean hasApplicationServices = serviceCatalog.getServices(ApplicationService.class).findFirst().isPresent(); if (hasApplicationServices) { - configurer.eventHandler(new CreateAttachmentsHandler(eventFactory, storage, defaultMaxSize)); + configurer.eventHandler( + new CreateAttachmentsHandler(eventFactory, storage, defaultMaxSize, runtime)); configurer.eventHandler( new UpdateAttachmentsHandler( eventFactory, attachmentsReader, outboxedAttachmentService, storage, defaultMaxSize)); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java index 6a10baf6..c3f1b620 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java @@ -10,8 +10,10 @@ import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation.AttachmentValidationHelper; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.EventContext; import com.sap.cds.services.ServiceException; import com.sap.cds.services.cds.ApplicationService; @@ -23,6 +25,7 @@ import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.utils.OrderConstants; import java.util.ArrayList; import java.util.List; @@ -41,14 +44,17 @@ public class CreateAttachmentsHandler implements EventHandler { private final ModifyAttachmentEventFactory eventFactory; private final ThreadDataStorageReader storageReader; private final String defaultMaxSize; + private final CdsRuntime cdsRuntime; public CreateAttachmentsHandler( ModifyAttachmentEventFactory eventFactory, ThreadDataStorageReader storageReader, - String defaultMaxSize) { + String defaultMaxSize, + CdsRuntime cdsRuntime) { this.eventFactory = requireNonNull(eventFactory, "eventFactory must not be null"); this.storageReader = requireNonNull(storageReader, "storageReader must not be null"); this.defaultMaxSize = requireNonNull(defaultMaxSize, "defaultMaxSize must not be null"); + this.cdsRuntime = requireNonNull(cdsRuntime, "cdsRuntime must not be null"); } @Before @@ -61,6 +67,13 @@ void processBeforeForDraft(CdsCreateEventContext context, List data) { context.getTarget(), data, storageReader.get()); } + @Before(event = {CqnService.EVENT_CREATE, DraftService.EVENT_DRAFT_NEW}) + @HandlerOrder(HandlerOrder.BEFORE) + void processBeforeForMetadata(EventContext context, List data) { + CdsEntity target = context.getTarget(); + AttachmentValidationHelper.validateMediaAttachments(target, data, cdsRuntime); + } + @Before @HandlerOrder(HandlerOrder.LATE) void processBefore(CdsCreateEventContext context, List data) { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java new file mode 100644 index 00000000..906982a2 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java @@ -0,0 +1,167 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; + +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.handler.common.ApplicationHandlerHelper; +import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public final class AttachmentDataExtractor { + private static final String FILE_NAME_FIELD = "fileName"; + public static final Filter FILE_NAME_FILTER = + (path, element, type) -> element.getName().contentEquals(FILE_NAME_FIELD); + + /** + * Extracts and validates file names of attachments from the given entity data. + * + * @param entity the CDS entity definition + * @param data the incoming data containing attachment values + * @return a map of element names to sets of associated file names + */ + public static Map> extractAndValidateFileNamesByElement( + CdsEntity entity, List data) { + // Collects file names from attachment-related elements in the entity + Map> fileNamesByElementName = collectFileNamesByElementName(entity, data); + // Ensures that all attachments have valid (non-null, non-empty) file names. + ensureAttachmentsHaveFileNames(entity, data, fileNamesByElementName); + return fileNamesByElementName; + } + + private static Map> collectFileNamesByElementName( + CdsEntity entity, List data) { + // Use CdsProcessor to traverse the data and collect file names for elements + // named "fileName" + Map> fileNamesByElementName = new HashMap<>(); + CdsDataProcessor processor = CdsDataProcessor.create(); + Validator fileNameValidator = generateFileNameFieldValidator(fileNamesByElementName); + processor.addValidator(FILE_NAME_FILTER, fileNameValidator).process(data, entity); + return fileNamesByElementName; + } + + private static Validator generateFileNameFieldValidator(Map> result) { + Validator validator = + (path, element, value) -> { + String fileName = requireString(value); + String normalizedFileName = validateAndNormalize(fileName); + String key = element.getDeclaringType().getQualifiedName(); + result.computeIfAbsent(key, k -> new HashSet<>()).add(normalizedFileName); + }; + return validator; + } + + private static String validateAndNormalize(String fileName) { + String trimmedFileName = fileName.trim(); + if (trimmedFileName.isEmpty()) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename must not be blank"); + } + + int lastDotIndex = trimmedFileName.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == trimmedFileName.length() - 1) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Invalid filename format: " + fileName); + } + return trimmedFileName; + } + + private static void ensureAttachmentsHaveFileNames( + CdsEntity entity, List data, Map> result) { + // Collect attachment-related elements/fields from the entity + List attachmentElements = + entity + .elements() + .filter( + e -> { + // Only consider associations + if (!e.getType().isAssociation()) { + return false; + } + // Keep only associations targeting media entities + // that define acceptable media types + CdsAssociationType association = e.getType().as(CdsAssociationType.class); + return ApplicationHandlerHelper.isMediaEntity(association.getTarget()) + && MediaTypeResolver.getAcceptableMediaTypesAnnotation( + association.getTarget()) + .isPresent(); + }) + .toList(); + + // Validate that required attachments have file names + ensureFilenamesPresent(data, result, attachmentElements); + } + + private static void ensureFilenamesPresent( + List data, + Map> result, + List attachmentElements) { + + // Extract keys of fields that actually contain data + Set dataKeys = collectValidDataKeys(data); + + // Check if any required attachment is missing a filename + boolean hasMissingFileName = hasMissingFileNames(result, attachmentElements, dataKeys); + + // If any attachment is missing a filename, throw and exception + if (hasMissingFileName) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); + } + } + + private static boolean hasMissingFileNames( + Map> result, + List availableAttachmentElements, + Set dataKeys) { + + return availableAttachmentElements.stream() + .filter(e -> dataKeys.contains(e.getName())) + .anyMatch( + element -> { + CdsAssociationType assoc = element.getType().as(CdsAssociationType.class); + String target = assoc.getTarget().getQualifiedName(); + Set fileNames = result.get(target); + return fileNames == null || fileNames.isEmpty(); + }); + } + + private static Set collectValidDataKeys(List data) { + return data.stream() + .flatMap(d -> d.entrySet().stream()) + .filter(entry -> !isEmpty(entry.getValue())) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + private static boolean isEmpty(Object value) { + return value == null + || (value instanceof String s && s.isBlank()) + || (value instanceof Collection c && c.isEmpty()) + || (value instanceof Iterable i && !i.iterator().hasNext()); + } + + private static String requireString(Object value) { + if (value == null) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); + } + if (!(value instanceof String s)) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename must be a string"); + } + return s; + } + + private AttachmentDataExtractor() { + // Private constructor to prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java new file mode 100644 index 00000000..71bce5e4 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java @@ -0,0 +1,130 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.feature.attachments.handler.common.AssociationCascader; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public final class AttachmentValidationHelper { + public static final List WILDCARD_MEDIA_TYPE = List.of("*/*"); + private static AssociationCascader cascader = new AssociationCascader(); + + static void setCascader(AssociationCascader testCascader) { + cascader = testCascader; + } + + /** + * Validates if the media type of the attachment in the given fileName is acceptable + * + * @param entity the {@link CdsEntity entity} type of the given data + * @param data the list of {@link CdsData} to process + * @throws ServiceException if the media type of the attachment is not acceptable + */ + public static void validateMediaAttachments( + CdsEntity entity, List data, CdsRuntime cdsRuntime) { + if (entity == null) { + return; + } + CdsModel cdsModel = cdsRuntime.getCdsModel(); + + boolean areAttachmentsAvailable = + ApplicationHandlerHelper.isMediaEntity(entity) + || cascader.hasAttachmentPath(cdsModel, entity); + + if (!areAttachmentsAvailable) { + return; + } + + // validate the media types of the attachments + Map> allowedTypesByElementName = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity, cdsModel); + Map> fileNamesByElementName = + AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, data); + validateAttachmentMediaTypes(fileNamesByElementName, allowedTypesByElementName); + } + + private static void validateAttachmentMediaTypes( + Map> fileNamesByElementName, + Map> acceptableMediaTypesByElementName) { + + // Determine which uploaded files do not match the allowed media types + Map> invalidFiles = + findInvalidFilesByElementName(fileNamesByElementName, acceptableMediaTypesByElementName); + + if (!invalidFiles.isEmpty()) { + throw buildUnsupportedFileTypeMessage(acceptableMediaTypesByElementName, invalidFiles); + } + } + + private static Map> findInvalidFilesByElementName( + Map> fileNamesByElementName, + Map> acceptableMediaTypesByElementName) { + // If no files are provided, there is nothing to validate → return empty result + if (fileNamesByElementName == null || fileNamesByElementName.isEmpty()) { + return Map.of(); + } + // Will store, per element, the list of files that violate media type + // constraints + Map> invalidFiles = new HashMap<>(); + fileNamesByElementName.forEach( + (elementName, files) -> { + // Resolve the allowed media types for this field / element. + List acceptableTypes = + acceptableMediaTypesByElementName.getOrDefault(elementName, WILDCARD_MEDIA_TYPE); + + // Filter out files whose media type is NOT allowed for this element + List invalid = + files.stream() + .filter( + fileName -> { + String mimeType = MediaTypeService.resolveMimeType(fileName); + return !MediaTypeService.isMimeTypeAllowed(acceptableTypes, mimeType); + }) + .toList(); + + if (!invalid.isEmpty()) { + invalidFiles.put(elementName, invalid); + } + }); + + return invalidFiles; + } + + private static ServiceException buildUnsupportedFileTypeMessage( + Map> acceptableMediaTypesByElementName, + Map> invalidFilesByElement) { + String message = + invalidFilesByElement.entrySet().stream() + .map( + entry -> { + String element = entry.getKey(); + String files = String.join(", ", entry.getValue()); + String allowed = + String.join( + ", ", + acceptableMediaTypesByElementName.getOrDefault( + element, WILDCARD_MEDIA_TYPE)); + return files + " (allowed: " + allowed + ") "; + }) + .collect(Collectors.joining("; ")); + + return new ServiceException( + ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Unsupported file types detected: " + message); + } + + private AttachmentValidationHelper() { + // to prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java new file mode 100644 index 00000000..da7f54a3 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java @@ -0,0 +1,72 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.attachments.handler.common.AssociationCascader; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public final class MediaTypeResolver { + private static final String CONTENT_ELEMENT = "content"; + private static final String ACCEPTABLE_MEDIA_TYPES_ANNOTATION = "Core.AcceptableMediaTypes"; + private static final TypeReference> STRING_LIST_TYPE_REF = new TypeReference<>() {}; + private static AssociationCascader cascader = new AssociationCascader(); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Resolves the acceptable media (MIME) types for the given {@link CdsEntity}. + * + *

The method behaves differently depending on whether the provided entity itself is a media + * entity or a root entity containing compositions: + * + *

If no media entities are found (neither the root nor its composition targets), an empty map + * is returned. + * + * @param entity the CDS entity to inspect (root or media entity) + * @return a map of entity qualified names to their allowed media types; empty if no media + * entities are found + */ + static void setCascader(AssociationCascader testCascader) { + cascader = testCascader; + } + + public static Map> getAcceptableMediaTypesFromEntity( + CdsEntity entity, CdsModel model) { + Map> result = new HashMap<>(); + List mediaEntityNames = cascader.findMediaEntityNames(model, entity); + if (mediaEntityNames.isEmpty()) { + return result; + } + for (String entityName : mediaEntityNames) { + CdsEntity mediaEntity = model.getEntity(entityName); + result.put(entityName, fetchAcceptableMediaTypes(mediaEntity)); + } + + return result; + } + + private static List fetchAcceptableMediaTypes(CdsEntity entity) { + return getAcceptableMediaTypesAnnotation(entity) + .map(CdsAnnotation::getValue) + .map(value -> objectMapper.convertValue(value, STRING_LIST_TYPE_REF)) + .orElse(AttachmentValidationHelper.WILDCARD_MEDIA_TYPE); + } + + public static Optional> getAcceptableMediaTypesAnnotation( + CdsEntity entity) { + return Optional.ofNullable(entity.getElement(CONTENT_ELEMENT)) + .flatMap(element -> element.findAnnotation(ACCEPTABLE_MEDIA_TYPES_ANNOTATION)); + } + + private MediaTypeResolver() { + // to prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java new file mode 100644 index 00000000..fe1e85e1 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java @@ -0,0 +1,85 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; + +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import java.net.FileNameMap; +import java.net.URLConnection; +import java.util.Collection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class MediaTypeService { + private static final Logger logger = LoggerFactory.getLogger(MediaTypeService.class); + public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; + + /** + * Resolves the MIME type of a file based on its filename (specifically its extension). + * + * @param fileName the name of the file (including extension) + * @return the resolved MIME type, or a default MIME type if it cannot be determined + * @throws ServiceException if the filename is null or blank + */ + public static String resolveMimeType(String fileName) { + if (fileName == null || fileName.isBlank()) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); + } + + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == fileName.length() - 1) { + return fallbackToDefaultMimeType(fileName); + } + + FileNameMap fileNameMap = URLConnection.getFileNameMap(); + String actualMimeType = fileNameMap.getContentTypeFor(fileName); + + if (actualMimeType == null) { + return fallbackToDefaultMimeType(fileName); + } + return actualMimeType; + } + + /** + * Checks if a given MIME type is allowed based on a collection of acceptable media + * + * @param acceptableMediaTypes + * @param mimeType + * @return + */ + public static boolean isMimeTypeAllowed( + Collection acceptableMediaTypes, String mimeType) { + if (mimeType == null) { + return false; + } + + if (acceptableMediaTypes == null + || acceptableMediaTypes.isEmpty() + || acceptableMediaTypes.contains("*/*")) return true; + + String baseMimeType = mimeType.trim().toLowerCase(); + Collection normalizedTypes = + acceptableMediaTypes.stream().map(type -> type.trim().toLowerCase()).toList(); + + return normalizedTypes.stream() + .anyMatch( + type -> { + return type.endsWith("/*") + ? baseMimeType.startsWith(type.substring(0, type.length() - 1)) + : baseMimeType.equals(type); + }); + } + + private static String fallbackToDefaultMimeType(String fileName) { + logger.warn( + "Could not determine mime type for file: {}. Setting mime type to default: {}", + fileName, + DEFAULT_MEDIA_TYPE); + return DEFAULT_MEDIA_TYPE; + } + + private MediaTypeService() { + // to prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java index 5941f14a..74b70a7b 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java @@ -25,6 +25,31 @@ public class AssociationCascader { private static final Logger logger = LoggerFactory.getLogger(AssociationCascader.class); + public boolean hasAttachmentPath(CdsModel model, CdsEntity entity) { + NodeTree tree = findEntityPath(model, entity); + return !tree.getChildren().isEmpty(); + } + + public List findMediaEntityNames(CdsModel model, CdsEntity entity) { + NodeTree tree = findEntityPath(model, entity); + List result = new ArrayList<>(); + collect(tree, result); + return result; + } + + private void collect(NodeTree node, List result) { + String entityName = node.getIdentifier().fullEntityName(); + + if (!node.getChildren().isEmpty()) { + for (NodeTree child : node.getChildren()) { + collect(child, result); + } + } else { + // leaf = media entity + result.add(entityName); + } + } + public NodeTree findEntityPath(CdsModel model, CdsEntity entity) { logger.debug("Start finding path to attachments for entity {}", entity.getQualifiedName()); var firstList = new LinkedList(); 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 4eabcf23..4b55da44 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 @@ -11,6 +11,7 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -27,6 +28,7 @@ import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation.AttachmentValidationHelper; 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.applicationservice.readhelper.CountingInputStream; @@ -48,6 +50,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.List; @@ -55,6 +58,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; class CreateAttachmentsHandlerTest { @@ -78,7 +82,10 @@ void setup() { storageReader = mock(ThreadDataStorageReader.class); cut = new CreateAttachmentsHandler( - eventFactory, storageReader, ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER); + eventFactory, + storageReader, + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + runtime); createContext = mock(CdsCreateEventContext.class); event = mock(ModifyAttachmentEvent.class); @@ -383,6 +390,41 @@ void restoreError_methodHasCorrectAnnotations() throws NoSuchMethodException { assertThat(handlerOrderAnnotation.value()).isEqualTo(HandlerOrder.EARLY); } + @Test + void processBeforeForMetadata_methodHasCorrectAnnotations() throws NoSuchMethodException { + Method method = + cut.getClass() + .getDeclaredMethod("processBeforeForMetadata", EventContext.class, List.class); + + Before beforeAnnotation = method.getAnnotation(Before.class); + HandlerOrder handlerOrderAnnotation = method.getAnnotation(HandlerOrder.class); + + assertThat(beforeAnnotation.event()) + .containsExactlyInAnyOrder(CqnService.EVENT_CREATE, DraftService.EVENT_DRAFT_NEW); + assertThat(handlerOrderAnnotation.value()).isEqualTo(HandlerOrder.BEFORE); + } + + @Test + void processBeforeForMetadata_executesValidation() { + EventContext context = mock(EventContext.class); + CdsEntity entity = mock(CdsEntity.class); + List data = List.of(mock(CdsData.class)); + when(context.getTarget()).thenReturn(entity); + + try (MockedStatic helper = + mockStatic(AttachmentValidationHelper.class)) { + helper + .when(() -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime)) + .thenAnswer(invocation -> null); + // when + new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime) + .processBeforeForMetadata(context, data); + // then + helper.verify( + () -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime)); + } + } + private void getEntityAndMockContext(String cdsName) { var serviceEntity = runtime.getCdsModel().findEntity(cdsName); mockTargetInCreateContext(serviceEntity.orElseThrow()); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractorTest.java new file mode 100644 index 00000000..a06c0d4f --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractorTest.java @@ -0,0 +1,338 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +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.handler.common.ApplicationHandlerHelper; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsType; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; + +class AttachmentDataExtractorTest { + + @Mock private CdsElement attachmentElement; + @Mock private CdsAssociationType associationType; + @Mock private CdsEntity targetEntity; + @Mock private CdsType cdsType; + @Mock private CdsDataProcessor processor; + @Mock private CdsAnnotation annotation; + private MockedStatic mediaMock; + private MockedStatic helperMock; + private MockedStatic processorMock; + + private static final String FILE_NAME = "fileName"; + private static final String ATTACHMENT_ENTITY = "test.Attachment"; + private static final String ATTACHMENT_FIELD = "mediaValidatedAttachments"; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + initStaticMocks(); + setupAttachmentModel(); + } + + @AfterEach + void tearDown() { + mediaMock.close(); + helperMock.close(); + processorMock.close(); + } + + @Test + void shouldReturnFileName_whenValidAttachmentProvided() { + // Arrange + CdsData cdsData = prepareCdsDataWithAttachments("test.jpeg"); + + // Act + Map> result = extractFileNames(cdsData); + + // Assert + assertThat(result).containsKey(ATTACHMENT_ENTITY); + assertThat(result.get(ATTACHMENT_ENTITY)).contains("test.jpeg"); + } + + @Test + void extractFileNames_whenFilenameBlank_throwsBadRequest() { + // Arrange + CdsData cdsData = prepareCdsDataWithAttachments(" "); + + // Act + ServiceException ex = assertThrows(ServiceException.class, () -> extractFileNames(cdsData)); + + // Assert + assertThat(ex.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); + } + + @Test + void extractFileNames_whenFilenameIsDot_throwsBadRequest() { + // Arrange + CdsData cdsData = prepareCdsDataWithAttachments("."); + + // Act + ServiceException ex = assertThrows(ServiceException.class, () -> extractFileNames(cdsData)); + + // Assert + assertThat(ex.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); + assertThat(ex.getMessage()).contains("Invalid filename format"); + } + + @Test + void extractFileNames_whenFilenameNull_throwsBadRequest() { + // Arrange + CdsData cdsData = prepareCdsDataWithAttachments((Object) null); + + // Act + ServiceException ex = assertThrows(ServiceException.class, () -> extractFileNames(cdsData)); + + // Assert + assertThat(ex.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); + assertThat(ex.getMessage()).contains("Filename is missing"); + } + + @Test + void extractFileNames_whenFilenameNotString_throwsBadRequest() { + // Arrange + CdsData cdsData = prepareCdsDataWithAttachments(123); + + // Act + ServiceException ex = assertThrows(ServiceException.class, () -> extractFileNames(cdsData)); + + // Assert + assertThat(ex.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); + assertThat(ex.getMessage()).contains("Filename must be a string"); + } + + @Test + void extractFileNames_multipleFiles_groupedCorrectly() { + // Arrange + CdsData cdsData = prepareCdsDataWithAttachments("attachment1.txt", "attachment2.txt"); + mockValidatorExecution("attachment1.txt", "attachment2.txt"); + + // Act + Map> result = extractFileNames(cdsData); + + // Assert + assertThat(result.get(ATTACHMENT_ENTITY)) + .containsExactlyInAnyOrder("attachment1.txt", "attachment2.txt"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("skipConditionsProvider") + void shouldSkipProcessing_whenConditionNotMet( + String testName, Consumer setupMock) { + + // Arrange + setupMock.accept(this); + CdsData data = prepareCdsDataWithAttachments("file.txt"); + + // Act + Map> result = extractFileNames(data); + + // Assert + assertThat(result).isNotNull(); + } + + private static Stream skipConditionsProvider() { + return Stream.of( + Arguments.of( + "Not an association", + (Consumer) + test -> when(test.cdsType.isAssociation()).thenReturn(false)), + Arguments.of( + "Not a composition", + (Consumer) + test -> when(test.associationType.isComposition()).thenReturn(false)), + Arguments.of( + "Not a media entity", + (Consumer) + test -> + test.helperMock + .when(() -> ApplicationHandlerHelper.isMediaEntity(test.targetEntity)) + .thenReturn(false)), + Arguments.of( + "Missing media annotation", + (Consumer) + test -> + test.mediaMock + .when( + () -> + MediaTypeResolver.getAcceptableMediaTypesAnnotation( + test.targetEntity)) + .thenReturn(Optional.empty()))); + } + + @Test + void filter_acceptsElement_whenAllConditionsTrue() { + CdsData data = prepareCdsDataWithAttachments("file.txt"); + Map> result = extractFileNames(data); + assertThat(result.get(ATTACHMENT_ENTITY)).contains("file.txt"); + } + + @Test + void ensureFilenamesPresent_whenResultMissingKey_throwsException() { + // Arrange + doAnswer(invocation -> processor).when(processor).addValidator(any(), any()); + doNothing().when(processor).process(anyList(), any()); + when(targetEntity.elements()).thenReturn(Stream.of(attachmentElement)); + when(attachmentElement.getName()).thenReturn(ATTACHMENT_FIELD); + CdsData data = + CdsData.create( + Map.of(ATTACHMENT_FIELD, List.of(CdsData.create(Map.of(FILE_NAME, "file.txt"))))); + + // Act + Assert + ServiceException ex = assertThrows(ServiceException.class, () -> extractFileNames(data)); + assertThat(ex.getMessage()).contains("Filename is missing"); + } + + @Test + void hasMissingFileNames_whenFileNamesEmpty_returnsTrue() throws Exception { + // Arrange + Map> result = new HashMap<>(); + result.put(ATTACHMENT_ENTITY, new HashSet<>()); + when(attachmentElement.getName()).thenReturn(ATTACHMENT_FIELD); + when(attachmentElement.getType()).thenReturn(cdsType); + when(cdsType.as(CdsAssociationType.class)).thenReturn(associationType); + when(associationType.getTarget()).thenReturn(targetEntity); + when(targetEntity.getQualifiedName()).thenReturn(ATTACHMENT_ENTITY); + List elements = List.of(attachmentElement); + Set dataKeys = Set.of(ATTACHMENT_FIELD); + + // Act + var method = + AttachmentDataExtractor.class.getDeclaredMethod( + "hasMissingFileNames", Map.class, List.class, Set.class); + method.setAccessible(true); + boolean resultValue = (boolean) method.invoke(null, result, elements, dataKeys); + + // Assert + assertThat(resultValue).isTrue(); + } + + @Test + void isEmptyValue_shouldCoverAllBranches() throws Exception { + var method = AttachmentDataExtractor.class.getDeclaredMethod("isEmpty", Object.class); + method.setAccessible(true); + Iterable emptyIterable = () -> Collections.emptyIterator(); + Iterable nonEmptyIterable = () -> List.of("x").iterator(); + + assertThat(method.invoke(null, (Object) null)).isEqualTo(true); + assertThat(method.invoke(null, " ")).isEqualTo(true); + assertThat(method.invoke(null, "abc")).isEqualTo(false); + assertThat(method.invoke(null, List.of())).isEqualTo(true); + assertThat(method.invoke(null, List.of("x"))).isEqualTo(false); + assertThat(method.invoke(null, emptyIterable)).isEqualTo(true); + assertThat(method.invoke(null, nonEmptyIterable)).isEqualTo(false); + } + + // ------------------ Mocks and Test Setup ------------------ + + private void mockAttachmentElementBasics() { + when(attachmentElement.getType()).thenReturn(cdsType); + when(attachmentElement.getDeclaringType()).thenReturn(targetEntity); + when(attachmentElement.getName()).thenReturn(FILE_NAME); + when(attachmentElement.getDeclaringType().getQualifiedName()).thenReturn(ATTACHMENT_ENTITY); + } + + private void mockCdsTypeAsAssociation() { + when(cdsType.isAssociation()).thenReturn(true); + when(cdsType.as(CdsAssociationType.class)).thenReturn(associationType); + } + + private void mockAssociationAsComposition() { + when(associationType.isComposition()).thenReturn(true); + when(associationType.getTarget()).thenReturn(targetEntity); + } + + private void mockTargetEntityDefaults() { + when(targetEntity.elements()).thenAnswer(inv -> Stream.of(attachmentElement)); + when(targetEntity.getQualifiedName()).thenReturn(ATTACHMENT_ENTITY); + when(targetEntity.getAnnotationValue(anyString(), any())).thenReturn(Boolean.TRUE); + } + + private void initStaticMocks() { + mediaMock = mockStatic(MediaTypeResolver.class); + helperMock = mockStatic(ApplicationHandlerHelper.class); + processorMock = mockStatic(CdsDataProcessor.class); + } + + private void mockDefaultBehavior() { + mediaMock + .when(() -> MediaTypeResolver.getAcceptableMediaTypesAnnotation(targetEntity)) + .thenReturn(Optional.of("dummy")); + helperMock.when(() -> ApplicationHandlerHelper.isMediaEntity(targetEntity)).thenReturn(true); + processorMock.when(CdsDataProcessor::create).thenReturn(processor); + } + + private void setupAttachmentModel() { + mockDefaultBehavior(); + mockAttachmentElementBasics(); + mockCdsTypeAsAssociation(); + mockAssociationAsComposition(); + mockTargetEntityDefaults(); + } + + private Map> extractFileNames(CdsData cdsData) { + return AttachmentDataExtractor.extractAndValidateFileNamesByElement( + targetEntity, List.of(cdsData)); + } + + private CdsData prepareCdsDataWithAttachments(Object... fileNames) { + List attachments = + Arrays.stream(fileNames) + .map( + name -> { + Map map = new HashMap<>(); + map.put(FILE_NAME, name); + return CdsData.create(map); + }) + .toList(); + mockValidatorExecution(fileNames); + return CdsData.create(Map.of(ATTACHMENT_FIELD, attachments)); + } + + private void mockValidatorExecution(Object... values) { + doAnswer( + invocation -> { + Filter filter = invocation.getArgument(0); + Validator validator = invocation.getArgument(1); + + if (filter.test(null, attachmentElement, null)) { + for (Object value : values) { + validator.validate(null, attachmentElement, value); + } + } + return processor; + }) + .when(processor) + .addValidator(any(), any()); + + doNothing().when(processor).process(anyList(), any()); + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java new file mode 100644 index 00000000..712b59ce --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java @@ -0,0 +1,212 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.feature.attachments.handler.common.AssociationCascader; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; + +class AttachmentValidationHelperTest { + + @AfterEach + void reset() { + MediaTypeResolver.setCascader(new AssociationCascader()); + } + + @Test + void doesNothing_whenEntityIsNull() { + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateMediaAttachments(null, List.of(), null)); + } + + @Test + void doesNothing_whenEntityNotFoundInModel() { + CdsEntity entity = mock(CdsEntity.class); + when(entity.getQualifiedName()).thenReturn("Entity"); + + CdsModel model = mock(CdsModel.class); + when(model.findEntity("Entity")).thenReturn(Optional.empty()); + + CdsRuntime runtime = mockRuntime(model); + + try (MockedStatic helper = + mockStatic(ApplicationHandlerHelper.class)) { + helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(false); + + setupMockCascader(entity, model, false); + + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + } + } + + @Test + void doesNotThrow_whenNoFiles() { + CdsEntity entity = mockEntity("Entity"); + + Map> allowed = Map.of("Entity.attachments", List.of("image/png")); + + try (MockedStatic helper = + mockStatic(ApplicationHandlerHelper.class); + MockedStatic resolver = mockStatic(MediaTypeResolver.class); + MockedStatic extractor = + mockStatic(AttachmentDataExtractor.class)) { + CdsRuntime runtime = mockRuntime(entity); + helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + + resolver + .when( + () -> + MediaTypeResolver.getAcceptableMediaTypesFromEntity( + entity, runtime.getCdsModel())) + .thenReturn(allowed); + + extractor + .when( + () -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) + .thenReturn(null); + + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + } + } + + @ParameterizedTest + @MethodSource("validFileScenarios") + void doesNotThrow_whenFilesAreValid(boolean isMediaEntity, boolean hasAttachmentPath) { + + CdsEntity entity = mockEntity("Entity"); + CdsRuntime runtime = mockRuntime(entity); + + Map> allowed = Map.of("Entity.attachments", List.of("image/png")); + Map> files = Map.of("Entity.attachments", Set.of("file.png")); + + try (MockedStatic helper = + mockStatic(ApplicationHandlerHelper.class); + MockedStatic resolver = mockStatic(MediaTypeResolver.class); + MockedStatic extractor = + mockStatic(AttachmentDataExtractor.class)) { + + helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), hasAttachmentPath); + + resolver + .when( + () -> + MediaTypeResolver.getAcceptableMediaTypesFromEntity( + entity, runtime.getCdsModel())) + .thenReturn(allowed); + + extractor + .when( + () -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) + .thenReturn(files); + + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + } + } + + private static Stream validFileScenarios() { + return Stream.of( + org.junit.jupiter.params.provider.Arguments.of(true, false), // media entity + org.junit.jupiter.params.provider.Arguments.of(false, true) // attachment path + ); + } + + @ParameterizedTest + @MethodSource("invalidFileScenarios") + void throwsException_whenFilesAreInvalid(boolean isMediaEntity, boolean hasAttachmentPath) { + + CdsEntity entity = mockEntity("Entity"); + CdsRuntime runtime = mockRuntime(entity); + + Map> allowed = Map.of("Entity.attachments", List.of("image/png")); + Map> files = Map.of("Entity.attachments", Set.of("file.txt")); + + try (MockedStatic helper = + mockStatic(ApplicationHandlerHelper.class); + MockedStatic resolver = mockStatic(MediaTypeResolver.class); + MockedStatic extractor = + mockStatic(AttachmentDataExtractor.class)) { + + helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), hasAttachmentPath); + + resolver + .when( + () -> + MediaTypeResolver.getAcceptableMediaTypesFromEntity( + entity, runtime.getCdsModel())) + .thenReturn(allowed); + + extractor + .when( + () -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) + .thenReturn(files); + + ServiceException ex = + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + + assertTrue(ex.getMessage().contains("Unsupported file types detected")); + } + } + + private static Stream invalidFileScenarios() { + return Stream.of( + org.junit.jupiter.params.provider.Arguments.of(true, false), + org.junit.jupiter.params.provider.Arguments.of(false, true)); + } + + private void setupMockCascader(CdsEntity entity, CdsModel model, boolean hasAttachmentPath) { + AssociationCascader cascader = mock(AssociationCascader.class); + when(cascader.hasAttachmentPath(model, entity)).thenReturn(hasAttachmentPath); + AttachmentValidationHelper.setCascader(cascader); + } + + private CdsRuntime mockRuntime(CdsEntity entity) { + CdsModel model = mock(CdsModel.class); + when(model.findEntity(entity.getQualifiedName())).thenReturn(Optional.of(entity)); + + CdsRuntime runtime = mock(CdsRuntime.class); + when(runtime.getCdsModel()).thenReturn(model); + + return runtime; + } + + private CdsRuntime mockRuntime(CdsModel model) { + CdsRuntime runtime = mock(CdsRuntime.class); + when(runtime.getCdsModel()).thenReturn(model); + return runtime; + } + + private CdsEntity mockEntity(String name) { + CdsEntity entity = mock(CdsEntity.class); + when(entity.getQualifiedName()).thenReturn(name); + return entity; + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java new file mode 100644 index 00000000..09827737 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java @@ -0,0 +1,97 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.feature.attachments.handler.common.AssociationCascader; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class MediaTypeResolverTest { + + @AfterEach + void reset() { + MediaTypeResolver.setCascader(new AssociationCascader()); + } + + @Test + void shouldReturnEmptyMapWhenNoMediaEntitiesFound() { + AssociationCascader mockCascader = mock(AssociationCascader.class); + MediaTypeResolver.setCascader(mockCascader); + + CdsModel model = mock(CdsModel.class); + CdsEntity root = mock(CdsEntity.class); + + when(mockCascader.findMediaEntityNames(model, root)).thenReturn(List.of()); + + Map> result = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(root, model); + + assertThat(result).isEmpty(); + } + + @Test + void shouldReturnMediaTypesFromAnnotation() { + CdsModel model = mock(CdsModel.class); + CdsEntity root = mock(CdsEntity.class); + CdsEntity media = mock(CdsEntity.class); + CdsElement element = mock(CdsElement.class); + CdsAnnotation annotation = mock(CdsAnnotation.class); + + AssociationCascader cascader = mock(AssociationCascader.class); + MediaTypeResolver.setCascader(cascader); + + when(cascader.findMediaEntityNames(model, root)).thenReturn(List.of("MediaEntity")); + when(model.getEntity("MediaEntity")).thenReturn(media); + + when(media.getElement("content")).thenReturn(element); + when(element.findAnnotation("Core.AcceptableMediaTypes")).thenReturn(Optional.of(annotation)); + when(annotation.getValue()).thenReturn(List.of("image/png", "image/jpeg")); + + Map> result = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(root, model); + + assertThat(result.get("MediaEntity")).containsExactly("image/png", "image/jpeg"); + } + + @Test + void shouldResolveMediaTypesUsingCascader() { + try (MockedStatic mocked = + mockStatic(ApplicationHandlerHelper.class)) { + + // Arrange + CdsModel model = mock(CdsModel.class); + CdsEntity root = mock(CdsEntity.class); + CdsEntity media = mock(CdsEntity.class); + AssociationCascader mockCascader = mock(AssociationCascader.class); + MediaTypeResolver.setCascader(mockCascader); + + mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(any())).thenReturn(false); + when(mockCascader.findMediaEntityNames(model, root)).thenReturn(List.of("MediaEntity")); + when(model.getEntity("MediaEntity")).thenReturn(media); + when(media.getElement(any())).thenReturn(null); + + // Act + Map> result = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(root, model); + + // Assert + assertThat(result).containsKey("MediaEntity"); + } + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java new file mode 100644 index 00000000..9dc898b1 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java @@ -0,0 +1,164 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sap.cds.services.ServiceException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class MediaTypeServiceTest { + + @Test + void returnsCorrectMimeType_forKnownExtension() { + String result = MediaTypeService.resolveMimeType("file.png"); + + assertEquals("image/png", result); + } + + @Test + void returnsCorrectMimeType_caseInsensitive() { + String result = MediaTypeService.resolveMimeType("file.JPG"); + + assertEquals("image/jpeg", result); + } + + @Test + void returnsDefaultMimeType_forUnknownExtension() { + String result = MediaTypeService.resolveMimeType("file.unknown"); + + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void returnsDefaultMimeType_whenNoExtensionPresent() { + String result = MediaTypeService.resolveMimeType("file"); + + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void returnsLastExtension_whenMultipleDotsPresent() { + String result = MediaTypeService.resolveMimeType("archive.tar.gz"); + + assertEquals("application/gzip", result); + } + + @Test + void handlesDoubleDotFiles() { + String result = MediaTypeService.resolveMimeType("file..png"); + + assertEquals("image/png", result); + } + + @Test + void handlesTrailingDotFile() { + String result = MediaTypeService.resolveMimeType("file."); + + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void handlesHiddenDotFile() { + String result = MediaTypeService.resolveMimeType(".gitignore"); + + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void handlesOnlyDotsFile() { + String result = MediaTypeService.resolveMimeType("..."); + + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void handlesWeirdFilename() { + String result = MediaTypeService.resolveMimeType("file..unknown"); + + assertEquals(MediaTypeService.DEFAULT_MEDIA_TYPE, result); + } + + @Test + void returnsFalse_whenMimeTypeIsNull() { + boolean result = MediaTypeService.isMimeTypeAllowed(List.of("image/png"), null); + + assertFalse(result); + } + + @Test + void returnsTrue_whenAcceptableTypesIsNull() { + boolean result = MediaTypeService.isMimeTypeAllowed(null, "image/png"); + + assertTrue(result); + } + + @Test + void returnsTrue_whenAcceptableTypesIsEmpty() { + boolean result = MediaTypeService.isMimeTypeAllowed(List.of(), "image/png"); + + assertTrue(result); + } + + @Test + void returnsTrue_whenWildcardAllPresent() { + boolean result = MediaTypeService.isMimeTypeAllowed(List.of("*/*"), "application/json"); + + assertTrue(result); + } + + @Test + void returnsTrue_forExactMatch() { + boolean result = MediaTypeService.isMimeTypeAllowed(List.of("image/png"), "image/png"); + + assertTrue(result); + } + + @Test + void returnsFalse_forDifferentMimeType() { + boolean result = MediaTypeService.isMimeTypeAllowed(List.of("image/png"), "image/jpeg"); + + assertFalse(result); + } + + @Test + void returnsTrue_forWildcardTypeMatch() { + boolean result = MediaTypeService.isMimeTypeAllowed(List.of("image/*"), "image/jpeg"); + + assertTrue(result); + } + + @Test + void returnsFalse_forNonMatchingWildcardType() { + boolean result = MediaTypeService.isMimeTypeAllowed(List.of("image/*"), "application/json"); + + assertFalse(result); + } + + @Test + void trimsAndNormalizesMimeTypes() { + boolean result = MediaTypeService.isMimeTypeAllowed(List.of(" IMAGE/PNG "), " image/png "); + + assertTrue(result); + } + + @Test + void returnsTrue_whenOneOfMultipleMatches() { + boolean result = + MediaTypeService.isMimeTypeAllowed(List.of("application/json", "image/png"), "image/png"); + + assertTrue(result); + } + + @Test + void throws_whenFilenameIsNull() { + assertThrows(ServiceException.class, () -> MediaTypeService.resolveMimeType(null)); + } + + @Test + void throws_whenFileNameIsBlank() { + assertThrows(ServiceException.class, () -> MediaTypeService.resolveMimeType(" ")); + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java index bd12583d..d8d1f280 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java @@ -32,6 +32,49 @@ void setup() { cut = new AssociationCascader(); } + @Test + void hasAttachmentPath_returnsTrue_whenChildrenExist() { + var entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + + boolean result = cut.hasAttachmentPath(runtime.getCdsModel(), entity); + + assertThat(result).isTrue(); + } + + @Test + void findMediaEntityNames_returnsAllLeafMediaEntities_forRoot() { + var entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + + var result = cut.findMediaEntityNames(runtime.getCdsModel(), entity); + + assertThat(result) + .containsExactlyInAnyOrder( + "unit.test.TestService.RootTable.attachments", + Attachment_.CDS_NAME, + "unit.test.TestService.Items.itemAttachments", + "unit.test.TestService.EventItems.sizeLimitedAttachments", + "unit.test.TestService.EventItems.defaultSizeLimitedAttachments"); + } + + @Test + void findMediaEntityNames_doesNotIncludeNonLeafNodes() { + var entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + + var result = cut.findMediaEntityNames(runtime.getCdsModel(), entity); + + // RootTable and Items should NOT be included (they have children) + assertThat(result).doesNotContain(RootTable_.CDS_NAME).doesNotContain(Items_.CDS_NAME); + } + + @Test + void findMediaEntityNames_returnsSelf_whenEntityIsLeaf() { + var entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); + + var result = cut.findMediaEntityNames(runtime.getCdsModel(), entity); + + assertThat(result).containsExactly(Attachment_.CDS_NAME); + } + @Test void pathCorrectFoundForRoot() { var serviceEntity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME); diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index f6c35e19..b9dcd8dd 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -1,19 +1,21 @@ 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; + mimeValidatedAttachments : Composition of many Attachments; } entity Items : cuid { diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/MediaValidatedAttachmentsDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/MediaValidatedAttachmentsDraftTest.java new file mode 100644 index 00000000..f1ddbb49 --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/MediaValidatedAttachmentsDraftTest.java @@ -0,0 +1,159 @@ +/* + * © 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.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.CdsData; +import com.sap.cds.Struct; +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.util.Map; +import java.util.Objects; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles(Profiles.TEST_HANDLER_DISABLED) +public class MediaValidatedAttachmentsDraftTest extends DraftOdataRequestValidationBase { + + private static final String BASE_URL = MockHttpRequestHelper.ODATA_BASE_URL + "TestDraftService/"; + private static final String BASE_ROOT_URL = BASE_URL + "DraftRoots"; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setup() { + requestHelper.setContentType(MediaType.APPLICATION_JSON); + } + + @ParameterizedTest + @CsvSource({ + "test.png,201", + "test.jpeg,201", + "test.pdf,415", + "test.txt,415", + "'',400", + "' ',400", + ".gitignore,415", + ".env,415", + ".hiddenfile,415" + }) + void shouldValidateMediaType_whenCreatingAttachmentInDraft(String fileName, int expectedStatus) + throws Exception { + String rootId = createDraftRootAndReturnId(); + String metadata = objectMapper.writeValueAsString(Map.of("fileName", fileName)); + + requestHelper.executePostWithMatcher( + buildDraftAttachmentCreationUrl(rootId), metadata, status().is(expectedStatus)); + } + + private String buildDraftAttachmentCreationUrl(String rootId) { + return BASE_ROOT_URL + + "(ID=" + + rootId + + ",IsActiveEntity=false)" + + "/mediaValidatedAttachments"; + } + + @Test + void shouldPass_whenFileNameMissing_inDraft() throws Exception { + String rootId = createDraftRootAndReturnId(); + String metadata = "{}"; + requestHelper.executePostWithMatcher( + buildDraftAttachmentCreationUrl(rootId), metadata, status().isCreated()); + } + + // Helper methods + private String createDraftRootAndReturnId() throws Exception { + CdsData response = + requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, "{}"); + + DraftRoots draftRoot = Struct.access(response).as(DraftRoots.class); + String payload = objectMapper.writeValueAsString(Map.of("title", "Draft")); + requestHelper.executePatchWithODataResponseAndAssertStatusOk( + getRootUrl(draftRoot.getId(), false), payload); + + return draftRoot.getId(); + } + + private String getRootUrl(String rootId, boolean isActiveEntity) { + return BASE_ROOT_URL + "(ID=" + rootId + ",IsActiveEntity=" + isActiveEntity + ")"; + } + + // Required abstract method implementations + @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 + protected void verifyNoAttachmentEventsCalled() { + // Implementation not required for this test + } + + @Override + protected void clearServiceHandlerContext() { + // Implementation not required for this test + } + + @Override + protected void verifyEventContextEmptyForEvent(String... events) { + // Implementation not required for this test + } + + @Override + protected void verifyOnlyTwoCreateEvents( + String newAttachmentContent, String newAttachmentEntityContent) { + // Implementation not required for this test + } + + @Override + protected void verifyTwoCreateAndDeleteEvents( + String newAttachmentContent, String newAttachmentEntityContent) { + // Implementation not required for this test + } + + @Override + protected void verifyTwoReadEvents() { + // Implementation not required for this test + } + + @Override + protected void verifyOnlyTwoDeleteEvents( + String attachmentContentId, String attachmentEntityContentId) { + // Implementation not required for this test + } + + @Override + protected void verifyTwoUpdateEvents( + String newAttachmentContent, + String attachmentContentId, + String newAttachmentEntityContent, + String attachmentEntityContentId) { + // Implementation not required for this test + } + + @Override + protected void verifyTwoCreateAndRevertedDeleteEvents() { + // Implementation not required for this test + } +} diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java index bd332021..b28055fd 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java @@ -13,6 +13,7 @@ import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; import java.io.IOException; import java.io.InputStream; +import java.util.List; import java.util.Objects; import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; @@ -28,7 +29,7 @@ void uploadContentWithin5MBLimitSucceeds() throws Exception { // Arrange: Create draft with sizeLimitedAttachments var draftRoot = createNewDraftWithSizeLimitedAttachments(); var attachment = draftRoot.getSizeLimitedAttachments().get(0); - + attachment.setFileName("test.txt"); // Act & Assert: Upload 3MB content (within limit) succeeds byte[] content = new byte[3 * 1024 * 1024]; // 3MB var url = buildDraftSizeLimitedAttachmentContentUrl(draftRoot.getId(), attachment.getId()); @@ -41,14 +42,15 @@ void uploadContentExceeding5MBLimitFails() throws Exception { // Arrange: Create draft with sizeLimitedAttachments var draftRoot = createNewDraftWithSizeLimitedAttachments(); var attachment = draftRoot.getSizeLimitedAttachments().get(0); - + attachment.setFileName("test.txt"); // Act: Try to upload 6MB content (exceeds limit) byte[] content = new byte[6 * 1024 * 1024]; // 6MB var url = buildDraftSizeLimitedAttachmentContentUrl(draftRoot.getId(), attachment.getId()); requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); requestHelper.executePutWithMatcher(url, content, status().is(413)); - // Assert: Error response with HTTP 413 status code indicates size limit exceeded + // Assert: Error response with HTTP 413 status code indicates size limit + // exceeded } // Helper methods @@ -74,7 +76,7 @@ private DraftRoots createNewDraftWithSizeLimitedAttachments() throws Exception { var createdAttachment = Struct.access(responseAttachmentCdsData).as(Attachments.class); // Build result with the attachment - draftRoot.setSizeLimitedAttachments(java.util.List.of(createdAttachment)); + draftRoot.setSizeLimitedAttachments(List.of(createdAttachment)); return draftRoot; } diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java new file mode 100644 index 00000000..b28ec997 --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java @@ -0,0 +1,296 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.nondraftservice; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +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.common.MockHttpRequestHelper; +import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; +import com.sap.cds.feature.attachments.integrationtests.nondraftservice.helper.RootEntityBuilder; +import com.sap.cds.ql.Select; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles(Profiles.TEST_HANDLER_DISABLED) +class MediaValidatedAttachmentsNonDraftTest extends OdataRequestValidationBase { + private static final String BASE_URL = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots"; + private static final String MEDIA_VALIDATED_ATTACHMENTS = "mediaValidatedAttachments"; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + protected void postServiceRoot(Roots serviceRoot) throws Exception { + String url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots"; + requestHelper.executePostWithMatcher(url, serviceRoot.toJson(), status().isCreated()); + } + + 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); + } + + @BeforeEach + void setup() { + requestHelper.setContentType(MediaType.APPLICATION_JSON); + } + + @ParameterizedTest + @CsvSource({ + "image.jpg,image/jpeg,201", + "image.png,image/png,201", + "document.pdf,application/pdf,415", + "notes.txt,text/plain,415" + }) + void shouldValidateMediaTypes(String fileName, String mediaType, int expectedStatus) + throws Exception { + String rootId = createRootAndReturnId(); + String attachmentMetadata = createAttachmentMetadata(fileName); + + requestHelper.executePostWithMatcher( + createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), + attachmentMetadata, + status().is(expectedStatus)); + } + + @Test + void shouldRejectAttachment_whenFileNameIsEmpty() throws Exception { + String rootId = createRootAndReturnId(); + String fileName = ""; + String attachmentMetadata = createAttachmentMetadata(fileName); + + requestHelper.executePostWithMatcher( + createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), + attachmentMetadata, + status().isBadRequest()); + } + + @Test + void shouldAcceptUppercaseExtension_whenMimeTypeIsAllowed() throws Exception { + String rootId = createRootAndReturnId(); + String attachmentMetadata = createAttachmentMetadata("IMAGE.JPG"); + + requestHelper.executePostWithMatcher( + createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), attachmentMetadata, status().isCreated()); + } + + @Test + void shouldAcceptMixedCaseExtension() throws Exception { + String rootId = createRootAndReturnId(); + String attachmentMetadata = createAttachmentMetadata("image.JpEg"); + + requestHelper.executePostWithMatcher( + createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), attachmentMetadata, status().isCreated()); + } + + @Test + void shouldRejectAttachment_whenFileHasNoExtension() throws Exception { + String rootId = createRootAndReturnId(); + String attachmentMetadata = createAttachmentMetadata("filename"); + + requestHelper.executePostWithMatcher( + createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), + attachmentMetadata, + status().isBadRequest()); + } + + @Test + void shouldRejectHiddenFile_whenFileStartsWithDot() throws Exception { + String rootId = createRootAndReturnId(); + String attachmentMetadata = createAttachmentMetadata(".gitignore"); + + requestHelper.executePostWithMatcher( + createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), + attachmentMetadata, + status().isUnsupportedMediaType()); + } + + @ParameterizedTest + @CsvSource({ + // valid cases + "'test1.jpeg|test2.jpeg',201", + // invalid media types + "'test.pdf',415", + "'test1.jpeg|test2.pdf',415", + // invalid filenames + "'',400", + "' ',400", + // edge cases + "'.gitignore',415" + }) + void shouldValidateMediaTypes_forMultipleAttachments(String fileNames, int expectedStatus) + throws Exception { + String payload = buildPayload(fileNames); + requestHelper.executePostWithMatcher(BASE_URL, payload, status().is(expectedStatus)); + } + + @Test + void shouldAcceptWhenMediaValidatedAttachments_hasNoAttachments() throws Exception { + Map payload = new HashMap<>(); + payload.put("title", "Hello World!"); + payload.put("mediaValidatedAttachments", List.of()); + + String payloadStr = objectMapper.writeValueAsString(payload); + requestHelper.executePostWithMatcher(BASE_URL, payloadStr, status().is(201)); + } + + @Test + void shouldAcceptDeepCreate_whenMixedValidAndAllValidAttachments() throws Exception { + Map payload = new HashMap<>(); + payload.put("title", "Hello World!"); + payload.put( + "mediaValidatedAttachments", + List.of(Map.of("fileName", "test1.jpeg"), Map.of("fileName", "test2.jpeg"))); + + payload.put("mimeValidatedAttachments", List.of(Map.of("fileName", "test3.pdf"))); + + requestHelper.executePostWithMatcher( + BASE_URL, objectMapper.writeValueAsString(payload), status().isCreated()); + } + + @Test + void shouldRejectDeepCreate_whenMixedValidAndInvalidAttachments() throws Exception { + Map payload = new HashMap<>(); + payload.put("title", "Hello World!"); + payload.put( + "mediaValidatedAttachments", + List.of(Map.of("fileName", "test1.pdf"), Map.of("fileName", "test2.jpeg"))); + + payload.put("mimeValidatedAttachments", List.of(Map.of("fileName", "test3.pdf"))); + + requestHelper.executePostWithMatcher( + BASE_URL, objectMapper.writeValueAsString(payload), status().isUnsupportedMediaType()); + } + + private String createRootAndReturnId() throws Exception { + // Build the initial Java object.. Root + Roots serviceRoot = buildServiceRoot(); + + // POST the root object to the server to create it in the database + postServiceRoot(serviceRoot); + + // Read the newly created entity back from the database + Roots selectedRoot = selectStoredRootWithMediaValidatedAttachments(); + + return selectedRoot.getId(); + } + + private String buildPayload(String fileNames) throws JsonProcessingException { + List> attachments = new ArrayList<>(); + fileNames = fileNames.replaceAll("^'+|'+$", ""); + for (String name : fileNames.split("\\|")) { + attachments.add(Map.of("fileName", name)); + } + Map payload = new HashMap<>(); + payload.put("title", "Hello World!"); + payload.put("mediaValidatedAttachments", attachments); + + return objectMapper.writeValueAsString(payload); + } + + private String createUrl(String rootId, String path) { + return BASE_URL + "(" + rootId + ")" + (path == null || path.isBlank() ? "" : "/" + path); + } + + private String createAttachmentMetadata(String fileName) throws JsonProcessingException { + return objectMapper.writeValueAsString(Map.of("fileName", fileName)); + } + + // helper method + private Roots buildServiceRoot() { + return RootEntityBuilder.create().setTitle("Root").build(); + } + + // Override abstract methods from OdataRequestValidationBase + + @Override + protected void executeContentRequestAndValidateContent(String url, String content) + throws Exception { + // Implementation not required for this test + } + + @Override + protected void verifyContentId( + Attachments attachmentWithExpectedContent, String attachmentId, String contentId) { + // Implementation not required for this test + } + + @Override + protected void verifyContentAndContentId( + Attachments attachment, String testContent, Attachments itemAttachment) { + // Implementation not required for this test + } + + @Override + protected void verifyContentAndContentIdForAttachmentEntity( + AttachmentEntity attachment, String testContent, AttachmentEntity itemAttachment) { + // Implementation not required for this test + } + + @Override + public void verifySingleCreateAndUpdateEvent(String arg1, String arg2, String arg3) { + // Implementation not required for this test + } + + @Override + public void clearServiceHandlerContext() { + // Implementation not required for this test + } + + @Override + public void verifySingleReadEvent(String arg) { + // Implementation not required for this test + } + + @Override + public void verifyTwoDeleteEvents(AttachmentEntity entity, Attachments attachments) { + // Implementation not required for this test + } + + @Override + public void clearServiceHandlerDocuments() { + // Implementation not required for this test + } + + @Override + public void verifyEventContextEmptyForEvent(String... args) { + // Implementation not required for this test + } + + @Override + public void verifyNoAttachmentEventsCalled() { + // Implementation not required for this test + } + + @Override + public void verifyNumberOfEvents(String arg, int count) { + // Implementation not required for this test + } + + @Override + public void verifySingleCreateEvent(String arg1, String arg2) { + // Implementation not required for this test + } + + @Override + public void verifySingleDeletionEvent(String arg) { + // Implementation not required for this test + } +} diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java index 9dcb1fa3..9bc77e76 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java @@ -30,6 +30,7 @@ void uploadContentWithin5MBLimitSucceeds() throws Exception { var selectedRoot = selectStoredRootWithSizeLimitedAttachments(); var attachment = getRandomRootSizeLimitedAttachment(selectedRoot); + attachment.setFileName("test.txt"); // Act & Assert: Upload 3MB content (within limit) succeeds byte[] content = new byte[3 * 1024 * 1024]; // 3MB @@ -48,7 +49,7 @@ void uploadContentExceeding5MBLimitFails() throws Exception { var selectedRoot = selectStoredRootWithSizeLimitedAttachments(); var attachment = getRandomRootSizeLimitedAttachment(selectedRoot); - + attachment.setFileName("test.txt"); // Act: Try to upload 6MB content (exceeds limit) byte[] content = new byte[6 * 1024 * 1024]; // 6MB var url = @@ -57,7 +58,8 @@ void uploadContentExceeding5MBLimitFails() throws Exception { requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); requestHelper.executePutWithMatcher(url, content, status().is(413)); - // Assert: Error response with HTTP 413 status code indicates size limit exceeded + // Assert: Error response with HTTP 413 status code indicates size limit + // exceeded } // Helper methods 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..9efc70df 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 @@ -15,6 +15,7 @@ private RootEntityBuilder() { rootEntity = Roots.create(); rootEntity.setAttachments(new ArrayList<>()); rootEntity.setItems(new ArrayList<>()); + rootEntity.setSizeLimitedAttachments(new ArrayList<>()); } public static RootEntityBuilder create() { @@ -33,9 +34,6 @@ public RootEntityBuilder addAttachments(AttachmentsEntityBuilder... attachments) } public RootEntityBuilder addSizeLimitedAttachments(AttachmentsBuilder... attachments) { - if (rootEntity.getSizeLimitedAttachments() == null) { - rootEntity.setSizeLimitedAttachments(new ArrayList<>()); - } Arrays.stream(attachments) .forEach(attachment -> rootEntity.getSizeLimitedAttachments().add(attachment.build())); return this; diff --git a/integration-tests/srv/test-service.cds b/integration-tests/srv/test-service.cds index e4974ac0..ff68a31f 100644 --- a/integration-tests/srv/test-service.cds +++ b/integration-tests/srv/test-service.cds @@ -4,6 +4,18 @@ 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' + ]); +} + +annotate db.Roots.mimeValidatedAttachments with { + content @(Core.AcceptableMediaTypes: ['application/pdf']); +} + service TestService { entity Roots as projection on db.Roots; entity AttachmentEntity as projection on db.AttachmentEntity; diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 04f4d554..1f453323 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';