diff --git a/README.md b/README.md index a5b3f206..af5d0704 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu * [Storage Targets](#storage-targets) * [Malware Scanner](#malware-scanner) * [Specify the maximum file size](#specify-the-maximum-file-size) + * [Restrict allowed MIME types](#restrict-allowed-mime-types) * [Outbox](#outbox) * [Restore Endpoint](#restore-endpoint) * [Motivation](#motivation) @@ -214,6 +215,38 @@ The @Validation.Maximum value is a size string consisting of a number followed b The default is 400MB +### Restrict allowed MIME types + +You can restrict which MIME types are allowed for attachments by annotating the content property with @Core.AcceptableMediaTypes. This validation is performed during file upload. + +```cds +entity Books { + ... + attachments: Composition of many Attachments; +} + +annotate Books.attachments with { + content @Core.AcceptableMediaTypes : ['image/jpeg', 'image/png', 'application/pdf']; +} +``` + +Wildcard patterns are supported: + +```cds +annotate Books.attachments with { + content @Core.AcceptableMediaTypes : ['image/*', 'application/pdf']; +} +``` + +To allow all MIME types (default behavior), either omit the annotation or use: + +```cds +annotate Books.attachments with { + content @Core.AcceptableMediaTypes : ['*/*']; +} +``` + + ### Outbox In this plugin the [persistent outbox](https://cap.cloud.sap/docs/java/outbox#persistent) is used to mark attachments as diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java index e99586ea..9f5d4fcf 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,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 6a10baf6..8d3a3464 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 @@ -12,6 +12,7 @@ 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 +24,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 +43,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 +66,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(); + ApplicationHandlerHelper.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 00000000..0bf5c166 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelper.java @@ -0,0 +1,165 @@ +/* + * © 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.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import java.net.URLConnection; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AttachmentValidationHelper { + + public static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; + public static final Map EXT_TO_MEDIA_TYPE = + Map.ofEntries( + Map.entry("aac", "audio/aac"), + Map.entry("abw", "application/x-abiword"), + Map.entry("arc", "application/octet-stream"), + Map.entry("avi", "video/x-msvideo"), + Map.entry("azw", "application/vnd.amazon.ebook"), + Map.entry("bin", "application/octet-stream"), + Map.entry("png", "image/png"), + Map.entry("gif", "image/gif"), + Map.entry("bmp", "image/bmp"), + Map.entry("bz", "application/x-bzip"), + Map.entry("bz2", "application/x-bzip2"), + Map.entry("csh", "application/x-csh"), + Map.entry("css", "text/css"), + Map.entry("csv", "text/csv"), + Map.entry("doc", "application/msword"), + Map.entry( + "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + Map.entry("odp", "application/vnd.oasis.opendocument.presentation"), + Map.entry("ods", "application/vnd.oasis.opendocument.spreadsheet"), + Map.entry("odt", "application/vnd.oasis.opendocument.text"), + Map.entry("epub", "application/epub+zip"), + Map.entry("gz", "application/gzip"), + Map.entry("htm", "text/html"), + Map.entry("html", "text/html"), + Map.entry("ico", "image/x-icon"), + Map.entry("ics", "text/calendar"), + Map.entry("jar", "application/java-archive"), + Map.entry("jpg", "image/jpeg"), + Map.entry("jpeg", "image/jpeg"), + Map.entry("js", "text/javascript"), + Map.entry("json", "application/json"), + Map.entry("mid", "audio/midi"), + Map.entry("midi", "audio/midi"), + Map.entry("mjs", "text/javascript"), + Map.entry("mov", "video/quicktime"), + Map.entry("mp3", "audio/mpeg"), + Map.entry("mp4", "video/mp4"), + Map.entry("mpeg", "video/mpeg"), + Map.entry("mpkg", "application/vnd.apple.installer+xml"), + Map.entry("otf", "font/otf"), + Map.entry("pdf", "application/pdf"), + Map.entry("ppt", "application/vnd.ms-powerpoint"), + Map.entry( + "pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"), + Map.entry("rar", "application/x-rar-compressed"), + Map.entry("rtf", "application/rtf"), + Map.entry("svg", "image/svg+xml"), + Map.entry("tar", "application/x-tar"), + Map.entry("tif", "image/tiff"), + Map.entry("tiff", "image/tiff"), + Map.entry("ttf", "font/ttf"), + Map.entry("vsd", "application/vnd.visio"), + Map.entry("wav", "audio/wav"), + Map.entry("woff", "font/woff"), + Map.entry("woff2", "font/woff2"), + Map.entry("xhtml", "application/xhtml+xml"), + Map.entry("xls", "application/vnd.ms-excel"), + Map.entry("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + Map.entry("xml", "application/xml"), + Map.entry("zip", "application/zip"), + Map.entry("txt", "text/plain"), + Map.entry("webp", "image/webp")); + + private static final Logger logger = LoggerFactory.getLogger(AttachmentValidationHelper.class); + + /** + * Validates the file name and resolves its media type. Ensures that the detected media type is + * part of the list of acceptable media types. + * + * @param fileName the name of the attachment file + * @param acceptableMediaTypes list of allowed media types (e.g. "image/*", "application/pdf") + * @return the detected media type + * @throws ServiceException if the file name is invalid or the media type is not allowed + */ + public static String validateMediaTypeForAttachment( + String fileName, List acceptableMediaTypes) { + validateFileName(fileName); + String detectedMediaType = resolveMimeType(fileName); + validateAcceptableMediaType(acceptableMediaTypes, detectedMediaType); + return detectedMediaType; + } + + private static void validateFileName(String fileName) { + 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 actualMimeType = URLConnection.guessContentTypeFromName(fileName); + + if (actualMimeType == null) { + String fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(); + actualMimeType = EXT_TO_MEDIA_TYPE.get(fileExtension); + + if (actualMimeType == null) { + logger.warn( + "Could not determine mime type for file: {}. Setting mime type to default: {}", + fileName, + DEFAULT_MEDIA_TYPE); + actualMimeType = DEFAULT_MEDIA_TYPE; + } + } + return actualMimeType; + } + + 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); + }); + } +} 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 c8308f51..f70c671a 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 @@ -5,19 +5,29 @@ import static java.util.Objects.nonNull; +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.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.AttachmentValidationHelper; +import com.sap.cds.reflect.CdsAnnotation; import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; import com.sap.cds.reflect.CdsStructuredType; +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.draft.Drafts; +import com.sap.cds.services.runtime.CdsRuntime; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; /** * The class {@link ApplicationHandlerHelper} provides helper methods for the attachment application @@ -26,6 +36,12 @@ public final class ApplicationHandlerHelper { private static final String ANNOTATION_IS_MEDIA_DATA = "_is_media_data"; private static final String ANNOTATION_CORE_MEDIA_TYPE = "Core.MediaType"; + 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"); /** * A filter for media content fields. The filter checks if the entity is a media entity and if the @@ -123,6 +139,58 @@ public static Map removeDraftKey(Map keys) { return keyMap; } + /** + * 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 || !isMediaEntity(serviceEntity)) { + return; + } + List allowedTypes = getEntityAcceptableMediaTypes(serviceEntity); + String fileName = extractFileName(entity, data); + AttachmentValidationHelper.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(); + } + private ApplicationHandlerHelper() { // avoid 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 4eabcf23..437f3f3b 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; @@ -30,6 +31,7 @@ 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; +import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.ErrorStatuses; @@ -48,6 +50,7 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.List; @@ -55,6 +58,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; class CreateAttachmentsHandlerTest { @@ -78,7 +82,10 @@ void setup() { storageReader = mock(ThreadDataStorageReader.class); cut = new CreateAttachmentsHandler( - eventFactory, storageReader, ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER); + eventFactory, + storageReader, + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + runtime); createContext = mock(CdsCreateEventContext.class); event = mock(ModifyAttachmentEvent.class); @@ -383,6 +390,41 @@ void restoreError_methodHasCorrectAnnotations() throws NoSuchMethodException { assertThat(handlerOrderAnnotation.value()).isEqualTo(HandlerOrder.EARLY); } + @Test + void processBeforeForMetadata_methodHasCorrectAnnotations() throws NoSuchMethodException { + Method method = + cut.getClass() + .getDeclaredMethod("processBeforeForMetadata", EventContext.class, List.class); + + Before beforeAnnotation = method.getAnnotation(Before.class); + HandlerOrder handlerOrderAnnotation = method.getAnnotation(HandlerOrder.class); + + assertThat(beforeAnnotation.event()) + .containsExactlyInAnyOrder(CqnService.EVENT_CREATE, DraftService.EVENT_DRAFT_NEW); + assertThat(handlerOrderAnnotation.value()).isEqualTo(HandlerOrder.BEFORE); + } + + @Test + void processBeforeForMetadata_executesValidation() { + EventContext context = mock(EventContext.class); + CdsEntity entity = mock(CdsEntity.class); + List data = List.of(mock(CdsData.class)); + when(context.getTarget()).thenReturn(entity); + + try (MockedStatic helper = + mockStatic(ApplicationHandlerHelper.class)) { + helper + .when(() -> ApplicationHandlerHelper.validateAcceptableMediaTypes(entity, data, runtime)) + .thenAnswer(invocation -> null); + // when + new CreateAttachmentsHandler(eventFactory, storageReader, "400MB", runtime) + .processBeforeForMetadata(context, data); + // then + helper.verify( + () -> ApplicationHandlerHelper.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 00000000..4fc333d8 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/AttachmentValidationHelperTest.java @@ -0,0 +1,168 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import static org.junit.jupiter.api.Assertions.*; + +import com.sap.cds.services.ErrorStatuses; +import com.sap.cds.services.ServiceException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AttachmentValidationHelperTest { + + @Test + void constructor_shouldBeCallable_forCoverage() { + new AttachmentValidationHelper(); + } + + @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 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()); + } +} 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 e7d8cfa3..4b6e1684 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 @@ -4,15 +4,45 @@ 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 static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; import com.sap.cds.CdsData; -import java.util.Map; +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.AttachmentValidationHelper; +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.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 ApplicationHandlerHelperTest { + @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 keysAreInData() { Map keys = Map.of("key1", "value1", "key2", "value2"); @@ -57,4 +87,195 @@ void removeDraftKey() { assertFalse(result.containsKey("IsActiveEntity")); assertTrue(result.containsKey("key1")); } + + @Test + void validateAcceptableMediaTypes_shouldReturnWhenEntityIsNull() { + // given + CdsRuntime runtime = mock(CdsRuntime.class); + + // when / then + assertDoesNotThrow( + () -> ApplicationHandlerHelper.validateAcceptableMediaTypes(null, List.of(), runtime)); + + // ensure no further interaction happens + verifyNoInteractions(runtime); + } + + @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, ApplicationHandlerHelper.getEntityAcceptableMediaTypes(entity)); + } + + @Test + void getEntityAcceptableMediaTypes_missingAnnotation_returnsWildcard() { + when(entity.getElement("content")).thenReturn(cdsElement); + when(cdsElement.findAnnotation("Core.AcceptableMediaTypes")).thenReturn(Optional.empty()); + assertEquals(List.of("*/*"), ApplicationHandlerHelper.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("*/*"), ApplicationHandlerHelper.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 = ApplicationHandlerHelper.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, + () -> ApplicationHandlerHelper.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, + () -> ApplicationHandlerHelper.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( + () -> ApplicationHandlerHelper.validateAcceptableMediaTypes(entity, List.of(), cdsRuntime)); + } + + @Test + void validateAcceptableMediaTypes_notMediaEntity_doesNotCallValidation() { + when(entity.getQualifiedName()).thenReturn("TestService.Roots"); + when(cdsModel.findEntity("TestService.Roots")).thenReturn(Optional.of(serviceEntity)); + when(serviceEntity.getAnnotationValue("_is_media_data", false)).thenReturn(false); + + try (MockedStatic mocked = + mockStatic(AttachmentValidationHelper.class)) { + ApplicationHandlerHelper.validateAcceptableMediaTypes(entity, List.of(), cdsRuntime); + mocked.verifyNoInteractions(); + } + } + + @Test + void shouldNotThrowWhenEntityNotFoundInModel() { + when(entity.getQualifiedName()).thenReturn("Test.Entity"); + when(cdsModel.findEntity("Test.Entity")).thenReturn(Optional.empty()); + + assertDoesNotThrow( + () -> ApplicationHandlerHelper.validateAcceptableMediaTypes(entity, List.of(), cdsRuntime)); + } + + @Test + void validateAcceptableMediaTypes_mediaTypeMatches_succeeds() { + setupMediaEntity(List.of("image/png")); + try (MockedStatic helperStatic = + mockStatic(ApplicationHandlerHelper.class, CALLS_REAL_METHODS)) { + + // file.png → media type image/png → allowed + helperStatic + .when(() -> ApplicationHandlerHelper.extractFileName(entity, List.of(data))) + .thenReturn("file.png"); + + assertDoesNotThrow( + () -> + ApplicationHandlerHelper.validateAcceptableMediaTypes( + entity, List.of(data), cdsRuntime)); + } + } + + @Test + void validateAcceptableMediaTypes_mediaTypeMismatch_throws() { + setupMediaEntity(List.of("image/png")); + try (MockedStatic helperStatic = + mockStatic(ApplicationHandlerHelper.class, CALLS_REAL_METHODS)) { + + // file.jpg → media type image/jpeg → NOT allowed + helperStatic + .when(() -> ApplicationHandlerHelper.extractFileName(entity, List.of(data))) + .thenReturn("file.jpg"); + + assertThrows( + ServiceException.class, + () -> + ApplicationHandlerHelper.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/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index f6c35e19..5073824b 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 00000000..3447bb20 --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/MediaValidatedAttachmentsDraftTest.java @@ -0,0 +1,149 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.draftservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sap.cds.CdsData; +import com.sap.cds.Struct; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.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.Objects; +import org.junit.jupiter.api.BeforeEach; +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"; + + @BeforeEach + void setup() { + requestHelper.setContentType(MediaType.APPLICATION_JSON); + } + + @ParameterizedTest + @CsvSource({"test.png,201", "test.jpeg,201", "test.pdf,415", "test.txt,415"}) + void shouldValidateMediaType_whenCreatingAttachmentInDraft(String fileName, int expectedStatus) + throws Exception { + + String rootId = createDraftRootAndReturnId(); + String metadata = + """ + { + "fileName": "%s" + } + """ + .formatted(fileName); + + requestHelper.executePostWithMatcher( + buildDraftAttachmentCreationUrl(rootId), metadata, status().is(expectedStatus)); + } + + private String buildDraftAttachmentCreationUrl(String rootId) { + return BASE_ROOT_URL + + "(ID=" + + rootId + + ",IsActiveEntity=false)" + + "/mediaValidatedAttachments"; + } + + // Helper methods + private String createDraftRootAndReturnId() throws Exception { + CdsData response = + requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, "{}"); + + DraftRoots draftRoot = Struct.access(response).as(DraftRoots.class); + + requestHelper.executePatchWithODataResponseAndAssertStatusOk( + getRootUrl(draftRoot.getId(), false), + """ + { + "title": "Draft with mediaValidatedAttachments" + } + """); + + return draftRoot.getId(); + } + + private String getRootUrl(String rootId, boolean isActiveEntity) { + return BASE_ROOT_URL + "(ID=" + rootId + ",IsActiveEntity=" + isActiveEntity + ")"; + } + + // Required abstract method implementations + @Override + protected void verifyContentId(String contentId, String attachmentId) { + assertThat(contentId).isEqualTo(attachmentId); + } + + @Override + protected void verifyContent(InputStream attachment, String testContent) throws IOException { + if (Objects.nonNull(testContent)) { + assertThat(attachment.readAllBytes()) + .isEqualTo(testContent.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } else { + assertThat(attachment).isNull(); + } + } + + @Override + protected void verifyNoAttachmentEventsCalled() { + // Implementation not required for this test + } + + @Override + protected void clearServiceHandlerContext() { + // Implementation not required for this test + } + + @Override + protected void verifyEventContextEmptyForEvent(String... events) { + // Implementation not required for this test + } + + @Override + protected void verifyOnlyTwoCreateEvents( + String newAttachmentContent, String newAttachmentEntityContent) { + // Implementation not required for this test + } + + @Override + protected void verifyTwoCreateAndDeleteEvents( + String newAttachmentContent, String newAttachmentEntityContent) { + // Implementation not required for this test + } + + @Override + protected void verifyTwoReadEvents() { + // Implementation not required for this test + } + + @Override + protected void verifyOnlyTwoDeleteEvents( + String attachmentContentId, String attachmentEntityContentId) { + // Implementation not required for this test + } + + @Override + protected void verifyTwoUpdateEvents( + String newAttachmentContent, + String attachmentContentId, + String newAttachmentEntityContent, + String attachmentEntityContentId) { + // Implementation not required for this test + } + + @Override + protected void verifyTwoCreateAndRevertedDeleteEvents() { + // Implementation not required for this test + } +} diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java index bd332021..b28055fd 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SizeLimitedAttachmentsSizeValidationDraftTest.java @@ -13,6 +13,7 @@ import com.sap.cds.feature.attachments.integrationtests.constants.Profiles; import java.io.IOException; import java.io.InputStream; +import java.util.List; import java.util.Objects; import org.junit.jupiter.api.Test; import org.springframework.test.context.ActiveProfiles; @@ -28,7 +29,7 @@ void uploadContentWithin5MBLimitSucceeds() throws Exception { // Arrange: Create draft with sizeLimitedAttachments var draftRoot = createNewDraftWithSizeLimitedAttachments(); var attachment = draftRoot.getSizeLimitedAttachments().get(0); - + attachment.setFileName("test.txt"); // Act & Assert: Upload 3MB content (within limit) succeeds byte[] content = new byte[3 * 1024 * 1024]; // 3MB var url = buildDraftSizeLimitedAttachmentContentUrl(draftRoot.getId(), attachment.getId()); @@ -41,14 +42,15 @@ void uploadContentExceeding5MBLimitFails() throws Exception { // Arrange: Create draft with sizeLimitedAttachments var draftRoot = createNewDraftWithSizeLimitedAttachments(); var attachment = draftRoot.getSizeLimitedAttachments().get(0); - + attachment.setFileName("test.txt"); // Act: Try to upload 6MB content (exceeds limit) byte[] content = new byte[6 * 1024 * 1024]; // 6MB var url = buildDraftSizeLimitedAttachmentContentUrl(draftRoot.getId(), attachment.getId()); requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); requestHelper.executePutWithMatcher(url, content, status().is(413)); - // Assert: Error response with HTTP 413 status code indicates size limit exceeded + // Assert: Error response with HTTP 413 status code indicates size limit + // exceeded } // Helper methods @@ -74,7 +76,7 @@ private DraftRoots createNewDraftWithSizeLimitedAttachments() throws Exception { var createdAttachment = Struct.access(responseAttachmentCdsData).as(Attachments.class); // Build result with the attachment - draftRoot.setSizeLimitedAttachments(java.util.List.of(createdAttachment)); + draftRoot.setSizeLimitedAttachments(List.of(createdAttachment)); return draftRoot; } diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java new file mode 100644 index 00000000..04be3621 --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/MediaValidatedAttachmentsNonDraftTest.java @@ -0,0 +1,192 @@ +/* + * © 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.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 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"; + + 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()); + } + + 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) { + return String.format( + """ + { + "fileName": "%s" + } + """, + 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 9dcb1fa3..9bc77e76 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SizeLimitedAttachmentValidationNonDraftTest.java @@ -30,6 +30,7 @@ void uploadContentWithin5MBLimitSucceeds() throws Exception { var selectedRoot = selectStoredRootWithSizeLimitedAttachments(); var attachment = getRandomRootSizeLimitedAttachment(selectedRoot); + attachment.setFileName("test.txt"); // Act & Assert: Upload 3MB content (within limit) succeeds byte[] content = new byte[3 * 1024 * 1024]; // 3MB @@ -48,7 +49,7 @@ void uploadContentExceeding5MBLimitFails() throws Exception { var selectedRoot = selectStoredRootWithSizeLimitedAttachments(); var attachment = getRandomRootSizeLimitedAttachment(selectedRoot); - + attachment.setFileName("test.txt"); // Act: Try to upload 6MB content (exceeds limit) byte[] content = new byte[6 * 1024 * 1024]; // 6MB var url = @@ -57,7 +58,8 @@ void uploadContentExceeding5MBLimitFails() throws Exception { requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); requestHelper.executePutWithMatcher(url, content, status().is(413)); - // Assert: Error response with HTTP 413 status code indicates size limit exceeded + // Assert: Error response with HTTP 413 status code indicates size limit + // exceeded } // Helper methods diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/helper/RootEntityBuilder.java index cf91a423..f6dc5179 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 e4974ac0..c07ddaed 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 04f4d554..1f453323 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -4,15 +4,25 @@ using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; // Extend Books entity to support file attachments (images, PDFs, documents) // Each book can have multiple attachments via composition relationship extend my.Books with { - attachments : Composition of many Attachments; + attachments : Composition of many Attachments; @UI.Hidden - sizeLimitedAttachments : Composition of many Attachments; + sizeLimitedAttachments : Composition of many Attachments; + @UI.Hidden + mediaValidatedAttachments : Composition of many Attachments; } annotate my.Books.sizeLimitedAttachments with { content @Validation.Maximum: '5MB'; } +// Media type validation for attachments +annotate my.Books.mediaValidatedAttachments with { + content @Core.AcceptableMediaTypes: [ + 'image/jpeg', + 'image/png' + ]; +} + // Add UI component for attachments table to the Browse Books App using {CatalogService as service} from '../app/services';