From 63a7e88d315beecf84124ee223bf01029ae4c590 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:40:13 +0100 Subject: [PATCH 1/8] single commit --- README.md | 33 ++ .../configuration/Registration.java | 14 +- .../CreateAttachmentsHandler.java | 15 +- .../helper/AttachmentValidationHelper.java | 226 ++++++++++ .../CreateAttachmentsHandlerTest.java | 45 +- .../AttachmentValidationHelperTest.java | 412 ++++++++++++++++++ .../common/ApplicationHandlerHelperTest.java | 7 +- integration-tests/db/data-model.cds | 15 +- .../MediaValidatedAttachmentsDraftTest.java | 160 +++++++ ...tedAttachmentsSizeValidationDraftTest.java | 10 +- ...MediaValidatedAttachmentsNonDraftTest.java | 230 ++++++++++ ...mitedAttachmentValidationNonDraftTest.java | 6 +- .../helper/RootEntityBuilder.java | 11 +- integration-tests/srv/test-service.cds | 8 + samples/bookshop/srv/attachments.cds | 14 +- 15 files changed, 1177 insertions(+), 29 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/MediaValidatedAttachmentsDraftTest.java create mode 100644 integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java diff --git a/README.md b/README.md index a5b3f2062..af5d07044 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 e99586ea3..5fce2d0e7 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; @@ -89,7 +89,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { OutboxService.PERSISTENT_UNORDERED_NAME); } - // build malware scanner client, could be null if no service binding is available + // build malware scanner client, could be null if no service binding is + // available MalwareScanClient scanClient = buildMalwareScanClient(runtime.getEnvironment()); // determine default max size based on malware scanner binding availability @@ -118,12 +119,14 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { new AttachmentsReader(new AssociationCascader(), persistenceService); ThreadLocalDataStorage storage = new ThreadLocalDataStorage(); - // register event handlers for application service, if at least one application service is + // register event handlers for application service, if at least one application + // service is // available 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)); @@ -138,7 +141,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { "No application service is available. Application service event handlers will not be registered."); } - // register event handlers on draft service, if at least one draft service is available + // register event handlers on draft service, if at least one draft service is + // available boolean hasDraftServices = serviceCatalog.getServices(DraftService.class).findFirst().isPresent(); if (hasDraftServices) { 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 6a10baf6c..b43f105a0 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 @@ -6,12 +6,14 @@ import static java.util.Objects.requireNonNull; import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.AttachmentValidationHelper; 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.ReadonlyDataContextEnhancer; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; 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.validateAcceptableMediaTypes(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/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelper.java new file mode 100644 index 000000000..e8f2e2e3f --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelper.java @@ -0,0 +1,226 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +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.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.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.Collection; +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 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", "text/plain"), + Map.entry("webp", "image/webp")); + + private static final Logger logger = LoggerFactory.getLogger(AttachmentValidationHelper.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final TypeReference> STRING_LIST_TYPE_REF = new TypeReference<>() {}; + + /** Filter to support extraction of file name for attachment validation */ + public static final Filter FILE_NAME_FILTER = + (path, element, type) -> element.getName().contentEquals("fileName"); + + /** + * 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 validateAcceptableMediaTypes( + CdsEntity entity, List data, CdsRuntime cdsRuntime) { + if (entity == null) { + return; + } + CdsModel cdsModel = cdsRuntime.getCdsModel(); + CdsEntity serviceEntity = cdsModel.findEntity(entity.getQualifiedName()).orElse(null); + if (serviceEntity == null || !ApplicationHandlerHelper.isMediaEntity(serviceEntity)) { + return; + } + List allowedTypes = getEntityAcceptableMediaTypes(serviceEntity); + String fileName = extractFileName(entity, data); + validateMediaTypeForAttachment(fileName, allowedTypes); + } + + protected static List getEntityAcceptableMediaTypes(CdsEntity entity) { + Optional> flatMap = + entity.getElement("content").findAnnotation("Core.AcceptableMediaTypes"); + List result = + flatMap + .map( + annotation -> + objectMapper.convertValue(annotation.getValue(), STRING_LIST_TYPE_REF)) + .orElse(List.of("*/*")); + return result; + } + + protected static String extractFileName(CdsEntity entity, List data) { + CdsDataProcessor processor = CdsDataProcessor.create(); + AtomicReference fileNameRef = new AtomicReference<>(); + Validator validator = + (path, element, value) -> { + if (element.getName().contentEquals("fileName") && value instanceof String) { + fileNameRef.set((String) value); + } + }; + + processor.addValidator(FILE_NAME_FILTER, validator).process(data, entity); + + if (fileNameRef.get() == null || fileNameRef.get().isBlank()) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); + } + return fileNameRef.get(); + } + + 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) { + if (fileName == null || fileName.isBlank()) { + throw new ServiceException( + ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Filename must not be null or blank"); + } + 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 fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + String 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; + } + + protected static boolean checkMimeTypeMatch( + Collection acceptableMediaTypes, String mimeType) { + if (mimeType == null) { + return false; // forces UNSUPPORTED_MEDIA_TYPE + } + 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); + }); + } + + private AttachmentValidationHelper() { + // prevent instantiation + } +} 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 4eabcf239..28b1fa677 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; @@ -24,6 +25,7 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Items; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.AttachmentValidationHelper; 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; @@ -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,42 @@ 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.validateAcceptableMediaTypes(entity, data, runtime)) + .thenAnswer(invocation -> null); + // when + new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime) + .processBeforeForMetadata(context, data); + // then + helper.verify( + () -> AttachmentValidationHelper.validateAcceptableMediaTypes(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/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java new file mode 100644 index 000000000..b004b35fb --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java @@ -0,0 +1,412 @@ +/* + * © 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.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +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.CdsElement; +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.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.MockitoAnnotations; + +class AttachmentValidationHelperTest { + @Mock private CdsEntity entity; + @Mock private CdsData data; + @Mock private CdsRuntime cdsRuntime; + @Mock private CdsModel cdsModel; + @Mock private CdsEntity serviceEntity; + @Mock private CdsElement cdsElement; + @Mock private CdsAnnotation annotation; + @Mock private CdsDataProcessor processor; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(cdsRuntime.getCdsModel()).thenReturn(cdsModel); + } + + @Test + void shouldDetectMimeTypeFromURLConnection() { + assertMediaType("document.pdf", List.of("application/pdf"), "application/pdf"); + } + + @Test + void shouldUseExtensionFallbackWhenURLConnectionFails() { + assertMediaType("image.webp", List.of("image/webp"), "image/webp"); + } + + @Test + void shouldSupportWildcardSubtype() { + assertMediaType("image.jpeg", List.of("image/*"), "image/jpeg"); + } + + @Test + void shouldAllowAllWithStarSlashStar() { + assertMediaType("anyfile.jpeg", List.of("*/*"), "image/jpeg"); + } + + @Test + void shouldAllowUnknownExtensionWithStarSlashStar() { + assertMediaType( + "anyfile.anyext", List.of("*/*"), AttachmentValidationHelper.DEFAULT_MEDIA_TYPE); + } + + @Test + void shouldAllowUnknownExtensionWhenAcceptableTypesNull() { + assertMediaType("anyfile.anyext", null, AttachmentValidationHelper.DEFAULT_MEDIA_TYPE); + } + + @Test + void shouldAllowAllWhenAcceptableTypesEmpty() { + assertMediaType("anyfile.anyext", List.of(), AttachmentValidationHelper.DEFAULT_MEDIA_TYPE); + } + + @Test + void shouldUseDefaultWhenUnknownExtensionExplicitlyAllowed() { + assertMediaType( + "file.unknownext", + List.of(AttachmentValidationHelper.DEFAULT_MEDIA_TYPE), + AttachmentValidationHelper.DEFAULT_MEDIA_TYPE); + } + + @Test + void shouldHandleUppercaseExtension() { + assertMediaType("photo.JPG", List.of("image/jpeg"), "image/jpeg"); + } + + @Test + void shouldRejectUnknownExtensionWhenNotAllowed() { + assertUnsupported("anyfile.anyext", List.of("application/pdf")); + } + + @Test + void shouldThrowWhenFilenameHasNoExtension() { + assertUnsupported("invalidfilename", List.of("application/pdf")); + } + + @Test + void shouldThrowWhenFilenameEndsWithDot() { + assertUnsupported("file.", List.of("application/pdf")); + } + + @Test + void shouldThrowWhenMimeTypeNotAllowed() { + ServiceException ex = + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateMediaTypeForAttachment( + "document.pdf", List.of("image/png"))); + + assertTrue(ex.getMessage().contains("not allowed")); + } + + @Test + void shouldThrowWhenDefaultMimeTypeNotAllowed() { + ServiceException ex = + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateMediaTypeForAttachment( + "file.unknownext", List.of("application/pdf"))); + + assertTrue(ex.getMessage().contains("not allowed")); + } + + @Test + void shouldThrowWhenFileNameIsNull() { + ServiceException ex = + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateMediaTypeForAttachment( + null, List.of("application/pdf"))); + + assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + assertTrue(ex.getMessage().contains("must not be null or blank")); + } + + @Test + void shouldThrowWhenFileNameIsEmpty() { + ServiceException ex = + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateMediaTypeForAttachment( + "", List.of("application/pdf"))); + + assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + } + + @Test + void shouldThrowWhenMimeTypeDoesNotMatchWildcard() { + ServiceException ex = + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateMediaTypeForAttachment( + "test.pdf", List.of("image/*"))); + + assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + } + + @Test + void shouldThrowWhenFileNameIsBlank() { + ServiceException ex = + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateMediaTypeForAttachment( + " ", List.of("application/pdf"))); + + assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + } + + @Test + void shouldReturnFalseWhenMimeTypeIsNull() { + boolean result = AttachmentValidationHelper.checkMimeTypeMatch(List.of("image/png"), null); + + assertFalse(result); + } + + private void assertMediaType(String fileName, List allowed, String expectedType) { + + String result = AttachmentValidationHelper.validateMediaTypeForAttachment(fileName, allowed); + + assertEquals(expectedType, result); + } + + private void assertUnsupported(String fileName, List allowed) { + + ServiceException ex = + assertThrows( + ServiceException.class, + () -> AttachmentValidationHelper.validateMediaTypeForAttachment(fileName, allowed)); + + assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + } + + @Test + void shouldHandleDotFiles() { + assertUnsupported(".gitignore", List.of("text/plain", "application/octet-stream")); + assertUnsupported(".ssh", List.of("application/octet-stream")); + assertUnsupported(".dockerignore", List.of("text/plain", "application/octet-stream")); + assertUnsupported(".invalid.ext", List.of("application/pdf")); + } + + @Test + void validateAcceptableMediaTypes_shouldReturnWhenEntityIsNull() { + // given + CdsRuntime runtime = mock(CdsRuntime.class); + + // when / then + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateAcceptableMediaTypes(null, List.of(), runtime)); + + // ensure no further interaction happens + verifyNoInteractions(runtime); + } + + @Test + void validateAcceptableMediaTypes_whenNotMediaEntity_returns() { + when(entity.getQualifiedName()).thenReturn("Test.Entity"); + when(cdsModel.findEntity("Test.Entity")).thenReturn(Optional.of(serviceEntity)); + + // IMPORTANT: force isMediaEntity = false + try (MockedStatic mocked = + mockStatic(ApplicationHandlerHelper.class)) { + + mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(serviceEntity)).thenReturn(false); + + assertDoesNotThrow( + () -> + AttachmentValidationHelper.validateAcceptableMediaTypes( + entity, List.of(data), cdsRuntime)); + } + } + + @Test + void getEntityAcceptableMediaTypes_returnsAnnotationValue() { + List expectedTypes = List.of("image/png", "application/pdf"); + + when(entity.getElement("content")).thenReturn(cdsElement); + when(cdsElement.findAnnotation("Core.AcceptableMediaTypes")) + .thenReturn(Optional.of(annotation)); + when(annotation.getValue()).thenReturn(expectedTypes); + assertEquals(expectedTypes, AttachmentValidationHelper.getEntityAcceptableMediaTypes(entity)); + } + + @Test + void getEntityAcceptableMediaTypes_missingAnnotation_returnsWildcard() { + when(entity.getElement("content")).thenReturn(cdsElement); + when(cdsElement.findAnnotation("Core.AcceptableMediaTypes")).thenReturn(Optional.empty()); + assertEquals(List.of("*/*"), AttachmentValidationHelper.getEntityAcceptableMediaTypes(entity)); + } + + @Test + void getEntityAcceptableMediaTypes_nullAnnotationValue_returnsWildcard() { + when(entity.getElement("content")).thenReturn(cdsElement); + when(cdsElement.findAnnotation("Core.AcceptableMediaTypes")) + .thenReturn(Optional.of(annotation)); + when(annotation.getValue()).thenReturn(null); + assertEquals(List.of("*/*"), AttachmentValidationHelper.getEntityAcceptableMediaTypes(entity)); + } + + @Test + void extractFileName_whenFileNamePresent_returnsValue() { + when(cdsElement.getName()).thenReturn("fileName"); + try (MockedStatic mocked = mockStatic(CdsDataProcessor.class)) { + mocked.when(CdsDataProcessor::create).thenReturn(processor); + doAnswer( + invocation -> { + Filter filter = invocation.getArgument(0); + Validator validator = invocation.getArgument(1); + + // Simulate processor visiting fileName with String value + if (filter.test(null, cdsElement, null)) { + validator.validate(null, cdsElement, "test.pdf"); + } + return processor; + }) + .when(processor) + .addValidator(any(), any()); + + doNothing().when(processor).process(anyList(), any()); + String result = AttachmentValidationHelper.extractFileName(entity, List.of(data)); + assertThat(result).isEqualTo("test.pdf"); + } + } + + @Test + void extractFileName_whenElementIsNotFileName_throws() { + when(cdsElement.getName()).thenReturn("content"); + 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, cdsElement, null)) { + validator.validate(null, cdsElement, "test.pdf"); + } + return processor; + }) + .when(processor) + .addValidator(any(), any()); + + doNothing().when(processor).process(anyList(), any()); + assertThrows( + ServiceException.class, + () -> AttachmentValidationHelper.extractFileName(entity, List.of(data))); + } + } + + @Test + void extractFileName_valueIsNotString_branchCovered() { + when(cdsElement.getName()).thenReturn("fileName"); + 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, cdsElement, null)) { + validator.validate(null, cdsElement, 42); // non-String + } + return processor; + }) + .when(processor) + .addValidator(any(), any()); + doNothing().when(processor).process(anyList(), any()); + + assertThrows( + ServiceException.class, + () -> AttachmentValidationHelper.extractFileName(entity, List.of(data))); + } + } + + @Test + void validateAcceptableMediaTypes_nonMediaOrMissingEntity_doesNothing() { + when(entity.getQualifiedName()).thenReturn("TestService.Roots"); + when(cdsModel.findEntity("TestService.Roots")).thenReturn(Optional.empty()); + + assertDoesNotThrow( + () -> + AttachmentValidationHelper.validateAcceptableMediaTypes(entity, List.of(), cdsRuntime)); + } + + @Test + void shouldNotThrowWhenEntityNotFoundInModel() { + when(entity.getQualifiedName()).thenReturn("Test.Entity"); + when(cdsModel.findEntity("Test.Entity")).thenReturn(Optional.empty()); + + assertDoesNotThrow( + () -> + AttachmentValidationHelper.validateAcceptableMediaTypes(entity, List.of(), cdsRuntime)); + } + + @Test + void validateAcceptableMediaTypes_mediaTypeMatches_succeeds() { + setupMediaEntity(List.of("image/png")); + try (MockedStatic helperStatic = + mockStatic(AttachmentValidationHelper.class, CALLS_REAL_METHODS)) { + + // file.png → media type image/png → allowed + helperStatic + .when(() -> AttachmentValidationHelper.extractFileName(entity, List.of(data))) + .thenReturn("file.png"); + + assertDoesNotThrow( + () -> + AttachmentValidationHelper.validateAcceptableMediaTypes( + entity, List.of(data), cdsRuntime)); + } + } + + @Test + void validateAcceptableMediaTypes_mediaTypeMismatch_throws() { + setupMediaEntity(List.of("image/png")); + try (MockedStatic helperStatic = + mockStatic(AttachmentValidationHelper.class, CALLS_REAL_METHODS)) { + + // file.jpg → media type image/jpeg → NOT allowed + helperStatic + .when(() -> AttachmentValidationHelper.extractFileName(entity, List.of(data))) + .thenReturn("file.jpg"); + + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateAcceptableMediaTypes( + entity, List.of(data), cdsRuntime)); + } + } + + private void setupMediaEntity(List allowedTypes) { + when(entity.getQualifiedName()).thenReturn("Test.Entity"); + when(cdsModel.findEntity("Test.Entity")).thenReturn(Optional.of(serviceEntity)); + when(serviceEntity.getAnnotationValue("_is_media_data", false)).thenReturn(true); + when(serviceEntity.getElement("content")).thenReturn(cdsElement); + when(cdsElement.findAnnotation("Core.AcceptableMediaTypes")) + .thenReturn(Optional.of(annotation)); + when(annotation.getValue()).thenReturn(allowedTypes); + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java index e7d8cfa33..3c44c320b 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java @@ -1,14 +1,13 @@ /* - * © 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.handler.common; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import com.sap.cds.CdsData; -import java.util.Map; +import java.util.*; import org.junit.jupiter.api.Test; class ApplicationHandlerHelperTest { diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index f6c35e191..5073824bc 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; } 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 000000000..43ac22b42 --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/MediaValidatedAttachmentsDraftTest.java @@ -0,0 +1,160 @@ +/* + * © 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 shouldFail_whenFileNameMissing_inDraft() throws Exception { + String rootId = createDraftRootAndReturnId(); + String metadata = "{}"; + requestHelper.executePostWithMatcher( + buildDraftAttachmentCreationUrl(rootId), metadata, status().isBadRequest()); + } + + // 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 with mediaValidatedAttachments")); + 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 39918a286..df8db48a9 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 } @Test @@ -109,7 +111,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 000000000..26de4e65c --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java @@ -0,0 +1,230 @@ +/* + * © 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.AttachmentsBuilder; +import com.sap.cds.feature.attachments.integrationtests.nondraftservice.helper.RootEntityBuilder; +import com.sap.cds.ql.Select; +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().isUnsupportedMediaType()); + } + + @Test + void shouldRejectHiddenFile_whenFileStartsWithDot() throws Exception { + String rootId = createRootAndReturnId(); + String attachmentMetadata = createAttachmentMetadata(".gitignore"); + + requestHelper.executePostWithMatcher( + createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), + attachmentMetadata, + status().isUnsupportedMediaType()); + } + + private String createRootAndReturnId() throws Exception { + // Build the initial Java object.. Root + Roots serviceRoot = buildServiceRootWithMediaValidatedAttachments(); + + // 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 createUrl(String rootId, String path) { + return BASE_URL + "(" + rootId + ")/" + path; + } + + private String createAttachmentMetadata(String fileName) throws JsonProcessingException { + return objectMapper.writeValueAsString(Map.of("fileName", fileName)); + } + + // helper method + private Roots buildServiceRootWithMediaValidatedAttachments() { + return RootEntityBuilder.create() + .setTitle("Root with mediaValidatedAttachments") + .addMediaValidatedAttachments( + AttachmentsBuilder.create() + .setFileName("parent.text") + .setMimeType(MediaType.APPLICATION_JSON_VALUE)) + .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 9dcb1fa3b..9bc77e763 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 cf91a423e..f6dc5179c 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,8 @@ private RootEntityBuilder() { rootEntity = Roots.create(); rootEntity.setAttachments(new ArrayList<>()); rootEntity.setItems(new ArrayList<>()); + rootEntity.setSizeLimitedAttachments(new ArrayList<>()); + rootEntity.setMediaValidatedAttachments(new ArrayList<>()); } public static RootEntityBuilder create() { @@ -33,14 +35,17 @@ 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; } + public RootEntityBuilder addMediaValidatedAttachments(AttachmentsBuilder... attachments) { + Arrays.stream(attachments) + .forEach(attachment -> rootEntity.getMediaValidatedAttachments().add(attachment.build())); + return this; + } + public RootEntityBuilder addItems(ItemEntityBuilder... items) { Arrays.stream(items).forEach(item -> rootEntity.getItems().add(item.build())); return this; diff --git a/integration-tests/srv/test-service.cds b/integration-tests/srv/test-service.cds index e4974ac0a..c07ddaed0 100644 --- a/integration-tests/srv/test-service.cds +++ b/integration-tests/srv/test-service.cds @@ -4,6 +4,14 @@ 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; entity AttachmentEntity as projection on db.AttachmentEntity; diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 04f4d5549..1f453323f 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 aa31200c03696a5431c489c33a2df2a82da545ee Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:07:23 +0100 Subject: [PATCH 2/8] multi attachments --- .../CreateAttachmentsHandler.java | 4 +- .../helper/AttachmentDataExtractor.java | 134 ++++++ .../helper/AttachmentValidationHelper.java | 226 ---------- .../helper/media/MediaTypeResolver.java | 64 +++ .../helper/media/MediaTypeService.java | 119 +++++ .../AttachmentValidationHelper.java | 139 ++++++ .../helper/validation/FileNameValidator.java | 32 ++ .../common/ApplicationHandlerHelper.java | 25 ++ .../DraftCancelAttachmentsHandler.java | 28 +- .../CreateAttachmentsHandlerTest.java | 7 +- .../helper/AttachmentDataExtractorTest.java | 338 ++++++++++++++ .../AttachmentValidationHelperTest.java | 412 ------------------ .../helper/media/MediaTypeResolverTest.java | 115 +++++ .../helper/media/MediaTypeServiceTest.java | 153 +++++++ .../AttachmentValidationHelperTest.java | 202 +++++++++ .../validation/FileNameValidatorTest.java | 96 ++++ integration-tests/db/data-model.cds | 1 + .../MediaValidatedAttachmentsDraftTest.java | 28 +- ...MediaValidatedAttachmentsNonDraftTest.java | 113 ++++- ...aRequestValidationWithTestHandlerTest.java | 89 ++-- .../helper/RootEntityBuilder.java | 7 - integration-tests/srv/test-service.cds | 4 + samples/bookshop/srv/attachments.cds | 13 +- 23 files changed, 1591 insertions(+), 758 deletions(-) create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java delete 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/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java delete mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java 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 b43f105a0..638744eb6 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 @@ -6,11 +6,11 @@ import static java.util.Objects.requireNonNull; import com.sap.cds.CdsData; -import com.sap.cds.feature.attachments.handler.applicationservice.helper.AttachmentValidationHelper; 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.ReadonlyDataContextEnhancer; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.validation.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; @@ -71,7 +71,7 @@ void processBeforeForDraft(CdsCreateEventContext context, List data) { @HandlerOrder(HandlerOrder.BEFORE) void processBeforeForMetadata(EventContext context, List data) { CdsEntity target = context.getTarget(); - AttachmentValidationHelper.validateAcceptableMediaTypes(target, data, cdsRuntime); + AttachmentValidationHelper.validateMediaAttachments(target, data, cdsRuntime); } @Before diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java new file mode 100644 index 000000000..f297303ff --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java @@ -0,0 +1,134 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +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.applicationservice.helper.media.MediaTypeResolver; +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); + + public static Map> extractFileNamesByElement( + CdsEntity entity, List data) { + Map> fileNamesByElementName = collectFileNamesByElementName(entity, data); + ensureAttachmentsHaveFileNames(entity, data, fileNamesByElementName); + return fileNamesByElementName; + } + + private static Map> collectFileNamesByElementName( + CdsEntity entity, List data) { + Map> fileNamesByElementName = new HashMap<>(); + CdsDataProcessor processor = CdsDataProcessor.create(); + Validator fileNameValidator = createFileNameValidator(fileNamesByElementName); + processor.addValidator(FILE_NAME_FILTER, fileNameValidator).process(data, entity); + return fileNamesByElementName; + } + + private static Validator createFileNameValidator(Map> result) { + Validator validator = + (path, element, value) -> { + if (!(value instanceof String fileName)) { + throw new ServiceException( + ErrorStatuses.BAD_REQUEST, + value == null ? "Filename is missing" : "Filename must be a string"); + } + fileName = fileName.trim(); + if (fileName.isBlank()) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); + } + String key = element.getDeclaringType().getQualifiedName(); + result.computeIfAbsent(key, k -> new HashSet<>()).add(fileName); + }; + return validator; + } + + private static void ensureAttachmentsHaveFileNames( + CdsEntity entity, List data, Map> result) { + List attachmentElements = + entity + .elements() + .filter( + e -> { + if (!e.getType().isAssociation()) { + return false; + } + CdsAssociationType association = e.getType().as(CdsAssociationType.class); + return association.isComposition() + && ApplicationHandlerHelper.isMediaEntity(association.getTarget()) + && MediaTypeResolver.getAcceptableMediaTypesAnnotation( + association.getTarget()) + .isPresent(); + }) + .toList(); + ensureFilenamesPresent(data, result, attachmentElements); + } + + private static void ensureFilenamesPresent( + List data, + Map> result, + List attachmentElements) { + Set dataKeys = collectValidDataKeys(data); + List availableAttachmentElements = + filterAttachmentsPresentInData(attachmentElements, dataKeys); + boolean hasMissingFileNames = hasMissingFileNames(result, availableAttachmentElements); + if (hasMissingFileNames) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); + } + } + + private static boolean hasMissingFileNames( + Map> result, List availableAttachmentElements) { + + return availableAttachmentElements.stream() + .anyMatch( + element -> { + CdsAssociationType assoc = element.getType().as(CdsAssociationType.class); + String target = assoc.getTarget().getQualifiedName(); + + return !result.containsKey(target) || result.get(target).isEmpty(); + }); + } + + private static List filterAttachmentsPresentInData( + List attachmentElements, Set dataKeys) { + return attachmentElements.stream().filter(e -> dataKeys.contains(e.getName())).toList(); + } + + private static Set collectValidDataKeys(List data) { + return data.stream() + .flatMap(d -> d.entrySet().stream()) + .filter(entry -> !isEmptyValue(entry.getValue())) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } + + private static boolean isEmptyValue(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 AttachmentDataExtractor() { + // Private constructor to prevent instantiation + } +} 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 deleted file mode 100644 index e8f2e2e3f..000000000 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelper.java +++ /dev/null @@ -1,226 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. - */ -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.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.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.Collection; -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 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", "text/plain"), - Map.entry("webp", "image/webp")); - - private static final Logger logger = LoggerFactory.getLogger(AttachmentValidationHelper.class); - private static final ObjectMapper objectMapper = new ObjectMapper(); - private static final TypeReference> STRING_LIST_TYPE_REF = new TypeReference<>() {}; - - /** Filter to support extraction of file name for attachment validation */ - public static final Filter FILE_NAME_FILTER = - (path, element, type) -> element.getName().contentEquals("fileName"); - - /** - * 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 validateAcceptableMediaTypes( - CdsEntity entity, List data, CdsRuntime cdsRuntime) { - if (entity == null) { - return; - } - CdsModel cdsModel = cdsRuntime.getCdsModel(); - CdsEntity serviceEntity = cdsModel.findEntity(entity.getQualifiedName()).orElse(null); - if (serviceEntity == null || !ApplicationHandlerHelper.isMediaEntity(serviceEntity)) { - return; - } - List allowedTypes = getEntityAcceptableMediaTypes(serviceEntity); - String fileName = extractFileName(entity, data); - validateMediaTypeForAttachment(fileName, allowedTypes); - } - - protected static List getEntityAcceptableMediaTypes(CdsEntity entity) { - Optional> flatMap = - entity.getElement("content").findAnnotation("Core.AcceptableMediaTypes"); - List result = - flatMap - .map( - annotation -> - objectMapper.convertValue(annotation.getValue(), STRING_LIST_TYPE_REF)) - .orElse(List.of("*/*")); - return result; - } - - protected static String extractFileName(CdsEntity entity, List data) { - CdsDataProcessor processor = CdsDataProcessor.create(); - AtomicReference fileNameRef = new AtomicReference<>(); - Validator validator = - (path, element, value) -> { - if (element.getName().contentEquals("fileName") && value instanceof String) { - fileNameRef.set((String) value); - } - }; - - processor.addValidator(FILE_NAME_FILTER, validator).process(data, entity); - - if (fileNameRef.get() == null || fileNameRef.get().isBlank()) { - throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); - } - return fileNameRef.get(); - } - - 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) { - if (fileName == null || fileName.isBlank()) { - throw new ServiceException( - ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Filename must not be null or blank"); - } - 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 fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); - String 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; - } - - protected static boolean checkMimeTypeMatch( - Collection acceptableMediaTypes, String mimeType) { - if (mimeType == null) { - return false; // forces UNSUPPORTED_MEDIA_TYPE - } - 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); - }); - } - - private AttachmentValidationHelper() { - // prevent instantiation - } -} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java new file mode 100644 index 000000000..085bc613f --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java @@ -0,0 +1,64 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.media; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +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.CdsEntity; +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 List WILDCARD_MEDIA_TYPE = List.of("*/*"); + private static final String ACCEPTABLE_MEDIA_TYPES_ANNOTATION = "Core.AcceptableMediaTypes"; + private static final TypeReference> STRING_LIST_TYPE_REF = new TypeReference<>() {}; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public static Map> getAcceptableMediaTypesFromEntity(CdsEntity entity) { + // If this entity is a media entity + if (ApplicationHandlerHelper.isMediaEntity(entity)) { + return Map.of(entity.getQualifiedName(), fetchAcceptableMediaTypes(entity)); + } + + // If it's not a mediaEntity, if it's a root entity + Map> result = new HashMap<>(); + entity + .elements() + .filter(e -> e.getType().isAssociation()) + .map(e -> e.getType().as(CdsAssociationType.class)) + .filter(CdsAssociationType::isComposition) + .forEach( + association -> { + CdsEntity target = association.getTarget(); + if (target != null && ApplicationHandlerHelper.isMediaEntity(target)) { + result.put(target.getQualifiedName(), fetchAcceptableMediaTypes(target)); + } + }); + + return result; + } + + public static Optional> getAcceptableMediaTypesAnnotation( + CdsEntity entity) { + return Optional.ofNullable(entity.getElement(CONTENT_ELEMENT)) + .flatMap(element -> element.findAnnotation(ACCEPTABLE_MEDIA_TYPES_ANNOTATION)); + } + + private static List fetchAcceptableMediaTypes(CdsEntity entity) { + return getAcceptableMediaTypesAnnotation(entity) + .map(CdsAnnotation::getValue) + .map(value -> objectMapper.convertValue(value, STRING_LIST_TYPE_REF)) + .orElse(WILDCARD_MEDIA_TYPE); + } + + private MediaTypeResolver() { + // to prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java new file mode 100644 index 000000000..0f2d5cd6b --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java @@ -0,0 +1,119 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.media; + +import java.util.Collection; +import java.util.Map; +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"; + // TODO: in a different ticket, consider loading this mapping from a JSON file + // to cover more file extensions + 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", "text/plain"), + Map.entry("webp", "image/webp")); + + public static String resolveMimeType(String fileName) { + String fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + String 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; + } + + 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() - 2) + "/") + : baseMimeType.equals(type); + }); + } + + private MediaTypeService() { + // to prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java new file mode 100644 index 000000000..af60985be --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java @@ -0,0 +1,139 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.validation; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.AttachmentDataExtractor; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.media.MediaTypeResolver; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.media.MediaTypeService; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +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.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +public final class AttachmentValidationHelper { + public static final List WILDCARD_MEDIA_TYPE = List.of("*/*"); + + /** + * 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(); + Optional optionalServiceEntity = cdsModel.findEntity(entity.getQualifiedName()); + + if (optionalServiceEntity.isEmpty()) { + return; + } + + CdsEntity serviceEntity = optionalServiceEntity.get(); + boolean hasNoAttachmentCompositions = + !ApplicationHandlerHelper.deepSearchForAttachments(serviceEntity); + + if (!ApplicationHandlerHelper.isMediaEntity(serviceEntity) && hasNoAttachmentCompositions) { + return; + } + + Map> allowedTypesByElementName = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(serviceEntity); + Map> fileNamesByElementName = + AttachmentDataExtractor.extractFileNamesByElement(serviceEntity, data); + validateAttachmentMediaTypes(fileNamesByElementName, allowedTypesByElementName); + } + + public static void validateAttachmentMediaTypes( + Map> fileNamesByElementName, + Map> acceptableMediaTypesByElementName) { + + Map> invalidFiles = + findInvalidFiles(fileNamesByElementName, acceptableMediaTypesByElementName); + + assertNoInvalidFiles(invalidFiles, acceptableMediaTypesByElementName); + } + + private static Map> findInvalidFiles( + Map> fileNamesByElementName, + Map> acceptableMediaTypesByElementName) { + if (fileNamesByElementName == null || fileNamesByElementName.isEmpty()) { + return Map.of(); + } + Map> invalidFiles = new HashMap<>(); + fileNamesByElementName.forEach( + (elementName, files) -> { + List acceptableTypes = + acceptableMediaTypesByElementName.getOrDefault(elementName, WILDCARD_MEDIA_TYPE); + + List invalid = + files.stream().filter(file -> !isAttachmentTypeValid(file, acceptableTypes)).toList(); + + if (!invalid.isEmpty()) { + invalidFiles.put(elementName, invalid); + } + }); + + return invalidFiles; + } + + private static void assertNoInvalidFiles( + Map> invalidFiles, + Map> acceptableMediaTypesByElementName) { + + if (!invalidFiles.isEmpty()) { + throw buildUnsupportedFileTypeMessage(acceptableMediaTypesByElementName, invalidFiles); + } + } + + private static ServiceException buildUnsupportedFileTypeMessage( + Map> acceptableMediaTypesByElementName, + Map> invalidFilesByElement) { + String message = + invalidFilesByElement.entrySet().stream() + .map( + entry -> { + String element = entry.getKey(); + String simpleName = extractSimpleName(element); + String files = String.join(", ", entry.getValue()); + String allowed = + String.join( + ", ", + acceptableMediaTypesByElementName.getOrDefault( + element, WILDCARD_MEDIA_TYPE)); + return simpleName + ": " + files + " (allowed: " + allowed + ")"; + }) + .collect(Collectors.joining("; ")); + + return new ServiceException( + ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Unsupported file types detected: " + message); + } + + private static String extractSimpleName(String qualifiedName) { + int idx = qualifiedName.lastIndexOf('.'); + return idx >= 0 ? qualifiedName.substring(idx + 1) : qualifiedName; + } + + private static boolean isAttachmentTypeValid(String fileName, List acceptableTypes) { + FileNameValidator.validate(fileName); + String mimeType = MediaTypeService.resolveMimeType(fileName); + return MediaTypeService.isMimeTypeAllowed(acceptableTypes, mimeType); + } + + private AttachmentValidationHelper() { + // prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java new file mode 100644 index 000000000..a30ea3ef4 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java @@ -0,0 +1,32 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.validation; + +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; + +public final class FileNameValidator { + + public static void validate(String fileName) { + if (fileName == null) { + throw new ServiceException(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Filename must not be null"); + } + String trimmedFileName = fileName.trim(); + + if (trimmedFileName.isEmpty()) { + throw new ServiceException( + ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Filename must not be blank"); + } + + int lastDotIndex = trimmedFileName.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == trimmedFileName.length() - 1) { + throw new ServiceException( + ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Invalid filename format: " + fileName); + } + } + + private FileNameValidator() { + // to prevent instantiation + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index c8308f513..23836f571 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -10,11 +10,13 @@ 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.reflect.CdsAssociationType; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -123,6 +125,29 @@ public static Map removeDraftKey(Map keys) { return keyMap; } + public static boolean deepSearchForAttachments(CdsEntity entity) { + return deepSearchForAttachmentsRecursive(entity, new HashSet<>()); + } + + private static boolean deepSearchForAttachmentsRecursive( + CdsEntity entity, HashSet visited) { + + if (visited.contains(entity.getQualifiedName())) { + return false; + } + visited.add(entity.getQualifiedName()); + + if (isMediaEntity(entity)) { + return true; + } + + return entity + .compositions() + .map(element -> element.getType().as(CdsAssociationType.class)) + .anyMatch( + association -> deepSearchForAttachmentsRecursive(association.getTarget(), visited)); + } + private ApplicationHandlerHelper() { // avoid instantiation } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index b71231943..d9b4c42ca 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.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.handler.draftservice; @@ -15,7 +15,6 @@ import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; import com.sap.cds.ql.CQL; import com.sap.cds.ql.cqn.CqnDelete; -import com.sap.cds.reflect.CdsAssociationType; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.DraftCancelEventContext; @@ -25,7 +24,6 @@ import com.sap.cds.services.handler.annotations.Before; import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.ServiceName; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -62,7 +60,7 @@ public DraftCancelAttachmentsHandler( void processBeforeDraftCancel(DraftCancelEventContext context) { CdsEntity entity = context.getTarget(); - if (deepSearchForAttachments(entity)) { + if (ApplicationHandlerHelper.deepSearchForAttachments(entity)) { logger.debug( "Processing before {} event for entity {}", context.getEvent(), context.getTarget()); @@ -107,28 +105,6 @@ private Validator buildDeleteContentValidator( }; } - private boolean deepSearchForAttachments(CdsEntity entity) { - return deepSearchForAttachmentsRecursive(entity, new HashSet<>()); - } - - private boolean deepSearchForAttachmentsRecursive(CdsEntity entity, HashSet visited) { - - if (visited.contains(entity.getQualifiedName())) { - return false; - } - visited.add(entity.getQualifiedName()); - - if (ApplicationHandlerHelper.isMediaEntity(entity)) { - return true; - } - - return entity - .compositions() - .map(element -> element.getType().as(CdsAssociationType.class)) - .anyMatch( - association -> deepSearchForAttachmentsRecursive(association.getTarget(), visited)); - } - private List readAttachments( DraftCancelEventContext context, CdsStructuredType entity, boolean isActiveEntity) { logger.debug( 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 28b1fa677..b03a032f7 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 @@ -25,10 +25,10 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Items; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; -import com.sap.cds.feature.attachments.handler.applicationservice.helper.AttachmentValidationHelper; 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.validation.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; @@ -414,15 +414,14 @@ void processBeforeForMetadata_executesValidation() { try (MockedStatic helper = mockStatic(AttachmentValidationHelper.class)) { helper - .when( - () -> AttachmentValidationHelper.validateAcceptableMediaTypes(entity, data, runtime)) + .when(() -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime)) .thenAnswer(invocation -> null); // when new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime) .processBeforeForMetadata(context, data); // then helper.verify( - () -> AttachmentValidationHelper.validateAcceptableMediaTypes(entity, data, runtime)); + () -> AttachmentValidationHelper.validateMediaAttachments(entity, data, runtime)); } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java new file mode 100644 index 000000000..d7e480fb9 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/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; + +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.applicationservice.helper.media.MediaTypeResolver; +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_whenFilenameMissing_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() { + // default setup already does this + CdsData data = prepareCdsDataWithAttachments("file.txt"); + + Map> result = extractFileNames(data); + + assertThat(result.get(ATTACHMENT_ENTITY)).contains("file.txt"); + } + + @Test + void ensureFilenamesPresent_whenResultMissingKey_throwsException() { + // Arrange + + // IMPORTANT: disable validator so result stays EMPTY + doAnswer(invocation -> processor).when(processor).addValidator(any(), any()); + doNothing().when(processor).process(anyList(), any()); + + when(targetEntity.elements()).thenReturn(Stream.of(attachmentElement)); + + // Ensure attachment is detected + 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_keyExistsButEmptySet_returnsTrue() throws Exception { + Map> result = new HashMap<>(); + result.put(ATTACHMENT_ENTITY, new HashSet<>()); + + CdsElement element = mock(CdsElement.class); + CdsType type = mock(CdsType.class); + CdsAssociationType assoc = mock(CdsAssociationType.class); + CdsEntity target = mock(CdsEntity.class); + + when(element.getType()).thenReturn(type); + when(type.as(CdsAssociationType.class)).thenReturn(assoc); + when(assoc.getTarget()).thenReturn(target); + when(target.getQualifiedName()).thenReturn(ATTACHMENT_ENTITY); + + List elements = List.of(element); + + var method = + AttachmentDataExtractor.class.getDeclaredMethod( + "hasMissingFileNames", Map.class, List.class); + method.setAccessible(true); + + boolean resultValue = (boolean) method.invoke(null, result, elements); + + assertThat(resultValue).isTrue(); + } + + @Test + void isEmptyValue_shouldCoverAllBranches() throws Exception { + var method = AttachmentDataExtractor.class.getDeclaredMethod("isEmptyValue", 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.extractFileNamesByElement(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/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java deleted file mode 100644 index b004b35fb..000000000 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java +++ /dev/null @@ -1,412 +0,0 @@ -/* - * © 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.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -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.CdsElement; -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.*; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockedStatic; -import org.mockito.MockitoAnnotations; - -class AttachmentValidationHelperTest { - @Mock private CdsEntity entity; - @Mock private CdsData data; - @Mock private CdsRuntime cdsRuntime; - @Mock private CdsModel cdsModel; - @Mock private CdsEntity serviceEntity; - @Mock private CdsElement cdsElement; - @Mock private CdsAnnotation annotation; - @Mock private CdsDataProcessor processor; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - when(cdsRuntime.getCdsModel()).thenReturn(cdsModel); - } - - @Test - void shouldDetectMimeTypeFromURLConnection() { - assertMediaType("document.pdf", List.of("application/pdf"), "application/pdf"); - } - - @Test - void shouldUseExtensionFallbackWhenURLConnectionFails() { - assertMediaType("image.webp", List.of("image/webp"), "image/webp"); - } - - @Test - void shouldSupportWildcardSubtype() { - assertMediaType("image.jpeg", List.of("image/*"), "image/jpeg"); - } - - @Test - void shouldAllowAllWithStarSlashStar() { - assertMediaType("anyfile.jpeg", List.of("*/*"), "image/jpeg"); - } - - @Test - void shouldAllowUnknownExtensionWithStarSlashStar() { - assertMediaType( - "anyfile.anyext", List.of("*/*"), AttachmentValidationHelper.DEFAULT_MEDIA_TYPE); - } - - @Test - void shouldAllowUnknownExtensionWhenAcceptableTypesNull() { - assertMediaType("anyfile.anyext", null, AttachmentValidationHelper.DEFAULT_MEDIA_TYPE); - } - - @Test - void shouldAllowAllWhenAcceptableTypesEmpty() { - assertMediaType("anyfile.anyext", List.of(), AttachmentValidationHelper.DEFAULT_MEDIA_TYPE); - } - - @Test - void shouldUseDefaultWhenUnknownExtensionExplicitlyAllowed() { - assertMediaType( - "file.unknownext", - List.of(AttachmentValidationHelper.DEFAULT_MEDIA_TYPE), - AttachmentValidationHelper.DEFAULT_MEDIA_TYPE); - } - - @Test - void shouldHandleUppercaseExtension() { - assertMediaType("photo.JPG", List.of("image/jpeg"), "image/jpeg"); - } - - @Test - void shouldRejectUnknownExtensionWhenNotAllowed() { - assertUnsupported("anyfile.anyext", List.of("application/pdf")); - } - - @Test - void shouldThrowWhenFilenameHasNoExtension() { - assertUnsupported("invalidfilename", List.of("application/pdf")); - } - - @Test - void shouldThrowWhenFilenameEndsWithDot() { - assertUnsupported("file.", List.of("application/pdf")); - } - - @Test - void shouldThrowWhenMimeTypeNotAllowed() { - ServiceException ex = - assertThrows( - ServiceException.class, - () -> - AttachmentValidationHelper.validateMediaTypeForAttachment( - "document.pdf", List.of("image/png"))); - - assertTrue(ex.getMessage().contains("not allowed")); - } - - @Test - void shouldThrowWhenDefaultMimeTypeNotAllowed() { - ServiceException ex = - assertThrows( - ServiceException.class, - () -> - AttachmentValidationHelper.validateMediaTypeForAttachment( - "file.unknownext", List.of("application/pdf"))); - - assertTrue(ex.getMessage().contains("not allowed")); - } - - @Test - void shouldThrowWhenFileNameIsNull() { - ServiceException ex = - assertThrows( - ServiceException.class, - () -> - AttachmentValidationHelper.validateMediaTypeForAttachment( - null, List.of("application/pdf"))); - - assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); - assertTrue(ex.getMessage().contains("must not be null or blank")); - } - - @Test - void shouldThrowWhenFileNameIsEmpty() { - ServiceException ex = - assertThrows( - ServiceException.class, - () -> - AttachmentValidationHelper.validateMediaTypeForAttachment( - "", List.of("application/pdf"))); - - assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); - } - - @Test - void shouldThrowWhenMimeTypeDoesNotMatchWildcard() { - ServiceException ex = - assertThrows( - ServiceException.class, - () -> - AttachmentValidationHelper.validateMediaTypeForAttachment( - "test.pdf", List.of("image/*"))); - - assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); - } - - @Test - void shouldThrowWhenFileNameIsBlank() { - ServiceException ex = - assertThrows( - ServiceException.class, - () -> - AttachmentValidationHelper.validateMediaTypeForAttachment( - " ", List.of("application/pdf"))); - - assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); - } - - @Test - void shouldReturnFalseWhenMimeTypeIsNull() { - boolean result = AttachmentValidationHelper.checkMimeTypeMatch(List.of("image/png"), null); - - assertFalse(result); - } - - private void assertMediaType(String fileName, List allowed, String expectedType) { - - String result = AttachmentValidationHelper.validateMediaTypeForAttachment(fileName, allowed); - - assertEquals(expectedType, result); - } - - private void assertUnsupported(String fileName, List allowed) { - - ServiceException ex = - assertThrows( - ServiceException.class, - () -> AttachmentValidationHelper.validateMediaTypeForAttachment(fileName, allowed)); - - assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); - } - - @Test - void shouldHandleDotFiles() { - assertUnsupported(".gitignore", List.of("text/plain", "application/octet-stream")); - assertUnsupported(".ssh", List.of("application/octet-stream")); - assertUnsupported(".dockerignore", List.of("text/plain", "application/octet-stream")); - assertUnsupported(".invalid.ext", List.of("application/pdf")); - } - - @Test - void validateAcceptableMediaTypes_shouldReturnWhenEntityIsNull() { - // given - CdsRuntime runtime = mock(CdsRuntime.class); - - // when / then - assertDoesNotThrow( - () -> AttachmentValidationHelper.validateAcceptableMediaTypes(null, List.of(), runtime)); - - // ensure no further interaction happens - verifyNoInteractions(runtime); - } - - @Test - void validateAcceptableMediaTypes_whenNotMediaEntity_returns() { - when(entity.getQualifiedName()).thenReturn("Test.Entity"); - when(cdsModel.findEntity("Test.Entity")).thenReturn(Optional.of(serviceEntity)); - - // IMPORTANT: force isMediaEntity = false - try (MockedStatic mocked = - mockStatic(ApplicationHandlerHelper.class)) { - - mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(serviceEntity)).thenReturn(false); - - assertDoesNotThrow( - () -> - AttachmentValidationHelper.validateAcceptableMediaTypes( - entity, List.of(data), cdsRuntime)); - } - } - - @Test - void getEntityAcceptableMediaTypes_returnsAnnotationValue() { - List expectedTypes = List.of("image/png", "application/pdf"); - - when(entity.getElement("content")).thenReturn(cdsElement); - when(cdsElement.findAnnotation("Core.AcceptableMediaTypes")) - .thenReturn(Optional.of(annotation)); - when(annotation.getValue()).thenReturn(expectedTypes); - assertEquals(expectedTypes, AttachmentValidationHelper.getEntityAcceptableMediaTypes(entity)); - } - - @Test - void getEntityAcceptableMediaTypes_missingAnnotation_returnsWildcard() { - when(entity.getElement("content")).thenReturn(cdsElement); - when(cdsElement.findAnnotation("Core.AcceptableMediaTypes")).thenReturn(Optional.empty()); - assertEquals(List.of("*/*"), AttachmentValidationHelper.getEntityAcceptableMediaTypes(entity)); - } - - @Test - void getEntityAcceptableMediaTypes_nullAnnotationValue_returnsWildcard() { - when(entity.getElement("content")).thenReturn(cdsElement); - when(cdsElement.findAnnotation("Core.AcceptableMediaTypes")) - .thenReturn(Optional.of(annotation)); - when(annotation.getValue()).thenReturn(null); - assertEquals(List.of("*/*"), AttachmentValidationHelper.getEntityAcceptableMediaTypes(entity)); - } - - @Test - void extractFileName_whenFileNamePresent_returnsValue() { - when(cdsElement.getName()).thenReturn("fileName"); - try (MockedStatic mocked = mockStatic(CdsDataProcessor.class)) { - mocked.when(CdsDataProcessor::create).thenReturn(processor); - doAnswer( - invocation -> { - Filter filter = invocation.getArgument(0); - Validator validator = invocation.getArgument(1); - - // Simulate processor visiting fileName with String value - if (filter.test(null, cdsElement, null)) { - validator.validate(null, cdsElement, "test.pdf"); - } - return processor; - }) - .when(processor) - .addValidator(any(), any()); - - doNothing().when(processor).process(anyList(), any()); - String result = AttachmentValidationHelper.extractFileName(entity, List.of(data)); - assertThat(result).isEqualTo("test.pdf"); - } - } - - @Test - void extractFileName_whenElementIsNotFileName_throws() { - when(cdsElement.getName()).thenReturn("content"); - 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, cdsElement, null)) { - validator.validate(null, cdsElement, "test.pdf"); - } - return processor; - }) - .when(processor) - .addValidator(any(), any()); - - doNothing().when(processor).process(anyList(), any()); - assertThrows( - ServiceException.class, - () -> AttachmentValidationHelper.extractFileName(entity, List.of(data))); - } - } - - @Test - void extractFileName_valueIsNotString_branchCovered() { - when(cdsElement.getName()).thenReturn("fileName"); - 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, cdsElement, null)) { - validator.validate(null, cdsElement, 42); // non-String - } - return processor; - }) - .when(processor) - .addValidator(any(), any()); - doNothing().when(processor).process(anyList(), any()); - - assertThrows( - ServiceException.class, - () -> AttachmentValidationHelper.extractFileName(entity, List.of(data))); - } - } - - @Test - void validateAcceptableMediaTypes_nonMediaOrMissingEntity_doesNothing() { - when(entity.getQualifiedName()).thenReturn("TestService.Roots"); - when(cdsModel.findEntity("TestService.Roots")).thenReturn(Optional.empty()); - - assertDoesNotThrow( - () -> - AttachmentValidationHelper.validateAcceptableMediaTypes(entity, List.of(), cdsRuntime)); - } - - @Test - void shouldNotThrowWhenEntityNotFoundInModel() { - when(entity.getQualifiedName()).thenReturn("Test.Entity"); - when(cdsModel.findEntity("Test.Entity")).thenReturn(Optional.empty()); - - assertDoesNotThrow( - () -> - AttachmentValidationHelper.validateAcceptableMediaTypes(entity, List.of(), cdsRuntime)); - } - - @Test - void validateAcceptableMediaTypes_mediaTypeMatches_succeeds() { - setupMediaEntity(List.of("image/png")); - try (MockedStatic helperStatic = - mockStatic(AttachmentValidationHelper.class, CALLS_REAL_METHODS)) { - - // file.png → media type image/png → allowed - helperStatic - .when(() -> AttachmentValidationHelper.extractFileName(entity, List.of(data))) - .thenReturn("file.png"); - - assertDoesNotThrow( - () -> - AttachmentValidationHelper.validateAcceptableMediaTypes( - entity, List.of(data), cdsRuntime)); - } - } - - @Test - void validateAcceptableMediaTypes_mediaTypeMismatch_throws() { - setupMediaEntity(List.of("image/png")); - try (MockedStatic helperStatic = - mockStatic(AttachmentValidationHelper.class, CALLS_REAL_METHODS)) { - - // file.jpg → media type image/jpeg → NOT allowed - helperStatic - .when(() -> AttachmentValidationHelper.extractFileName(entity, List.of(data))) - .thenReturn("file.jpg"); - - assertThrows( - ServiceException.class, - () -> - AttachmentValidationHelper.validateAcceptableMediaTypes( - entity, List.of(data), cdsRuntime)); - } - } - - private void setupMediaEntity(List allowedTypes) { - when(entity.getQualifiedName()).thenReturn("Test.Entity"); - when(cdsModel.findEntity("Test.Entity")).thenReturn(Optional.of(serviceEntity)); - when(serviceEntity.getAnnotationValue("_is_media_data", false)).thenReturn(true); - when(serviceEntity.getElement("content")).thenReturn(cdsElement); - when(cdsElement.findAnnotation("Core.AcceptableMediaTypes")) - .thenReturn(Optional.of(annotation)); - when(annotation.getValue()).thenReturn(allowedTypes); - } -} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java new file mode 100644 index 000000000..08801bec1 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java @@ -0,0 +1,115 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.media; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +import com.sap.cds.reflect.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class MediaTypeResolverTest { + + @Test + void returnsMediaTypes_whenEntityIsMediaEntity() { + CdsEntity entity = mediaEntity("MediaEntity", List.of("image/png", "image/jpeg")); + try (MockedStatic mocked = mockMedia(entity)) { + Map> result = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); + assertEquals(Map.of("MediaEntity", List.of("image/png", "image/jpeg")), result); + } + } + + @Test + void returnsWildcard_whenAnnotationMissing() { + CdsEntity entity = mediaEntity("MediaEntity", null); + try (MockedStatic mocked = mockMedia(entity)) { + Map> result = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); + assertEquals(List.of("*/*"), result.get("MediaEntity")); + } + } + + @Test + void returnsMediaTypes_fromComposedChildEntities() { + CdsEntity child = mediaEntity("ChildMedia", List.of("application/pdf")); + CdsEntity root = rootWithChild(child); + try (MockedStatic mocked = mockMedia(child)) { + mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); + Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); + assertEquals(Map.of("ChildMedia", List.of("application/pdf")), result); + } + } + + @Test + void ignoresNonMediaChildren() { + CdsEntity child = mock(CdsEntity.class); + CdsEntity root = rootWithChild(child); + try (MockedStatic mocked = + mockStatic(ApplicationHandlerHelper.class)) { + mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); + mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(child)).thenReturn(false); + Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); + assertTrue(result.isEmpty()); + } + } + + @Test + void returnsEmpty_whenNoAssociations() { + CdsEntity root = mock(CdsEntity.class); + when(root.elements()).thenReturn(Stream.empty()); + try (MockedStatic mocked = + mockStatic(ApplicationHandlerHelper.class)) { + mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); + Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); + assertTrue(result.isEmpty()); + } + } + + // ----------- HELPERS ---------- + private CdsEntity mediaEntity(String name, List mediaTypes) { + CdsEntity entity = mock(CdsEntity.class); + when(entity.getQualifiedName()).thenReturn(name); + if (mediaTypes != null) { + CdsAnnotation annotation = mock(CdsAnnotation.class); + when(annotation.getValue()).thenReturn(mediaTypes); + CdsElement content = mock(CdsElement.class); + when(content.findAnnotation("Core.AcceptableMediaTypes")).thenReturn(Optional.of(annotation)); + when(entity.getElement("content")).thenReturn(content); + } else { + when(entity.getElement("content")).thenReturn(null); + } + return entity; + } + + private CdsEntity rootWithChild(CdsEntity child) { + CdsAssociationType association = mock(CdsAssociationType.class); + when(association.isComposition()).thenReturn(true); + when(association.getTarget()).thenReturn(child); + + CdsType type = mock(CdsType.class); + when(type.isAssociation()).thenReturn(true); + when(type.as(CdsAssociationType.class)).thenReturn(association); + + CdsElement element = mock(CdsElement.class); + when(element.getType()).thenReturn(type); + + CdsEntity root = mock(CdsEntity.class); + when(root.elements()).thenReturn(Stream.of(element)); + + return root; + } + + private MockedStatic mockMedia(CdsEntity mediaEntity) { + MockedStatic mocked = mockStatic(ApplicationHandlerHelper.class); + mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(mediaEntity)).thenReturn(true); + return mocked; + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java new file mode 100644 index 000000000..9d099a669 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java @@ -0,0 +1,153 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.media; + +import static org.junit.jupiter.api.Assertions.*; + +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); + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java new file mode 100644 index 000000000..828f122b8 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java @@ -0,0 +1,202 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.validation; + +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.applicationservice.helper.AttachmentDataExtractor; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.media.MediaTypeResolver; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; +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 org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class AttachmentValidationHelperTest { + + @Test + void returns_whenEntityIsNull() { + assertDoesNotThrow( + () -> + AttachmentValidationHelper.validateMediaAttachments( + null, List.of(), mock(CdsRuntime.class))); + } + + @Test + void returns_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); + + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + } + + @Test + void returns_whenNotMediaEntityAndNoAttachments() { + CdsEntity entity = mockEntity("Entity"); + + try (MockedStatic mocked = + mockStatic(ApplicationHandlerHelper.class)) { + mocked + .when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)) + .thenReturn(false); + mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(false); + + CdsRuntime runtime = mockRuntime(entity); + + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + } + } + + @Test + void passes_whenAllFilesValid() { + CdsEntity entity = mockEntity("Entity"); + + Map> allowed = Map.of("Entity", List.of("image/png")); + Map> files = Map.of("Entity", Set.of("file.png")); + + try (MockedStatic helper = + mockStatic(ApplicationHandlerHelper.class); + MockedStatic resolver = mockStatic(MediaTypeResolver.class); + MockedStatic extractor = + mockStatic(AttachmentDataExtractor.class)) { + + helper.when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + + resolver + .when(() -> MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity)) + .thenReturn(allowed); + + extractor + .when(() -> AttachmentDataExtractor.extractFileNamesByElement(entity, List.of())) + .thenReturn(files); + + CdsRuntime runtime = mockRuntime(entity); + + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + } + } + + @Test + void throwsException_whenInvalidFilesDetected() { + CdsEntity entity = mockEntity("Entity"); + + Map> allowed = Map.of("Entity", List.of("image/png")); + Map> files = Map.of("Entity", Set.of("file.txt")); + + try (MockedStatic helper = + mockStatic(ApplicationHandlerHelper.class); + MockedStatic resolver = mockStatic(MediaTypeResolver.class); + MockedStatic extractor = + mockStatic(AttachmentDataExtractor.class)) { + + helper.when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + + resolver + .when(() -> MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity)) + .thenReturn(allowed); + + extractor + .when(() -> AttachmentDataExtractor.extractFileNamesByElement(entity, List.of())) + .thenReturn(files); + + CdsRuntime runtime = mockRuntime(entity); + + ServiceException ex = + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + + assertTrue(ex.getMessage().contains("Unsupported file types detected")); + assertTrue(ex.getMessage().contains("file.txt")); + assertTrue(ex.getMessage().contains("image/png")); + } + } + + @Test + void groupsInvalidFilesByElement_andBuildsReadableMessage() { + Map> files = Map.of("com.test.Entity", Set.of("file.txt", "file.pdf")); + + Map> allowed = Map.of("com.test.Entity", List.of("image/png")); + + ServiceException ex = + assertThrows( + ServiceException.class, + () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, allowed)); + + assertTrue(ex.getMessage().contains("Entity")); // simple name extraction + assertTrue(ex.getMessage().contains("file.txt")); + assertTrue(ex.getMessage().contains("file.pdf")); + assertTrue(ex.getMessage().contains("image/png")); + } + + @Test + void doesNothing_whenNoFilesProvided() { + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateAttachmentMediaTypes(Map.of(), Map.of())); + } + + @Test + void usesWildcard_whenNoAllowedTypesDefined() { + Map> files = Map.of("Entity", Set.of("file.anything")); + + assertDoesNotThrow( + () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, Map.of())); + } + + @Test + void throwsException_whenFilenameInvalid() { + Map> files = Map.of("Entity", Set.of("invalid")); // no extension + + ServiceException ex = + assertThrows( + ServiceException.class, + () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, Map.of())); + + assertTrue(ex.getMessage().contains("Invalid filename format")); + } + + 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/validation/FileNameValidatorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java new file mode 100644 index 000000000..b092665b7 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java @@ -0,0 +1,96 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper.validation; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import org.junit.jupiter.api.Test; + +class FileNameValidatorTest { + + @Test + void throwsException_whenFileNameIsNull() { + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validate(null)); + + assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + assertTrue(ex.getMessage().contains("must not be null")); + } + + @Test + void throwsException_whenFileNameIsBlank() { + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validate(" ")); + + assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + assertTrue(ex.getMessage().contains("must not be blank")); + } + + @Test + void throwsException_whenNoExtensionPresent() { + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validate("file")); + + assertTrue(ex.getMessage().contains("Invalid filename format")); + } + + @Test + void throwsException_whenOnlyDotFile() { + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validate(".")); + + assertTrue(ex.getMessage().contains("Invalid filename format")); + } + + @Test + void throwsException_whenTrailingDot() { + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validate("file.")); + + assertTrue(ex.getMessage().contains("Invalid filename format")); + } + + @Test + void throwsException_whenOnlyDots() { + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validate("...")); + + assertTrue(ex.getMessage().contains("Invalid filename format")); + } + + @Test + void throwsException_whenTrimmedBecomesInvalid() { + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validate(" file ")); + + assertTrue(ex.getMessage().contains("Invalid filename format")); + } + + @Test + void doesNotThrow_whenValidFileName() { + assertDoesNotThrow(() -> FileNameValidator.validate("file.txt")); + } + + @Test + void doesNotThrow_whenValidFileNameWithMultipleDots() { + assertDoesNotThrow(() -> FileNameValidator.validate("archive.tar.gz")); + } + + @Test + void doesNotThrow_whenValidFileNameWithWhitespace() { + assertDoesNotThrow(() -> FileNameValidator.validate(" file.txt ")); + } + + @Test + void doesNotThrow_whenDoubleDotButValidExtension() { + assertDoesNotThrow(() -> FileNameValidator.validate("file..txt")); + } + + @Test + void doesNotThrow_whenHiddenFileWithoutName() { + assertDoesNotThrow(() -> FileNameValidator.validate(".config.json")); + } +} diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index 5073824bc..b9dcd8ddb 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -15,6 +15,7 @@ entity Roots : cuid { 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 index 43ac22b42..4f5cf199a 100644 --- 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 @@ -37,15 +37,15 @@ void setup() { @ParameterizedTest @CsvSource({ - "test.png,201", - "test.jpeg,201", - "test.pdf,415", - "test.txt,415", - "'',400", - "' ',400", - ".gitignore,415", - ".env,415", - ".hiddenfile,415" + "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 { @@ -65,21 +65,19 @@ private String buildDraftAttachmentCreationUrl(String rootId) { } @Test - void shouldFail_whenFileNameMissing_inDraft() throws Exception { + void shouldPass_whenFileNameMissing_inDraft() throws Exception { String rootId = createDraftRootAndReturnId(); String metadata = "{}"; requestHelper.executePostWithMatcher( - buildDraftAttachmentCreationUrl(rootId), metadata, status().isBadRequest()); + buildDraftAttachmentCreationUrl(rootId), metadata, status().isCreated()); } // Helper methods private String createDraftRootAndReturnId() throws Exception { - CdsData response = - requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, "{}"); + CdsData response = requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, "{}"); DraftRoots draftRoot = Struct.access(response).as(DraftRoots.class); - String payload = - objectMapper.writeValueAsString(Map.of("title", "Draft with mediaValidatedAttachments")); + String payload = objectMapper.writeValueAsString(Map.of("title", "Draft")); requestHelper.executePatchWithODataResponseAndAssertStatusOk( getRootUrl(draftRoot.getId(), false), payload); 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 index 26de4e65c..39ab598d1 100644 --- 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 @@ -17,6 +17,10 @@ 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.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; @@ -37,9 +41,8 @@ protected void postServiceRoot(Roots serviceRoot) throws Exception { } private Roots selectStoredRootWithMediaValidatedAttachments() { - Select select = - Select.from(Roots_.class) - .columns(r -> r._all(), r -> r.mediaValidatedAttachments().expand()); + Select select = Select.from(Roots_.class) + .columns(r -> r._all(), r -> r.mediaValidatedAttachments().expand()); Result result = persistenceService.run(select); return result.single(Roots.class); @@ -52,10 +55,10 @@ void setup() { @ParameterizedTest @CsvSource({ - "image.jpg,image/jpeg,201", - "image.png,image/png,201", - "document.pdf,application/pdf,415", - "notes.txt,text/plain,415" + "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 { @@ -120,9 +123,79 @@ void shouldRejectHiddenFile_whenFileStartsWithDot() throws Exception { 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"), // invalid + Map.of("fileName", "test2.jpeg") // valid + )); + + 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 = buildServiceRootWithMediaValidatedAttachments(); + Roots serviceRoot = buildServiceRoot(); // POST the root object to the server to create it in the database postServiceRoot(serviceRoot); @@ -133,8 +206,22 @@ private String createRootAndReturnId() throws Exception { 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; + return BASE_URL + "(" + rootId + ")" + + (path == null || path.isBlank() ? "" : "/" + path); } private String createAttachmentMetadata(String fileName) throws JsonProcessingException { @@ -142,13 +229,9 @@ private String createAttachmentMetadata(String fileName) throws JsonProcessingEx } // helper method - private Roots buildServiceRootWithMediaValidatedAttachments() { + private Roots buildServiceRoot() { return RootEntityBuilder.create() - .setTitle("Root with mediaValidatedAttachments") - .addMediaValidatedAttachments( - AttachmentsBuilder.create() - .setFileName("parent.text") - .setMimeType(MediaType.APPLICATION_JSON_VALUE)) + .setTitle("Root") .build(); } diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java index 1853ec28c..9eb705f15 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java @@ -43,17 +43,16 @@ protected void verifyTwoDeleteEvents( waitTillExpectedHandlerMessageSize(2); verifyEventContextEmptyForEvent( AttachmentService.EVENT_READ_ATTACHMENT, AttachmentService.EVENT_CREATE_ATTACHMENT); - var deleteEvents = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var deleteEvents = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); assertThat(deleteEvents).hasSize(2); assertThat( - deleteEvents.stream() - .anyMatch( - verifyContentIdAndUserInfo(itemAttachmentEntityAfterChange.getContentId()))) + deleteEvents.stream() + .anyMatch( + verifyContentIdAndUserInfo(itemAttachmentEntityAfterChange.getContentId()))) .isTrue(); assertThat( - deleteEvents.stream() - .anyMatch(verifyContentIdAndUserInfo(itemAttachmentAfterChange.getContentId()))) + deleteEvents.stream() + .anyMatch(verifyContentIdAndUserInfo(itemAttachmentAfterChange.getContentId()))) .isTrue(); } @@ -97,8 +96,7 @@ protected void verifySingleCreateEvent(String contentId, String content) { verifyEventContextEmptyForEvent( AttachmentService.EVENT_READ_ATTACHMENT, AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); - var createEvent = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + var createEvent = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); assertThat(createEvent) .hasSize(1) .first() @@ -117,34 +115,28 @@ protected void verifySingleCreateAndUpdateEvent( String resultContentId, String toBeDeletedContentId, String content) { waitTillExpectedHandlerMessageSize(3); verifyEventContextEmptyForEvent(AttachmentService.EVENT_READ_ATTACHMENT); - var createEvents = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + var createEvents = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); assertThat(createEvents).hasSize(2); verifyCreateEventsContainsContentId(toBeDeletedContentId, createEvents); verifyCreateEventsContainsContentId(resultContentId, createEvents); - var deleteEvents = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); - - var deleteContentId = - !resultContentId.equals(toBeDeletedContentId) - ? toBeDeletedContentId - : createEvents.stream() - .filter( - event -> - !resultContentId.equals( - ((AttachmentCreateEventContext) event.context()).getContentId())) - .findFirst() - .orElseThrow() - .context() - .get(Attachments.CONTENT_ID); - - var eventFound = - deleteEvents.stream() - .anyMatch( - event -> - ((AttachmentMarkAsDeletedEventContext) event.context()) - .getContentId() - .equals(deleteContentId)); + var deleteEvents = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + + var deleteContentId = !resultContentId.equals(toBeDeletedContentId) + ? toBeDeletedContentId + : createEvents.stream() + .filter( + event -> !resultContentId.equals( + ((AttachmentCreateEventContext) event.context()).getContentId())) + .findFirst() + .orElseThrow() + .context() + .get(Attachments.CONTENT_ID); + + var eventFound = deleteEvents.stream() + .anyMatch( + event -> ((AttachmentMarkAsDeletedEventContext) event.context()) + .getContentId() + .equals(deleteContentId)); assertThat(eventFound).isTrue(); } @@ -153,8 +145,7 @@ protected void verifySingleDeletionEvent(String contentId) { waitTillExpectedHandlerMessageSize(1); verifyEventContextEmptyForEvent( AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); - var deleteEvents = - serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var deleteEvents = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); assertThat(deleteEvents) .hasSize(1) .first() @@ -196,25 +187,23 @@ protected void verifyEventContextEmptyForEvent(String... events) { private Predicate verifyContentIdAndUserInfo( String itemAttachmentEntityAfterChange) { - return event -> - ((AttachmentMarkAsDeletedEventContext) event.context()) - .getContentId() - .equals(itemAttachmentEntityAfterChange) - && ((AttachmentMarkAsDeletedEventContext) event.context()) - .getDeletionUserInfo() - .getName() - .equals("anonymous"); + return event -> ((AttachmentMarkAsDeletedEventContext) event.context()) + .getContentId() + .equals(itemAttachmentEntityAfterChange) + && ((AttachmentMarkAsDeletedEventContext) event.context()) + .getDeletionUserInfo() + .getName() + .equals("anonymous"); } private void verifyCreateEventsContainsContentId( String contentId, List createEvents) { assertThat( - createEvents.stream() - .anyMatch( - event -> - ((AttachmentCreateEventContext) event.context()) - .getContentId() - .equals(contentId))) + createEvents.stream() + .anyMatch( + event -> ((AttachmentCreateEventContext) event.context()) + .getContentId() + .equals(contentId))) .isTrue(); } 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 f6dc5179c..9efc70dfc 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 @@ -16,7 +16,6 @@ private RootEntityBuilder() { rootEntity.setAttachments(new ArrayList<>()); rootEntity.setItems(new ArrayList<>()); rootEntity.setSizeLimitedAttachments(new ArrayList<>()); - rootEntity.setMediaValidatedAttachments(new ArrayList<>()); } public static RootEntityBuilder create() { @@ -40,12 +39,6 @@ public RootEntityBuilder addSizeLimitedAttachments(AttachmentsBuilder... attachm return this; } - public RootEntityBuilder addMediaValidatedAttachments(AttachmentsBuilder... attachments) { - Arrays.stream(attachments) - .forEach(attachment -> rootEntity.getMediaValidatedAttachments().add(attachment.build())); - return this; - } - public RootEntityBuilder addItems(ItemEntityBuilder... items) { Arrays.stream(items).forEach(item -> rootEntity.getItems().add(item.build())); return this; diff --git a/integration-tests/srv/test-service.cds b/integration-tests/srv/test-service.cds index c07ddaed0..ff68a31ff 100644 --- a/integration-tests/srv/test-service.cds +++ b/integration-tests/srv/test-service.cds @@ -12,6 +12,10 @@ annotate db.Roots.mediaValidatedAttachments with { ]); } +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 1f453323f..872db8205 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -9,6 +9,8 @@ extend my.Books with { sizeLimitedAttachments : Composition of many Attachments; @UI.Hidden mediaValidatedAttachments : Composition of many Attachments; + @UI.Hidden + attachments2 : Composition of many Attachments; } annotate my.Books.sizeLimitedAttachments with { @@ -23,6 +25,10 @@ annotate my.Books.mediaValidatedAttachments with { ]; } +annotate my.Books.attachments2 with { + content @Core.AcceptableMediaTypes: ['application/pdf'] +} + // Add UI component for attachments table to the Browse Books App using {CatalogService as service} from '../app/services'; @@ -40,5 +46,10 @@ annotate adminService.Books with @(UI.Facets: [{ $Type : 'UI.ReferenceFacet', ID : 'AttachmentsFacet', Label : '{i18n>attachments}', - Target: 'attachments/@UI.LineItem' + Target: 'mediaValidatedAttachments/@UI.LineItem' }]); + +service nonDraft { + @odata.draft.enabled: false + entity Books as projection on my.Books; +} From 9e26aaf0e908706d58452cac1f7d1537d59ffd37 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:08:33 +0100 Subject: [PATCH 3/8] solve bot reiew comments --- .../helper/AttachmentDataExtractor.java | 45 ++++++++----------- .../helper/media/MediaTypeResolver.java | 4 +- .../helper/media/MediaTypeService.java | 27 ++++++++--- .../AttachmentValidationHelper.java | 9 +--- .../helper/validation/FileNameValidator.java | 11 +++-- .../helper/AttachmentDataExtractorTest.java | 39 ++++++---------- .../helper/media/MediaTypeServiceTest.java | 11 +++++ .../AttachmentValidationHelperTest.java | 16 ------- .../validation/FileNameValidatorTest.java | 34 +++++++------- ...MediaValidatedAttachmentsNonDraftTest.java | 7 ++- 10 files changed, 89 insertions(+), 114 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java index f297303ff..6e0467cf3 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java @@ -8,6 +8,7 @@ import com.sap.cds.CdsDataProcessor.Filter; import com.sap.cds.CdsDataProcessor.Validator; import com.sap.cds.feature.attachments.handler.applicationservice.helper.media.MediaTypeResolver; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.validation.FileNameValidator; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.reflect.CdsAssociationType; import com.sap.cds.reflect.CdsElement; @@ -44,21 +45,16 @@ private static Map> collectFileNamesByElementName( } private static Validator createFileNameValidator(Map> result) { - Validator validator = - (path, element, value) -> { - if (!(value instanceof String fileName)) { - throw new ServiceException( - ErrorStatuses.BAD_REQUEST, - value == null ? "Filename is missing" : "Filename must be a string"); - } - fileName = fileName.trim(); - if (fileName.isBlank()) { - throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); - } - String key = element.getDeclaringType().getQualifiedName(); - result.computeIfAbsent(key, k -> new HashSet<>()).add(fileName); - }; - return validator; + return (path, element, value) -> { + if (!(value instanceof String fileName)) { + throw new ServiceException( + ErrorStatuses.BAD_REQUEST, + value == null ? "Filename is missing" : "Filename must be a string"); + } + String normalizedFileName = FileNameValidator.validateAndNormalize(fileName); + String key = element.getDeclaringType().getQualifiedName(); + result.computeIfAbsent(key, k -> new HashSet<>()).add(normalizedFileName); + }; } private static void ensureAttachmentsHaveFileNames( @@ -87,32 +83,29 @@ private static void ensureFilenamesPresent( Map> result, List attachmentElements) { Set dataKeys = collectValidDataKeys(data); - List availableAttachmentElements = - filterAttachmentsPresentInData(attachmentElements, dataKeys); - boolean hasMissingFileNames = hasMissingFileNames(result, availableAttachmentElements); - if (hasMissingFileNames) { + boolean hasMissingFileName = hasMissingFileNames(result, attachmentElements, dataKeys); + if (hasMissingFileName) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); } } private static boolean hasMissingFileNames( - Map> result, List availableAttachmentElements) { + 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(); - return !result.containsKey(target) || result.get(target).isEmpty(); + Set fileNames = result.get(target); + return fileNames == null || fileNames.isEmpty(); }); } - private static List filterAttachmentsPresentInData( - List attachmentElements, Set dataKeys) { - return attachmentElements.stream().filter(e -> dataKeys.contains(e.getName())).toList(); - } - private static Set collectValidDataKeys(List data) { return data.stream() .flatMap(d -> d.entrySet().stream()) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java index 085bc613f..1d995f787 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.validation.AttachmentValidationHelper; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.reflect.CdsAnnotation; import com.sap.cds.reflect.CdsAssociationType; @@ -16,7 +17,6 @@ public final class MediaTypeResolver { private static final String CONTENT_ELEMENT = "content"; - private static final List WILDCARD_MEDIA_TYPE = List.of("*/*"); private static final String ACCEPTABLE_MEDIA_TYPES_ANNOTATION = "Core.AcceptableMediaTypes"; private static final TypeReference> STRING_LIST_TYPE_REF = new TypeReference<>() {}; private static final ObjectMapper objectMapper = new ObjectMapper(); @@ -55,7 +55,7 @@ private static List fetchAcceptableMediaTypes(CdsEntity entity) { return getAcceptableMediaTypesAnnotation(entity) .map(CdsAnnotation::getValue) .map(value -> objectMapper.convertValue(value, STRING_LIST_TYPE_REF)) - .orElse(WILDCARD_MEDIA_TYPE); + .orElse(AttachmentValidationHelper.WILDCARD_MEDIA_TYPE); } private MediaTypeResolver() { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java index 0f2d5cd6b..9e3a28bff 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java @@ -3,6 +3,8 @@ */ package com.sap.cds.feature.attachments.handler.applicationservice.helper.media; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; import java.util.Collection; import java.util.Map; import org.slf4j.Logger; @@ -79,18 +81,29 @@ public final class MediaTypeService { Map.entry("webp", "image/webp")); public static String resolveMimeType(String fileName) { - String fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + 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); + } + String fileExtension = fileName.substring(lastDotIndex + 1).toLowerCase(); String 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 fallbackToDefaultMimeType(fileName); } return actualMimeType; } + 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; + } + public static boolean isMimeTypeAllowed( Collection acceptableMediaTypes, String mimeType) { if (mimeType == null) { @@ -108,7 +121,7 @@ public static boolean isMimeTypeAllowed( .anyMatch( type -> { return type.endsWith("/*") - ? baseMimeType.startsWith(type.substring(0, type.length() - 2) + "/") + ? baseMimeType.startsWith(type.substring(0, type.length() - 1)) : baseMimeType.equals(type); }); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java index af60985be..6473e13e4 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java @@ -107,14 +107,13 @@ private static ServiceException buildUnsupportedFileTypeMessage( .map( entry -> { String element = entry.getKey(); - String simpleName = extractSimpleName(element); String files = String.join(", ", entry.getValue()); String allowed = String.join( ", ", acceptableMediaTypesByElementName.getOrDefault( element, WILDCARD_MEDIA_TYPE)); - return simpleName + ": " + files + " (allowed: " + allowed + ")"; + return files + " (allowed: " + allowed + ") "; }) .collect(Collectors.joining("; ")); @@ -122,13 +121,7 @@ private static ServiceException buildUnsupportedFileTypeMessage( ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Unsupported file types detected: " + message); } - private static String extractSimpleName(String qualifiedName) { - int idx = qualifiedName.lastIndexOf('.'); - return idx >= 0 ? qualifiedName.substring(idx + 1) : qualifiedName; - } - private static boolean isAttachmentTypeValid(String fileName, List acceptableTypes) { - FileNameValidator.validate(fileName); String mimeType = MediaTypeService.resolveMimeType(fileName); return MediaTypeService.isMimeTypeAllowed(acceptableTypes, mimeType); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java index a30ea3ef4..f124facf0 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java @@ -8,22 +8,21 @@ public final class FileNameValidator { - public static void validate(String fileName) { + public static String validateAndNormalize(String fileName) { if (fileName == null) { - throw new ServiceException(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Filename must not be null"); + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename must not be null"); } String trimmedFileName = fileName.trim(); if (trimmedFileName.isEmpty()) { - throw new ServiceException( - ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, "Filename must not be blank"); + 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.UNSUPPORTED_MEDIA_TYPE, "Invalid filename format: " + fileName); + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Invalid filename format: " + fileName); } + return trimmedFileName; } private FileNameValidator() { diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java index d7e480fb9..7b8be4ef4 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java @@ -178,61 +178,48 @@ private static Stream skipConditionsProvider() { @Test void filter_acceptsElement_whenAllConditionsTrue() { - // default setup already does this CdsData data = prepareCdsDataWithAttachments("file.txt"); - Map> result = extractFileNames(data); - assertThat(result.get(ATTACHMENT_ENTITY)).contains("file.txt"); } @Test void ensureFilenamesPresent_whenResultMissingKey_throwsException() { // Arrange - - // IMPORTANT: disable validator so result stays EMPTY doAnswer(invocation -> processor).when(processor).addValidator(any(), any()); doNothing().when(processor).process(anyList(), any()); - when(targetEntity.elements()).thenReturn(Stream.of(attachmentElement)); - - // Ensure attachment is detected 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_keyExistsButEmptySet_returnsTrue() throws Exception { + 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); - CdsElement element = mock(CdsElement.class); - CdsType type = mock(CdsType.class); - CdsAssociationType assoc = mock(CdsAssociationType.class); - CdsEntity target = mock(CdsEntity.class); - - when(element.getType()).thenReturn(type); - when(type.as(CdsAssociationType.class)).thenReturn(assoc); - when(assoc.getTarget()).thenReturn(target); - when(target.getQualifiedName()).thenReturn(ATTACHMENT_ENTITY); - - List elements = List.of(element); - + // Act var method = AttachmentDataExtractor.class.getDeclaredMethod( - "hasMissingFileNames", Map.class, List.class); + "hasMissingFileNames", Map.class, List.class, Set.class); method.setAccessible(true); + boolean resultValue = (boolean) method.invoke(null, result, elements, dataKeys); - boolean resultValue = (boolean) method.invoke(null, result, elements); - + // Assert assertThat(resultValue).isTrue(); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java index 9d099a669..e5b2c698f 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*; +import com.sap.cds.services.ServiceException; import java.util.List; import org.junit.jupiter.api.Test; @@ -150,4 +151,14 @@ void returnsTrue_whenOneOfMultipleMatches() { 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/applicationservice/helper/validation/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java index 828f122b8..03afe2273 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java @@ -138,15 +138,11 @@ void throwsException_whenInvalidFilesDetected() { @Test void groupsInvalidFilesByElement_andBuildsReadableMessage() { Map> files = Map.of("com.test.Entity", Set.of("file.txt", "file.pdf")); - Map> allowed = Map.of("com.test.Entity", List.of("image/png")); - ServiceException ex = assertThrows( ServiceException.class, () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, allowed)); - - assertTrue(ex.getMessage().contains("Entity")); // simple name extraction assertTrue(ex.getMessage().contains("file.txt")); assertTrue(ex.getMessage().contains("file.pdf")); assertTrue(ex.getMessage().contains("image/png")); @@ -166,18 +162,6 @@ void usesWildcard_whenNoAllowedTypesDefined() { () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, Map.of())); } - @Test - void throwsException_whenFilenameInvalid() { - Map> files = Map.of("Entity", Set.of("invalid")); // no extension - - ServiceException ex = - assertThrows( - ServiceException.class, - () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, Map.of())); - - assertTrue(ex.getMessage().contains("Invalid filename format")); - } - private CdsRuntime mockRuntime(CdsEntity entity) { CdsModel model = mock(CdsModel.class); when(model.findEntity(entity.getQualifiedName())).thenReturn(Optional.of(entity)); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java index b092665b7..a30632f03 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java @@ -14,83 +14,79 @@ class FileNameValidatorTest { @Test void throwsException_whenFileNameIsNull() { ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validate(null)); + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(null)); - assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + assertEquals(ErrorStatuses.BAD_REQUEST, ex.getErrorStatus()); assertTrue(ex.getMessage().contains("must not be null")); } @Test void throwsException_whenFileNameIsBlank() { ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validate(" ")); + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(" ")); - assertEquals(ErrorStatuses.UNSUPPORTED_MEDIA_TYPE, ex.getErrorStatus()); + assertEquals(ErrorStatuses.BAD_REQUEST, ex.getErrorStatus()); assertTrue(ex.getMessage().contains("must not be blank")); } @Test void throwsException_whenNoExtensionPresent() { ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validate("file")); - + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenOnlyDotFile() { ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validate(".")); - + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(".")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenTrailingDot() { ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validate("file.")); - + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file.")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenOnlyDots() { ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validate("...")); - + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("...")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenTrimmedBecomesInvalid() { ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validate(" file ")); - + assertThrows( + ServiceException.class, () -> FileNameValidator.validateAndNormalize(" file ")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void doesNotThrow_whenValidFileName() { - assertDoesNotThrow(() -> FileNameValidator.validate("file.txt")); + assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize("file.txt")); } @Test void doesNotThrow_whenValidFileNameWithMultipleDots() { - assertDoesNotThrow(() -> FileNameValidator.validate("archive.tar.gz")); + assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize("archive.tar.gz")); } @Test void doesNotThrow_whenValidFileNameWithWhitespace() { - assertDoesNotThrow(() -> FileNameValidator.validate(" file.txt ")); + assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize(" file.txt ")); } @Test void doesNotThrow_whenDoubleDotButValidExtension() { - assertDoesNotThrow(() -> FileNameValidator.validate("file..txt")); + assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize("file..txt")); } @Test void doesNotThrow_whenHiddenFileWithoutName() { - assertDoesNotThrow(() -> FileNameValidator.validate(".config.json")); + assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize(".config.json")); } } 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 index 39ab598d1..374c2f5ef 100644 --- 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 @@ -109,7 +109,7 @@ void shouldRejectAttachment_whenFileHasNoExtension() throws Exception { requestHelper.executePostWithMatcher( createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), attachmentMetadata, - status().isUnsupportedMediaType()); + status().isBadRequest()); } @Test @@ -163,9 +163,8 @@ void shouldAcceptDeepCreate_whenMixedValidAndAllValidAttachments() throws Except Map payload = new HashMap<>(); payload.put("title", "Hello World!"); payload.put("mediaValidatedAttachments", List.of( - Map.of("fileName", "test1.jpeg"), // invalid - Map.of("fileName", "test2.jpeg") // valid - )); + Map.of("fileName", "test1.jpeg"), + Map.of("fileName", "test2.jpeg"))); payload.put("mimeValidatedAttachments", List.of( Map.of("fileName", "test3.pdf"))); From 99d9742d2b3de5c4853581c0a2168fbd561f5205 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:10:14 +0100 Subject: [PATCH 4/8] apply formatting --- .../MediaValidatedAttachmentsDraftTest.java | 21 ++--- ...MediaValidatedAttachmentsNonDraftTest.java | 78 +++++++--------- ...aRequestValidationWithTestHandlerTest.java | 89 +++++++++++-------- 3 files changed, 92 insertions(+), 96 deletions(-) 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 index 4f5cf199a..f1ddbb49d 100644 --- 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 @@ -37,15 +37,15 @@ void setup() { @ParameterizedTest @CsvSource({ - "test.png,201", - "test.jpeg,201", - "test.pdf,415", - "test.txt,415", - "'',400", - "' ',400", - ".gitignore,415", - ".env,415", - ".hiddenfile,415" + "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 { @@ -74,7 +74,8 @@ void shouldPass_whenFileNameMissing_inDraft() throws Exception { // Helper methods private String createDraftRootAndReturnId() throws Exception { - CdsData response = requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, "{}"); + CdsData response = + requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, "{}"); DraftRoots draftRoot = Struct.access(response).as(DraftRoots.class); String payload = objectMapper.writeValueAsString(Map.of("title", "Draft")); 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 index 374c2f5ef..b28ec9971 100644 --- 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 @@ -14,10 +14,8 @@ 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.AttachmentsBuilder; 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; @@ -41,8 +39,9 @@ protected void postServiceRoot(Roots serviceRoot) throws Exception { } private Roots selectStoredRootWithMediaValidatedAttachments() { - Select select = Select.from(Roots_.class) - .columns(r -> r._all(), r -> r.mediaValidatedAttachments().expand()); + Select select = + Select.from(Roots_.class) + .columns(r -> r._all(), r -> r.mediaValidatedAttachments().expand()); Result result = persistenceService.run(select); return result.single(Roots.class); @@ -55,10 +54,10 @@ void setup() { @ParameterizedTest @CsvSource({ - "image.jpg,image/jpeg,201", - "image.png,image/png,201", - "document.pdf,application/pdf,415", - "notes.txt,text/plain,415" + "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 { @@ -125,24 +124,21 @@ void shouldRejectHiddenFile_whenFileStartsWithDot() throws Exception { @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" + // 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)); + requestHelper.executePostWithMatcher(BASE_URL, payload, status().is(expectedStatus)); } @Test @@ -152,44 +148,35 @@ void shouldAcceptWhenMediaValidatedAttachments_hasNoAttachments() throws Excepti payload.put("mediaValidatedAttachments", List.of()); String payloadStr = objectMapper.writeValueAsString(payload); - requestHelper.executePostWithMatcher( - BASE_URL, - payloadStr, - status().is(201)); + 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( + "mediaValidatedAttachments", + List.of(Map.of("fileName", "test1.jpeg"), Map.of("fileName", "test2.jpeg"))); - payload.put("mimeValidatedAttachments", List.of( - Map.of("fileName", "test3.pdf"))); + payload.put("mimeValidatedAttachments", List.of(Map.of("fileName", "test3.pdf"))); requestHelper.executePostWithMatcher( - BASE_URL, - objectMapper.writeValueAsString(payload), - status().isCreated()); + 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( + "mediaValidatedAttachments", + List.of(Map.of("fileName", "test1.pdf"), Map.of("fileName", "test2.jpeg"))); - payload.put("mimeValidatedAttachments", List.of( - Map.of("fileName", "test3.pdf"))); + payload.put("mimeValidatedAttachments", List.of(Map.of("fileName", "test3.pdf"))); requestHelper.executePostWithMatcher( - BASE_URL, - objectMapper.writeValueAsString(payload), - status().isUnsupportedMediaType()); + BASE_URL, objectMapper.writeValueAsString(payload), status().isUnsupportedMediaType()); } private String createRootAndReturnId() throws Exception { @@ -219,8 +206,7 @@ private String buildPayload(String fileNames) throws JsonProcessingException { } private String createUrl(String rootId, String path) { - return BASE_URL + "(" + rootId + ")" + - (path == null || path.isBlank() ? "" : "/" + path); + return BASE_URL + "(" + rootId + ")" + (path == null || path.isBlank() ? "" : "/" + path); } private String createAttachmentMetadata(String fileName) throws JsonProcessingException { @@ -229,9 +215,7 @@ private String createAttachmentMetadata(String fileName) throws JsonProcessingEx // helper method private Roots buildServiceRoot() { - return RootEntityBuilder.create() - .setTitle("Root") - .build(); + return RootEntityBuilder.create().setTitle("Root").build(); } // Override abstract methods from OdataRequestValidationBase diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java index 9eb705f15..1853ec28c 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationWithTestHandlerTest.java @@ -43,16 +43,17 @@ protected void verifyTwoDeleteEvents( waitTillExpectedHandlerMessageSize(2); verifyEventContextEmptyForEvent( AttachmentService.EVENT_READ_ATTACHMENT, AttachmentService.EVENT_CREATE_ATTACHMENT); - var deleteEvents = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); assertThat(deleteEvents).hasSize(2); assertThat( - deleteEvents.stream() - .anyMatch( - verifyContentIdAndUserInfo(itemAttachmentEntityAfterChange.getContentId()))) + deleteEvents.stream() + .anyMatch( + verifyContentIdAndUserInfo(itemAttachmentEntityAfterChange.getContentId()))) .isTrue(); assertThat( - deleteEvents.stream() - .anyMatch(verifyContentIdAndUserInfo(itemAttachmentAfterChange.getContentId()))) + deleteEvents.stream() + .anyMatch(verifyContentIdAndUserInfo(itemAttachmentAfterChange.getContentId()))) .isTrue(); } @@ -96,7 +97,8 @@ protected void verifySingleCreateEvent(String contentId, String content) { verifyEventContextEmptyForEvent( AttachmentService.EVENT_READ_ATTACHMENT, AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); - var createEvent = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + var createEvent = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); assertThat(createEvent) .hasSize(1) .first() @@ -115,28 +117,34 @@ protected void verifySingleCreateAndUpdateEvent( String resultContentId, String toBeDeletedContentId, String content) { waitTillExpectedHandlerMessageSize(3); verifyEventContextEmptyForEvent(AttachmentService.EVENT_READ_ATTACHMENT); - var createEvents = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); + var createEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT); assertThat(createEvents).hasSize(2); verifyCreateEventsContainsContentId(toBeDeletedContentId, createEvents); verifyCreateEventsContainsContentId(resultContentId, createEvents); - var deleteEvents = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); - - var deleteContentId = !resultContentId.equals(toBeDeletedContentId) - ? toBeDeletedContentId - : createEvents.stream() - .filter( - event -> !resultContentId.equals( - ((AttachmentCreateEventContext) event.context()).getContentId())) - .findFirst() - .orElseThrow() - .context() - .get(Attachments.CONTENT_ID); - - var eventFound = deleteEvents.stream() - .anyMatch( - event -> ((AttachmentMarkAsDeletedEventContext) event.context()) - .getContentId() - .equals(deleteContentId)); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + + var deleteContentId = + !resultContentId.equals(toBeDeletedContentId) + ? toBeDeletedContentId + : createEvents.stream() + .filter( + event -> + !resultContentId.equals( + ((AttachmentCreateEventContext) event.context()).getContentId())) + .findFirst() + .orElseThrow() + .context() + .get(Attachments.CONTENT_ID); + + var eventFound = + deleteEvents.stream() + .anyMatch( + event -> + ((AttachmentMarkAsDeletedEventContext) event.context()) + .getContentId() + .equals(deleteContentId)); assertThat(eventFound).isTrue(); } @@ -145,7 +153,8 @@ protected void verifySingleDeletionEvent(String contentId) { waitTillExpectedHandlerMessageSize(1); verifyEventContextEmptyForEvent( AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT); - var deleteEvents = serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); + var deleteEvents = + serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED); assertThat(deleteEvents) .hasSize(1) .first() @@ -187,23 +196,25 @@ protected void verifyEventContextEmptyForEvent(String... events) { private Predicate verifyContentIdAndUserInfo( String itemAttachmentEntityAfterChange) { - return event -> ((AttachmentMarkAsDeletedEventContext) event.context()) - .getContentId() - .equals(itemAttachmentEntityAfterChange) - && ((AttachmentMarkAsDeletedEventContext) event.context()) - .getDeletionUserInfo() - .getName() - .equals("anonymous"); + return event -> + ((AttachmentMarkAsDeletedEventContext) event.context()) + .getContentId() + .equals(itemAttachmentEntityAfterChange) + && ((AttachmentMarkAsDeletedEventContext) event.context()) + .getDeletionUserInfo() + .getName() + .equals("anonymous"); } private void verifyCreateEventsContainsContentId( String contentId, List createEvents) { assertThat( - createEvents.stream() - .anyMatch( - event -> ((AttachmentCreateEventContext) event.context()) - .getContentId() - .equals(contentId))) + createEvents.stream() + .anyMatch( + event -> + ((AttachmentCreateEventContext) event.context()) + .getContentId() + .equals(contentId))) .isTrue(); } From 5a8add8b5aecde3a73d258fc1c43e18b51f88e54 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:00:56 +0100 Subject: [PATCH 5/8] add comments --- .../helper/AttachmentDataExtractor.java | 93 ++++++---- .../helper/media/MediaTypeResolver.java | 30 +++- .../helper/media/MediaTypeService.java | 170 ++++++++++-------- .../AttachmentValidationHelper.java | 88 +++++---- .../helper/validation/FileNameValidator.java | 13 ++ .../helper/AttachmentDataExtractorTest.java | 99 +++++----- .../helper/media/MediaTypeResolverTest.java | 16 +- .../AttachmentValidationHelperTest.java | 69 ++++--- .../validation/FileNameValidatorTest.java | 24 +-- 9 files changed, 330 insertions(+), 272 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java index 6e0467cf3..461c46c63 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java @@ -25,56 +25,70 @@ 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); + public static final Filter FILE_NAME_FILTER = (path, element, type) -> element.getName() + .contentEquals(FILE_NAME_FIELD); - public static Map> extractFileNamesByElement( + /** + * 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; } + // -------------------- Extraction -------------------- + 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 = createFileNameValidator(fileNamesByElementName); + Validator fileNameValidator = generateFileNameFieldValidator(fileNamesByElementName); processor.addValidator(FILE_NAME_FILTER, fileNameValidator).process(data, entity); return fileNamesByElementName; } - private static Validator createFileNameValidator(Map> result) { - return (path, element, value) -> { - if (!(value instanceof String fileName)) { - throw new ServiceException( - ErrorStatuses.BAD_REQUEST, - value == null ? "Filename is missing" : "Filename must be a string"); - } + private static Validator generateFileNameFieldValidator(Map> result) { + Validator validator = (path, element, value) -> { + String fileName = requireString(value); String normalizedFileName = FileNameValidator.validateAndNormalize(fileName); String key = element.getDeclaringType().getQualifiedName(); result.computeIfAbsent(key, k -> new HashSet<>()).add(normalizedFileName); }; + return validator; } private static void ensureAttachmentsHaveFileNames( CdsEntity entity, List data, Map> result) { - List attachmentElements = - entity - .elements() - .filter( - e -> { - if (!e.getType().isAssociation()) { - return false; - } - CdsAssociationType association = e.getType().as(CdsAssociationType.class); - return association.isComposition() - && ApplicationHandlerHelper.isMediaEntity(association.getTarget()) - && MediaTypeResolver.getAcceptableMediaTypesAnnotation( - association.getTarget()) - .isPresent(); - }) - .toList(); + // 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); } @@ -82,8 +96,14 @@ 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"); } @@ -100,7 +120,6 @@ private static boolean hasMissingFileNames( element -> { CdsAssociationType assoc = element.getType().as(CdsAssociationType.class); String target = assoc.getTarget().getQualifiedName(); - Set fileNames = result.get(target); return fileNames == null || fileNames.isEmpty(); }); @@ -109,18 +128,32 @@ private static boolean hasMissingFileNames( private static Set collectValidDataKeys(List data) { return data.stream() .flatMap(d -> d.entrySet().stream()) - .filter(entry -> !isEmptyValue(entry.getValue())) + .filter(entry -> !isEmpty(entry.getValue())) .map(Map.Entry::getKey) .collect(Collectors.toSet()); } - private static boolean isEmptyValue(Object value) { + 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 missingFileNameError(); + } + if (!(value instanceof String s)) { + throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename must be a string"); + } + return s; + } + + private static ServiceException missingFileNameError() { + return new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); + } + private AttachmentDataExtractor() { // Private constructor to prevent instantiation } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java index 1d995f787..aeac3aa5f 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java @@ -18,9 +18,25 @@ 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 final TypeReference> STRING_LIST_TYPE_REF = new TypeReference<>() { + }; 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 + */ public static Map> getAcceptableMediaTypesFromEntity(CdsEntity entity) { // If this entity is a media entity if (ApplicationHandlerHelper.isMediaEntity(entity)) { @@ -45,12 +61,6 @@ public static Map> getAcceptableMediaTypesFromEntity(CdsEnt return result; } - public static Optional> getAcceptableMediaTypesAnnotation( - CdsEntity entity) { - return Optional.ofNullable(entity.getElement(CONTENT_ELEMENT)) - .flatMap(element -> element.findAnnotation(ACCEPTABLE_MEDIA_TYPES_ANNOTATION)); - } - private static List fetchAcceptableMediaTypes(CdsEntity entity) { return getAcceptableMediaTypesAnnotation(entity) .map(CdsAnnotation::getValue) @@ -58,6 +68,12 @@ private static List fetchAcceptableMediaTypes(CdsEntity entity) { .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/media/MediaTypeService.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java index 9e3a28bff..9d0303e87 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java @@ -15,107 +15,119 @@ public final class MediaTypeService { public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; // TODO: in a different ticket, consider loading this mapping from a JSON file // to cover more file extensions - 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", "text/plain"), - Map.entry("webp", "image/webp")); + 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", "text/plain"), + Map.entry("webp", "image/webp")); + /** + * 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); } + String fileExtension = fileName.substring(lastDotIndex + 1).toLowerCase(); String actualMimeType = EXT_TO_MEDIA_TYPE.get(fileExtension); + if (actualMimeType == null) { return fallbackToDefaultMimeType(fileName); } return actualMimeType; } - 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; - } - + /** + * 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; + || acceptableMediaTypes.contains("*/*")) + return true; String baseMimeType = mimeType.trim().toLowerCase(); - Collection normalizedTypes = - acceptableMediaTypes.stream().map(type -> type.trim().toLowerCase()).toList(); + Collection normalizedTypes = acceptableMediaTypes.stream().map(type -> type.trim().toLowerCase()).toList(); return normalizedTypes.stream() .anyMatch( @@ -126,6 +138,14 @@ public static boolean isMimeTypeAllowed( }); } + 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/applicationservice/helper/validation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java index 6473e13e4..92e32cdcc 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java @@ -24,11 +24,13 @@ public final class AttachmentValidationHelper { public static final List WILDCARD_MEDIA_TYPE = List.of("*/*"); /** - * Validates if the media type of the attachment in the given fileName is acceptable + * 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 + * @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) { @@ -43,17 +45,17 @@ public static void validateMediaAttachments( } CdsEntity serviceEntity = optionalServiceEntity.get(); - boolean hasNoAttachmentCompositions = - !ApplicationHandlerHelper.deepSearchForAttachments(serviceEntity); - + boolean hasNoAttachmentCompositions = !ApplicationHandlerHelper.deepSearchForAttachments(serviceEntity); + // Skip if entity is irrelevant for attachments if (!ApplicationHandlerHelper.isMediaEntity(serviceEntity) && hasNoAttachmentCompositions) { return; } - - Map> allowedTypesByElementName = - MediaTypeResolver.getAcceptableMediaTypesFromEntity(serviceEntity); - Map> fileNamesByElementName = - AttachmentDataExtractor.extractFileNamesByElement(serviceEntity, data); + // validate the media types of the attachments + Map> allowedTypesByElementName = MediaTypeResolver + .getAcceptableMediaTypesFromEntity(serviceEntity); + Map> fileNamesByElementName = AttachmentDataExtractor.extractAndValidateFileNamesByElement( + serviceEntity, + data); validateAttachmentMediaTypes(fileNamesByElementName, allowedTypesByElementName); } @@ -61,26 +63,33 @@ public static void validateAttachmentMediaTypes( Map> fileNamesByElementName, Map> acceptableMediaTypesByElementName) { - Map> invalidFiles = - findInvalidFiles(fileNamesByElementName, acceptableMediaTypesByElementName); + // Determine which uploaded files do not match the allowed media types + Map> invalidFiles = findInvalidFilesByElementName(fileNamesByElementName, + acceptableMediaTypesByElementName); - assertNoInvalidFiles(invalidFiles, acceptableMediaTypesByElementName); + if (!invalidFiles.isEmpty()) { + throw buildUnsupportedFileTypeMessage(acceptableMediaTypesByElementName, invalidFiles); + } } - private static Map> findInvalidFiles( + 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) -> { - List acceptableTypes = - acceptableMediaTypesByElementName.getOrDefault(elementName, WILDCARD_MEDIA_TYPE); + // Resolve the allowed media types for this field / element. + List acceptableTypes = acceptableMediaTypesByElementName.getOrDefault(elementName, + WILDCARD_MEDIA_TYPE); - List invalid = - files.stream().filter(file -> !isAttachmentTypeValid(file, acceptableTypes)).toList(); + // Filter out files whose media type is NOT allowed for this element + List invalid = files.stream().filter(file -> !isAttachmentTypeValid(file, acceptableTypes)).toList(); if (!invalid.isEmpty()) { invalidFiles.put(elementName, invalid); @@ -90,42 +99,31 @@ private static Map> findInvalidFiles( return invalidFiles; } - private static void assertNoInvalidFiles( - Map> invalidFiles, - Map> acceptableMediaTypesByElementName) { - - if (!invalidFiles.isEmpty()) { - throw buildUnsupportedFileTypeMessage(acceptableMediaTypesByElementName, invalidFiles); - } + private static boolean isAttachmentTypeValid(String fileName, List acceptableTypes) { + String mimeType = MediaTypeService.resolveMimeType(fileName); + return MediaTypeService.isMimeTypeAllowed(acceptableTypes, mimeType); } 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("; ")); + 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 static boolean isAttachmentTypeValid(String fileName, List acceptableTypes) { - String mimeType = MediaTypeService.resolveMimeType(fileName); - return MediaTypeService.isMimeTypeAllowed(acceptableTypes, mimeType); - } - private AttachmentValidationHelper() { // prevent instantiation } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java index f124facf0..56ac83fa2 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java @@ -8,6 +8,19 @@ public final class FileNameValidator { + /** + * Validates and normalizes a file name before further processing. + * + *

+ * This method ensures that filenames are non-null, non-blank, and follow a + * basic "name.extension" format before being used in further processing such as + * MIME type resolution. + * + * @param fileName the original filename to validate + * @return the trimmed and validated filename + * @throws ServiceException if the filename is null, blank, or has an invalid + * format + */ public static String validateAndNormalize(String fileName) { if (fileName == null) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename must not be null"); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java index 7b8be4ef4..a18d47f9d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java @@ -38,12 +38,18 @@ 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; + @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; @@ -151,29 +157,23 @@ private static Stream skipConditionsProvider() { return Stream.of( Arguments.of( "Not an association", - (Consumer) - test -> when(test.cdsType.isAssociation()).thenReturn(false)), + (Consumer) test -> when(test.cdsType.isAssociation()).thenReturn(false)), Arguments.of( "Not a composition", - (Consumer) - test -> when(test.associationType.isComposition()).thenReturn(false)), + (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)), + (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()))); + (Consumer) test -> test.mediaMock + .when( + () -> MediaTypeResolver.getAcceptableMediaTypesAnnotation( + test.targetEntity)) + .thenReturn(Optional.empty()))); } @Test @@ -190,9 +190,8 @@ void ensureFilenamesPresent_whenResultMissingKey_throwsException() { 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"))))); + 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)); @@ -213,9 +212,8 @@ void hasMissingFileNames_whenFileNamesEmpty_returnsTrue() throws Exception { Set dataKeys = Set.of(ATTACHMENT_FIELD); // Act - var method = - AttachmentDataExtractor.class.getDeclaredMethod( - "hasMissingFileNames", Map.class, List.class, Set.class); + var method = AttachmentDataExtractor.class.getDeclaredMethod( + "hasMissingFileNames", Map.class, List.class, Set.class); method.setAccessible(true); boolean resultValue = (boolean) method.invoke(null, result, elements, dataKeys); @@ -225,7 +223,7 @@ void hasMissingFileNames_whenFileNamesEmpty_returnsTrue() throws Exception { @Test void isEmptyValue_shouldCoverAllBranches() throws Exception { - var method = AttachmentDataExtractor.class.getDeclaredMethod("isEmptyValue", Object.class); + var method = AttachmentDataExtractor.class.getDeclaredMethod("isEmpty", Object.class); method.setAccessible(true); Iterable emptyIterable = () -> Collections.emptyIterator(); Iterable nonEmptyIterable = () -> List.of("x").iterator(); @@ -287,36 +285,35 @@ private void setupAttachmentModel() { } private Map> extractFileNames(CdsData cdsData) { - return AttachmentDataExtractor.extractFileNamesByElement(targetEntity, List.of(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(); + 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; - }) + 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()); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java index 08801bec1..3c55023ad 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java @@ -21,8 +21,7 @@ class MediaTypeResolverTest { void returnsMediaTypes_whenEntityIsMediaEntity() { CdsEntity entity = mediaEntity("MediaEntity", List.of("image/png", "image/jpeg")); try (MockedStatic mocked = mockMedia(entity)) { - Map> result = - MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); + Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); assertEquals(Map.of("MediaEntity", List.of("image/png", "image/jpeg")), result); } } @@ -31,20 +30,19 @@ void returnsMediaTypes_whenEntityIsMediaEntity() { void returnsWildcard_whenAnnotationMissing() { CdsEntity entity = mediaEntity("MediaEntity", null); try (MockedStatic mocked = mockMedia(entity)) { - Map> result = - MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); + Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); assertEquals(List.of("*/*"), result.get("MediaEntity")); } } @Test void returnsMediaTypes_fromComposedChildEntities() { - CdsEntity child = mediaEntity("ChildMedia", List.of("application/pdf")); + CdsEntity child = mediaEntity("ChildMediaEntity", List.of("application/pdf")); CdsEntity root = rootWithChild(child); try (MockedStatic mocked = mockMedia(child)) { mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); - assertEquals(Map.of("ChildMedia", List.of("application/pdf")), result); + assertEquals(Map.of("ChildMediaEntity", List.of("application/pdf")), result); } } @@ -52,8 +50,7 @@ void returnsMediaTypes_fromComposedChildEntities() { void ignoresNonMediaChildren() { CdsEntity child = mock(CdsEntity.class); CdsEntity root = rootWithChild(child); - try (MockedStatic mocked = - mockStatic(ApplicationHandlerHelper.class)) { + try (MockedStatic mocked = mockStatic(ApplicationHandlerHelper.class)) { mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(child)).thenReturn(false); Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); @@ -65,8 +62,7 @@ void ignoresNonMediaChildren() { void returnsEmpty_whenNoAssociations() { CdsEntity root = mock(CdsEntity.class); when(root.elements()).thenReturn(Stream.empty()); - try (MockedStatic mocked = - mockStatic(ApplicationHandlerHelper.class)) { + try (MockedStatic mocked = mockStatic(ApplicationHandlerHelper.class)) { mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); assertTrue(result.isEmpty()); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java index 03afe2273..497eab2a9 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java @@ -27,15 +27,14 @@ class AttachmentValidationHelperTest { @Test - void returns_whenEntityIsNull() { + void validateMediaAttachments_doesNothing_whenEntityIsNull() { assertDoesNotThrow( - () -> - AttachmentValidationHelper.validateMediaAttachments( - null, List.of(), mock(CdsRuntime.class))); + () -> AttachmentValidationHelper.validateMediaAttachments( + null, List.of(), mock(CdsRuntime.class))); } @Test - void returns_whenEntityNotFoundInModel() { + void validateMediaAttachments_doesNothing_whenEntityNotFoundInModel() { CdsEntity entity = mock(CdsEntity.class); when(entity.getQualifiedName()).thenReturn("Entity"); @@ -49,11 +48,10 @@ void returns_whenEntityNotFoundInModel() { } @Test - void returns_whenNotMediaEntityAndNoAttachments() { + void validateMediaAttachments_doesNothing_whenNotMediaEntityAndNoAttachments() { CdsEntity entity = mockEntity("Entity"); - try (MockedStatic mocked = - mockStatic(ApplicationHandlerHelper.class)) { + try (MockedStatic mocked = mockStatic(ApplicationHandlerHelper.class)) { mocked .when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)) .thenReturn(false); @@ -67,17 +65,15 @@ void returns_whenNotMediaEntityAndNoAttachments() { } @Test - void passes_whenAllFilesValid() { + void validateMediaAttachments_doesNotThrow_whenAllFilesAreAllowed() { CdsEntity entity = mockEntity("Entity"); - Map> allowed = Map.of("Entity", List.of("image/png")); - Map> files = Map.of("Entity", Set.of("file.png")); + 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); + try (MockedStatic helper = mockStatic(ApplicationHandlerHelper.class); MockedStatic resolver = mockStatic(MediaTypeResolver.class); - MockedStatic extractor = - mockStatic(AttachmentDataExtractor.class)) { + MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { helper.when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)).thenReturn(true); helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); @@ -87,7 +83,7 @@ void passes_whenAllFilesValid() { .thenReturn(allowed); extractor - .when(() -> AttachmentDataExtractor.extractFileNamesByElement(entity, List.of())) + .when(() -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) .thenReturn(files); CdsRuntime runtime = mockRuntime(entity); @@ -98,17 +94,15 @@ void passes_whenAllFilesValid() { } @Test - void throwsException_whenInvalidFilesDetected() { + void validateMediaAttachments_throwsServiceException_whenUnsupportedFileTypesDetected() { CdsEntity entity = mockEntity("Entity"); - Map> allowed = Map.of("Entity", List.of("image/png")); - Map> files = Map.of("Entity", Set.of("file.txt")); + 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); + try (MockedStatic helper = mockStatic(ApplicationHandlerHelper.class); MockedStatic resolver = mockStatic(MediaTypeResolver.class); - MockedStatic extractor = - mockStatic(AttachmentDataExtractor.class)) { + MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { helper.when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)).thenReturn(true); helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); @@ -118,16 +112,14 @@ void throwsException_whenInvalidFilesDetected() { .thenReturn(allowed); extractor - .when(() -> AttachmentDataExtractor.extractFileNamesByElement(entity, List.of())) + .when(() -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) .thenReturn(files); CdsRuntime runtime = mockRuntime(entity); - ServiceException ex = - assertThrows( - ServiceException.class, - () -> - AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + ServiceException ex = assertThrows( + ServiceException.class, + () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); assertTrue(ex.getMessage().contains("Unsupported file types detected")); assertTrue(ex.getMessage().contains("file.txt")); @@ -136,27 +128,26 @@ void throwsException_whenInvalidFilesDetected() { } @Test - void groupsInvalidFilesByElement_andBuildsReadableMessage() { - Map> files = Map.of("com.test.Entity", Set.of("file.txt", "file.pdf")); - Map> allowed = Map.of("com.test.Entity", List.of("image/png")); - ServiceException ex = - assertThrows( - ServiceException.class, - () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, allowed)); + void validateAttachmentMediaTypes_groupsUnsupportedFilesByElement_inExceptionMessage() { + Map> files = Map.of("Entity.attachments", Set.of("file.txt", "file.pdf")); + Map> allowed = Map.of("Entity.attachments", List.of("image/png")); + ServiceException ex = assertThrows( + ServiceException.class, + () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, allowed)); assertTrue(ex.getMessage().contains("file.txt")); assertTrue(ex.getMessage().contains("file.pdf")); assertTrue(ex.getMessage().contains("image/png")); } @Test - void doesNothing_whenNoFilesProvided() { + void validateAttachmentMediaTypes_doesNotThrow_whenNoFilesProvided() { assertDoesNotThrow( () -> AttachmentValidationHelper.validateAttachmentMediaTypes(Map.of(), Map.of())); } @Test - void usesWildcard_whenNoAllowedTypesDefined() { - Map> files = Map.of("Entity", Set.of("file.anything")); + void validateAttachmentMediaTypes_allowsAnyType_whenNoAllowedTypesConfigured() { + Map> files = Map.of("Entity.attachments", Set.of("file.anything")); assertDoesNotThrow( () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, Map.of())); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java index a30632f03..2064a99c8 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java @@ -13,8 +13,7 @@ class FileNameValidatorTest { @Test void throwsException_whenFileNameIsNull() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(null)); + ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(null)); assertEquals(ErrorStatuses.BAD_REQUEST, ex.getErrorStatus()); assertTrue(ex.getMessage().contains("must not be null")); @@ -22,8 +21,7 @@ void throwsException_whenFileNameIsNull() { @Test void throwsException_whenFileNameIsBlank() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(" ")); + ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(" ")); assertEquals(ErrorStatuses.BAD_REQUEST, ex.getErrorStatus()); assertTrue(ex.getMessage().contains("must not be blank")); @@ -31,37 +29,32 @@ void throwsException_whenFileNameIsBlank() { @Test void throwsException_whenNoExtensionPresent() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file")); + ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenOnlyDotFile() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(".")); + ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(".")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenTrailingDot() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file.")); + ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file.")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenOnlyDots() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("...")); + ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("...")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenTrimmedBecomesInvalid() { - ServiceException ex = - assertThrows( - ServiceException.class, () -> FileNameValidator.validateAndNormalize(" file ")); + ServiceException ex = assertThrows( + ServiceException.class, () -> FileNameValidator.validateAndNormalize(" file ")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @@ -89,4 +82,5 @@ void doesNotThrow_whenDoubleDotButValidExtension() { void doesNotThrow_whenHiddenFileWithoutName() { assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize(".config.json")); } + } From 2c24ef3d682d65c6d397fe3c139475bda79d48f6 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:13:39 +0100 Subject: [PATCH 6/8] code formatting --- .../helper/AttachmentDataExtractor.java | 56 +++---- .../helper/media/MediaTypeResolver.java | 17 +-- .../helper/media/MediaTypeService.java | 144 +++++++++--------- .../AttachmentValidationHelper.java | 57 +++---- .../helper/validation/FileNameValidator.java | 9 +- .../helper/AttachmentDataExtractorTest.java | 98 ++++++------ .../helper/media/MediaTypeResolverTest.java | 12 +- .../AttachmentValidationHelperTest.java | 41 +++-- .../validation/FileNameValidatorTest.java | 24 +-- 9 files changed, 239 insertions(+), 219 deletions(-) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java index 461c46c63..2ad8c7e7d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java @@ -25,14 +25,14 @@ 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); + 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 + * @param data the incoming data containing attachment values * @return a map of element names to sets of associated file names */ public static Map> extractAndValidateFileNamesByElement( @@ -58,35 +58,37 @@ private static Map> collectFileNamesByElementName( } private static Validator generateFileNameFieldValidator(Map> result) { - Validator validator = (path, element, value) -> { - String fileName = requireString(value); - String normalizedFileName = FileNameValidator.validateAndNormalize(fileName); - String key = element.getDeclaringType().getQualifiedName(); - result.computeIfAbsent(key, k -> new HashSet<>()).add(normalizedFileName); - }; + Validator validator = + (path, element, value) -> { + String fileName = requireString(value); + String normalizedFileName = FileNameValidator.validateAndNormalize(fileName); + String key = element.getDeclaringType().getQualifiedName(); + result.computeIfAbsent(key, k -> new HashSet<>()).add(normalizedFileName); + }; return validator; } 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(); + 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); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java index aeac3aa5f..d9e6d0805 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java @@ -18,24 +18,21 @@ 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 final TypeReference> STRING_LIST_TYPE_REF = new TypeReference<>() {}; 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: + *

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. + *

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 + * @return a map of entity qualified names to their allowed media types; empty if no media + * entities are found */ public static Map> getAcceptableMediaTypesFromEntity(CdsEntity entity) { // If this entity is a media entity diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java index 9d0303e87..7c539db1e 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java @@ -15,77 +15,76 @@ public final class MediaTypeService { public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; // TODO: in a different ticket, consider loading this mapping from a JSON file // to cover more file extensions - 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", "text/plain"), - Map.entry("webp", "image/webp")); + 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", "text/plain"), + Map.entry("webp", "image/webp")); /** - * Resolves the MIME type of a file based on its filename (specifically its - * extension). + * 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 + * @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) { @@ -108,9 +107,8 @@ public static String resolveMimeType(String fileName) { } /** - * Checks if a given MIME type is allowed based on a collection of acceptable - * media - * + * Checks if a given MIME type is allowed based on a collection of acceptable media + * * @param acceptableMediaTypes * @param mimeType * @return @@ -123,11 +121,11 @@ public static boolean isMimeTypeAllowed( if (acceptableMediaTypes == null || acceptableMediaTypes.isEmpty() - || acceptableMediaTypes.contains("*/*")) - return true; + || acceptableMediaTypes.contains("*/*")) return true; String baseMimeType = mimeType.trim().toLowerCase(); - Collection normalizedTypes = acceptableMediaTypes.stream().map(type -> type.trim().toLowerCase()).toList(); + Collection normalizedTypes = + acceptableMediaTypes.stream().map(type -> type.trim().toLowerCase()).toList(); return normalizedTypes.stream() .anyMatch( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java index 92e32cdcc..1d059ae1a 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java @@ -24,13 +24,11 @@ public final class AttachmentValidationHelper { public static final List WILDCARD_MEDIA_TYPE = List.of("*/*"); /** - * Validates if the media type of the attachment in the given fileName is - * acceptable + * 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 + * @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) { @@ -45,17 +43,17 @@ public static void validateMediaAttachments( } CdsEntity serviceEntity = optionalServiceEntity.get(); - boolean hasNoAttachmentCompositions = !ApplicationHandlerHelper.deepSearchForAttachments(serviceEntity); + boolean hasNoAttachmentCompositions = + !ApplicationHandlerHelper.deepSearchForAttachments(serviceEntity); // Skip if entity is irrelevant for attachments if (!ApplicationHandlerHelper.isMediaEntity(serviceEntity) && hasNoAttachmentCompositions) { return; } // validate the media types of the attachments - Map> allowedTypesByElementName = MediaTypeResolver - .getAcceptableMediaTypesFromEntity(serviceEntity); - Map> fileNamesByElementName = AttachmentDataExtractor.extractAndValidateFileNamesByElement( - serviceEntity, - data); + Map> allowedTypesByElementName = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(serviceEntity); + Map> fileNamesByElementName = + AttachmentDataExtractor.extractAndValidateFileNamesByElement(serviceEntity, data); validateAttachmentMediaTypes(fileNamesByElementName, allowedTypesByElementName); } @@ -64,8 +62,8 @@ public static void validateAttachmentMediaTypes( Map> acceptableMediaTypesByElementName) { // Determine which uploaded files do not match the allowed media types - Map> invalidFiles = findInvalidFilesByElementName(fileNamesByElementName, - acceptableMediaTypesByElementName); + Map> invalidFiles = + findInvalidFilesByElementName(fileNamesByElementName, acceptableMediaTypesByElementName); if (!invalidFiles.isEmpty()) { throw buildUnsupportedFileTypeMessage(acceptableMediaTypesByElementName, invalidFiles); @@ -85,11 +83,12 @@ private static Map> findInvalidFilesByElementName( fileNamesByElementName.forEach( (elementName, files) -> { // Resolve the allowed media types for this field / element. - List acceptableTypes = acceptableMediaTypesByElementName.getOrDefault(elementName, - WILDCARD_MEDIA_TYPE); + 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(file -> !isAttachmentTypeValid(file, acceptableTypes)).toList(); + List invalid = + files.stream().filter(file -> !isAttachmentTypeValid(file, acceptableTypes)).toList(); if (!invalid.isEmpty()) { invalidFiles.put(elementName, invalid); @@ -107,18 +106,20 @@ private static boolean isAttachmentTypeValid(String fileName, List accep 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("; ")); + 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); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java index 56ac83fa2..d1357db95 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java @@ -11,15 +11,12 @@ public final class FileNameValidator { /** * Validates and normalizes a file name before further processing. * - *

- * This method ensures that filenames are non-null, non-blank, and follow a - * basic "name.extension" format before being used in further processing such as - * MIME type resolution. + *

This method ensures that filenames are non-null, non-blank, and follow a basic + * "name.extension" format before being used in further processing such as MIME type resolution. * * @param fileName the original filename to validate * @return the trimmed and validated filename - * @throws ServiceException if the filename is null, blank, or has an invalid - * format + * @throws ServiceException if the filename is null, blank, or has an invalid format */ public static String validateAndNormalize(String fileName) { if (fileName == null) { diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java index a18d47f9d..b9dc1a937 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java @@ -38,18 +38,12 @@ 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; + @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; @@ -157,23 +151,29 @@ private static Stream skipConditionsProvider() { return Stream.of( Arguments.of( "Not an association", - (Consumer) test -> when(test.cdsType.isAssociation()).thenReturn(false)), + (Consumer) + test -> when(test.cdsType.isAssociation()).thenReturn(false)), Arguments.of( "Not a composition", - (Consumer) test -> when(test.associationType.isComposition()) - .thenReturn(false)), + (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)), + (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()))); + (Consumer) + test -> + test.mediaMock + .when( + () -> + MediaTypeResolver.getAcceptableMediaTypesAnnotation( + test.targetEntity)) + .thenReturn(Optional.empty()))); } @Test @@ -190,8 +190,9 @@ void ensureFilenamesPresent_whenResultMissingKey_throwsException() { 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"))))); + 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)); @@ -212,8 +213,9 @@ void hasMissingFileNames_whenFileNamesEmpty_returnsTrue() throws Exception { Set dataKeys = Set.of(ATTACHMENT_FIELD); // Act - var method = AttachmentDataExtractor.class.getDeclaredMethod( - "hasMissingFileNames", Map.class, List.class, Set.class); + var method = + AttachmentDataExtractor.class.getDeclaredMethod( + "hasMissingFileNames", Map.class, List.class, Set.class); method.setAccessible(true); boolean resultValue = (boolean) method.invoke(null, result, elements, dataKeys); @@ -285,35 +287,37 @@ private void setupAttachmentModel() { } private Map> extractFileNames(CdsData cdsData) { - return AttachmentDataExtractor.extractAndValidateFileNamesByElement(targetEntity, List.of(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(); + 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; - }) + 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()); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java index 3c55023ad..0cabf5985 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java @@ -21,7 +21,8 @@ class MediaTypeResolverTest { void returnsMediaTypes_whenEntityIsMediaEntity() { CdsEntity entity = mediaEntity("MediaEntity", List.of("image/png", "image/jpeg")); try (MockedStatic mocked = mockMedia(entity)) { - Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); + Map> result = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); assertEquals(Map.of("MediaEntity", List.of("image/png", "image/jpeg")), result); } } @@ -30,7 +31,8 @@ void returnsMediaTypes_whenEntityIsMediaEntity() { void returnsWildcard_whenAnnotationMissing() { CdsEntity entity = mediaEntity("MediaEntity", null); try (MockedStatic mocked = mockMedia(entity)) { - Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); + Map> result = + MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); assertEquals(List.of("*/*"), result.get("MediaEntity")); } } @@ -50,7 +52,8 @@ void returnsMediaTypes_fromComposedChildEntities() { void ignoresNonMediaChildren() { CdsEntity child = mock(CdsEntity.class); CdsEntity root = rootWithChild(child); - try (MockedStatic mocked = mockStatic(ApplicationHandlerHelper.class)) { + try (MockedStatic mocked = + mockStatic(ApplicationHandlerHelper.class)) { mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(child)).thenReturn(false); Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); @@ -62,7 +65,8 @@ void ignoresNonMediaChildren() { void returnsEmpty_whenNoAssociations() { CdsEntity root = mock(CdsEntity.class); when(root.elements()).thenReturn(Stream.empty()); - try (MockedStatic mocked = mockStatic(ApplicationHandlerHelper.class)) { + try (MockedStatic mocked = + mockStatic(ApplicationHandlerHelper.class)) { mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); assertTrue(result.isEmpty()); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java index 497eab2a9..77243437f 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java @@ -29,8 +29,9 @@ class AttachmentValidationHelperTest { @Test void validateMediaAttachments_doesNothing_whenEntityIsNull() { assertDoesNotThrow( - () -> AttachmentValidationHelper.validateMediaAttachments( - null, List.of(), mock(CdsRuntime.class))); + () -> + AttachmentValidationHelper.validateMediaAttachments( + null, List.of(), mock(CdsRuntime.class))); } @Test @@ -51,7 +52,8 @@ void validateMediaAttachments_doesNothing_whenEntityNotFoundInModel() { void validateMediaAttachments_doesNothing_whenNotMediaEntityAndNoAttachments() { CdsEntity entity = mockEntity("Entity"); - try (MockedStatic mocked = mockStatic(ApplicationHandlerHelper.class)) { + try (MockedStatic mocked = + mockStatic(ApplicationHandlerHelper.class)) { mocked .when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)) .thenReturn(false); @@ -71,9 +73,11 @@ void validateMediaAttachments_doesNotThrow_whenAllFilesAreAllowed() { 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); + try (MockedStatic helper = + mockStatic(ApplicationHandlerHelper.class); MockedStatic resolver = mockStatic(MediaTypeResolver.class); - MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { + MockedStatic extractor = + mockStatic(AttachmentDataExtractor.class)) { helper.when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)).thenReturn(true); helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); @@ -83,7 +87,8 @@ void validateMediaAttachments_doesNotThrow_whenAllFilesAreAllowed() { .thenReturn(allowed); extractor - .when(() -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) + .when( + () -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) .thenReturn(files); CdsRuntime runtime = mockRuntime(entity); @@ -100,9 +105,11 @@ void validateMediaAttachments_throwsServiceException_whenUnsupportedFileTypesDet 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); + try (MockedStatic helper = + mockStatic(ApplicationHandlerHelper.class); MockedStatic resolver = mockStatic(MediaTypeResolver.class); - MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { + MockedStatic extractor = + mockStatic(AttachmentDataExtractor.class)) { helper.when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)).thenReturn(true); helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); @@ -112,14 +119,17 @@ void validateMediaAttachments_throwsServiceException_whenUnsupportedFileTypesDet .thenReturn(allowed); extractor - .when(() -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) + .when( + () -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) .thenReturn(files); CdsRuntime runtime = mockRuntime(entity); - ServiceException ex = assertThrows( - ServiceException.class, - () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + ServiceException ex = + assertThrows( + ServiceException.class, + () -> + AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); assertTrue(ex.getMessage().contains("Unsupported file types detected")); assertTrue(ex.getMessage().contains("file.txt")); @@ -131,9 +141,10 @@ void validateMediaAttachments_throwsServiceException_whenUnsupportedFileTypesDet void validateAttachmentMediaTypes_groupsUnsupportedFilesByElement_inExceptionMessage() { Map> files = Map.of("Entity.attachments", Set.of("file.txt", "file.pdf")); Map> allowed = Map.of("Entity.attachments", List.of("image/png")); - ServiceException ex = assertThrows( - ServiceException.class, - () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, allowed)); + ServiceException ex = + assertThrows( + ServiceException.class, + () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, allowed)); assertTrue(ex.getMessage().contains("file.txt")); assertTrue(ex.getMessage().contains("file.pdf")); assertTrue(ex.getMessage().contains("image/png")); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java index 2064a99c8..a30632f03 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java @@ -13,7 +13,8 @@ class FileNameValidatorTest { @Test void throwsException_whenFileNameIsNull() { - ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(null)); + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(null)); assertEquals(ErrorStatuses.BAD_REQUEST, ex.getErrorStatus()); assertTrue(ex.getMessage().contains("must not be null")); @@ -21,7 +22,8 @@ void throwsException_whenFileNameIsNull() { @Test void throwsException_whenFileNameIsBlank() { - ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(" ")); + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(" ")); assertEquals(ErrorStatuses.BAD_REQUEST, ex.getErrorStatus()); assertTrue(ex.getMessage().contains("must not be blank")); @@ -29,32 +31,37 @@ void throwsException_whenFileNameIsBlank() { @Test void throwsException_whenNoExtensionPresent() { - ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file")); + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenOnlyDotFile() { - ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(".")); + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(".")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenTrailingDot() { - ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file.")); + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file.")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenOnlyDots() { - ServiceException ex = assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("...")); + ServiceException ex = + assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("...")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @Test void throwsException_whenTrimmedBecomesInvalid() { - ServiceException ex = assertThrows( - ServiceException.class, () -> FileNameValidator.validateAndNormalize(" file ")); + ServiceException ex = + assertThrows( + ServiceException.class, () -> FileNameValidator.validateAndNormalize(" file ")); assertTrue(ex.getMessage().contains("Invalid filename format")); } @@ -82,5 +89,4 @@ void doesNotThrow_whenDoubleDotButValidExtension() { void doesNotThrow_whenHiddenFileWithoutName() { assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize(".config.json")); } - } From 4c34db9e07c5c1dc72ecd0e42492dfde7f9fb78a Mon Sep 17 00:00:00 2001 From: Samyuktha Prabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:44:19 +0100 Subject: [PATCH 7/8] Update cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java Co-authored-by: Marvin Update cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java Co-authored-by: Marvin Update cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java Co-authored-by: Marvin Update cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java Co-authored-by: Marvin apply mvnspotless:apply Rename AttachmentDataExtractor to validation package Refactor MediaTypeService to use URLConnection for MIME type resolution and update AttachmentValidationHelper method visibility Refactor AttachmentValidationHelper to streamline MIME type validation logic Add unit tests for AssociationCascader and refactor AttachmentValidationHelper and MediaTypeResolver to utilize AssociationCascader for media entity resolution Refactor MediaTypeResolver to remove dependency on ApplicationHandlerHelper and update related tests Refactor file name validation logic and migrate to mimeTypeValidation package Refactor AttachmentDataExtractor to integrate filename validation logic and remove FileNameValidator class Refactor AttachmentDataExtractor and update tests to improve structure and maintainability Refactor AttachmentValidationHelper and MediaTypeResolver to improve structure and add unit tests for media type resolution revert a change --- .../configuration/Registration.java | 9 +- .../CreateAttachmentsHandler.java | 2 +- .../helper/media/MediaTypeService.java | 150 ------------------ .../AttachmentDataExtractor.java | 27 ++-- .../AttachmentValidationHelper.java | 47 +++--- .../MediaTypeResolver.java | 39 ++--- .../mimeTypeValidation/MediaTypeService.java | 85 ++++++++++ .../helper/validation/FileNameValidator.java | 41 ----- .../common/ApplicationHandlerHelper.java | 25 --- .../handler/common/AssociationCascader.java | 25 +++ .../DraftCancelAttachmentsHandler.java | 28 +++- .../CreateAttachmentsHandlerTest.java | 2 +- .../helper/media/MediaTypeResolverTest.java | 115 -------------- .../AttachmentDataExtractorTest.java | 18 ++- .../AttachmentValidationHelperTest.java | 136 +++++++++------- .../MediaTypeResolverTest.java | 97 +++++++++++ .../MediaTypeServiceTest.java | 2 +- .../validation/FileNameValidatorTest.java | 92 ----------- .../common/ApplicationHandlerHelperTest.java | 7 +- .../common/AssociationCascaderTest.java | 43 +++++ samples/bookshop/srv/attachments.cds | 13 +- 21 files changed, 438 insertions(+), 565 deletions(-) delete mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java rename cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/{ => mimeTypeValidation}/AttachmentDataExtractor.java (89%) rename cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/{validation => mimeTypeValidation}/AttachmentValidationHelper.java (75%) rename cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/{media => mimeTypeValidation}/MediaTypeResolver.java (67%) create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeService.java delete mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java delete mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java rename cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/{ => mimeTypeValidation}/AttachmentDataExtractorTest.java (95%) rename cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/{validation => mimeTypeValidation}/AttachmentValidationHelperTest.java (57%) create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java rename cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/{media => mimeTypeValidation}/MediaTypeServiceTest.java (99%) delete mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java 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 5fce2d0e7..d749a42fb 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 @@ -89,8 +89,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { OutboxService.PERSISTENT_UNORDERED_NAME); } - // build malware scanner client, could be null if no service binding is - // available + // build malware scanner client, could be null if no service binding is available MalwareScanClient scanClient = buildMalwareScanClient(runtime.getEnvironment()); // determine default max size based on malware scanner binding availability @@ -119,8 +118,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { new AttachmentsReader(new AssociationCascader(), persistenceService); ThreadLocalDataStorage storage = new ThreadLocalDataStorage(); - // register event handlers for application service, if at least one application - // service is + // register event handlers for application service, if at least one application service is // available boolean hasApplicationServices = serviceCatalog.getServices(ApplicationService.class).findFirst().isPresent(); @@ -141,8 +139,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { "No application service is available. Application service event handlers will not be registered."); } - // register event handlers on draft service, if at least one draft service is - // available + // register event handlers on draft service, if at least one draft service is available boolean hasDraftServices = serviceCatalog.getServices(DraftService.class).findFirst().isPresent(); if (hasDraftServices) { 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 638744eb6..c3f1b620a 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,7 +10,7 @@ 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.validation.AttachmentValidationHelper; +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; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java deleted file mode 100644 index 7c539db1e..000000000 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeService.java +++ /dev/null @@ -1,150 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. - */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper.media; - -import com.sap.cds.services.ErrorStatuses; -import com.sap.cds.services.ServiceException; -import java.util.Collection; -import java.util.Map; -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"; - // TODO: in a different ticket, consider loading this mapping from a JSON file - // to cover more file extensions - 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", "text/plain"), - Map.entry("webp", "image/webp")); - - /** - * 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); - } - - String fileExtension = fileName.substring(lastDotIndex + 1).toLowerCase(); - String actualMimeType = EXT_TO_MEDIA_TYPE.get(fileExtension); - - 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/applicationservice/helper/AttachmentDataExtractor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java similarity index 89% rename from cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java rename to cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java index 2ad8c7e7d..906982a2d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractor.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractor.java @@ -1,14 +1,12 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper; +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.applicationservice.helper.media.MediaTypeResolver; -import com.sap.cds.feature.attachments.handler.applicationservice.helper.validation.FileNameValidator; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.reflect.CdsAssociationType; import com.sap.cds.reflect.CdsElement; @@ -44,8 +42,6 @@ public static Map> extractAndValidateFileNamesByElement( return fileNamesByElementName; } - // -------------------- Extraction -------------------- - private static Map> collectFileNamesByElementName( CdsEntity entity, List data) { // Use CdsProcessor to traverse the data and collect file names for elements @@ -61,13 +57,26 @@ private static Validator generateFileNameFieldValidator(Map> Validator validator = (path, element, value) -> { String fileName = requireString(value); - String normalizedFileName = FileNameValidator.validateAndNormalize(fileName); + 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 @@ -144,7 +153,7 @@ private static boolean isEmpty(Object value) { private static String requireString(Object value) { if (value == null) { - throw missingFileNameError(); + 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"); @@ -152,10 +161,6 @@ private static String requireString(Object value) { return s; } - private static ServiceException missingFileNameError() { - return new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename is missing"); - } - private AttachmentDataExtractor() { // Private constructor to prevent instantiation } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java similarity index 75% rename from cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java rename to cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java index 1d059ae1a..71bce5e44 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java @@ -1,13 +1,11 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper.validation; +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; import com.sap.cds.CdsData; -import com.sap.cds.feature.attachments.handler.applicationservice.helper.AttachmentDataExtractor; -import com.sap.cds.feature.attachments.handler.applicationservice.helper.media.MediaTypeResolver; -import com.sap.cds.feature.attachments.handler.applicationservice.helper.media.MediaTypeService; 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; @@ -16,12 +14,16 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; 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 @@ -36,28 +38,24 @@ public static void validateMediaAttachments( return; } CdsModel cdsModel = cdsRuntime.getCdsModel(); - Optional optionalServiceEntity = cdsModel.findEntity(entity.getQualifiedName()); - if (optionalServiceEntity.isEmpty()) { - return; - } + boolean areAttachmentsAvailable = + ApplicationHandlerHelper.isMediaEntity(entity) + || cascader.hasAttachmentPath(cdsModel, entity); - CdsEntity serviceEntity = optionalServiceEntity.get(); - boolean hasNoAttachmentCompositions = - !ApplicationHandlerHelper.deepSearchForAttachments(serviceEntity); - // Skip if entity is irrelevant for attachments - if (!ApplicationHandlerHelper.isMediaEntity(serviceEntity) && hasNoAttachmentCompositions) { + if (!areAttachmentsAvailable) { return; } + // validate the media types of the attachments Map> allowedTypesByElementName = - MediaTypeResolver.getAcceptableMediaTypesFromEntity(serviceEntity); + MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity, cdsModel); Map> fileNamesByElementName = - AttachmentDataExtractor.extractAndValidateFileNamesByElement(serviceEntity, data); + AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, data); validateAttachmentMediaTypes(fileNamesByElementName, allowedTypesByElementName); } - public static void validateAttachmentMediaTypes( + private static void validateAttachmentMediaTypes( Map> fileNamesByElementName, Map> acceptableMediaTypesByElementName) { @@ -88,7 +86,13 @@ private static Map> findInvalidFilesByElementName( // Filter out files whose media type is NOT allowed for this element List invalid = - files.stream().filter(file -> !isAttachmentTypeValid(file, acceptableTypes)).toList(); + files.stream() + .filter( + fileName -> { + String mimeType = MediaTypeService.resolveMimeType(fileName); + return !MediaTypeService.isMimeTypeAllowed(acceptableTypes, mimeType); + }) + .toList(); if (!invalid.isEmpty()) { invalidFiles.put(elementName, invalid); @@ -98,11 +102,6 @@ private static Map> findInvalidFilesByElementName( return invalidFiles; } - private static boolean isAttachmentTypeValid(String fileName, List acceptableTypes) { - String mimeType = MediaTypeService.resolveMimeType(fileName); - return MediaTypeService.isMimeTypeAllowed(acceptableTypes, mimeType); - } - private static ServiceException buildUnsupportedFileTypeMessage( Map> acceptableMediaTypesByElementName, Map> invalidFilesByElement) { @@ -126,6 +125,6 @@ private static ServiceException buildUnsupportedFileTypeMessage( } private AttachmentValidationHelper() { - // prevent instantiation + // to prevent instantiation } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java similarity index 67% rename from cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java rename to cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java index d9e6d0805..da7f54a37 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolver.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java @@ -1,15 +1,14 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper.media; +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.applicationservice.helper.validation.AttachmentValidationHelper; -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.CdsAssociationType; import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -19,6 +18,7 @@ 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(); /** @@ -34,26 +34,21 @@ public final class MediaTypeResolver { * @return a map of entity qualified names to their allowed media types; empty if no media * entities are found */ - public static Map> getAcceptableMediaTypesFromEntity(CdsEntity entity) { - // If this entity is a media entity - if (ApplicationHandlerHelper.isMediaEntity(entity)) { - return Map.of(entity.getQualifiedName(), fetchAcceptableMediaTypes(entity)); - } + static void setCascader(AssociationCascader testCascader) { + cascader = testCascader; + } - // If it's not a mediaEntity, if it's a root entity + public static Map> getAcceptableMediaTypesFromEntity( + CdsEntity entity, CdsModel model) { Map> result = new HashMap<>(); - entity - .elements() - .filter(e -> e.getType().isAssociation()) - .map(e -> e.getType().as(CdsAssociationType.class)) - .filter(CdsAssociationType::isComposition) - .forEach( - association -> { - CdsEntity target = association.getTarget(); - if (target != null && ApplicationHandlerHelper.isMediaEntity(target)) { - result.put(target.getQualifiedName(), fetchAcceptableMediaTypes(target)); - } - }); + 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; } 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 000000000..fe1e85e16 --- /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/applicationservice/helper/validation/FileNameValidator.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java deleted file mode 100644 index d1357db95..000000000 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidator.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. - */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper.validation; - -import com.sap.cds.services.ErrorStatuses; -import com.sap.cds.services.ServiceException; - -public final class FileNameValidator { - - /** - * Validates and normalizes a file name before further processing. - * - *

This method ensures that filenames are non-null, non-blank, and follow a basic - * "name.extension" format before being used in further processing such as MIME type resolution. - * - * @param fileName the original filename to validate - * @return the trimmed and validated filename - * @throws ServiceException if the filename is null, blank, or has an invalid format - */ - public static String validateAndNormalize(String fileName) { - if (fileName == null) { - throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename must not be null"); - } - 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 FileNameValidator() { - // to prevent instantiation - } -} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index 23836f571..c8308f513 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -10,13 +10,11 @@ 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.reflect.CdsAssociationType; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -125,29 +123,6 @@ public static Map removeDraftKey(Map keys) { return keyMap; } - public static boolean deepSearchForAttachments(CdsEntity entity) { - return deepSearchForAttachmentsRecursive(entity, new HashSet<>()); - } - - private static boolean deepSearchForAttachmentsRecursive( - CdsEntity entity, HashSet visited) { - - if (visited.contains(entity.getQualifiedName())) { - return false; - } - visited.add(entity.getQualifiedName()); - - if (isMediaEntity(entity)) { - return true; - } - - return entity - .compositions() - .map(element -> element.getType().as(CdsAssociationType.class)) - .anyMatch( - association -> deepSearchForAttachmentsRecursive(association.getTarget(), visited)); - } - private ApplicationHandlerHelper() { // avoid 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 5941f14ab..74b70a7b1 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/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index d9b4c42ca..b71231943 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java @@ -1,5 +1,5 @@ /* - * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + * © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ package com.sap.cds.feature.attachments.handler.draftservice; @@ -15,6 +15,7 @@ import com.sap.cds.feature.attachments.handler.common.AttachmentsReader; import com.sap.cds.ql.CQL; import com.sap.cds.ql.cqn.CqnDelete; +import com.sap.cds.reflect.CdsAssociationType; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.DraftCancelEventContext; @@ -24,6 +25,7 @@ import com.sap.cds.services.handler.annotations.Before; import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.ServiceName; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -60,7 +62,7 @@ public DraftCancelAttachmentsHandler( void processBeforeDraftCancel(DraftCancelEventContext context) { CdsEntity entity = context.getTarget(); - if (ApplicationHandlerHelper.deepSearchForAttachments(entity)) { + if (deepSearchForAttachments(entity)) { logger.debug( "Processing before {} event for entity {}", context.getEvent(), context.getTarget()); @@ -105,6 +107,28 @@ private Validator buildDeleteContentValidator( }; } + private boolean deepSearchForAttachments(CdsEntity entity) { + return deepSearchForAttachmentsRecursive(entity, new HashSet<>()); + } + + private boolean deepSearchForAttachmentsRecursive(CdsEntity entity, HashSet visited) { + + if (visited.contains(entity.getQualifiedName())) { + return false; + } + visited.add(entity.getQualifiedName()); + + if (ApplicationHandlerHelper.isMediaEntity(entity)) { + return true; + } + + return entity + .compositions() + .map(element -> element.getType().as(CdsAssociationType.class)) + .anyMatch( + association -> deepSearchForAttachmentsRecursive(association.getTarget(), visited)); + } + private List readAttachments( DraftCancelEventContext context, CdsStructuredType entity, boolean isActiveEntity) { logger.debug( 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 b03a032f7..4b55da445 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 @@ -28,7 +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.validation.AttachmentValidationHelper; +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; diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java deleted file mode 100644 index 0cabf5985..000000000 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeResolverTest.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. - */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper.media; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; -import com.sap.cds.reflect.*; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Stream; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; - -class MediaTypeResolverTest { - - @Test - void returnsMediaTypes_whenEntityIsMediaEntity() { - CdsEntity entity = mediaEntity("MediaEntity", List.of("image/png", "image/jpeg")); - try (MockedStatic mocked = mockMedia(entity)) { - Map> result = - MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); - assertEquals(Map.of("MediaEntity", List.of("image/png", "image/jpeg")), result); - } - } - - @Test - void returnsWildcard_whenAnnotationMissing() { - CdsEntity entity = mediaEntity("MediaEntity", null); - try (MockedStatic mocked = mockMedia(entity)) { - Map> result = - MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity); - assertEquals(List.of("*/*"), result.get("MediaEntity")); - } - } - - @Test - void returnsMediaTypes_fromComposedChildEntities() { - CdsEntity child = mediaEntity("ChildMediaEntity", List.of("application/pdf")); - CdsEntity root = rootWithChild(child); - try (MockedStatic mocked = mockMedia(child)) { - mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); - Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); - assertEquals(Map.of("ChildMediaEntity", List.of("application/pdf")), result); - } - } - - @Test - void ignoresNonMediaChildren() { - CdsEntity child = mock(CdsEntity.class); - CdsEntity root = rootWithChild(child); - try (MockedStatic mocked = - mockStatic(ApplicationHandlerHelper.class)) { - mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); - mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(child)).thenReturn(false); - Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); - assertTrue(result.isEmpty()); - } - } - - @Test - void returnsEmpty_whenNoAssociations() { - CdsEntity root = mock(CdsEntity.class); - when(root.elements()).thenReturn(Stream.empty()); - try (MockedStatic mocked = - mockStatic(ApplicationHandlerHelper.class)) { - mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(root)).thenReturn(false); - Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(root); - assertTrue(result.isEmpty()); - } - } - - // ----------- HELPERS ---------- - private CdsEntity mediaEntity(String name, List mediaTypes) { - CdsEntity entity = mock(CdsEntity.class); - when(entity.getQualifiedName()).thenReturn(name); - if (mediaTypes != null) { - CdsAnnotation annotation = mock(CdsAnnotation.class); - when(annotation.getValue()).thenReturn(mediaTypes); - CdsElement content = mock(CdsElement.class); - when(content.findAnnotation("Core.AcceptableMediaTypes")).thenReturn(Optional.of(annotation)); - when(entity.getElement("content")).thenReturn(content); - } else { - when(entity.getElement("content")).thenReturn(null); - } - return entity; - } - - private CdsEntity rootWithChild(CdsEntity child) { - CdsAssociationType association = mock(CdsAssociationType.class); - when(association.isComposition()).thenReturn(true); - when(association.getTarget()).thenReturn(child); - - CdsType type = mock(CdsType.class); - when(type.isAssociation()).thenReturn(true); - when(type.as(CdsAssociationType.class)).thenReturn(association); - - CdsElement element = mock(CdsElement.class); - when(element.getType()).thenReturn(type); - - CdsEntity root = mock(CdsEntity.class); - when(root.elements()).thenReturn(Stream.of(element)); - - return root; - } - - private MockedStatic mockMedia(CdsEntity mediaEntity) { - MockedStatic mocked = mockStatic(ApplicationHandlerHelper.class); - mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(mediaEntity)).thenReturn(true); - return mocked; - } -} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractorTest.java similarity index 95% rename from cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java rename to cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractorTest.java index b9dc1a937..a06c0d4fb 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentDataExtractorTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentDataExtractorTest.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper; +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; @@ -14,7 +14,6 @@ import com.sap.cds.CdsDataProcessor; import com.sap.cds.CdsDataProcessor.Filter; import com.sap.cds.CdsDataProcessor.Validator; -import com.sap.cds.feature.attachments.handler.applicationservice.helper.media.MediaTypeResolver; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.reflect.CdsAnnotation; import com.sap.cds.reflect.CdsAssociationType; @@ -92,7 +91,20 @@ void extractFileNames_whenFilenameBlank_throwsBadRequest() { } @Test - void extractFileNames_whenFilenameMissing_throwsBadRequest() { + 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); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java similarity index 57% rename from cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java rename to cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java index 77243437f..712b59ce6 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/AttachmentValidationHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper.validation; +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; @@ -10,9 +10,8 @@ import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; -import com.sap.cds.feature.attachments.handler.applicationservice.helper.AttachmentDataExtractor; -import com.sap.cds.feature.attachments.handler.applicationservice.helper.media.MediaTypeResolver; 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; @@ -21,21 +20,28 @@ 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 validateMediaAttachments_doesNothing_whenEntityIsNull() { + void doesNothing_whenEntityIsNull() { assertDoesNotThrow( - () -> - AttachmentValidationHelper.validateMediaAttachments( - null, List.of(), mock(CdsRuntime.class))); + () -> AttachmentValidationHelper.validateMediaAttachments(null, List.of(), null)); } @Test - void validateMediaAttachments_doesNothing_whenEntityNotFoundInModel() { + void doesNothing_whenEntityNotFoundInModel() { CdsEntity entity = mock(CdsEntity.class); when(entity.getQualifiedName()).thenReturn("Entity"); @@ -44,31 +50,54 @@ void validateMediaAttachments_doesNothing_whenEntityNotFoundInModel() { CdsRuntime runtime = mockRuntime(model); - assertDoesNotThrow( - () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); + 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 validateMediaAttachments_doesNothing_whenNotMediaEntityAndNoAttachments() { + void doesNotThrow_whenNoFiles() { CdsEntity entity = mockEntity("Entity"); - try (MockedStatic mocked = - mockStatic(ApplicationHandlerHelper.class)) { - mocked - .when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)) - .thenReturn(false); - mocked.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(false); + 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)); } } - @Test - void validateMediaAttachments_doesNotThrow_whenAllFilesAreAllowed() { + @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")); @@ -79,11 +108,14 @@ void validateMediaAttachments_doesNotThrow_whenAllFilesAreAllowed() { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)).thenReturn(true); - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), hasAttachmentPath); resolver - .when(() -> MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity)) + .when( + () -> + MediaTypeResolver.getAcceptableMediaTypesFromEntity( + entity, runtime.getCdsModel())) .thenReturn(allowed); extractor @@ -91,16 +123,24 @@ void validateMediaAttachments_doesNotThrow_whenAllFilesAreAllowed() { () -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) .thenReturn(files); - CdsRuntime runtime = mockRuntime(entity); - assertDoesNotThrow( () -> AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); } } - @Test - void validateMediaAttachments_throwsServiceException_whenUnsupportedFileTypesDetected() { + 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")); @@ -111,11 +151,14 @@ void validateMediaAttachments_throwsServiceException_whenUnsupportedFileTypesDet MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.deepSearchForAttachments(entity)).thenReturn(true); - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), hasAttachmentPath); resolver - .when(() -> MediaTypeResolver.getAcceptableMediaTypesFromEntity(entity)) + .when( + () -> + MediaTypeResolver.getAcceptableMediaTypesFromEntity( + entity, runtime.getCdsModel())) .thenReturn(allowed); extractor @@ -123,8 +166,6 @@ void validateMediaAttachments_throwsServiceException_whenUnsupportedFileTypesDet () -> AttachmentDataExtractor.extractAndValidateFileNamesByElement(entity, List.of())) .thenReturn(files); - CdsRuntime runtime = mockRuntime(entity); - ServiceException ex = assertThrows( ServiceException.class, @@ -132,36 +173,19 @@ void validateMediaAttachments_throwsServiceException_whenUnsupportedFileTypesDet AttachmentValidationHelper.validateMediaAttachments(entity, List.of(), runtime)); assertTrue(ex.getMessage().contains("Unsupported file types detected")); - assertTrue(ex.getMessage().contains("file.txt")); - assertTrue(ex.getMessage().contains("image/png")); } } - @Test - void validateAttachmentMediaTypes_groupsUnsupportedFilesByElement_inExceptionMessage() { - Map> files = Map.of("Entity.attachments", Set.of("file.txt", "file.pdf")); - Map> allowed = Map.of("Entity.attachments", List.of("image/png")); - ServiceException ex = - assertThrows( - ServiceException.class, - () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, allowed)); - assertTrue(ex.getMessage().contains("file.txt")); - assertTrue(ex.getMessage().contains("file.pdf")); - assertTrue(ex.getMessage().contains("image/png")); + 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)); } - @Test - void validateAttachmentMediaTypes_doesNotThrow_whenNoFilesProvided() { - assertDoesNotThrow( - () -> AttachmentValidationHelper.validateAttachmentMediaTypes(Map.of(), Map.of())); - } - - @Test - void validateAttachmentMediaTypes_allowsAnyType_whenNoAllowedTypesConfigured() { - Map> files = Map.of("Entity.attachments", Set.of("file.anything")); - - assertDoesNotThrow( - () -> AttachmentValidationHelper.validateAttachmentMediaTypes(files, Map.of())); + 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) { 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 000000000..09827737a --- /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/media/MediaTypeServiceTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java similarity index 99% rename from cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java rename to cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java index e5b2c698f..9dc898b1e 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/media/MediaTypeServiceTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeServiceTest.java @@ -1,7 +1,7 @@ /* * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper.media; +package com.sap.cds.feature.attachments.handler.applicationservice.helper.mimeTypeValidation; import static org.junit.jupiter.api.Assertions.*; diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java deleted file mode 100644 index a30632f03..000000000 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/validation/FileNameValidatorTest.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. - */ -package com.sap.cds.feature.attachments.handler.applicationservice.helper.validation; - -import static org.junit.jupiter.api.Assertions.*; - -import com.sap.cds.services.ErrorStatuses; -import com.sap.cds.services.ServiceException; -import org.junit.jupiter.api.Test; - -class FileNameValidatorTest { - - @Test - void throwsException_whenFileNameIsNull() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(null)); - - assertEquals(ErrorStatuses.BAD_REQUEST, ex.getErrorStatus()); - assertTrue(ex.getMessage().contains("must not be null")); - } - - @Test - void throwsException_whenFileNameIsBlank() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(" ")); - - assertEquals(ErrorStatuses.BAD_REQUEST, ex.getErrorStatus()); - assertTrue(ex.getMessage().contains("must not be blank")); - } - - @Test - void throwsException_whenNoExtensionPresent() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file")); - assertTrue(ex.getMessage().contains("Invalid filename format")); - } - - @Test - void throwsException_whenOnlyDotFile() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize(".")); - assertTrue(ex.getMessage().contains("Invalid filename format")); - } - - @Test - void throwsException_whenTrailingDot() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("file.")); - assertTrue(ex.getMessage().contains("Invalid filename format")); - } - - @Test - void throwsException_whenOnlyDots() { - ServiceException ex = - assertThrows(ServiceException.class, () -> FileNameValidator.validateAndNormalize("...")); - assertTrue(ex.getMessage().contains("Invalid filename format")); - } - - @Test - void throwsException_whenTrimmedBecomesInvalid() { - ServiceException ex = - assertThrows( - ServiceException.class, () -> FileNameValidator.validateAndNormalize(" file ")); - assertTrue(ex.getMessage().contains("Invalid filename format")); - } - - @Test - void doesNotThrow_whenValidFileName() { - assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize("file.txt")); - } - - @Test - void doesNotThrow_whenValidFileNameWithMultipleDots() { - assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize("archive.tar.gz")); - } - - @Test - void doesNotThrow_whenValidFileNameWithWhitespace() { - assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize(" file.txt ")); - } - - @Test - void doesNotThrow_whenDoubleDotButValidExtension() { - assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize("file..txt")); - } - - @Test - void doesNotThrow_whenHiddenFileWithoutName() { - assertDoesNotThrow(() -> FileNameValidator.validateAndNormalize(".config.json")); - } -} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java index 3c44c320b..e7d8cfa33 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java @@ -1,13 +1,14 @@ /* - * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + * © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ package com.sap.cds.feature.attachments.handler.common; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.sap.cds.CdsData; -import java.util.*; +import java.util.Map; import org.junit.jupiter.api.Test; class ApplicationHandlerHelperTest { 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 bd12583db..d8d1f280c 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/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 872db8205..1f453323f 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -9,8 +9,6 @@ extend my.Books with { sizeLimitedAttachments : Composition of many Attachments; @UI.Hidden mediaValidatedAttachments : Composition of many Attachments; - @UI.Hidden - attachments2 : Composition of many Attachments; } annotate my.Books.sizeLimitedAttachments with { @@ -25,10 +23,6 @@ annotate my.Books.mediaValidatedAttachments with { ]; } -annotate my.Books.attachments2 with { - content @Core.AcceptableMediaTypes: ['application/pdf'] -} - // Add UI component for attachments table to the Browse Books App using {CatalogService as service} from '../app/services'; @@ -46,10 +40,5 @@ annotate adminService.Books with @(UI.Facets: [{ $Type : 'UI.ReferenceFacet', ID : 'AttachmentsFacet', Label : '{i18n>attachments}', - Target: 'mediaValidatedAttachments/@UI.LineItem' + Target: 'attachments/@UI.LineItem' }]); - -service nonDraft { - @odata.draft.enabled: false - entity Books as projection on my.Books; -} From bbe590fd7dfd0840228aa8ea8bb6dee73e6df757 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:43:57 +0100 Subject: [PATCH 8/8] PR Review fixes - I --- .../AttachmentDataExtractor.java | 4 ++-- .../mimeTypeValidation/MediaTypeService.java | 19 +++++++++++-------- ...MediaValidatedAttachmentsNonDraftTest.java | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) 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 index 906982a2d..1250c034a 100644 --- 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 @@ -70,10 +70,10 @@ private static String validateAndNormalize(String fileName) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Filename must not be blank"); } - int lastDotIndex = trimmedFileName.lastIndexOf('.'); - if (lastDotIndex == -1 || lastDotIndex == trimmedFileName.length() - 1) { + if (trimmedFileName.endsWith(".")) { throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Invalid filename format: " + fileName); } + return trimmedFileName; } 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 index fe1e85e16..a647ad240 100644 --- 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 @@ -54,21 +54,24 @@ public static boolean isMimeTypeAllowed( return false; } - if (acceptableMediaTypes == null - || acceptableMediaTypes.isEmpty() - || acceptableMediaTypes.contains("*/*")) return true; + if (acceptableMediaTypes == null || acceptableMediaTypes.isEmpty()) { + return true; + } String baseMimeType = mimeType.trim().toLowerCase(); Collection normalizedTypes = acceptableMediaTypes.stream().map(type -> type.trim().toLowerCase()).toList(); + if (normalizedTypes.contains("*/*")) { + return true; + } + return normalizedTypes.stream() .anyMatch( - type -> { - return type.endsWith("/*") - ? baseMimeType.startsWith(type.substring(0, type.length() - 1)) - : baseMimeType.equals(type); - }); + type -> + type.endsWith("/*") + ? baseMimeType.startsWith(type.substring(0, type.length() - 1)) + : baseMimeType.equals(type)); } private static String fallbackToDefaultMimeType(String fileName) { 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 index b28ec9971..35a3b549b 100644 --- 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 @@ -108,7 +108,7 @@ void shouldRejectAttachment_whenFileHasNoExtension() throws Exception { requestHelper.executePostWithMatcher( createUrl(rootId, MEDIA_VALIDATED_ATTACHMENTS), attachmentMetadata, - status().isBadRequest()); + status().isUnsupportedMediaType()); } @Test