diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java index 0a193b774..3054e1b09 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java @@ -6,8 +6,6 @@ import static java.util.Objects.requireNonNull; import com.sap.cds.CdsData; -import com.sap.cds.CdsDataProcessor; -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.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer; @@ -44,9 +42,9 @@ public class UpdateAttachmentsHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(UpdateAttachmentsHandler.class); private final ModifyAttachmentEventFactory eventFactory; - private final AttachmentsReader attachmentsReader; private final AttachmentService attachmentService; private final ThreadDataStorageReader storageReader; + private final AttachmentsReader attachmentsReader; public UpdateAttachmentsHandler( ModifyAttachmentEventFactory eventFactory, @@ -54,17 +52,18 @@ public UpdateAttachmentsHandler( AttachmentService attachmentService, ThreadDataStorageReader storageReader) { this.eventFactory = requireNonNull(eventFactory, "eventFactory must not be null"); - this.attachmentsReader = - requireNonNull(attachmentsReader, "attachmentsReader must not be null"); this.attachmentService = requireNonNull(attachmentService, "attachmentService must not be null"); this.storageReader = requireNonNull(storageReader, "storageReader must not be null"); + this.attachmentsReader = + requireNonNull(attachmentsReader, "attachmentsReader must not be null"); } @Before @HandlerOrder(OrderConstants.Before.CHECK_CAPABILITIES) void processBeforeForDraft(CdsUpdateEventContext context, List data) { - // before the attachment's readonly fields are removed by the runtime, preserve them in a custom + // before the attachment's readonly fields are removed by the runtime, preserve + // them in a custom // field in data ReadonlyDataContextEnhancer.preserveReadonlyFields( context.getTarget(), data, storageReader.get()); @@ -73,20 +72,20 @@ void processBeforeForDraft(CdsUpdateEventContext context, List data) { @Before @HandlerOrder(HandlerOrder.LATE) void processBefore(CdsUpdateEventContext context, List data) { + CdsEntity target = context.getTarget(); boolean associationsAreUnchanged = associationsAreUnchanged(target, data); if (ApplicationHandlerHelper.containsContentField(target, data) || !associationsAreUnchanged) { logger.debug("Processing before {} event for entity {}", context.getEvent(), target); + // Query database only for validation (single query for all attachments) CqnSelect select = CqnUtils.toSelect(context.getCqn(), context.getTarget()); List attachments = attachmentsReader.readAttachments(context.getModel(), target, select); - List condensedAttachments = - ApplicationHandlerHelper.condenseAttachments(attachments, target); ModifyApplicationHandlerHelper.handleAttachmentForEntities( - target, data, condensedAttachments, eventFactory, context); + target, data, attachments, eventFactory, context); if (!associationsAreUnchanged) { deleteRemovedAttachments(attachments, data, target, context.getUserInfo()); @@ -95,7 +94,8 @@ void processBefore(CdsUpdateEventContext context, List data) { } private boolean associationsAreUnchanged(CdsEntity entity, List data) { - // TODO: check if this should be replaced with entity.assocations().noneMatch(...) + // TODO: check if this should be replaced with + // entity.assocations().noneMatch(...) return entity .compositions() .noneMatch( @@ -103,27 +103,27 @@ private boolean associationsAreUnchanged(CdsEntity entity, List data) { } private void deleteRemovedAttachments( - List existingAttachments, - List data, + List dbAttachments, + List requestData, CdsEntity entity, UserInfo userInfo) { - List condensedAttachments = - ApplicationHandlerHelper.condenseAttachments(data, entity); - - Validator validator = - (path, element, value) -> { - Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); - boolean entryExists = - condensedAttachments.stream() - .anyMatch( - updatedData -> ApplicationHandlerHelper.areKeysInData(keys, updatedData)); - if (!entryExists) { - String contentId = (String) path.target().values().get(Attachments.CONTENT_ID); - attachmentService.markAttachmentAsDeleted(new MarkAsDeletedInput(contentId, userInfo)); - } - }; - CdsDataProcessor.create() - .addValidator(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, validator) - .process(existingAttachments, entity); + List requestAttachments = + ApplicationHandlerHelper.condenseAttachments(requestData, entity); + + for (Attachments dbAttachment : dbAttachments) { + Map dbKeys = ApplicationHandlerHelper.extractKeys(dbAttachment, entity); + Map keys = ApplicationHandlerHelper.removeDraftKey(dbKeys); + + boolean existsInRequest = + requestAttachments.stream() + .anyMatch( + requestAttachment -> + ApplicationHandlerHelper.areKeysInData(keys, requestAttachment)); + + if (!existsInRequest && dbAttachment.getContentId() != null) { + attachmentService.markAttachmentAsDeleted( + new MarkAsDeletedInput(dbAttachment.getContentId(), userInfo)); + } + } } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ExtendedErrorStatuses.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ExtendedErrorStatuses.java new file mode 100644 index 000000000..ee898761a --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ExtendedErrorStatuses.java @@ -0,0 +1,48 @@ +/* + * © 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.ErrorStatus; + +public enum ExtendedErrorStatuses implements ErrorStatus { + CONTENT_TOO_LARGE(413, "The content size exceeds the maximum allowed limit.", 413); + + private final int code; + private final String description; + private final int httpStatus; + + ExtendedErrorStatuses(int code, String description, int httpStatus) { + this.code = code; + this.description = description; + this.httpStatus = httpStatus; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public int getHttpStatus() { + return httpStatus; + } + + /** + * @param code the code + * @return the ErrorStatus from this enum, associated with the given code or {@code null} + */ + public static ErrorStatus getByCode(int code) { + for (ExtendedErrorStatuses errorStatus : values()) { + if (Integer.parseInt(errorStatus.getCodeString()) == code) { + return errorStatus; + } + } + return null; + } + + @Override + public String getCodeString() { + return Integer.toString(code); + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/FileSizeUtils.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/FileSizeUtils.java new file mode 100644 index 000000000..7a6a69e9a --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/FileSizeUtils.java @@ -0,0 +1,53 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class FileSizeUtils { + private static final Pattern SIZE = + Pattern.compile("^\\s*([0-9]+(?:\\.[0-9]+)?)\\s*([a-zA-Z]*)\\s*$"); + private static final Map MULTIPLIER = + Map.ofEntries( + Map.entry("", 1L), + Map.entry("B", 1L), + + // Decimal + Map.entry("KB", 1000L), + Map.entry("MB", 1000L * 1000), + Map.entry("GB", 1000L * 1000 * 1000), + Map.entry("TB", 1000L * 1000 * 1000 * 1000), + + // Binary + Map.entry("KIB", 1024L), + Map.entry("MIB", 1024L * 1024), + Map.entry("GIB", 1024L * 1024 * 1024), + Map.entry("TIB", 1024L * 1024 * 1024 * 1024)); + + private FileSizeUtils() {} + + public static long parseFileSizeToBytes(String input) { + // First validate string + if (input == null) throw new IllegalArgumentException("Value for Max File Size is null"); + + Matcher m = SIZE.matcher(input); + if (!m.matches()) { + throw new IllegalArgumentException("Invalid size: " + input); + } + BigDecimal value = new BigDecimal(m.group(1)); + String unitRaw = m.group(2); // Cannot be null due to * quantifier in regex + String unit = unitRaw.toUpperCase(); + + // if (unit.length() == 1) unit = unit + "B"; // for people using K instead of KB + Long mul = MULTIPLIER.get(unit); + if (mul == null) { + throw new IllegalArgumentException("Unknown Unit: " + unitRaw); + } + BigDecimal bytes = value.multiply(BigDecimal.valueOf(mul)); + return bytes.longValueExact(); + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index 8bf509708..23d145db2 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -6,27 +6,37 @@ import com.sap.cds.CdsData; import com.sap.cds.CdsDataProcessor; import com.sap.cds.CdsDataProcessor.Converter; +import com.sap.cds.CdsDataProcessor.Filter; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEvent; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory; +import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.CountingInputStream; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; import com.sap.cds.ql.cqn.Path; import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.ErrorStatuses; import com.sap.cds.services.EventContext; +import com.sap.cds.services.ServiceException; import java.io.InputStream; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; public final class ModifyApplicationHandlerHelper { + private static final Filter VALMAX_FILTER = (path, element, type) -> element.getName().contentEquals("content") + && element.findAnnotation("Validation.Maximum").isPresent(); + /** * Handles attachments for entities. * - * @param entity the {@link CdsEntity entity} to handle attachments for - * @param data the given list of {@link CdsData data} + * @param entity the {@link CdsEntity entity} to handle attachments + * for + * @param data the given list of {@link CdsData data} * @param existingAttachments the given list of existing {@link CdsData data} - * @param eventFactory the {@link ModifyAttachmentEventFactory} to create the corresponding event - * @param eventContext the current {@link EventContext} + * @param eventFactory the {@link ModifyAttachmentEventFactory} to create + * the corresponding event + * @param eventContext the current {@link EventContext} */ public static void handleAttachmentForEntities( CdsEntity entity, @@ -34,10 +44,8 @@ public static void handleAttachmentForEntities( List existingAttachments, ModifyAttachmentEventFactory eventFactory, EventContext eventContext) { - Converter converter = - (path, element, value) -> - handleAttachmentForEntity( - existingAttachments, eventFactory, eventContext, path, (InputStream) value); + Converter converter = (path, element, value) -> handleAttachmentForEntity( + existingAttachments, eventFactory, eventContext, path, (InputStream) value); CdsDataProcessor.create() .addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter) @@ -47,11 +55,13 @@ public static void handleAttachmentForEntities( /** * Handles attachments for a single entity. * - * @param existingAttachments the list of existing {@link Attachments} to check against - * @param eventFactory the {@link ModifyAttachmentEventFactory} to create the corresponding event - * @param eventContext the current {@link EventContext} - * @param path the {@link Path} of the attachment - * @param content the content of the attachment + * @param existingAttachments the list of existing {@link Attachments} to check + * against + * @param eventFactory the {@link ModifyAttachmentEventFactory} to create + * the corresponding event + * @param eventContext the current {@link EventContext} + * @param path the {@link Path} of the attachment + * @param content the content of the attachment * @return the processed content as an {@link InputStream} */ public static InputStream handleAttachmentForEntity( @@ -64,12 +74,30 @@ public static InputStream handleAttachmentForEntity( ReadonlyDataContextEnhancer.restoreReadonlyFields((CdsData) path.target().values()); Attachments attachment = getExistingAttachment(keys, existingAttachments); String contentId = (String) path.target().values().get(Attachments.CONTENT_ID); + String contentLength = eventContext.getParameterInfo().getHeader("Content-Length"); + + InputStream wrappedContent = wrapWithCountingStream(content, path.target().entity(), existingAttachments, + contentLength); // for the current request find the event to process - ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(content, contentId, attachment); + ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(wrappedContent, contentId, attachment); // process the event - return eventToProcess.processEvent(path, content, attachment, eventContext); + return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext); + } + + private static String getValMaxValue(CdsEntity entity, List data) { + AtomicReference annotationValue = new AtomicReference<>(); + CdsDataProcessor.create() + .addValidator( + VALMAX_FILTER, + (path, element, value) -> { + element + .findAnnotation("Validation.Maximum") + .ifPresent(annotation -> annotationValue.set(annotation.getValue().toString())); + }) + .process(data, entity); + return annotationValue.get(); } private static Attachments getExistingAttachment( @@ -80,6 +108,27 @@ private static Attachments getExistingAttachment( .orElse(Attachments.create()); } + private static InputStream wrapWithCountingStream( + InputStream content, CdsEntity entity, List data, String contentLength) { + String maxSizeStr = getValMaxValue(entity, data); + + if (maxSizeStr != null && content != null) { + try { + long maxSize = FileSizeUtils.parseFileSizeToBytes(maxSizeStr); + // if (contentLength != null && Long.parseLong(contentLength) > maxSize) { + // throw new RuntimeException(); + // } + return new CountingInputStream(content, maxSize); + } catch (ArithmeticException e) { + throw new ServiceException("Maximum file size value is too large", e); + } catch (RuntimeException e) { + throw new ServiceException( + ExtendedErrorStatuses.CONTENT_TOO_LARGE, "AttachmentSizeExceeded", maxSizeStr); + } + } + return content; + } + private ModifyApplicationHandlerHelper() { // avoid instantiation } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java new file mode 100644 index 000000000..5c503a386 --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStream.java @@ -0,0 +1,73 @@ +/* + * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.readhelper; + +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses; +import com.sap.cds.services.ServiceException; +import java.io.IOException; +import java.io.InputStream; + +public class CountingInputStream extends InputStream { + + private final InputStream delegate; + private long byteCount = 0; + private long maxBytes; + + public CountingInputStream(InputStream delegate, long maxBytes) { + this.delegate = delegate; + this.maxBytes = maxBytes; + } + + @Override + public int read() throws IOException { + int b = delegate.read(); + if (b != -1) { + checkLimit(1); + } + return b; + } + + @Override + public int read(byte[] b) throws IOException { + int bytesRead = delegate.read(b); + if (bytesRead > 0) { + checkLimit(bytesRead); + } + return bytesRead; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int bytesRead = delegate.read(b, off, len); + if (bytesRead > 0) { + checkLimit(bytesRead); + } + return bytesRead; + } + + @Override + public long skip(long n) throws IOException { + long skipped = delegate.skip(n); + if (skipped > 0) { + checkLimit(skipped); + } + return skipped; + } + + @Override + public void close() throws IOException { + if (delegate != null) + delegate.close(); + } + + private void checkLimit(long bytes) { + byteCount += bytes; + if (byteCount > maxBytes) { + throw new ServiceException( + ExtendedErrorStatuses.CONTENT_TOO_LARGE, + "AttachmentSizeExceeded", + maxBytes); + } + } +} 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 97fbca93a..18eea5567 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 @@ -62,6 +62,28 @@ public static boolean isMediaEntity(CdsStructuredType baseEntity) { return baseEntity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false); } + /** + * Extracts key fields from CdsData based on the entity definition. + * + * @param data The CdsData to extract keys from + * @param entity The entity definition + * @return A map of key fields and their values + */ + public static Map extractKeys(CdsData data, CdsEntity entity) { + Map keys = new HashMap<>(); + entity + .keyElements() + .forEach( + keyElement -> { + String keyName = keyElement.getName(); + Object value = data.get(keyName); + if (value != null) { + keys.put(keyName, value); + } + }); + return keys; + } + /** * Condenses the attachments from the given data into a list of {@link Attachments attachments}. * diff --git a/cds-feature-attachments/src/main/resources/messages.properties b/cds-feature-attachments/src/main/resources/messages.properties new file mode 100644 index 000000000..e9af11c93 --- /dev/null +++ b/cds-feature-attachments/src/main/resources/messages.properties @@ -0,0 +1 @@ +AttachmentSizeExceeded = File size exceeds the limit of {0}. \ No newline at end of file diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java index a02e0f583..534206126 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 @@ -32,6 +32,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 com.sap.cds.services.request.ParameterInfo; import com.sap.cds.services.runtime.CdsRuntime; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -67,6 +68,9 @@ void setup() { createContext = mock(CdsCreateEventContext.class); event = mock(ModifyAttachmentEvent.class); + + ParameterInfo parameterInfo = mock(ParameterInfo.class); + when(createContext.getParameterInfo()).thenReturn(parameterInfo); } @Test diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java index 7c40fedc2..9a9103979 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java @@ -38,6 +38,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 com.sap.cds.services.request.ParameterInfo; import com.sap.cds.services.request.UserInfo; import com.sap.cds.services.runtime.CdsRuntime; import java.io.InputStream; @@ -89,6 +90,9 @@ void setup() { selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); userInfo = mock(UserInfo.class); + + ParameterInfo parameterInfo = mock(ParameterInfo.class); + when(updateContext.getParameterInfo()).thenReturn(parameterInfo); } @Test @@ -249,7 +253,7 @@ void existingDataFoundAndUsed() { var target = updateContext.getTarget(); when(attachmentsReader.readAttachments( eq(model), eq(target), any(CqnFilterableStatement.class))) - .thenReturn(List.of(Attachments.of(root))); + .thenReturn(root.getAttachments().stream().map(Attachments::of).toList()); cut.processBefore(updateContext, List.of(root)); @@ -264,10 +268,15 @@ void existingDataFoundAndUsed() { void noExistingDataFound() { var id = getEntityAndMockContext(RootTable_.CDS_NAME); when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) - .thenReturn(List.of(Attachments.create())); + .thenReturn(List.of()); var testStream = mock(InputStream.class); - var root = fillRootData(testStream, id); + var root = RootTable.create(); + root.setId(id); + var attachment = Attachments.create(); + // No ID set - this is a new attachment + attachment.setContent(testStream); + root.setAttachments(List.of(attachment)); cut.processBefore(updateContext, List.of(root)); @@ -299,6 +308,8 @@ void selectIsUsedWithFilterAndWhere() { CqnUpdate update = Update.entity(entityWithKeys).byId("test"); var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); mockTargetInUpdateContext(serviceEntity, update); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(List.of(attachment)); cut.processBefore(updateContext, List.of(attachment)); @@ -320,6 +331,8 @@ void selectIsUsedWithFilter() { CqnUpdate update = Update.entity(entityWithKeys); var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); mockTargetInUpdateContext(serviceEntity, update); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(List.of(attachment)); cut.processBefore(updateContext, List.of(attachment)); @@ -339,6 +352,8 @@ void selectIsUsedWithWhere() { CqnUpdate update = Update.entity(Attachment_.CDS_NAME).byId("test"); var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); mockTargetInUpdateContext(serviceEntity, update); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(List.of(attachment)); cut.processBefore(updateContext, List.of(attachment)); @@ -360,6 +375,8 @@ void selectIsUsedWithAttachmentId() { CqnUpdate update = Update.entity(Attachment_.class).where(entity -> entity.ID().eq(attachment.getId())); mockTargetInUpdateContext(serviceEntity, update); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(List.of(attachment)); cut.processBefore(updateContext, List.of(attachment)); @@ -390,6 +407,8 @@ void selectIsCorrectForMultipleAttachments() { .or(attachment.ID().eq(attachment2.getId()))); var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow(); mockTargetInUpdateContext(serviceEntity, update); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(List.of(attachment1, attachment2)); cut.processBefore(updateContext, List.of(attachment1, attachment2)); @@ -425,12 +444,14 @@ void noContentInDataButAssociationIsChangedAndDeleteCalled() { var attachment = Attachments.create(); attachment.setId(UUID.randomUUID().toString()); + attachment.put(UP_ID, id); // Set parent key so deletion logic can match it attachment.setContent(mock(InputStream.class)); attachment.setContentId("document id"); var existingRoot = RootTable.create(); + existingRoot.setId(id); existingRoot.setAttachments(List.of(attachment)); when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) - .thenReturn(List.of(Attachments.of(existingRoot))); + .thenReturn(existingRoot.getAttachments().stream().map(Attachments::of).toList()); when(updateContext.getUserInfo()).thenReturn(userInfo); cut.processBefore(updateContext, List.of(root)); @@ -499,8 +520,8 @@ private Map getAttachmentKeyMap(Attachments attachment) { private String getRefString(String key, String value) { return """ - {"ref":["%s"]},"=",{"val":"%s"} - """ + {"ref":["%s"]},"=",{"val":"%s"} + """ .formatted(key, value) .replace(" ", "") .replace("\n", ""); @@ -508,8 +529,8 @@ private String getRefString(String key, String value) { private String getOrCondition(String key1, String key2) { return """ - [{"ref":["ID"]},"=",{"val":"%s"},"or",{"ref":["ID"]},"=",{"val":"%s"}] - """ + [{"ref":["ID"]},"=",{"val":"%s"},"or",{"ref":["ID"]},"=",{"val":"%s"}] + """ .formatted(key1, key2) .replace(" ", "") .replace("\n", ""); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ExtendedErrorStatusesTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ExtendedErrorStatusesTest.java new file mode 100644 index 000000000..74fdd89ce --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ExtendedErrorStatusesTest.java @@ -0,0 +1,31 @@ +/* + * © 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 org.junit.jupiter.api.Test; + +class ExtendedErrorStatusesTest { + + @Test + void contentTooLargeHasCorrectProperties() { + assertThat(ExtendedErrorStatuses.CONTENT_TOO_LARGE.getCodeString()).isEqualTo("413"); + assertThat(ExtendedErrorStatuses.CONTENT_TOO_LARGE.getDescription()) + .isEqualTo("The content size exceeds the maximum allowed limit."); + assertThat(ExtendedErrorStatuses.CONTENT_TOO_LARGE.getHttpStatus()).isEqualTo(413); + } + + @Test + void getByCode_existingCode_returnsErrorStatus() { + var result = ExtendedErrorStatuses.getByCode(413); + assertThat(result).isNotNull(); + } + + @Test + void getByCode_nonExistingCode_returnsNull() { + var result = ExtendedErrorStatuses.getByCode(999); + assertThat(result).isNull(); + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/FileSizeUtilsTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/FileSizeUtilsTest.java new file mode 100644 index 000000000..ee3da49c2 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/FileSizeUtilsTest.java @@ -0,0 +1,108 @@ +/* + * © 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.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class FileSizeUtilsTest { + + @ParameterizedTest + @CsvSource({ + "100, 100", + "100B, 100", + "1KB, 1000", + "1MB, 1000000", + "1GB, 1000000000", + "1TB, 1000000000000", + "1KIB, 1024", + "1MIB, 1048576", + "1GIB, 1073741824", + "1TIB, 1099511627776", + "2.5KB, 2500", + "1.5MB, 1500000", + " 100 , 100", + " 100 KB , 100000", + "0, 0", + "0B, 0", + "001KB, 1000", + "0.5MB, 500000" + }) + void parseFileSizeToBytes_validInput(String input, long expected) { + assertThat(FileSizeUtils.parseFileSizeToBytes(input)).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "1kb, 1000", + "1mb, 1000000", + "1gb, 1000000000", + "1tb, 1000000000000", + "1kib, 1024", + "1mib, 1048576", + "1gib, 1073741824", + "1tib, 1099511627776", + "100b, 100" + }) + void parseFileSizeToBytes_lowercaseUnits(String input, long expected) { + assertThat(FileSizeUtils.parseFileSizeToBytes(input)).isEqualTo(expected); + } + + @ParameterizedTest + @CsvSource({ + "1Kb, 1000", + "1Mb, 1000000", + "1Gb, 1000000000", + "1KiB, 1024", + "1MiB, 1048576", + "1GiB, 1073741824" + }) + void parseFileSizeToBytes_mixedCaseUnits(String input, long expected) { + assertThat(FileSizeUtils.parseFileSizeToBytes(input)).isEqualTo(expected); + } + + @Test + void parseFileSizeToBytes_nullInput_throwsException() { + assertThatThrownBy(() -> FileSizeUtils.parseFileSizeToBytes(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Value for Max File Size is null"); + } + + @ParameterizedTest + @CsvSource({"invalid", "abc KB", "-100", "''"}) + void parseFileSizeToBytes_invalidFormat_throwsException(String input) { + assertThatThrownBy(() -> FileSizeUtils.parseFileSizeToBytes(input)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void parseFileSizeToBytes_whitespaceOnly_throwsException() { + assertThatThrownBy(() -> FileSizeUtils.parseFileSizeToBytes(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid size"); + } + + @Test + void parseFileSizeToBytes_fractionalBytes_throwsException() { + assertThatThrownBy(() -> FileSizeUtils.parseFileSizeToBytes("0.5")) + .isInstanceOf(ArithmeticException.class); + } + + @Test + void parseFileSizeToBytes_fractionalBytesWithUnit_throwsException() { + assertThatThrownBy(() -> FileSizeUtils.parseFileSizeToBytes("0.5B")) + .isInstanceOf(ArithmeticException.class); + } + + @Test + void parseFileSizeToBytes_unknownUnit_throwsException() { + assertThatThrownBy(() -> FileSizeUtils.parseFileSizeToBytes("100XB")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown Unit"); + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index fe203e9ac..b37f8879d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -29,6 +29,7 @@ import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.request.ParameterInfo; import com.sap.cds.services.runtime.CdsRuntime; import java.io.InputStream; import java.util.List; @@ -63,6 +64,8 @@ void setup() { event = mock(ModifyAttachmentEvent.class); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); selectCaptor = ArgumentCaptor.forClass(CqnSelect.class); + ParameterInfo parameterInfo = mock(ParameterInfo.class); + when(eventContext.getParameterInfo()).thenReturn(parameterInfo); } @Test diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index a06fb7cc1..aae7c285f 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -13,6 +13,7 @@ entity Roots : cuid { on attachments.parentKey = $self.ID; items : Composition of many Items on items.parentID = $self.ID; + attachments2 : Composition of many Attachments; } entity Items : cuid { diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index c3df8bc73..67686ed49 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -26,7 +26,7 @@ org.springframework.boot spring-boot-dependencies - 3.5.7 + 3.5.9 pom import diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/Attachments2SizeValidationDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/Attachments2SizeValidationDraftTest.java new file mode 100644 index 000000000..da7d349cd --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/Attachments2SizeValidationDraftTest.java @@ -0,0 +1,213 @@ +/* + * © 2024-2024 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.Struct; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testdraftservice.DraftRoots; +import com.sap.cds.feature.attachments.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 com.sap.cds.ql.Select; +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles(Profiles.TEST_HANDLER_DISABLED) +class Attachments2SizeValidationDraftTest extends DraftOdataRequestValidationBase { + + private static final String BASE_URL = MockHttpRequestHelper.ODATA_BASE_URL + "TestDraftService/"; + private static final String BASE_ROOT_URL = BASE_URL + "DraftRoots"; + + @Test + void uploadContentWithin5MBLimitSucceeds() throws Exception { + // Arrange: Create draft with attachments2 + var draftRoot = createNewDraftWithAttachments2(); + var attachment = draftRoot.getAttachments2().get(0); + + // Act & Assert: Upload 3MB content (within limit) succeeds + byte[] content = new byte[3 * 1024 * 1024]; // 3MB + var url = buildDraftAttachment2ContentUrl(draftRoot.getId(), attachment.getId()); + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, content, status().isNoContent()); + } + + @Test + void uploadContentExceeding5MBLimitFails() throws Exception { + // Arrange: Create draft with attachments2 + var draftRoot = createNewDraftWithAttachments2(); + var attachment = draftRoot.getAttachments2().get(0); + + // Act: Try to upload 6MB content (exceeds limit) + byte[] content = new byte[6 * 1024 * 1024]; // 6MB + var url = buildDraftAttachment2ContentUrl(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 + } + + @Test + void updateContentStayingWithin5MBLimitSucceeds() throws Exception { + // Arrange: Create draft with attachments2 and upload initial content + var draftRoot = createNewDraftWithAttachments2(); + var attachment = draftRoot.getAttachments2().get(0); + + // Upload initial 2MB content + byte[] initialContent = new byte[2 * 1024 * 1024]; // 2MB + var url = buildDraftAttachment2ContentUrl(draftRoot.getId(), attachment.getId()); + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, initialContent, status().isNoContent()); + + // Act & Assert: Update with 3MB content (still within limit) succeeds + byte[] updatedContent = new byte[3 * 1024 * 1024]; // 3MB + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, updatedContent, status().isNoContent()); + } + + @Test + void updateContentExceeding5MBLimitFails() throws Exception { + // Arrange: Create draft with attachments2 and upload initial content + var draftRoot = createNewDraftWithAttachments2(); + var attachment = draftRoot.getAttachments2().get(0); + + // Upload initial 2MB content + byte[] initialContent = new byte[2 * 1024 * 1024]; // 2MB + var url = buildDraftAttachment2ContentUrl(draftRoot.getId(), attachment.getId()); + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, initialContent, status().isNoContent()); + + // Act & Assert: Try to update with 6MB content (exceeds limit) - should fail with HTTP 413 + byte[] updatedContent = new byte[6 * 1024 * 1024]; // 6MB + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, updatedContent, status().is(413)); + } + + // Helper methods + private DraftRoots createNewDraftWithAttachments2() throws Exception { + // Create new draft + var responseRootCdsData = + requestHelper.executePostWithODataResponseAndAssertStatusCreated(BASE_ROOT_URL, "{}"); + var draftRoot = Struct.access(responseRootCdsData).as(DraftRoots.class); + + // Update root with title + draftRoot.setTitle("Root with attachments2"); + var rootUrl = getRootUrl(draftRoot.getId(), false); + requestHelper.executePatchWithODataResponseAndAssertStatusOk(rootUrl, draftRoot.toJson()); + + // Create attachment2 + var attachment = Attachments.create(); + attachment.setFileName("testFile.txt"); + attachment.setMimeType("text/plain"); + var attachmentUrl = rootUrl + "/attachments2"; + var responseAttachmentCdsData = + requestHelper.executePostWithODataResponseAndAssertStatusCreated( + attachmentUrl, attachment.toJson()); + var createdAttachment = Struct.access(responseAttachmentCdsData).as(Attachments.class); + + // Build result with the attachment + draftRoot.setAttachments2(java.util.List.of(createdAttachment)); + return draftRoot; + } + + private DraftRoots selectStoredDraftWithAttachments2(String rootId) { + var select = + Select.from(DraftRoots_.class) + .where(r -> r.ID().eq(rootId).and(r.IsActiveEntity().eq(false))) + .columns(r -> r._all(), r -> r.attachments2().expand()); + + var result = persistenceService.run(select); + return result.single(DraftRoots.class); + } + + private String getRootUrl(String rootId, boolean isActiveEntity) { + return BASE_ROOT_URL + "(ID=" + rootId + ",IsActiveEntity=" + isActiveEntity + ")"; + } + + private String buildDraftAttachment2ContentUrl(String rootId, String attachmentId) { + return BASE_ROOT_URL + + "(ID=" + + rootId + + ",IsActiveEntity=false)" + + "/attachments2(ID=" + + attachmentId + + ",up__ID=" + + rootId + + ",IsActiveEntity=false)" + + "/content"; + } + + // 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() { + // no service handler - nothing to do + } + + @Override + protected void clearServiceHandlerContext() { + // no service handler - nothing to do + } + + @Override + protected void verifyEventContextEmptyForEvent(String... events) { + // no service handler - nothing to do + } + + @Override + protected void verifyOnlyTwoCreateEvents( + String newAttachmentContent, String newAttachmentEntityContent) { + // no service handler - nothing to do + } + + @Override + protected void verifyTwoCreateAndDeleteEvents( + String newAttachmentContent, String newAttachmentEntityContent) { + // no service handler - nothing to do + } + + @Override + protected void verifyTwoReadEvents() { + // no service handler - nothing to do + } + + @Override + protected void verifyOnlyTwoDeleteEvents( + String attachmentContentId, String attachmentEntityContentId) { + // no service handler - nothing to do + } + + @Override + protected void verifyTwoUpdateEvents( + String newAttachmentContent, + String attachmentContentId, + String newAttachmentEntityContent, + String attachmentEntityContentId) { + // no service handler - nothing to do + } + + @Override + protected void verifyTwoCreateAndRevertedDeleteEvents() { + // no service handler - nothing to do + } +} diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java index 117726c4e..104b4bc38 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/DraftOdataRequestValidationBase.java @@ -51,7 +51,7 @@ abstract class DraftOdataRequestValidationBase { protected TestPluginAttachmentsServiceHandler serviceHandler; @Autowired protected MockHttpRequestHelper requestHelper; - @Autowired private PersistenceService persistenceService; + @Autowired protected PersistenceService persistenceService; @Autowired private TableDataDeleter dataDeleter; @Autowired private TestPersistenceHandler testPersistenceHandler; diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/Attachments2SizeValidationNonDraftTest.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/Attachments2SizeValidationNonDraftTest.java new file mode 100644 index 000000000..f74fa24b2 --- /dev/null +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/Attachments2SizeValidationNonDraftTest.java @@ -0,0 +1,211 @@ +/* + * © 2024-2024 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.integrationtests.nondraftservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.sap.cds.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.integrationtests.constants.Profiles; +import com.sap.cds.feature.attachments.integrationtests.nondraftservice.helper.AttachmentsBuilder; +import com.sap.cds.feature.attachments.integrationtests.nondraftservice.helper.RootEntityBuilder; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles(Profiles.TEST_HANDLER_DISABLED) +class Attachments2SizeValidationNonDraftTest extends OdataRequestValidationBase { + + @Test + void uploadContentWithin5MBLimitSucceeds() throws Exception { + // Arrange: Create root with attachments2 + var serviceRoot = buildServiceRootWithAttachments2(); + postServiceRoot(serviceRoot); + + var selectedRoot = selectStoredRootWithAttachments2(); + var attachment = getRandomRootAttachment2(selectedRoot); + + // Act & Assert: Upload 3MB content (within limit) succeeds + byte[] content = new byte[3 * 1024 * 1024]; // 3MB + var url = buildNavigationAttachment2Url(selectedRoot.getId(), attachment.getId()) + "/content"; + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, content, status().isNoContent()); + } + + @Test + void uploadContentExceeding5MBLimitFails() throws Exception { + // Arrange: Create root with attachments2 + var serviceRoot = buildServiceRootWithAttachments2(); + postServiceRoot(serviceRoot); + + var selectedRoot = selectStoredRootWithAttachments2(); + var attachment = getRandomRootAttachment2(selectedRoot); + + // Act: Try to upload 6MB content (exceeds limit) + byte[] content = new byte[6 * 1024 * 1024]; // 6MB + var url = buildNavigationAttachment2Url(selectedRoot.getId(), attachment.getId()) + "/content"; + 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 + } + + @Test + void updateContentStayingWithin5MBLimitSucceeds() throws Exception { + // Arrange: Create root with attachments2 and upload initial content + var serviceRoot = buildServiceRootWithAttachments2(); + postServiceRoot(serviceRoot); + + var selectedRoot = selectStoredRootWithAttachments2(); + var attachment = getRandomRootAttachment2(selectedRoot); + + // Upload initial 2MB content + byte[] initialContent = new byte[2 * 1024 * 1024]; // 2MB + var url = buildNavigationAttachment2Url(selectedRoot.getId(), attachment.getId()) + "/content"; + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, initialContent, status().isNoContent()); + + // Act & Assert: Update with 3MB content (still within limit) succeeds + byte[] updatedContent = new byte[3 * 1024 * 1024]; // 3MB + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, updatedContent, status().isNoContent()); + } + + @Test + void updateContentExceeding5MBLimitFails() throws Exception { + // Arrange: Create root with attachments2 and upload initial content + var serviceRoot = buildServiceRootWithAttachments2(); + postServiceRoot(serviceRoot); + + var selectedRoot = selectStoredRootWithAttachments2(); + var attachment = getRandomRootAttachment2(selectedRoot); + + // Upload initial 2MB content + byte[] initialContent = new byte[2 * 1024 * 1024]; // 2MB + var url = buildNavigationAttachment2Url(selectedRoot.getId(), attachment.getId()) + "/content"; + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, initialContent, status().isNoContent()); + + // Act & Assert: Try to update with 6MB content (exceeds limit) - should fail with HTTP 413 + byte[] updatedContent = new byte[6 * 1024 * 1024]; // 6MB + requestHelper.setContentType(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, updatedContent, status().is(413)); + } + + // Helper methods + private Roots buildServiceRootWithAttachments2() { + return RootEntityBuilder.create() + .setTitle("Root with attachments2") + .addAttachments2( + AttachmentsBuilder.create().setFileName("testFile.txt").setMimeType("text/plain")) + .build(); + } + + private Roots selectStoredRootWithAttachments2() { + var select = + com.sap.cds.ql.Select.from( + com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots_ + .class) + .columns(r -> r._all(), r -> r.attachments2().expand()); + + var result = persistenceService.run(select); + return result.single(Roots.class); + } + + // Required abstract method implementations + @Override + protected void executeContentRequestAndValidateContent(String url, String content) + throws Exception { + Awaitility.await() + .atMost(10, TimeUnit.SECONDS) + .until( + () -> { + var response = requestHelper.executeGet(url); + return response.getResponse().getContentAsString().equals(content); + }); + + var response = requestHelper.executeGet(url); + assertThat(response.getResponse().getContentAsString()).isEqualTo(content); + } + + @Override + protected void verifyTwoDeleteEvents( + AttachmentEntity itemAttachmentEntityAfterChange, Attachments itemAttachmentAfterChange) { + // no service handler - nothing to do + } + + @Override + protected void verifyNumberOfEvents(String event, int number) { + // no service handler - nothing to do + } + + @Override + protected void verifyContentId( + Attachments attachmentWithExpectedContent, String attachmentId, String contentId) { + assertThat(attachmentWithExpectedContent.getContentId()).isEqualTo(attachmentId); + } + + @Override + protected void verifyContentAndContentId( + Attachments attachment, String testContent, Attachments itemAttachment) throws IOException { + assertThat(attachment.getContent().readAllBytes()) + .isEqualTo(testContent.getBytes(StandardCharsets.UTF_8)); + assertThat(attachment.getContentId()).isEqualTo(itemAttachment.getId()); + } + + @Override + protected void verifyContentAndContentIdForAttachmentEntity( + AttachmentEntity attachment, String testContent, AttachmentEntity itemAttachment) + throws IOException { + assertThat(attachment.getContent().readAllBytes()) + .isEqualTo(testContent.getBytes(StandardCharsets.UTF_8)); + assertThat(attachment.getContentId()).isEqualTo(itemAttachment.getId()); + } + + @Override + protected void clearServiceHandlerContext() { + // no service handler - nothing to do + } + + @Override + protected void clearServiceHandlerDocuments() { + // no service handler - nothing to do + } + + @Override + protected void verifySingleCreateEvent(String contentId, String content) { + // no service handler - nothing to do + } + + @Override + protected void verifySingleCreateAndUpdateEvent( + String resultContentId, String toBeDeletedContentId, String content) { + // no service handler - nothing to do + } + + @Override + protected void verifySingleDeletionEvent(String contentId) { + // no service handler - nothing to do + } + + @Override + protected void verifySingleReadEvent(String contentId) { + // no service handler - nothing to do + } + + @Override + protected void verifyNoAttachmentEventsCalled() { + // no service handler - nothing to do + } + + @Override + protected void verifyEventContextEmptyForEvent(String... events) { + // no service handler - nothing to do + } +} diff --git a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java index 3757d976e..ea17a69ce 100644 --- a/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java +++ b/integration-tests/srv/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/OdataRequestValidationBase.java @@ -54,7 +54,7 @@ abstract class OdataRequestValidationBase { protected TestPluginAttachmentsServiceHandler serviceHandler; @Autowired protected MockHttpRequestHelper requestHelper; - @Autowired private PersistenceService persistenceService; + @Autowired protected PersistenceService persistenceService; @Autowired private TableDataDeleter dataDeleter; @Autowired private TestPersistenceHandler testPersistenceHandler; @@ -696,6 +696,10 @@ protected Attachments getRandomItemAttachment(Items selectedItem) { return selectedItem.getAttachments().get(0); } + protected Attachments getRandomRootAttachment2(Roots selectedRoot) { + return selectedRoot.getAttachments2().get(0); + } + private AttachmentEntity getRandomItemAttachmentEntity(Items selectedItem) { return selectedItem.getAttachmentEntities().get(0); } @@ -754,6 +758,30 @@ protected String buildNavigationAttachmentUrl(String rootId, String itemId, Stri + ")"; } + protected String buildNavigationAttachment2Url(String rootId, String attachmentId) { + return "/odata/v4/TestService/Roots(" + + rootId + + ")/attachments2(ID=" + + attachmentId + + ",up__ID=" + + rootId + + ")"; + } + + protected String putContentForAttachment2(Roots selectedRoot, Attachments attachment) + throws Exception { + return putContentForAttachment2(selectedRoot, attachment, status().isNoContent()); + } + + protected String putContentForAttachment2( + Roots selectedRoot, Attachments attachment, ResultMatcher matcher) throws Exception { + var url = buildNavigationAttachment2Url(selectedRoot.getId(), attachment.getId()) + "/content"; + var testContent = "testContent" + attachment.getNote(); + requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM); + requestHelper.executePutWithMatcher(url, testContent.getBytes(StandardCharsets.UTF_8), matcher); + return testContent; + } + protected String buildExpandAttachmentUrl(String rootId, String itemId) { return "/odata/v4/TestService/Roots(" + rootId 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 6f9ea09dc..61491794c 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 @@ -225,7 +225,7 @@ private void waitTillExpectedHandlerMessageSize(int expectedSize) { .until( () -> { var eventCalls = serviceHandler.getEventContext().size(); - logger.info( + logger.debug( "Waiting for expected size '{}' in handler context, was '{}'", expectedSize, eventCalls); 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 d7799949b..d7ecc0734 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 @@ -32,6 +32,15 @@ public RootEntityBuilder addAttachments(AttachmentsEntityBuilder... attachments) return this; } + public RootEntityBuilder addAttachments2(AttachmentsBuilder... attachments) { + if (rootEntity.getAttachments2() == null) { + rootEntity.setAttachments2(new ArrayList<>()); + } + Arrays.stream(attachments) + .forEach(attachment -> rootEntity.getAttachments2().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 8ac47e53f..bd49a3b18 100644 --- a/integration-tests/srv/test-service.cds +++ b/integration-tests/srv/test-service.cds @@ -1,5 +1,9 @@ using test.data.model as db from '../db/data-model'; +annotate db.Roots.attachments2 with { + content @Validation.Maximum: '5MB'; +}; + service TestService { entity Roots as projection on db.Roots; entity AttachmentEntity as projection on db.AttachmentEntity; diff --git a/pom.xml b/pom.xml index 528beb241..71432d383 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,7 @@ - 1.2.4 + 1.2.5-SNAPSHOT 17 ${java.version} UTF-8 diff --git a/samples/bookshop/.gitignore b/samples/bookshop/.gitignore index c161f228e..2ecc68df6 100644 --- a/samples/bookshop/.gitignore +++ b/samples/bookshop/.gitignore @@ -29,3 +29,6 @@ hs_err* .vscode .idea .reloadtrigger + +# added by cds +.cdsrc-private.json diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml index f18606817..18d403302 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -51,7 +51,7 @@ com.sap.cds cds-feature-attachments - 1.2.4-SNAPSHOT + 1.2.5-SNAPSHOT diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 30db69511..07381ba78 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -7,6 +7,10 @@ extend my.Books with { attachments : Composition of many Attachments; } +annotate my.Books.attachments with { + content @Validation.Maximum: '20MB'; +} + // Add UI component for attachments table to the Browse Books App using {CatalogService as service} from '../app/services';